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