feat: Implement Authentik group synchronization and add confirmation dialogs for service management
This commit is contained in:
18
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs
Normal file
18
OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user