191 lines
7.3 KiB
C#
191 lines
7.3 KiB
C#
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;
|
|
using w4c_workflows.Services.Security;
|
|
|
|
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 EgressGuard _egress;
|
|
private readonly ILogger<CredentialsController> _logger;
|
|
|
|
public CredentialsController(
|
|
WorkflowsDbContext db,
|
|
CredentialVault vault,
|
|
CredentialTypeCatalog types,
|
|
IHttpClientFactory http,
|
|
EgressGuard egress,
|
|
ILogger<CredentialsController> logger)
|
|
{
|
|
_db = db;
|
|
_vault = vault;
|
|
_types = types;
|
|
_http = http;
|
|
_egress = egress;
|
|
_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"));
|
|
|
|
// The probe URL is caller-supplied and the request carries a decrypted
|
|
// credential, so it must never be allowed to reach a private/loopback/
|
|
// metadata address. Vet it exactly like a workflow HTTP node; the named
|
|
// client is registered without automatic redirects so a public URL
|
|
// cannot bounce the credential to an internal host.
|
|
var egress = await _egress.AuthorizeAsync(uri, ct);
|
|
if (!egress.Allowed)
|
|
return Ok(new CredentialTestResult(false, null, $"probe blocked by egress policy: {egress.Reason}"));
|
|
|
|
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);
|