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.
This commit is contained in:
230
OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs
Normal file
230
OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user