Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs
Matt Batchelder 150549a20d feat: Implement customer invitation infrastructure in Authentik
- Added IInvitationSetupService and InvitationSetupService to orchestrate the setup of invitation infrastructure for customers.
- Introduced methods for creating groups, enrollment flows, invitation stages, roles, and policies in Authentik.
- Updated PostInstanceInitService to call the new invitation setup methods during post-initialization.
- Enhanced InstanceService to pass customer name during SAML configuration deployment.
- Updated App.axaml.cs to register the new IInvitationSetupService.
- Modified settings-custom.php.template to include documentation for SAML authentication configuration with group-based admin assignment.
- Added logic to exclude specific groups from being synced to Xibo during group synchronization.
2026-03-04 21:58:59 -05:00

231 lines
13 KiB
C#

using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// 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 (<c>customer-{abbrev}</c>)
/// 2. An invitation stage (<c>{abbrev}-invitation-stage</c>)
/// 3. An enrollment flow (<c>{abbrev}-enrollment</c>) with stages bound in order
/// 4. An expression policy on the UserWrite stage to auto-assign users to the group
/// 5. A role (<c>{abbrev}-invite-manager</c>) with invitation CRUD permissions
/// 6. A scoping policy on the flow so only group members can access it
/// </summary>
public class InvitationSetupService : IInvitationSetupService
{
private readonly IAuthentikService _authentik;
private readonly SettingsService _settings;
private readonly ILogger<InvitationSetupService> _logger;
public InvitationSetupService(
IAuthentikService authentik,
SettingsService settings,
ILogger<InvitationSetupService> logger)
{
_authentik = authentik;
_settings = settings;
_logger = logger;
}
/// <inheritdoc />
public async Task<InvitationSetupResult> 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,
};
}
}
}