using Microsoft.Extensions.Logging; namespace OTSSignsOrchestrator.Core.Services; /// /// Orchestrates the 6-step Authentik invitation infrastructure setup for a customer. /// All operations are idempotent — the underlying Authentik API methods check for /// existing resources before creating new ones. /// /// Per customer, this creates: /// 1. A group (customer-{abbrev}) /// 2. An invitation stage ({abbrev}-invitation-stage) /// 3. An enrollment flow ({abbrev}-enrollment) with stages bound in order /// 4. An expression policy on the UserWrite stage to auto-assign users to the group /// 5. A role ({abbrev}-invite-manager) with invitation CRUD permissions /// 6. A scoping policy on the flow so only group members can access it /// public class InvitationSetupService : IInvitationSetupService { private readonly IAuthentikService _authentik; private readonly SettingsService _settings; private readonly ILogger _logger; public InvitationSetupService( IAuthentikService authentik, SettingsService settings, ILogger logger) { _authentik = authentik; _settings = settings; _logger = logger; } /// public async Task SetupCustomerInvitationAsync( string customerAbbrev, string customerName, CancellationToken ct = default) { var abbrev = customerAbbrev.Trim().ToLowerInvariant(); var groupName = $"customer-{abbrev}"; var flowSlug = $"{abbrev}-enrollment"; var flowName = $"{customerName} Enrollment"; var roleName = $"{abbrev}-invite-manager"; var invitationStageName = $"{abbrev}-invitation-stage"; _logger.LogInformation( "[InviteSetup] Starting invitation infrastructure setup for customer '{Customer}' (abbrev={Abbrev})", customerName, abbrev); try { // ═══════════════════════════════════════════════════════════════════ // Step 1: Create customer group // ═══════════════════════════════════════════════════════════════════ _logger.LogInformation("[InviteSetup] Step 1/6: Creating customer group '{Group}'", groupName); var groupPk = await _authentik.CreateGroupAsync(groupName, ct); // ═══════════════════════════════════════════════════════════════════ // Step 2: Create invitation stage // ═══════════════════════════════════════════════════════════════════ _logger.LogInformation("[InviteSetup] Step 2/6: Creating invitation stage '{Stage}'", invitationStageName); var invitationStagePk = await _authentik.CreateInvitationStageAsync( invitationStageName, continueWithoutInvitation: false, ct); // ═══════════════════════════════════════════════════════════════════ // Step 3: Create enrollment flow and bind stages // ═══════════════════════════════════════════════════════════════════ _logger.LogInformation("[InviteSetup] Step 3/6: Creating enrollment flow '{Slug}'", flowSlug); var flowPk = await _authentik.CreateEnrollmentFlowAsync(flowName, flowSlug, ct); // Resolve built-in Authentik stages for the enrollment pipeline var promptStagePk = await _authentik.FindStageByNameAsync("default-enrollment-prompt", ct); var userWriteStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-write", ct); var userLoginStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-login", ct); // Bind stages in order: 10=Invitation, 20=Prompt, 30=UserWrite, 40=UserLogin await _authentik.BindStageToFlowAsync(flowSlug, invitationStagePk, 10, ct); if (promptStagePk != null) { await _authentik.BindStageToFlowAsync(flowSlug, promptStagePk, 20, ct); _logger.LogInformation("[InviteSetup] Bound default prompt stage at order 20"); } else { _logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-prompt' stage not found — " + "you may need to create a prompt stage manually and bind it to flow '{Slug}' at order 20", flowSlug); } if (userWriteStagePk != null) { await _authentik.BindStageToFlowAsync(flowSlug, userWriteStagePk, 30, ct); _logger.LogInformation("[InviteSetup] Bound default user-write stage at order 30"); } else { _logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-write' stage not found — " + "you may need to create a user-write stage manually and bind it to flow '{Slug}' at order 30", flowSlug); } if (userLoginStagePk != null) { await _authentik.BindStageToFlowAsync(flowSlug, userLoginStagePk, 40, ct); _logger.LogInformation("[InviteSetup] Bound default user-login stage at order 40"); } else { _logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-login' stage not found — " + "you may need to create a user-login stage manually and bind it to flow '{Slug}' at order 40", flowSlug); } // ═══════════════════════════════════════════════════════════════════ // Step 4: Create group-assignment policy and bind to UserWrite stage // ═══════════════════════════════════════════════════════════════════ _logger.LogInformation("[InviteSetup] Step 4/6: Creating group-assignment expression policy"); var groupAssignPolicyName = $"{abbrev}-auto-assign-group"; var groupAssignExpression = $""" from authentik.core.models import Group # Auto-assign to customer group on registration group = Group.objects.filter(name="{groupName}").first() if group and context.get("pending_user"): context["pending_user"].ak_groups.add(group) return True """; var groupAssignPolicyPk = await _authentik.CreateExpressionPolicyAsync( groupAssignPolicyName, groupAssignExpression, ct); // Bind policy to the UserWrite stage binding (order 30) if (userWriteStagePk != null) { var userWriteBindingPk = await _authentik.GetFlowStageBindingPkAsync(flowSlug, 30, ct); if (userWriteBindingPk != null) { await _authentik.BindPolicyToFlowStageBoundAsync(userWriteBindingPk, groupAssignPolicyPk, ct); _logger.LogInformation("[InviteSetup] Group-assignment policy bound to UserWrite stage"); } else { _logger.LogWarning("[InviteSetup] Could not find flow-stage binding at order 30 to attach group policy"); } } // ═══════════════════════════════════════════════════════════════════ // Step 5: Create invite-manager role with permissions // ═══════════════════════════════════════════════════════════════════ _logger.LogInformation("[InviteSetup] Step 5/6: Creating invite-manager role '{Role}'", roleName); var rolePk = await _authentik.CreateRoleAsync(roleName, ct); // Assign invitation CRUD permissions var invitationPermissions = new[] { "add_invitation", "view_invitation", "delete_invitation", }; await _authentik.AssignPermissionsToRoleAsync(rolePk, invitationPermissions, ct); // Assign role to the customer group await _authentik.AssignRoleToGroupAsync(rolePk, groupPk, ct); _logger.LogInformation("[InviteSetup] Role '{Role}' assigned to group '{Group}'", roleName, groupName); // ═══════════════════════════════════════════════════════════════════ // Step 6: Create scoping policy and bind to flow // ═══════════════════════════════════════════════════════════════════ _logger.LogInformation("[InviteSetup] Step 6/6: Creating invitation scoping policy"); var scopePolicyName = $"scope-invitations-to-{abbrev}"; var scopeExpression = $""" # Only allow users in {groupName} group to manage these invitations user = context.get("pending_user") or request.user if user.ak_groups.filter(name="{groupName}").exists(): return True return False """; var scopePolicyPk = await _authentik.CreateExpressionPolicyAsync(scopePolicyName, scopeExpression, ct); // Bind scoping policy to the enrollment flow await _authentik.BindPolicyToFlowAsync(flowSlug, scopePolicyPk, ct); _logger.LogInformation("[InviteSetup] Scoping policy bound to enrollment flow"); // ═══════════════════════════════════════════════════════════════════ // Build result with management URL // ═══════════════════════════════════════════════════════════════════ var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl); var managementUrl = !string.IsNullOrWhiteSpace(authentikUrl) ? $"{authentikUrl.TrimEnd('/')}/if/admin/#/core/invitations" : null; var result = new InvitationSetupResult { Success = true, Message = $"Invitation infrastructure created for {customerName}. " + $"Group: {groupName}, Flow: {flowSlug}, Role: {roleName}.", GroupName = groupName, EnrollmentFlowSlug = flowSlug, RoleName = roleName, InvitationManagementUrl = managementUrl, }; _logger.LogInformation( "[InviteSetup] Setup complete for '{Customer}': group={Group}, flow={Flow}, role={Role}, url={Url}", customerName, groupName, flowSlug, roleName, managementUrl ?? "(no URL)"); return result; } catch (Exception ex) { _logger.LogError(ex, "[InviteSetup] Invitation setup failed for '{Customer}' (abbrev={Abbrev}): {Message}", customerName, abbrev, ex.Message); return new InvitationSetupResult { Success = false, Message = $"Invitation setup failed: {ex.Message}", GroupName = groupName, EnrollmentFlowSlug = flowSlug, RoleName = roleName, }; } } }