- 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.
231 lines
13 KiB
C#
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,
|
|
};
|
|
}
|
|
}
|
|
}
|