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; /// /// 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. /// [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 _logger; public CredentialsController( WorkflowsDbContext db, CredentialVault vault, CredentialTypeCatalog types, IHttpClientFactory http, ILogger 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"); /// The credential types the vault accepts, with their field schemas. [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))); /// Lists the tenant's credentials (metadata only, no secrets). [HttpGet] [RequireScope("read")] public async Task List(CancellationToken ct) { var credentials = await _vault.ListAsync(_db, TenantId, ct); return Ok(credentials.Select(Summarize)); } [HttpGet("{id:guid}")] [RequireScope("read")] public async Task 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 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 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 Delete(Guid id, CancellationToken ct) => await _vault.DeleteAsync(_db, TenantId, id, ct) ? NoContent() : NotFound(); /// /// Tests a credential: always validates the stored fields; when a probe URL is /// supplied it also performs an authenticated GET and reports the status. /// [HttpPost("{id:guid}/test")] [RequireScope("manage")] public async Task 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 Fields, string Injection);