Files
OPC/ControlPlane.Worker/Steps/KeycloakStep.cs
T
2026-04-26 12:48:07 -04:00

102 lines
5.1 KiB
C#

using ControlPlane.Core.Config;
using ControlPlane.Core.Interfaces;
using ControlPlane.Core.Models;
using ControlPlane.Worker.Services;
using Microsoft.Extensions.Options;
namespace ControlPlane.Worker.Steps;
public class KeycloakStep(
KeycloakAdminClient adminClient,
IConfiguration config,
IOptions<ClarityInfraOptions> infraOptions,
ILogger<KeycloakStep> logger) : ISagaStep
{
public string StepName => "Identity Bootstrapping (Keycloak)";
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
{
var realmId = RealmId(context);
logger.LogInformation("[{JobId}] Creating Keycloak realm {Realm}.", context.Job.Id, realmId);
await adminClient.CreateRealmAsync(realmId, context.Job.ClientName, cancellationToken);
logger.LogInformation("[{JobId}] Creating AgencyAdmin role.", context.Job.Id);
await adminClient.CreateRealmRoleAsync(realmId, "AgencyAdmin", "Day-zero administrator for this Clarity tenant.", cancellationToken);
// Derive the tenant's public-facing origin from ClarityInfraOptions.
var tenantOrigin = infraOptions.Value.TenantPublicUrl(context.Job.Subdomain);
logger.LogInformation("[{JobId}] Creating Keycloak clients for realm {Realm} (origin: {Origin}).",
context.Job.Id, realmId, tenantOrigin);
// clarity-rest-api: bearer-only resource server — just registers the audience so JWT validation passes.
await adminClient.CreateClientAsync(realmId, new
{
clientId = "clarity-rest-api",
name = "Clarity REST API",
enabled = true,
bearerOnly = true,
publicClient = false,
}, 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",
name = "Clarity Web App",
enabled = true,
publicClient = true,
standardFlowEnabled = true,
directAccessGrantsEnabled = false,
rootUrl = tenantOrigin,
baseUrl = "/",
redirectUris,
webOrigins = new[] { webOrigins },
}, cancellationToken);
// Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim
// so that Clarity.Server JWT bearer validation (Audience = "clarity-rest-api") passes.
logger.LogInformation("[{JobId}] Adding audience mapper for clarity-rest-api on clarity-web-app.", context.Job.Id);
var webAppUuid = await adminClient.GetClientUuidAsync(realmId, "clarity-web-app", cancellationToken);
await adminClient.AddAudienceMapperAsync(realmId, webAppUuid, "clarity-rest-api", cancellationToken);
logger.LogInformation("[{JobId}] Creating day-zero user {Email}.", context.Job.Id, context.Job.AdminEmail);
var userId = await adminClient.CreateUserAsync(realmId, context.Job.AdminEmail, context.Job.ClientName, cancellationToken);
logger.LogInformation("[{JobId}] Assigning AgencyAdmin role.", context.Job.Id);
await adminClient.AssignRealmRoleAsync(realmId, userId, "AgencyAdmin", cancellationToken);
// TODO No SMTP right now
//logger.LogInformation("[{JobId}] Sending required actions email to {Email}.", context.Job.Id, context.Job.AdminEmail);
//await adminClient.SendRequiredActionsEmailAsync(realmId, userId, ["UPDATE_PASSWORD", "VERIFY_EMAIL"], cancellationToken);
context.DayZeroUserSubjectId = userId;
//context.MagicLink = $"Action email sent to {context.Job.AdminEmail} for realm '{realmId}'.";
context.Job.CompletedSteps |= CompletedSteps.KeycloakProvisioned;
logger.LogInformation("[{JobId}] Keycloak provisioning complete for realm {Realm}.", context.Job.Id, realmId);
}
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
{
var realmId = RealmId(context);
logger.LogWarning("[{JobId}] Compensating Keycloak - deleting realm {Realm}.", context.Job.Id, realmId);
await adminClient.DeleteRealmAsync(realmId, cancellationToken);
}
private static string RealmId(SagaContext context) =>
$"clarity-{context.Job.Subdomain.ToLowerInvariant()}";
}