114 lines
4.1 KiB
C#
114 lines
4.1 KiB
C#
|
|
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>();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|