w4c-workflows-api/Controllers/KeysController.cs

114 lines
4.1 KiB
C#
Raw Permalink Normal View History

using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using w4c_workflows.Services;
namespace w4c_workflows.Controllers;
/// <summary>
/// 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.
/// </summary>
[ApiController]
[Route("api/keys")]
public class KeysController : ControllerBase
{
private static readonly string[] AllScopes = { "manage", "run", "read" };
private readonly ApiKeyService _keys;
private readonly ILogger<KeysController> _logger;
public KeysController(ApiKeyService keys, ILogger<KeysController> 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);
/// <summary>
/// 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).
/// </summary>
[HttpPost("bootstrap")]
public async Task<IActionResult> 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." });
}
/// <summary>Mints a new operator key (rotation).</summary>
[HttpPost]
public async Task<IActionResult> 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." });
}
/// <summary>Lists the tenant's keys (hashes are never exposed).</summary>
[HttpGet]
public async Task<IActionResult> 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);
}
/// <summary>Revokes an operator key.</summary>
[HttpDelete("{id:guid}")]
public async Task<IActionResult> 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<string> { "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<string>();
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
}
catch
{
return Array.Empty<string>();
}
}
}