b9f0f6dd5f
Co-authored-by: Copilot <copilot@github.com>
128 lines
5.7 KiB
C#
128 lines
5.7 KiB
C#
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<VaultStep> 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<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;
|
|
}
|
|
|
|
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.");
|
|
}
|
|
}
|