using ControlPlane.Core.Interfaces; using ControlPlane.Core.Models; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace ControlPlane.Worker.Steps; public class VaultStep(ILogger logger, IConfiguration config) : ISagaStep { public string StepName => "Cryptographic Pre-Flight (Vault)"; // Policy grants the tenant token exactly the three Transit operations Clarity.Server needs: // GenerateTenantKEKAsync → datakey/plaintext (first boot only) // DecryptTenantKEKAsync → decrypt (every restart) // RewrapTenantKEKAsync → rewrap (key rotation) private const string PolicyTemplate = """ path "clarity-transit/datakey/plaintext/master-key" { capabilities = ["update"] } path "clarity-transit/decrypt/master-key" { capabilities = ["update"] } path "clarity-transit/rewrap/master-key" { capabilities = ["update"] } """; public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) { var rootToken = ReadRootToken(); var vaultAddr = (config["Vault:Address"] ?? "http://localhost:8200").TrimEnd('/'); var subdomain = context.Job.Subdomain.ToLowerInvariant(); var policyName = $"clarity-tenant-{subdomain}"; using var http = new HttpClient { BaseAddress = new Uri(vaultAddr) }; http.DefaultRequestHeaders.Add("X-Vault-Token", rootToken); // ── 1. Assert Transit engine + master-key are healthy ───────────────── logger.LogInformation("[{JobId}] Verifying Vault Transit engine and master-key.", context.Job.Id); var healthRes = await http.GetAsync("v1/clarity-transit/keys/master-key", cancellationToken); if (!healthRes.IsSuccessStatusCode) throw new InvalidOperationException( $"Vault Transit master-key not found at {vaultAddr}. " + "Ensure OPC infra is running and the entrypoint has bootstrapped Vault."); // ── 2. Upsert per-tenant policy (idempotent PUT) ────────────────────── logger.LogInformation("[{JobId}] Writing Vault policy '{Policy}'.", context.Job.Id, policyName); var policyBody = JsonSerializer.Serialize(new { policy = PolicyTemplate }); var policyRes = await http.PutAsync( $"v1/sys/policies/acl/{policyName}", new StringContent(policyBody, Encoding.UTF8, "application/json"), cancellationToken); policyRes.EnsureSuccessStatusCode(); // ── 3. Create scoped periodic token bound to tenant policy ──────────── logger.LogInformation("[{JobId}] Creating scoped Vault token for policy '{Policy}'.", context.Job.Id, policyName); var tokenBody = JsonSerializer.Serialize(new { policies = new[] { policyName }, period = "72h", renewable = true, metadata = new Dictionary { ["tenant"] = subdomain, ["createdBy"] = "ControlPlane.Worker", }, }); var tokenRes = await http.PostAsync( "v1/auth/token/create", new StringContent(tokenBody, Encoding.UTF8, "application/json"), cancellationToken); tokenRes.EnsureSuccessStatusCode(); var tokenJson = JsonNode.Parse(await tokenRes.Content.ReadAsStringAsync(cancellationToken))!; context.VaultToken = tokenJson["auth"]!["client_token"]!.GetValue(); context.VaultTokenAccessor = tokenJson["auth"]!["accessor"]!.GetValue(); logger.LogInformation("[{JobId}] Vault step complete. Token accessor: {Accessor}", context.Job.Id, context.VaultTokenAccessor); context.Job.CompletedSteps |= CompletedSteps.VaultVerified; } public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(context.VaultTokenAccessor)) return; logger.LogWarning("[{JobId}] Compensating Vault — revoking token accessor {Accessor}.", context.Job.Id, context.VaultTokenAccessor); try { var rootToken = ReadRootToken(); var vaultAddr = (config["Vault:Address"] ?? "http://localhost:8200").TrimEnd('/'); using var http = new HttpClient { BaseAddress = new Uri(vaultAddr) }; http.DefaultRequestHeaders.Add("X-Vault-Token", rootToken); var body = JsonSerializer.Serialize(new { accessor = context.VaultTokenAccessor }); await http.PostAsync( "v1/auth/token/revoke-accessor", new StringContent(body, Encoding.UTF8, "application/json"), cancellationToken); } catch (Exception ex) { logger.LogError(ex, "[{JobId}] Failed to revoke Vault token accessor {Accessor} during compensation.", context.Job.Id, context.VaultTokenAccessor); } } internal string ReadRootToken() { var path = config["Vault:KeysFile"] ?? config["Vault__KeysFile"]; if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { using var doc = JsonDocument.Parse(File.ReadAllText(path)); if (doc.RootElement.TryGetProperty("root_token", out var tok)) return tok.GetString()!; } return config["Vault:Token"] ?? throw new InvalidOperationException( "Cannot resolve Vault root token: neither Vault:KeysFile nor Vault:Token is configured."); } }