diff --git a/ControlPlane.Core/Interfaces/SagaContext.cs b/ControlPlane.Core/Interfaces/SagaContext.cs index 67b6dd5..1e70ece 100644 --- a/ControlPlane.Core/Interfaces/SagaContext.cs +++ b/ControlPlane.Core/Interfaces/SagaContext.cs @@ -24,6 +24,11 @@ public class SagaContext // Written by LaunchStep — primary app container name public string? ContainerName { get; set; } + // Written by VaultStep — scoped periodic token for the tenant (not the root token) + // and its accessor used for compensation/revocation + public string? VaultToken { get; set; } + public string? VaultTokenAccessor { get; set; } + // Written by PulumiStep (DedicatedVM/Enterprise tier) — target host details for subsequent steps public string? VmIpAddress { get; set; } public string? VmSshKeyPath { get; set; } diff --git a/ControlPlane.Worker/Services/ClarityContainerService.cs b/ControlPlane.Worker/Services/ClarityContainerService.cs index 726c714..e189c66 100644 --- a/ControlPlane.Worker/Services/ClarityContainerService.cs +++ b/ControlPlane.Worker/Services/ClarityContainerService.cs @@ -101,10 +101,13 @@ public class ClarityContainerService( }, Labels = new Dictionary { - ["clarity.managed"] = "true", - ["clarity.subdomain"] = subdomain, - ["clarity.siteCode"] = siteCode, - ["clarity.env"] = environment, + ["clarity.managed"] = "true", + ["clarity.subdomain"] = subdomain, + ["clarity.siteCode"] = siteCode, + ["clarity.env"] = environment, + // Groups containers in Docker Desktop by environment tier (fdev / uat / prod). + ["com.docker.compose.project"] = $"clarity-{environment.ToLowerInvariant()}", + ["com.docker.compose.service"] = name, }, }, cancellationToken); diff --git a/ControlPlane.Worker/Steps/KeycloakStep.cs b/ControlPlane.Worker/Steps/KeycloakStep.cs index b512d18..26aa81c 100644 --- a/ControlPlane.Worker/Steps/KeycloakStep.cs +++ b/ControlPlane.Worker/Steps/KeycloakStep.cs @@ -41,6 +41,17 @@ public class KeycloakStep( }, cancellationToken); // clarity-web-app: public OIDC client used by the React frontend. + // fdev is a developer dogfood environment — allow localhost redirect URIs so that a + // local Aspire dev loop (any port) can complete the OIDC flow against the shared + // OPC infra Keycloak without any post-provisioning patching. + var isFdev = string.Equals(context.Job.Environment, "fdev", StringComparison.OrdinalIgnoreCase); + var redirectUris = isFdev + ? new[] { $"{tenantOrigin}/*", "http://localhost:*/*", "http://*.dev.localhost:*/*" } + : new[] { $"{tenantOrigin}/*" }; + var webOrigins = isFdev + ? "+" // match all valid redirect URI origins + : tenantOrigin; + await adminClient.CreateClientAsync(realmId, new { clientId = "clarity-web-app", @@ -51,8 +62,8 @@ public class KeycloakStep( directAccessGrantsEnabled = false, rootUrl = tenantOrigin, baseUrl = "/", - redirectUris = new[] { $"{tenantOrigin}/*" }, - webOrigins = new[] { tenantOrigin }, + redirectUris, + webOrigins = new[] { webOrigins }, }, cancellationToken); // Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim diff --git a/ControlPlane.Worker/Steps/LaunchStep.cs b/ControlPlane.Worker/Steps/LaunchStep.cs index 650457e..6e9f0d8 100644 --- a/ControlPlane.Worker/Steps/LaunchStep.cs +++ b/ControlPlane.Worker/Steps/LaunchStep.cs @@ -32,7 +32,7 @@ public class LaunchStep( subdomain: job.Subdomain, keycloakRealm: $"clarity-{job.Subdomain.ToLowerInvariant()}", postgresConnectionString: context.TenantConnectionString, - vaultToken: ReadVaultToken(config), + vaultToken: context.VaultToken ?? ReadVaultToken(config), jobId: job.Id, cancellationToken: cancellationToken); diff --git a/ControlPlane.Worker/Steps/VaultStep.cs b/ControlPlane.Worker/Steps/VaultStep.cs index f665782..fbd0ea7 100644 --- a/ControlPlane.Worker/Steps/VaultStep.cs +++ b/ControlPlane.Worker/Steps/VaultStep.cs @@ -1,6 +1,9 @@ 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; @@ -8,38 +11,117 @@ public class VaultStep(ILogger logger, IConfiguration config) : ISaga { public string StepName => "Cryptographic Pre-Flight (Vault)"; - public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken) + // 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) { - // TODO: VaultSharp - // 1. Assert Transit engine is active and healthy - // 2. Derive/validate TenantContextId (e.g. FL_COM_001) - // 3. Register TenantContextId in a KV entry or TenantRegistry table - // so Clarity.Server can resolve the derivation path later - // - // Root token is read at runtime from the persisted init.json on the Vault volume: - // var token = ReadRootToken(); - logger.LogInformation("[{JobId}] Vault step is a stub - VaultSharp not yet wired.", context.Job.Id); + 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; - return Task.CompletedTask; } - public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) + public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken) { - logger.LogInformation("[{JobId}] Vault step: no compensation needed.", context.Job.Id); - return Task.CompletedTask; + 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); + } } - /// - /// Reads the root token from the init.json written by the Vault entrypoint on first boot. - /// Path is injected via Vault__KeysFile config. - /// internal string ReadRootToken() { - var path = config["Vault__KeysFile"] - ?? throw new InvalidOperationException("Vault__KeysFile is not configured."); + 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()!; + } - using var doc = JsonDocument.Parse(File.ReadAllText(path)); - return doc.RootElement.GetProperty("root_token").GetString() - ?? throw new InvalidOperationException("root_token not found in Vault init.json."); + return config["Vault:Token"] + ?? throw new InvalidOperationException( + "Cannot resolve Vault root token: neither Vault:KeysFile nor Vault:Token is configured."); } } diff --git a/ControlPlane.Worker/appsettings.Development.json b/ControlPlane.Worker/appsettings.Development.json new file mode 100644 index 0000000..57cea3c --- /dev/null +++ b/ControlPlane.Worker/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "Vault": { + "KeysFile": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC\\infra\\vault\\data\\init.json" + } +} diff --git a/ControlPlane.Worker/appsettings.json b/ControlPlane.Worker/appsettings.json index 46d63b5..33f7130 100644 --- a/ControlPlane.Worker/appsettings.json +++ b/ControlPlane.Worker/appsettings.json @@ -20,7 +20,7 @@ // ── Vault ───────────────────────────────────────────────────────────────────── // Worker uses localhost:8200 for admin calls. - // Vault__KeysFile is machine-specific → still injected by Aspire AppHost. + // Vault:KeysFile is machine-specific → set in appsettings.Development.json. "Vault": { "Address": "http://localhost:8200", "ContainerAddress": "http://vault:8200"