using System.Text.Json; using Microsoft.AspNetCore.Mvc; using w4c_workflows.Services; namespace w4c_workflows.Controllers; /// /// Per-tenant operator API keys. This whole surface authenticates with the /// tenant's MAIN JWT (see AuthMiddleware) so a logged-in owner can mint, list, /// rotate and revoke the keys that the frontend/worker then use everywhere else. /// [ApiController] [Route("api/keys")] public class KeysController : ControllerBase { private static readonly string[] AllScopes = { "manage", "run", "read" }; private readonly ApiKeyService _keys; private readonly ILogger _logger; public KeysController(ApiKeyService keys, ILogger logger) { _keys = keys; _logger = logger; } private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); public sealed record MintRequest(string? Label = null, string[]? Scopes = null); /// /// Mints the tenant's first operator key. Idempotent-by-guard: fails with /// 409 once any active key exists — subsequent keys are created via /// POST /api/keys (rotate). /// [HttpPost("bootstrap")] public async Task Bootstrap(CancellationToken ct) { if (await _keys.CountActiveAsync(TenantId, ct) > 0) return Conflict(new { error = "Operator keys already exist — use POST /api/keys to rotate." }); var (id, raw) = await _keys.MintAsync(TenantId, "default", AllScopes, ct); _logger.LogInformation("Bootstrapped operator key {KeyId} for tenant {TenantId}", id, TenantId); return Ok(new { id, key = raw, scopes = AllScopes, note = "Store this key now — it is shown only once." }); } /// Mints a new operator key (rotation). [HttpPost] public async Task Mint([FromBody] MintRequest? request, CancellationToken ct) { var label = string.IsNullOrWhiteSpace(request?.Label) ? "default" : request.Label.Trim(); var scopes = NormalizeScopes(request?.Scopes); var (id, raw) = await _keys.MintAsync(TenantId, label, scopes, ct); _logger.LogInformation("Minted operator key {KeyId} for tenant {TenantId}", id, TenantId); return Ok(new { id, key = raw, scopes, note = "Store this key now — it is shown only once." }); } /// Lists the tenant's keys (hashes are never exposed). [HttpGet] public async Task List(CancellationToken ct) { var keys = await _keys.ListAsync(TenantId, ct); var result = keys.Select(k => new { k.Id, k.Label, scopes = ParseScopes(k.ScopesJson), k.CreatedAt, k.LastUsedAt, k.RevokedAt, active = k.RevokedAt == null, }); return Ok(result); } /// Revokes an operator key. [HttpDelete("{id:guid}")] public async Task Revoke(Guid id, CancellationToken ct) { var revoked = await _keys.RevokeAsync(TenantId, id, ct); if (!revoked) return NotFound(new { error = "Key not found." }); _logger.LogInformation("Revoked operator key {KeyId} for tenant {TenantId}", id, TenantId); return NoContent(); } private static string[] NormalizeScopes(string[]? requested) { if (requested == null || requested.Length == 0) return AllScopes; var valid = new HashSet { "manage", "run", "read" }; var normalized = requested.Where(s => valid.Contains(s)).Distinct().ToArray(); return normalized.Length == 0 ? AllScopes : normalized; } private static string[] ParseScopes(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return JsonSerializer.Deserialize(json) ?? Array.Empty(); } catch { return Array.Empty(); } } }