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:
@@ -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<AuthentikGroupItem>();
|
||||
}
|
||||
|
||||
var groups = new List<AuthentikGroupItem>();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the SAML property mapping for <c>usertypeid</c> (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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks for an existing SAML property mapping named "saml-usertypeid".
|
||||
/// Returns its ID if found, null otherwise.
|
||||
/// </summary>
|
||||
private async Task<string?> 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<AuthentikListResponse<AuthentikPropertyMapping>>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SAML property mapping named "saml-usertypeid" that returns "1"
|
||||
/// for users in the "OTS IT" group and empty string otherwise.
|
||||
/// </summary>
|
||||
private async Task<string?> 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<string, object>
|
||||
{
|
||||
["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<AuthentikPropertyMapping>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the usertypeid property mapping to the given SAML provider
|
||||
/// so it's included in outgoing SAML assertions.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
|
||||
// ── 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<string, object>
|
||||
{
|
||||
["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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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<string, object> { ["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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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<string, object>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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<string, object>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, object>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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<string, object>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, object>
|
||||
{
|
||||
["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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, object>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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<string, object> { ["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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AssignPermissionsToRoleAsync(
|
||||
string rolePk, IEnumerable<string> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string>();
|
||||
|
||||
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<string, object>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> 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<string?> 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<string?> 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<string?> 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<string?> 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<List<int>> ResolvePermissionIdsAsync(
|
||||
HttpClient client, IEnumerable<string> codenames, CancellationToken ct)
|
||||
{
|
||||
var ids = new List<int>();
|
||||
var codenameSet = new HashSet<string>(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)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,4 +51,76 @@ public interface IAuthentikService
|
||||
string? overrideUrl = null,
|
||||
string? overrideApiKey = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Customer invitation infrastructure
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<string> CreateGroupAsync(string groupName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invitation stage in Authentik.
|
||||
/// Returns the stage PK. If a stage with that name already exists, returns its PK.
|
||||
/// </summary>
|
||||
Task<string> CreateInvitationStageAsync(string stageName, bool continueWithoutInvitation = false, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<string> CreateEnrollmentFlowAsync(string name, string slug, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Binds a stage to a flow at the specified order.
|
||||
/// </summary>
|
||||
Task BindStageToFlowAsync(string flowSlug, string stagePk, int order, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expression policy in Authentik.
|
||||
/// Returns the policy PK. If a policy with that name already exists, returns its PK.
|
||||
/// </summary>
|
||||
Task<string> CreateExpressionPolicyAsync(string name, string expression, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Binds a policy to a flow stage binding (so it executes when that stage runs).
|
||||
/// </summary>
|
||||
Task BindPolicyToFlowStageBoundAsync(string flowStageBindingPk, string policyPk, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Binds a policy to a flow (policy/group/user binding tab).
|
||||
/// </summary>
|
||||
Task BindPolicyToFlowAsync(string flowSlug, string policyPk, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a role in Authentik with the given name.
|
||||
/// Returns the role PK. If a role with that name already exists, returns its PK.
|
||||
/// </summary>
|
||||
Task<string> CreateRoleAsync(string roleName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Assigns a set of permissions to a role.
|
||||
/// Permission codenames follow Django format, e.g. "add_invitation", "view_invitation".
|
||||
/// </summary>
|
||||
Task AssignPermissionsToRoleAsync(string rolePk, IEnumerable<string> permissionCodenames, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Assigns a role to a group so all members of the group inherit the role's permissions.
|
||||
/// </summary>
|
||||
Task AssignRoleToGroupAsync(string rolePk, string groupPk, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the flow-stage binding PK for a specific stage bound to a flow at a given order.
|
||||
/// Returns null if not found.
|
||||
/// </summary>
|
||||
Task<string?> GetFlowStageBindingPkAsync(string flowSlug, int order, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<string?> FindStageByNameAsync(string nameContains, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IInvitationSetupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets up the full invitation infrastructure for a customer in Authentik:
|
||||
/// <list type="number">
|
||||
/// <item>Create customer group (e.g. <c>customer-acme</c>).</item>
|
||||
/// <item>Create invitation stage (invite-only, no anonymous enrollment).</item>
|
||||
/// <item>Create enrollment flow with stages: Invitation → Prompt → UserWrite → UserLogin.</item>
|
||||
/// <item>Bind expression policy to UserWrite stage to auto-assign users to the customer group.</item>
|
||||
/// <item>Create invite-manager role with invitation CRUD permissions.</item>
|
||||
/// <item>Assign role to customer group and bind scoping policy to flow.</item>
|
||||
/// </list>
|
||||
/// All operations are idempotent — safe to call multiple times for the same customer.
|
||||
/// </summary>
|
||||
/// <param name="customerAbbrev">Short customer identifier (e.g. "acme").</param>
|
||||
/// <param name="customerName">Human-readable customer name (e.g. "Acme Corp").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result describing what was created and the enrollment flow URL.</returns>
|
||||
Task<InvitationSetupResult> SetupCustomerInvitationAsync(
|
||||
string customerAbbrev,
|
||||
string customerName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the invitation infrastructure setup.
|
||||
/// </summary>
|
||||
public class InvitationSetupResult
|
||||
{
|
||||
/// <summary>Whether the setup completed successfully.</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Human-readable status message.</summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Name of the customer group created in Authentik.</summary>
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Slug of the enrollment flow (used in invite links).</summary>
|
||||
public string EnrollmentFlowSlug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Name of the role created for invitation management.</summary>
|
||||
public string RoleName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Full URL to the Authentik user portal where the customer admin
|
||||
/// can manage invitations.
|
||||
/// </summary>
|
||||
public string? InvitationManagementUrl { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the customer invitation infrastructure in Authentik (group, enrollment flow,
|
||||
/// stages, role, and policies). Errors are logged but do not block other operations.
|
||||
/// </summary>
|
||||
private async Task SetupCustomerInvitationInfrastructureAsync(
|
||||
string abbrev, string? customerName, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var invitationSetup = scope.ServiceProvider.GetRequiredService<IInvitationSetupService>();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
@@ -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<string>(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);
|
||||
|
||||
@@ -158,6 +158,7 @@ public class App : Application
|
||||
services.AddTransient<InstanceService>();
|
||||
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
|
||||
services.AddTransient<IAuthentikService, AuthentikService>();
|
||||
services.AddTransient<IInvitationSetupService, InvitationSetupService>();
|
||||
services.AddSingleton<PostInstanceInitService>();
|
||||
|
||||
// ViewModels
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* SAML Authentication Configuration with Group-Based Admin Assignment
|
||||
*
|
||||
* Group-Based Admin Assignment:
|
||||
* ────────────────────────────────────────────────────────────────────────
|
||||
* To make members of specific Authentik groups admins in Xibo:
|
||||
*
|
||||
* 1. In Authentik, create a custom property mapping for your SAML provider:
|
||||
* - Name: saml-usertypeid
|
||||
* - Expression: Return "1" if user in admin group, else return empty string
|
||||
* - Example: return "1" if user.groups.all() | selectattr("name", "equalto", "OTS IT") else ""
|
||||
*
|
||||
* 2. Attach this mapping to the SAML provider via the API or UI
|
||||
*
|
||||
* 3. The usertypeid mapping below will read this attribute from the SAML response
|
||||
*
|
||||
* 4. On JIT provisioning, Xibo will assign users with usertypeid=1 as super-admins
|
||||
*
|
||||
* Excluded Groups:
|
||||
* ────────────────────────────────────────────────────────────────────────
|
||||
* Groups listed in {{EXCLUDED_GROUPS}} are not synced to Xibo during provisioning.
|
||||
* However, users in excluded groups can still log in via SSO (they'll use the
|
||||
* default 'Users' group). Use this to prevent internal admin groups from appearing
|
||||
* as Xibo user groups.
|
||||
*/
|
||||
|
||||
$authentication = new \Xibo\Middleware\SAMLAuthentication();
|
||||
$samlSettings = [
|
||||
'workflow' => [
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user