OPC # 0007: Patch FDEV provisioning for local aspire development

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-26 12:48:07 -04:00
parent e8ac7b017c
commit b9f0f6dd5f
7 changed files with 137 additions and 31 deletions
@@ -24,6 +24,11 @@ public class SagaContext
// Written by LaunchStep — primary app container name // Written by LaunchStep — primary app container name
public string? ContainerName { get; set; } 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 // Written by PulumiStep (DedicatedVM/Enterprise tier) — target host details for subsequent steps
public string? VmIpAddress { get; set; } public string? VmIpAddress { get; set; }
public string? VmSshKeyPath { get; set; } public string? VmSshKeyPath { get; set; }
@@ -105,6 +105,9 @@ public class ClarityContainerService(
["clarity.subdomain"] = subdomain, ["clarity.subdomain"] = subdomain,
["clarity.siteCode"] = siteCode, ["clarity.siteCode"] = siteCode,
["clarity.env"] = environment, ["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); }, cancellationToken);
+13 -2
View File
@@ -41,6 +41,17 @@ public class KeycloakStep(
}, cancellationToken); }, cancellationToken);
// clarity-web-app: public OIDC client used by the React frontend. // 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 await adminClient.CreateClientAsync(realmId, new
{ {
clientId = "clarity-web-app", clientId = "clarity-web-app",
@@ -51,8 +62,8 @@ public class KeycloakStep(
directAccessGrantsEnabled = false, directAccessGrantsEnabled = false,
rootUrl = tenantOrigin, rootUrl = tenantOrigin,
baseUrl = "/", baseUrl = "/",
redirectUris = new[] { $"{tenantOrigin}/*" }, redirectUris,
webOrigins = new[] { tenantOrigin }, webOrigins = new[] { webOrigins },
}, cancellationToken); }, cancellationToken);
// Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim // Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim
+1 -1
View File
@@ -32,7 +32,7 @@ public class LaunchStep(
subdomain: job.Subdomain, subdomain: job.Subdomain,
keycloakRealm: $"clarity-{job.Subdomain.ToLowerInvariant()}", keycloakRealm: $"clarity-{job.Subdomain.ToLowerInvariant()}",
postgresConnectionString: context.TenantConnectionString, postgresConnectionString: context.TenantConnectionString,
vaultToken: ReadVaultToken(config), vaultToken: context.VaultToken ?? ReadVaultToken(config),
jobId: job.Id, jobId: job.Id,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
+105 -23
View File
@@ -1,6 +1,9 @@
using ControlPlane.Core.Interfaces; using ControlPlane.Core.Interfaces;
using ControlPlane.Core.Models; using ControlPlane.Core.Models;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
namespace ControlPlane.Worker.Steps; namespace ControlPlane.Worker.Steps;
@@ -8,38 +11,117 @@ public class VaultStep(ILogger<VaultStep> logger, IConfiguration config) : ISaga
{ {
public string StepName => "Cryptographic Pre-Flight (Vault)"; 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 var rootToken = ReadRootToken();
// 1. Assert Transit engine is active and healthy var vaultAddr = (config["Vault:Address"] ?? "http://localhost:8200").TrimEnd('/');
// 2. Derive/validate TenantContextId (e.g. FL_COM_001) var subdomain = context.Job.Subdomain.ToLowerInvariant();
// 3. Register TenantContextId in a KV entry or TenantRegistry table var policyName = $"clarity-tenant-{subdomain}";
// so Clarity.Server can resolve the derivation path later
// using var http = new HttpClient { BaseAddress = new Uri(vaultAddr) };
// Root token is read at runtime from the persisted init.json on the Vault volume: http.DefaultRequestHeaders.Add("X-Vault-Token", rootToken);
// var token = ReadRootToken();
logger.LogInformation("[{JobId}] Vault step is a stub - VaultSharp not yet wired.", context.Job.Id); // ── 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<string, string>
{
["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<string>();
context.VaultTokenAccessor = tokenJson["auth"]!["accessor"]!.GetValue<string>();
logger.LogInformation("[{JobId}] Vault step complete. Token accessor: {Accessor}",
context.Job.Id, context.VaultTokenAccessor);
context.Job.CompletedSteps |= CompletedSteps.VaultVerified; 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); if (string.IsNullOrWhiteSpace(context.VaultTokenAccessor)) return;
return Task.CompletedTask;
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);
}
} }
/// <summary>
/// Reads the root token from the init.json written by the Vault entrypoint on first boot.
/// Path is injected via Vault__KeysFile config.
/// </summary>
internal string ReadRootToken() internal string ReadRootToken()
{ {
var path = config["Vault__KeysFile"] var path = config["Vault:KeysFile"] ?? config["Vault__KeysFile"];
?? throw new InvalidOperationException("Vault__KeysFile is not configured."); if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
using var doc = JsonDocument.Parse(File.ReadAllText(path)); using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.GetProperty("root_token").GetString() if (doc.RootElement.TryGetProperty("root_token", out var tok))
?? throw new InvalidOperationException("root_token not found in Vault init.json."); return tok.GetString()!;
}
return config["Vault:Token"]
?? throw new InvalidOperationException(
"Cannot resolve Vault root token: neither Vault:KeysFile nor Vault:Token is configured.");
} }
} }
@@ -0,0 +1,5 @@
{
"Vault": {
"KeysFile": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC\\infra\\vault\\data\\init.json"
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
// ── Vault ───────────────────────────────────────────────────────────────────── // ── Vault ─────────────────────────────────────────────────────────────────────
// Worker uses localhost:8200 for admin calls. // 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": { "Vault": {
"Address": "http://localhost:8200", "Address": "http://localhost:8200",
"ContainerAddress": "http://vault:8200" "ContainerAddress": "http://vault:8200"