w4c-workflows-api/Controllers/CredentialsController.cs

178 lines
6.6 KiB
C#
Raw Normal View History

2026-09-11 22:02:46 +00:00
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using w4c_workflows.Data;
using w4c_workflows.Filters;
using w4c_workflows.Models;
using w4c_workflows.Models.Credentials;
using w4c_workflows.Services.Credentials;
namespace w4c_workflows.Controllers;
/// <summary>
/// Tenant credential vault surface. Secrets are write-only: they are accepted on
/// create/update, encrypted at rest, and never returned by any endpoint (only
/// metadata is exposed). All endpoints authenticate with the tenant operator key.
/// </summary>
[ApiController]
[Route("api/credentials")]
public class CredentialsController : ControllerBase
{
private readonly WorkflowsDbContext _db;
private readonly CredentialVault _vault;
private readonly CredentialTypeCatalog _types;
private readonly IHttpClientFactory _http;
private readonly ILogger<CredentialsController> _logger;
public CredentialsController(
WorkflowsDbContext db,
CredentialVault vault,
CredentialTypeCatalog types,
IHttpClientFactory http,
ILogger<CredentialsController> logger)
{
_db = db;
_vault = vault;
_types = types;
_http = http;
_logger = logger;
}
private string TenantId => (string?)HttpContext.Items["TenantId"]
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
/// <summary>The credential types the vault accepts, with their field schemas.</summary>
[HttpGet("types")]
[RequireScope("read")]
public IActionResult Types()
=> Ok(_types.All.Select(t => new CredentialTypeSummary(
t.Type, t.DisplayName, t.Description, t.DocumentationUrl, t.Fields, t.Injection.Kind)));
/// <summary>Lists the tenant's credentials (metadata only, no secrets).</summary>
[HttpGet]
[RequireScope("read")]
public async Task<IActionResult> List(CancellationToken ct)
{
var credentials = await _vault.ListAsync(_db, TenantId, ct);
return Ok(credentials.Select(Summarize));
}
[HttpGet("{id:guid}")]
[RequireScope("read")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var credential = await _vault.GetAsync(_db, TenantId, id, ct);
return credential == null ? NotFound() : Ok(Summarize(credential));
}
[HttpPost]
[RequireScope("manage")]
public async Task<IActionResult> Create([FromBody] CredentialWriteRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Type))
return BadRequest(new { error = "name and type are required" });
try
{
var created = await _vault.CreateAsync(
_db, TenantId, request.Name!, request.Type!, request.Data ?? new JsonObject(), ct);
return CreatedAtAction(nameof(Get), new { id = created.Id }, Summarize(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("{id:guid}")]
[RequireScope("manage")]
public async Task<IActionResult> Update(Guid id, [FromBody] CredentialWriteRequest request, CancellationToken ct)
{
try
{
var updated = await _vault.UpdateAsync(_db, TenantId, id, request.Name, request.Type, request.Data, ct);
return updated == null ? NotFound() : Ok(Summarize(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("{id:guid}")]
[RequireScope("manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
=> await _vault.DeleteAsync(_db, TenantId, id, ct) ? NoContent() : NotFound();
/// <summary>
/// Tests a credential: always validates the stored fields; when a probe URL is
/// supplied it also performs an authenticated GET and reports the status.
/// </summary>
[HttpPost("{id:guid}/test")]
[RequireScope("manage")]
public async Task<IActionResult> Test(Guid id, [FromBody] CredentialTestRequest? request, CancellationToken ct)
{
var entity = await _vault.GetAsync(_db, TenantId, id, ct);
if (entity == null)
return NotFound();
var type = _types.Get(entity.Type);
if (type == null)
return BadRequest(new { error = $"unknown credential type '{entity.Type}'" });
JsonObject data;
try
{
data = _vault.Decrypt(entity);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Credential {CredentialId} could not be decrypted", id);
return Ok(new CredentialTestResult(false, null, "the stored credential could not be decrypted"));
}
if (string.IsNullOrWhiteSpace(request?.Url))
return Ok(new CredentialTestResult(true, null, "credential fields are valid"));
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
return Ok(new CredentialTestResult(false, null, $"'{request.Url}' is not a valid absolute URL"));
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
var injectionError = CredentialInjector.Apply(new CredentialData(entity.Type, data), _types, httpRequest);
if (injectionError != null)
return Ok(new CredentialTestResult(false, null, injectionError));
try
{
var response = await _http.CreateClient("credential-test").SendAsync(httpRequest, ct);
using (response)
{
var status = (int)response.StatusCode;
return Ok(new CredentialTestResult(status < 400, status, $"HTTP {status}"));
}
}
catch (HttpRequestException ex)
{
return Ok(new CredentialTestResult(false, null, ex.Message));
}
}
private static CredentialSummary Summarize(Credential credential)
=> new(credential.Id, credential.Name, credential.Type, credential.CreatedAt, credential.UpdatedAt);
}
public sealed record CredentialWriteRequest(string? Name, string? Type, JsonObject? Data);
public sealed record CredentialTestRequest(string? Url);
public sealed record CredentialTestResult(bool Ok, int? Status, string? Message);
public sealed record CredentialSummary(Guid Id, string Name, string Type, DateTime CreatedAt, DateTime UpdatedAt);
public sealed record CredentialTypeSummary(
string Type,
string DisplayName,
string? Description,
string? DocumentationUrl,
IReadOnlyList<w4c_workflows.Models.Nodes.NodeParameter> Fields,
string Injection);