feat: Implement Authentik group synchronization and add confirmation dialogs for service management

This commit is contained in:
Matt Batchelder
2026-03-04 21:33:29 -05:00
parent 56d48b6062
commit 9493bdb9df
19 changed files with 556 additions and 21 deletions

View File

@@ -0,0 +1,18 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
/// <summary>
/// Represents an Authentik group for display and sync operations.
/// </summary>
public class AuthentikGroupItem
{
public string Pk { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int MemberCount { get; set; }
/// <summary>Display text for UI: "Group Name (N members)".</summary>
public string DisplayText => MemberCount > 0
? $"{Name} ({MemberCount} member{(MemberCount == 1 ? "" : "s")})"
: Name;
public override string ToString() => DisplayText;
}

View File

@@ -95,7 +95,7 @@ public class AuthentikService : IAuthentikService
}
// ── 4. Create application linked to provider ──────────────────
await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct);
await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, instanceBaseUrl, ct);
}
// ── 5. Ensure provider has a signing keypair (required for metadata) ──
@@ -175,6 +175,62 @@ public class AuthentikService : IAuthentikService
}).OrderBy(k => k.Name).ToList() ?? new();
}
/// <inheritdoc />
public async Task<List<AuthentikGroupItem>> ListGroupsAsync(
string? overrideUrl = null, string? overrideApiKey = null,
CancellationToken ct = default)
{
var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey);
var groups = new List<AuthentikGroupItem>();
var nextUrl = "/api/v3/core/groups/?page_size=200";
while (!string.IsNullOrEmpty(nextUrl))
{
var resp = await client.GetAsync(nextUrl, ct);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array)
{
foreach (var g in results.EnumerateArray())
{
var pk = g.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() ?? "" : "";
var name = g.TryGetProperty("name", out var nProp) ? nProp.GetString() ?? "" : "";
var memberCount = g.TryGetProperty("users_obj", out var usersObj) && usersObj.ValueKind == JsonValueKind.Array
? usersObj.GetArrayLength()
: (g.TryGetProperty("users", out var users) && users.ValueKind == JsonValueKind.Array
? users.GetArrayLength()
: 0);
// Skip Authentik built-in groups (authentik Admins, etc.)
if (!string.IsNullOrEmpty(name) && !name.StartsWith("authentik ", StringComparison.OrdinalIgnoreCase))
{
groups.Add(new AuthentikGroupItem
{
Pk = pk,
Name = name,
MemberCount = memberCount,
});
}
}
}
// Handle pagination
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()}"
: null;
}
_logger.LogInformation("[Authentik] Found {Count} group(s)", groups.Count);
return groups.OrderBy(g => g.Name).ToList();
}
// ─────────────────────────────────────────────────────────────────────────
// HTTP client setup
// ─────────────────────────────────────────────────────────────────────────
@@ -640,7 +696,7 @@ public class AuthentikService : IAuthentikService
private async Task CreateApplicationAsync(
HttpClient client, string baseUrl,
string abbrev, string slug, int providerId,
CancellationToken ct)
string instanceBaseUrl, CancellationToken ct)
{
_logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId);
@@ -649,6 +705,7 @@ public class AuthentikService : IAuthentikService
["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}",
["slug"] = slug,
["provider"] = providerId,
["meta_launch_url"] = instanceBaseUrl.TrimEnd('/'),
};
var jsonBody = JsonSerializer.Serialize(payload);

View File

@@ -42,4 +42,13 @@ public interface IAuthentikService
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
/// <summary>
/// Returns all groups from Authentik, optionally filtered to those with
/// at least one member. Used for syncing groups to Xibo instances.
/// </summary>
Task<List<AuthentikGroupItem>> ListGroupsAsync(
string? overrideUrl = null,
string? overrideApiKey = null,
CancellationToken ct = default);
}

View File

