diff --git a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs index c92a111..4e1d748 100644 --- a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs @@ -104,6 +104,10 @@ public class AuthentikService : IAuthentikService // ── 5b. Ensure property mappings are attached (required for valid SAML responses) ── await EnsureProviderHasPropertyMappingsAsync(client, providerId, ct); + // ── 5c. Ensure usertypeid property mapping is created and attached ── + // (for admin group assignment via SAML) + await EnsureUserTypeidMappingAsync(client, providerId, ct); + // ── 6. Fetch and parse metadata ─────────────────────────────────── var config = await FetchAndParseMetadataAsync(client, baseUrl, providerId, ct); config.ApplicationSlug = slug; @@ -182,13 +186,46 @@ public class AuthentikService : IAuthentikService { var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey); + // Authentik has moved the groups endpoint between versions. + // Try each known path until one succeeds. + var endpoints = new[] + { + "/api/v3/core/groups/", + "/api/v3/groups/", + "/api/v3/core/group/", + }; + + string? workingEndpoint = null; + foreach (var ep in endpoints) + { + var probe = await client.GetAsync($"{ep}?page_size=1", ct); + if (probe.IsSuccessStatusCode) + { + workingEndpoint = ep; + _logger.LogDebug("[Authentik] Groups endpoint resolved: {Endpoint}", ep); + break; + } + _logger.LogDebug("[Authentik] Groups endpoint {Endpoint} returned HTTP {Status}", ep, (int)probe.StatusCode); + } + + if (workingEndpoint == null) + { + _logger.LogWarning("[Authentik] No working groups endpoint found — tried: {Endpoints}", + string.Join(", ", endpoints)); + return new List(); + } + var groups = new List(); - var nextUrl = "/api/v3/core/groups/?page_size=200"; + var nextUrl = $"{workingEndpoint}?page_size=200"; while (!string.IsNullOrEmpty(nextUrl)) { var resp = await client.GetAsync(nextUrl, ct); - resp.EnsureSuccessStatusCode(); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[Authentik] Groups page request failed (HTTP {Status})", (int)resp.StatusCode); + break; + } var body = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(body); @@ -223,7 +260,7 @@ public class AuthentikService : IAuthentikService nextUrl = root.TryGetProperty("pagination", out var pagination) && pagination.TryGetProperty("next", out var nextProp) && nextProp.ValueKind == JsonValueKind.Number - ? $"/api/v3/core/groups/?page_size=200&page={nextProp.GetInt32()}" + ? $"{workingEndpoint}?page_size=200&page={nextProp.GetInt32()}" : null; } @@ -561,6 +598,213 @@ public class AuthentikService : IAuthentikService } } + /// + /// Ensures that the SAML property mapping for usertypeid (group-based admin assignment) exists. + /// Creates it if missing and attaches it to the provider. + /// The mapping returns "1" (super-admin) for users in the "OTS IT" group, empty string otherwise. + /// + private async Task EnsureUserTypeidMappingAsync( + HttpClient client, int providerId, CancellationToken ct) + { + try + { + _logger.LogInformation("[Authentik] Ensuring usertypeid property mapping exists for provider {Id}", providerId); + + // ── 1. Try to find existing mapping by name ──────────────────────── + var mappingId = await TryFindUserTypeidMappingAsync(client, ct); + if (!string.IsNullOrEmpty(mappingId)) + { + _logger.LogInformation("[Authentik] Found existing usertypeid mapping: {Id}", mappingId); + } + else + { + // ── 2. Create new mapping ──────────────────────────────────── + mappingId = await CreateUserTypeidMappingAsync(client, ct); + if (string.IsNullOrEmpty(mappingId)) + { + _logger.LogWarning("[Authentik] Could not create usertypeid mapping"); + return; + } + } + + // ── 3. Attach mapping to provider ──────────────────────────────── + await AttachUserTypeidMappingToProviderAsync(client, providerId, mappingId, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Authentik] Could not ensure usertypeid mapping for provider {Id}", providerId); + } + } + + /// + /// Looks for an existing SAML property mapping named "saml-usertypeid". + /// Returns its ID if found, null otherwise. + /// + private async Task TryFindUserTypeidMappingAsync(HttpClient client, CancellationToken ct) + { + try + { + var endpoints = new[] + { + "/api/v3/propertymappings/provider/saml/?page_size=200", + "/api/v3/propertymappings/saml/?page_size=200", + }; + + foreach (var endpoint in endpoints) + { + try + { + var resp = await client.GetAsync(endpoint, ct); + if (!resp.IsSuccessStatusCode) continue; + + var json = await resp.Content.ReadFromJsonAsync>(cancellationToken: ct); + var match = json?.Results?.FirstOrDefault(m => + string.Equals(m.Name, "saml-usertypeid", StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + _logger.LogDebug("[Authentik] Found usertypeid mapping: {Id} ({Name})", match.Pk, match.Name); + return match.Pk; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Error searching property mappings at {Endpoint}", endpoint); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Could not search for existing usertypeid mapping"); + } + + return null; + } + + /// + /// Creates a new SAML property mapping named "saml-usertypeid" that returns "1" + /// for users in the "OTS IT" group and empty string otherwise. + /// + private async Task CreateUserTypeidMappingAsync(HttpClient client, CancellationToken ct) + { + try + { + _logger.LogInformation("[Authentik] Creating usertypeid property mapping"); + + var expression = @"return ""1"" if user.groups.all() | selectattr(""name"", ""equalto"", ""OTS IT"") else """""; + + var payload = new Dictionary + { + ["name"] = "saml-usertypeid", + ["saml_name"] = "usertypeid", + ["expression"] = expression, + }; + + var jsonBody = JsonSerializer.Serialize(payload); + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + var resp = await client.PostAsync("/api/v3/propertymappings/provider/saml/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("[Authentik] Failed to create usertypeid mapping (HTTP {Status}): {Error}", + (int)resp.StatusCode, errorBody); + return null; + } + + var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); + if (result?.Pk != null) + { + _logger.LogInformation("[Authentik] Created usertypeid mapping: {Id}", result.Pk); + return result.Pk; + } + + _logger.LogWarning("[Authentik] Created usertypeid mapping but response had no Pk"); + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Authentik] Error creating usertypeid mapping"); + return null; + } + } + + /// + /// Attaches the usertypeid property mapping to the given SAML provider + /// so it's included in outgoing SAML assertions. + /// + private async Task AttachUserTypeidMappingToProviderAsync( + HttpClient client, int providerId, string mappingId, CancellationToken ct) + { + try + { + _logger.LogInformation("[Authentik] Attaching usertypeid mapping {Id} to provider {ProviderId}", + mappingId, providerId); + + // ── 1. Fetch current provider to get existing mappings ─────────── + var getResp = await client.GetAsync($"/api/v3/providers/saml/{providerId}/", ct); + if (!getResp.IsSuccessStatusCode) + { + _logger.LogWarning("[Authentik] Could not fetch provider {Id} (HTTP {Status})", + providerId, (int)getResp.StatusCode); + return; + } + + var body = await getResp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var mappings = new List(); + + // ── 2. Get existing mappings ─────────────────────────────────── + if (doc.RootElement.TryGetProperty("property_mappings", out var mapsProp) && + mapsProp.ValueKind == JsonValueKind.Array) + { + foreach (var mapEl in mapsProp.EnumerateArray()) + { + var mapId = mapEl.ValueKind == JsonValueKind.String + ? mapEl.GetString() + : (mapEl.TryGetProperty("pk", out var pkProp) + ? pkProp.GetString() + : null); + + if (!string.IsNullOrEmpty(mapId) && !mappings.Contains(mapId)) + mappings.Add(mapId); + } + } + + // ── 3. Add new mapping if not already present ────────────────── + if (!mappings.Contains(mappingId)) + mappings.Add(mappingId); + + // ── 4. Patch provider with updated mappings ─────────────────── + var patchPayload = JsonSerializer.Serialize(new Dictionary + { + ["property_mappings"] = mappings, + }); + var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); + var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/providers/saml/{providerId}/") + { + Content = patchContent, + }; + + var patchResp = await client.SendAsync(patchReq, ct); + if (patchResp.IsSuccessStatusCode) + { + _logger.LogInformation("[Authentik] Usertypeid mapping attached to provider {Id}", providerId); + } + else + { + var err = await patchResp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("[Authentik] Failed to attach usertypeid mapping to provider {Id} (HTTP {Status}): {Error}", + providerId, (int)patchResp.StatusCode, err); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Authentik] Error attaching usertypeid mapping to provider {Id}", providerId); + } + } + // ───────────────────────────────────────────────────────────────────────── // Flow resolution // ───────────────────────────────────────────────────────────────────────── @@ -997,6 +1241,592 @@ public class AuthentikService : IAuthentikService return config; } + // ───────────────────────────────────────────────────────────────────────── + // Customer invitation infrastructure + // ───────────────────────────────────────────────────────────────────────── + + /// + public async Task CreateGroupAsync(string groupName, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Creating group '{Name}'", groupName); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Check if the group already exists + var existingPk = await TryGetGroupPkByNameAsync(client, groupName, ct); + if (existingPk != null) + { + _logger.LogInformation("[Authentik] Group '{Name}' already exists (pk={Pk})", groupName, existingPk); + return existingPk; + } + + var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = groupName }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/core/groups/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException($"Failed to create Authentik group '{groupName}' (HTTP {(int)resp.StatusCode}): {error}"); + } + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var pk = doc.RootElement.GetProperty("pk").GetString() + ?? throw new InvalidOperationException("Authentik returned null PK for created group."); + + _logger.LogInformation("[Authentik] Group '{Name}' created (pk={Pk})", groupName, pk); + return pk; + } + + /// + public async Task CreateInvitationStageAsync( + string stageName, bool continueWithoutInvitation = false, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Creating invitation stage '{Name}'", stageName); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Check if stage already exists + var existingPk = await FindStageByNameAsync(stageName, ct); + if (existingPk != null) + { + _logger.LogInformation("[Authentik] Invitation stage '{Name}' already exists (pk={Pk})", stageName, existingPk); + return existingPk; + } + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["name"] = stageName, + ["continue_flow_without_invitation"] = continueWithoutInvitation, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/stages/invitation/stages/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create invitation stage '{stageName}' (HTTP {(int)resp.StatusCode}): {error}"); + } + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var pk = doc.RootElement.GetProperty("pk").GetString() + ?? throw new InvalidOperationException("Authentik returned null PK for created invitation stage."); + + _logger.LogInformation("[Authentik] Invitation stage '{Name}' created (pk={Pk})", stageName, pk); + return pk; + } + + /// + public async Task CreateEnrollmentFlowAsync(string name, string slug, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Creating enrollment flow '{Name}' (slug={Slug})", name, slug); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Check if flow already exists by slug + var existingPk = await TryGetFlowPkBySlugAsync(client, slug, ct); + if (existingPk != null) + { + _logger.LogInformation("[Authentik] Flow '{Slug}' already exists (pk={Pk})", slug, existingPk); + return existingPk; + } + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["name"] = name, + ["slug"] = slug, + ["designation"] = "enrollment", + ["title"] = name, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/flows/instances/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create enrollment flow '{slug}' (HTTP {(int)resp.StatusCode}): {error}"); + } + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var pk = doc.RootElement.GetProperty("pk").GetString() + ?? throw new InvalidOperationException("Authentik returned null PK for created flow."); + + _logger.LogInformation("[Authentik] Enrollment flow '{Slug}' created (pk={Pk})", slug, pk); + return pk; + } + + /// + public async Task BindStageToFlowAsync(string flowSlug, string stagePk, int order, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Binding stage {StagePk} to flow '{Slug}' at order {Order}", stagePk, flowSlug, order); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Check if binding already exists at this order + var existingBinding = await GetFlowStageBindingPkAsync(flowSlug, order, ct); + if (existingBinding != null) + { + _logger.LogInformation("[Authentik] Flow '{Slug}' already has a stage binding at order {Order} — skipping", flowSlug, order); + return; + } + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["target"] = flowSlug, + ["stage"] = stagePk, + ["order"] = order, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/flows/bindings/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to bind stage to flow '{flowSlug}' at order {order} (HTTP {(int)resp.StatusCode}): {error}"); + } + + _logger.LogInformation("[Authentik] Stage bound to flow '{Slug}' at order {Order}", flowSlug, order); + } + + /// + public async Task CreateExpressionPolicyAsync(string name, string expression, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Creating expression policy '{Name}'", name); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Check if policy already exists + var existingPk = await TryGetPolicyPkByNameAsync(client, name, ct); + if (existingPk != null) + { + _logger.LogInformation("[Authentik] Expression policy '{Name}' already exists (pk={Pk})", name, existingPk); + return existingPk; + } + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["name"] = name, + ["expression"] = expression, + ["execution_logging"] = true, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/policies/expression/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create expression policy '{name}' (HTTP {(int)resp.StatusCode}): {error}"); + } + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var pk = doc.RootElement.GetProperty("pk").GetString() + ?? throw new InvalidOperationException("Authentik returned null PK for created policy."); + + _logger.LogInformation("[Authentik] Expression policy '{Name}' created (pk={Pk})", name, pk); + return pk; + } + + /// + public async Task BindPolicyToFlowStageBoundAsync( + string flowStageBindingPk, string policyPk, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Binding policy {PolicyPk} to flow-stage binding {FsbPk}", policyPk, flowStageBindingPk); + var (_, client) = await CreateAuthenticatedClientAsync(); + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["target"] = flowStageBindingPk, + ["policy"] = policyPk, + ["order"] = 0, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/policies/bindings/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to bind policy to flow-stage binding (HTTP {(int)resp.StatusCode}): {error}"); + } + + _logger.LogInformation("[Authentik] Policy bound to flow-stage binding"); + } + + /// + public async Task BindPolicyToFlowAsync(string flowSlug, string policyPk, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Binding policy {PolicyPk} to flow '{Slug}'", policyPk, flowSlug); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Get the flow PK from slug + var flowPk = await TryGetFlowPkBySlugAsync(client, flowSlug, ct) + ?? throw new InvalidOperationException($"Flow '{flowSlug}' not found."); + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["target"] = flowPk, + ["policy"] = policyPk, + ["order"] = 0, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/policies/bindings/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to bind policy to flow '{flowSlug}' (HTTP {(int)resp.StatusCode}): {error}"); + } + + _logger.LogInformation("[Authentik] Policy bound to flow '{Slug}'", flowSlug); + } + + /// + public async Task CreateRoleAsync(string roleName, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Creating role '{Name}'", roleName); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Check if role already exists + var existingPk = await TryGetRolePkByNameAsync(client, roleName, ct); + if (existingPk != null) + { + _logger.LogInformation("[Authentik] Role '{Name}' already exists (pk={Pk})", roleName, existingPk); + return existingPk; + } + + var payload = JsonSerializer.Serialize(new Dictionary { ["name"] = roleName }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync("/api/v3/rbac/roles/", content, ct); + + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create Authentik role '{roleName}' (HTTP {(int)resp.StatusCode}): {error}"); + } + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var pk = doc.RootElement.GetProperty("pk").GetString() + ?? throw new InvalidOperationException("Authentik returned null PK for created role."); + + _logger.LogInformation("[Authentik] Role '{Name}' created (pk={Pk})", roleName, pk); + return pk; + } + + /// + public async Task AssignPermissionsToRoleAsync( + string rolePk, IEnumerable permissionCodenames, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Assigning permissions to role {RolePk}", rolePk); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Resolve permission IDs from codenames + var permIds = await ResolvePermissionIdsAsync(client, permissionCodenames, ct); + if (permIds.Count == 0) + { + _logger.LogWarning("[Authentik] No permission IDs resolved — skipping assignment"); + return; + } + + var payload = JsonSerializer.Serialize(new Dictionary + { + ["permissions"] = permIds, + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var resp = await client.PostAsync($"/api/v3/rbac/roles/{rolePk}/assign_permission/", content, ct); + + // Some Authentik versions use a PATCH or different endpoint structure. + // Try alternative if POST fails. + if (!resp.IsSuccessStatusCode) + { + // Fallback: try assigning via the permissions endpoint with model/object_pk + _logger.LogDebug("[Authentik] Direct permission assignment returned HTTP {Status}, trying alternative approach", + (int)resp.StatusCode); + + foreach (var permId in permIds) + { + var singlePayload = JsonSerializer.Serialize(new Dictionary + { + ["permission"] = permId, + ["role"] = rolePk, + }); + var singleContent = new StringContent(singlePayload, Encoding.UTF8, "application/json"); + var singleResp = await client.PostAsync("/api/v3/rbac/permissions/assigned_by_roles/assign/", singleContent, ct); + if (!singleResp.IsSuccessStatusCode) + { + var error = await singleResp.Content.ReadAsStringAsync(ct); + _logger.LogWarning("[Authentik] Failed to assign permission {PermId} to role (HTTP {Status}): {Error}", + permId, (int)singleResp.StatusCode, error); + } + } + } + + _logger.LogInformation("[Authentik] Permissions assigned to role {RolePk}", rolePk); + } + + /// + public async Task AssignRoleToGroupAsync(string rolePk, string groupPk, CancellationToken ct = default) + { + _logger.LogInformation("[Authentik] Assigning role {RolePk} to group {GroupPk}", rolePk, groupPk); + var (_, client) = await CreateAuthenticatedClientAsync(); + + // Authentik manages role→group mapping via the group's roles list + // PATCH the group to add the role + var getResp = await client.GetAsync($"/api/v3/core/groups/{groupPk}/", ct); + if (!getResp.IsSuccessStatusCode) + { + var error = await getResp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to fetch group {groupPk} (HTTP {(int)getResp.StatusCode}): {error}"); + } + + var groupBody = await getResp.Content.ReadAsStringAsync(ct); + using var groupDoc = JsonDocument.Parse(groupBody); + var existingRoles = new List(); + + if (groupDoc.RootElement.TryGetProperty("roles", out var rolesProp) && + rolesProp.ValueKind == JsonValueKind.Array) + { + foreach (var r in rolesProp.EnumerateArray()) + { + var val = r.GetString(); + if (!string.IsNullOrEmpty(val)) + existingRoles.Add(val); + } + } + + if (!existingRoles.Contains(rolePk)) + existingRoles.Add(rolePk); + + var patchPayload = JsonSerializer.Serialize(new Dictionary + { + ["roles"] = existingRoles, + }); + var patchContent = new StringContent(patchPayload, Encoding.UTF8, "application/json"); + var patchReq = new HttpRequestMessage(HttpMethod.Patch, $"/api/v3/core/groups/{groupPk}/") + { + Content = patchContent, + }; + + var patchResp = await client.SendAsync(patchReq, ct); + if (!patchResp.IsSuccessStatusCode) + { + var error = await patchResp.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to assign role to group (HTTP {(int)patchResp.StatusCode}): {error}"); + } + + _logger.LogInformation("[Authentik] Role {RolePk} assigned to group {GroupPk}", rolePk, groupPk); + } + + /// + public async Task GetFlowStageBindingPkAsync(string flowSlug, int order, CancellationToken ct = default) + { + var (_, client) = await CreateAuthenticatedClientAsync(); + var resp = await client.GetAsync($"/api/v3/flows/bindings/?target={flowSlug}&ordering=order", ct); + + if (!resp.IsSuccessStatusCode) return null; + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var binding in results.EnumerateArray()) + { + if (binding.TryGetProperty("order", out var orderProp) && orderProp.GetInt32() == order) + { + return binding.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; + } + } + } + + return null; + } + + /// + public async Task FindStageByNameAsync(string nameContains, CancellationToken ct = default) + { + var (_, client) = await CreateAuthenticatedClientAsync(); + var resp = await client.GetAsync($"/api/v3/stages/all/?search={Uri.EscapeDataString(nameContains)}&page_size=50", ct); + + if (!resp.IsSuccessStatusCode) return null; + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var stage in results.EnumerateArray()) + { + var name = stage.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + if (name != null && name.Contains(nameContains, StringComparison.OrdinalIgnoreCase)) + { + return stage.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; + } + } + } + + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Invitation infrastructure helpers + // ───────────────────────────────────────────────────────────────────────── + + private async Task TryGetGroupPkByNameAsync(HttpClient client, string name, CancellationToken ct) + { + try + { + var resp = await client.GetAsync($"/api/v3/core/groups/?search={Uri.EscapeDataString(name)}&page_size=50", ct); + if (!resp.IsSuccessStatusCode) return null; + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var g in results.EnumerateArray()) + { + var gName = g.TryGetProperty("name", out var nProp) ? nProp.GetString() : null; + if (string.Equals(gName, name, StringComparison.OrdinalIgnoreCase)) + return g.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Error checking for existing group '{Name}'", name); + } + return null; + } + + private async Task TryGetFlowPkBySlugAsync(HttpClient client, string slug, CancellationToken ct) + { + try + { + var resp = await client.GetAsync($"/api/v3/flows/instances/?slug={Uri.EscapeDataString(slug)}", ct); + if (!resp.IsSuccessStatusCode) return null; + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var f in results.EnumerateArray()) + { + var fSlug = f.TryGetProperty("slug", out var sProp) ? sProp.GetString() : null; + if (string.Equals(fSlug, slug, StringComparison.OrdinalIgnoreCase)) + return f.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Error checking for existing flow '{Slug}'", slug); + } + return null; + } + + private async Task TryGetPolicyPkByNameAsync(HttpClient client, string name, CancellationToken ct) + { + try + { + var resp = await client.GetAsync($"/api/v3/policies/expression/?search={Uri.EscapeDataString(name)}&page_size=50", ct); + if (!resp.IsSuccessStatusCode) return null; + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var p in results.EnumerateArray()) + { + var pName = p.TryGetProperty("name", out var nProp) ? nProp.GetString() : null; + if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase)) + return p.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Error checking for existing policy '{Name}'", name); + } + return null; + } + + private async Task TryGetRolePkByNameAsync(HttpClient client, string name, CancellationToken ct) + { + try + { + var resp = await client.GetAsync($"/api/v3/rbac/roles/?search={Uri.EscapeDataString(name)}&page_size=50", ct); + if (!resp.IsSuccessStatusCode) return null; + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var r in results.EnumerateArray()) + { + var rName = r.TryGetProperty("name", out var nProp) ? nProp.GetString() : null; + if (string.Equals(rName, name, StringComparison.OrdinalIgnoreCase)) + return r.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() : null; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[Authentik] Error checking for existing role '{Name}'", name); + } + return null; + } + + private async Task> ResolvePermissionIdsAsync( + HttpClient client, IEnumerable codenames, CancellationToken ct) + { + var ids = new List(); + var codenameSet = new HashSet(codenames, StringComparer.OrdinalIgnoreCase); + + try + { + var resp = await client.GetAsync("/api/v3/rbac/permissions/?page_size=500", ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[Authentik] Could not list permissions (HTTP {Status})", (int)resp.StatusCode); + return ids; + } + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var perm in results.EnumerateArray()) + { + var codename = perm.TryGetProperty("codename", out var cnProp) ? cnProp.GetString() : null; + if (codename != null && codenameSet.Contains(codename)) + { + if (perm.TryGetProperty("id", out var idProp) && idProp.TryGetInt32(out var id)) + ids.Add(id); + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Authentik] Error resolving permission IDs"); + } + + _logger.LogDebug("[Authentik] Resolved {Count}/{Total} permission ID(s)", ids.Count, codenameSet.Count); + return ids; + } + // ───────────────────────────────────────────────────────────────────────── // API response DTOs (internal) // ───────────────────────────────────────────────────────────────────────── diff --git a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs index f98fb1d..2d23ca5 100644 --- a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs @@ -51,4 +51,76 @@ public interface IAuthentikService string? overrideUrl = null, string? overrideApiKey = null, CancellationToken ct = default); + + // ───────────────────────────────────────────────────────────────────────── + // Customer invitation infrastructure + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Creates a group in Authentik with the given name. + /// Returns the group PK (UUID string). If a group with that name already exists, returns its PK. + /// + Task CreateGroupAsync(string groupName, CancellationToken ct = default); + + /// + /// Creates an invitation stage in Authentik. + /// Returns the stage PK. If a stage with that name already exists, returns its PK. + /// + Task CreateInvitationStageAsync(string stageName, bool continueWithoutInvitation = false, CancellationToken ct = default); + + /// + /// Creates an enrollment flow in Authentik with the given name and slug. + /// Returns the flow PK (UUID string). If a flow with that slug already exists, returns its PK. + /// + Task CreateEnrollmentFlowAsync(string name, string slug, CancellationToken ct = default); + + /// + /// Binds a stage to a flow at the specified order. + /// + Task BindStageToFlowAsync(string flowSlug, string stagePk, int order, CancellationToken ct = default); + + /// + /// Creates an expression policy in Authentik. + /// Returns the policy PK. If a policy with that name already exists, returns its PK. + /// + Task CreateExpressionPolicyAsync(string name, string expression, CancellationToken ct = default); + + /// + /// Binds a policy to a flow stage binding (so it executes when that stage runs). + /// + Task BindPolicyToFlowStageBoundAsync(string flowStageBindingPk, string policyPk, CancellationToken ct = default); + + /// + /// Binds a policy to a flow (policy/group/user binding tab). + /// + Task BindPolicyToFlowAsync(string flowSlug, string policyPk, CancellationToken ct = default); + + /// + /// Creates a role in Authentik with the given name. + /// Returns the role PK. If a role with that name already exists, returns its PK. + /// + Task CreateRoleAsync(string roleName, CancellationToken ct = default); + + /// + /// Assigns a set of permissions to a role. + /// Permission codenames follow Django format, e.g. "add_invitation", "view_invitation". + /// + Task AssignPermissionsToRoleAsync(string rolePk, IEnumerable permissionCodenames, CancellationToken ct = default); + + /// + /// Assigns a role to a group so all members of the group inherit the role's permissions. + /// + Task AssignRoleToGroupAsync(string rolePk, string groupPk, CancellationToken ct = default); + + /// + /// Finds the flow-stage binding PK for a specific stage bound to a flow at a given order. + /// Returns null if not found. + /// + Task GetFlowStageBindingPkAsync(string flowSlug, int order, CancellationToken ct = default); + + /// + /// Looks up a built-in Authentik stage by partial name match (e.g. "default-enrollment-prompt"). + /// Returns the stage PK, or null if not found. + /// + Task FindStageByNameAsync(string nameContains, CancellationToken ct = default); } diff --git a/OTSSignsOrchestrator.Core/Services/IInvitationSetupService.cs b/OTSSignsOrchestrator.Core/Services/IInvitationSetupService.cs new file mode 100644 index 0000000..0bf191f --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/IInvitationSetupService.cs @@ -0,0 +1,57 @@ +namespace OTSSignsOrchestrator.Core.Services; + +/// +/// Orchestrates the complete Authentik invitation infrastructure setup for a customer. +/// Creates a group, enrollment flow with stages, role with invitation permissions, +/// and scoping policies so the customer admin can invite new users without OTS involvement. +/// +public interface IInvitationSetupService +{ + /// + /// Sets up the full invitation infrastructure for a customer in Authentik: + /// + /// Create customer group (e.g. customer-acme). + /// Create invitation stage (invite-only, no anonymous enrollment). + /// Create enrollment flow with stages: Invitation → Prompt → UserWrite → UserLogin. + /// Bind expression policy to UserWrite stage to auto-assign users to the customer group. + /// Create invite-manager role with invitation CRUD permissions. + /// Assign role to customer group and bind scoping policy to flow. + /// + /// All operations are idempotent — safe to call multiple times for the same customer. + /// + /// Short customer identifier (e.g. "acme"). + /// Human-readable customer name (e.g. "Acme Corp"). + /// Cancellation token. + /// Result describing what was created and the enrollment flow URL. + Task SetupCustomerInvitationAsync( + string customerAbbrev, + string customerName, + CancellationToken ct = default); +} + +/// +/// Result of the invitation infrastructure setup. +/// +public class InvitationSetupResult +{ + /// Whether the setup completed successfully. + public bool Success { get; set; } + + /// Human-readable status message. + public string Message { get; set; } = string.Empty; + + /// Name of the customer group created in Authentik. + public string GroupName { get; set; } = string.Empty; + + /// Slug of the enrollment flow (used in invite links). + public string EnrollmentFlowSlug { get; set; } = string.Empty; + + /// Name of the role created for invitation management. + public string RoleName { get; set; } = string.Empty; + + /// + /// Full URL to the Authentik user portal where the customer admin + /// can manage invitations. + /// + public string? InvitationManagementUrl { get; set; } +} diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs index ffea94b..71c6300 100644 --- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs +++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs @@ -253,7 +253,7 @@ public class InstanceService // This must happen before the stack is deployed so Xibo starts with SAML // authentication already configured. var instanceUrlForSaml = $"https://{cmsServerName}/{abbrev}"; - await _postInit.DeploySamlConfigurationAsync(abbrev, instanceUrlForSaml, _settings, default); + await _postInit.DeploySamlConfigurationAsync(abbrev, instanceUrlForSaml, _settings, dto.CustomerName); // ── 6. Deploy stack ───────────────────────────────────────────── var deployResult = await _docker.DeployStackAsync(stackName, composeYaml); diff --git a/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs b/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs new file mode 100644 index 0000000..5825e24 --- /dev/null +++ b/OTSSignsOrchestrator.Core/Services/InvitationSetupService.cs @@ -0,0 +1,230 @@ +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, + }; + } + } +} diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs index 1bf5d27..2a83e12 100644 --- a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs +++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs @@ -351,7 +351,8 @@ public class PostInstanceInitService string abbrev, string instanceUrl, SettingsService settings, - CancellationToken ct) + string? customerName = null, + CancellationToken ct = default) { try { @@ -465,6 +466,11 @@ public class PostInstanceInitService // Pre-create Authentik groups as Xibo user groups so they're available // immediately (before any user logs in via SSO). await SyncGroupsFromAuthentikAsync(abbrev, instanceUrl, settings, ct); + + // ── 6. Set up customer invitation infrastructure in Authentik ───── + // Creates a group, enrollment flow, invitation stage, role, and + // scoping policies so the customer admin can invite users directly. + await SetupCustomerInvitationInfrastructureAsync(abbrev, customerName, ct); } catch (Exception ex) { @@ -474,9 +480,57 @@ public class PostInstanceInitService } } + /// + /// Sets up the customer invitation infrastructure in Authentik (group, enrollment flow, + /// stages, role, and policies). Errors are logged but do not block other operations. + /// + private async Task SetupCustomerInvitationInfrastructureAsync( + string abbrev, string? customerName, CancellationToken ct) + { + try + { + using var scope = _services.CreateScope(); + var invitationSetup = scope.ServiceProvider.GetRequiredService(); + + var displayName = !string.IsNullOrWhiteSpace(customerName) + ? customerName + : abbrev.ToUpperInvariant(); + + _logger.LogInformation("[PostInit] Setting up invitation infrastructure for {Abbrev}", abbrev); + var result = await invitationSetup.SetupCustomerInvitationAsync(abbrev, displayName, ct); + + if (result.Success) + { + _logger.LogInformation( + "[PostInit] Invitation infrastructure ready for {Abbrev}: group={Group}, flow={Flow}, role={Role}", + abbrev, result.GroupName, result.EnrollmentFlowSlug, result.RoleName); + + if (!string.IsNullOrWhiteSpace(result.InvitationManagementUrl)) + { + _logger.LogInformation( + "[PostInit] Customer admin invitation URL: {Url}", result.InvitationManagementUrl); + } + } + else + { + _logger.LogWarning("[PostInit] Invitation setup reported failure for {Abbrev}: {Message}", + abbrev, result.Message); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "[PostInit] Invitation infrastructure setup failed for {Abbrev}: {Message}. " + + "Customer invitations can be configured manually in Authentik.", abbrev, ex.Message); + // Don't rethrow — invitation setup failure should not block post-init + } + } + /// /// Fetches all groups from Authentik and creates matching user groups in the - /// specified Xibo instance. Groups that already exist in Xibo are skipped. + /// specified Xibo instance, excluding any groups listed in the + /// "SamlGroupSyncExcludedGroups" setting (comma-separated group names). + /// Groups that already exist in Xibo are skipped. /// This ensures that groups are available in Xibo for permission assignment /// before any user logs in via SAML SSO. /// @@ -505,6 +559,22 @@ public class PostInstanceInitService _logger.LogInformation("[GroupSync] Found {Count} Authentik group(s) to sync", authentikGroups.Count); + // ── 1b. Read excluded groups from settings ──────────────────────── + var excludedGroupsSetting = await settings.GetAsync("SamlGroupSyncExcludedGroups"); + var excludedGroups = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(excludedGroupsSetting)) + { + var excluded = excludedGroupsSetting + .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(g => g.Trim()) + .Where(g => !string.IsNullOrWhiteSpace(g)); + foreach (var g in excluded) + { + excludedGroups.Add(g); + } + _logger.LogInformation("[GroupSync] Excluded groups: {Groups}", string.Join(", ", excludedGroups)); + } + // ── 2. Authenticate to Xibo ─────────────────────────────────────── var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev)); var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)); @@ -525,9 +595,16 @@ public class PostInstanceInitService existingGroups.Select(g => g.Group), StringComparer.OrdinalIgnoreCase); - // ── 4. Create missing groups in Xibo ────────────────────────────── + // ── 4. Create missing groups in Xibo (excluding specified ones) ──── foreach (var group in authentikGroups) { + // Skip excluded groups + if (excludedGroups.Contains(group.Name)) + { + _logger.LogInformation("[GroupSync] Skipping excluded group '{Name}'", group.Name); + continue; + } + if (existingNames.Contains(group.Name)) { _logger.LogDebug("[GroupSync] Group '{Name}' already exists in Xibo", group.Name); diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs index 6ec1646..0a1f389 100644 --- a/OTSSignsOrchestrator.Desktop/App.axaml.cs +++ b/OTSSignsOrchestrator.Desktop/App.axaml.cs @@ -158,6 +158,7 @@ public class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(); // ViewModels diff --git a/templates/settings-custom.php.template b/templates/settings-custom.php.template index d70f7de..9833448 100644 --- a/templates/settings-custom.php.template +++ b/templates/settings-custom.php.template @@ -1,4 +1,30 @@ [ @@ -9,7 +35,9 @@ $samlSettings = [ 'slo' => true, 'mapping' => [ 'UserID' => '', - 'usertypeid' => '', + // usertypeid: Set to 1 (super-admin) for members of admin groups. + // Requires a custom SAML property mapping in Authentik (see notes above). + 'usertypeid' => 'http://schemas.goauthentik.io/2021/02/saml/usertypeid', 'UserName' => 'http://schemas.goauthentik.io/2021/02/saml/username', 'email' => 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', ], @@ -61,3 +89,5 @@ $samlSettings = [ 'wantNameIdEncrypted' => false, ], ]; + +// {{ EXCLUDED_GROUPS_COMMENT: Groups to exclude from Xibo sync: OTS IT }}