@@ -460,6 +460,11 @@ public class PostInstanceInitService
samlConfig != null
? $" (Authentik provider={samlConfig.ProviderId})"
: " (without Authentik — needs manual IdP config)");
// ── 5. Sync Authentik groups to Xibo ──────────────────────────────
// 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);
}
catch (Exception ex)
{
@@ -469,6 +474,89 @@ public class PostInstanceInitService
}
}
/// <summary>
/// Fetches all groups from Authentik and creates matching user groups in the
/// specified Xibo instance. 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>
public async Task<int> SyncGroupsFromAuthentikAsync(
string abbrev,
string instanceUrl,
SettingsService settings,
CancellationToken ct = default)
{
var synced = 0;
try
{
_logger.LogInformation("[GroupSync] Syncing Authentik groups to Xibo for {Abbrev}", abbrev);
using var scope = _services.CreateScope();
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
// ── 1. Fetch groups from Authentik ────────────────────────────────
var authentikGroups = await authentik.ListGroupsAsync(ct: ct);
if (authentikGroups.Count == 0)
{
_logger.LogInformation("[GroupSync] No groups found in Authentik — nothing to sync");
return 0;
}
_logger.LogInformation("[GroupSync] Found {Count} Authentik group(s) to sync", authentikGroups.Count);
// ── 2. Authenticate to Xibo ───────────────────────────────────────
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId))
{
_logger.LogWarning("[GroupSync] No OAuth credentials for {Abbrev} — cannot sync groups", abbrev);
return 0;
}
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
// ── 3. List existing Xibo groups ──────────────────────────────────
var existingGroups = await xibo.ListUserGroupsAsync(instanceUrl, accessToken);
var existingNames = new HashSet<string>(
existingGroups.Select(g => g.Group),
StringComparer.OrdinalIgnoreCase);
// ── 4. Create missing groups in Xibo ──────────────────────────────
foreach (var group in authentikGroups)
{
if (existingNames.Contains(group.Name))
{
_logger.LogDebug("[GroupSync] Group '{Name}' already exists in Xibo", group.Name);
continue;
}
try
{
var groupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, group.Name);
_logger.LogInformation("[GroupSync] Created Xibo group '{Name}' (id={Id})", group.Name, groupId);
synced++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[GroupSync] Failed to create Xibo group '{Name}'", group.Name);
}
}
_logger.LogInformation("[GroupSync] Sync complete for {Abbrev}: {Synced} group(s) created", abbrev, synced);
}
catch (Exception ex)
{
_logger.LogError(ex, "[GroupSync] Group sync failed for {Abbrev}: {Message}", abbrev, ex.Message);
// Don't rethrow — group sync failure should not block other operations
}
return synced;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -321,6 +321,60 @@ public class XiboApiService
// User groups
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Lists all user groups in the Xibo instance and returns their names and IDs.
/// </summary>
public async Task<List<XiboGroupInfo>> ListUserGroupsAsync(
string instanceUrl,
string accessToken)
{
var client = _httpClientFactory.CreateClient("XiboApi");
var baseUrl = instanceUrl.TrimEnd('/');
SetBearer(client, accessToken);
var response = await client.GetAsync($"{baseUrl}/api/group");
await EnsureSuccessAsync(response, "list Xibo user groups");
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
var groups = new List<XiboGroupInfo>();
foreach (var el in doc.RootElement.EnumerateArray())
{
groups.Add(new XiboGroupInfo
{
GroupId = el.GetProperty("groupId").GetInt32(),
Group = el.GetProperty("group").GetString() ?? "",
});
}
return groups;
}
/// <summary>
/// Finds an existing Xibo group by name or creates it if it doesn't exist.
/// Returns the group ID.
/// </summary>
public async Task<int> GetOrCreateUserGroupAsync(
string instanceUrl,
string accessToken,
string groupName)
{
// Try to find existing group first
var existing = await ListUserGroupsAsync(instanceUrl, accessToken);
var match = existing.FirstOrDefault(g =>
string.Equals(g.Group, groupName, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
_logger.LogDebug("Xibo group '{Name}' already exists (id={Id})", groupName, match.GroupId);
return match.GroupId;
}
// Create new group
return await CreateUserGroupAsync(instanceUrl, accessToken, groupName);
}
/// <summary>
/// Creates a new user group and returns its numeric group ID.
/// </summary>
@@ -529,6 +583,12 @@ public class XiboTestResult
public int HttpStatus { get; set; }
}
public class XiboGroupInfo
{
public int GroupId { get; set; }
public string Group { get; set; } = string.Empty;
}
public class XiboAuthException : Exception
{
public int HttpStatus { get; }