Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Matt Batchelder 150549a20d 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.
2026-03-04 21:58:59 -05:00

765 lines
40 KiB
C#

using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Configuration;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
/// <list type="number">
/// <item>Waits for the Xibo web service to become available.</item>
/// <item>Authenticates using the OAuth2 application credentials supplied by the user.</item>
/// <item>Creates the OTS admin user with a random password.</item>
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
/// <item>Activates the <c>otssigns</c> theme.</item>
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
/// <item>Deletes the default <c>xibo_admin</c> account.</item>
/// </list>
///
/// The user must first create an OAuth2 application (client_credentials) in the
/// Xibo web UI using the default <c>xibo_admin / password</c> account that ships
/// with every new Xibo CMS instance.
///
/// Invoked from the Create Instance UI after the user supplies the OAuth credentials.
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
/// </summary>
/// </summary>
public class PostInstanceInitService
{
private readonly IServiceProvider _services;
private readonly ILogger<PostInstanceInitService> _logger;
// How long to wait for Xibo to become healthy before aborting post-init.
private static readonly TimeSpan XiboReadinessTimeout = TimeSpan.FromMinutes(15);
public PostInstanceInitService(
IServiceProvider services,
ILogger<PostInstanceInitService> logger)
{
_services = services;
_logger = logger;
}
// ─────────────────────────────────────────────────────────────────────────
// Entry point
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Executes the post-instance initialisation sequence.
/// Called from the UI after the user supplies OAuth2 client credentials.
/// </summary>
public async Task RunAsync(
string abbrev,
string instanceUrl,
string clientId,
string clientSecret,
CancellationToken ct = default)
{
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
try
{
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
// ── 1. Wait for Xibo readiness ────────────────────────────────────
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
if (!ready)
throw new TimeoutException(
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
// ── 2. Authenticate with user-supplied OAuth2 credentials ─────────
_logger.LogInformation("[PostInit] Obtaining access token via client_credentials");
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
// ── 3. Generate credentials ───────────────────────────────────────
var adminUsername = $"ots-admin-{abbrev}";
var adminPassword = GeneratePassword(24);
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
// ── 4. Create OTS admin group ─────────────────────────────────────
var adminGroupName = $"ots-admins-{abbrev}";
_logger.LogInformation("[PostInit] Creating OTS admin group '{GroupName}'", adminGroupName);
var adminGroupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, adminGroupName);
// ── 5. Create OTS admin user ──────────────────────────────────────
_logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername);
int userId = await xibo.CreateAdminUserAsync(
instanceUrl, accessToken,
adminUsername, adminPassword, adminEmail, adminGroupId);
// ── 5a. Assign admin user to OTS admin group ──────────────────────
_logger.LogInformation("[PostInit] Assigning '{Username}' to group '{GroupName}'", adminUsername, adminGroupName);
await xibo.AssignUserToGroupAsync(instanceUrl, accessToken, adminGroupId, userId);
// ── 6. Register dedicated OAuth2 application for OTS ──────────────
_logger.LogInformation("[PostInit] Registering OTS OAuth2 application");
var (otsClientId, otsClientSecret) = await xibo.RegisterOAuthClientAsync(
instanceUrl, accessToken,
$"OTS Signs — {abbrev.ToUpperInvariant()}");
// ── 6. Set theme ──────────────────────────────────────────────────
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
// ── 7. Store credentials in Bitwarden ─────────────────────────────
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
var adminSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-admin-password",
value: adminPassword,
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
var oauthSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-oauth-secret",
value: otsClientSecret,
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {otsClientId}");
// Persist Bitwarden secret IDs + OAuth client ID as config settings
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), otsClientId,
SettingsService.CatInstance, isSensitive: false);
// ── 8. Remove the default xibo_admin account ──────────────────────
_logger.LogInformation("[PostInit] Removing default xibo_admin user");
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
await xibo.DeleteUserAsync(instanceUrl, accessToken, xiboAdminId);
_logger.LogInformation("[PostInit] xibo_admin user removed (userId={UserId})", xiboAdminId);
_logger.LogInformation(
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
abbrev, adminUsername, otsClientId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
throw; // Propagate to calling UI so the user sees the error
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialise using caller-supplied OAuth credentials (no new app registration)
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user.
/// Unlike <see cref="RunAsync"/>, this method does NOT register a new OAuth application;
/// instead it stores the caller-supplied credentials for future API operations.
/// Steps: wait → authenticate → create OTS admin → set theme → remove xibo_admin → store credentials.
/// </summary>
public async Task InitializeWithOAuthAsync(
string abbrev,
string instanceUrl,
string clientId,
string clientSecret,
CancellationToken ct = default)
{
_logger.LogInformation("[PostInit] Starting initialisation for {Abbrev} ({Url})", abbrev, instanceUrl);
try
{
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
// ── 1. Wait for Xibo readiness ────────────────────────────────────
_logger.LogInformation("[PostInit] Waiting for Xibo at {Url}...", instanceUrl);
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
if (!ready)
throw new TimeoutException(
$"Xibo at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
// ── 2. Authenticate with caller-supplied OAuth2 credentials ───────
_logger.LogInformation("[PostInit] Obtaining access token");
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
// ── 3. Generate OTS admin credentials ─────────────────────────────
var adminUsername = $"ots-admin-{abbrev}";
var adminPassword = GeneratePassword(24);
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
// ── 4. Rename built-in xibo_admin to OTS admin ───────────────────
_logger.LogInformation("[PostInit] Looking up xibo_admin user");
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
_logger.LogInformation("[PostInit] Updating xibo_admin (id={Id}) → '{Username}'", xiboAdminId, adminUsername);
await xibo.UpdateUserAsync(instanceUrl, accessToken, xiboAdminId, adminUsername, adminPassword, adminEmail);
// ── 5. Set theme ──────────────────────────────────────────────────
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
// ── 6. Store admin password in Bitwarden ──────────────────────────
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
var adminSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-admin-password",
value: adminPassword,
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
SettingsService.CatInstance, isSensitive: false);
// ── 7. Store caller-supplied OAuth credentials in Bitwarden ───────
var oauthSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-oauth-secret",
value: clientSecret,
note: $"Xibo CMS OAuth2 client secret for instance {abbrev}. ClientId: {clientId}");
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), clientId,
SettingsService.CatInstance, isSensitive: false);
_logger.LogInformation("[PostInit] xibo_admin removed");
_logger.LogInformation("[PostInit] Initialisation complete for {Abbrev}", abbrev);
}
catch (Exception ex)
{
_logger.LogError(ex, "[PostInit] Initialisation failed for {Abbrev}: {Message}", abbrev, ex.Message);
throw;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Credential rotation (called from the UI)
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret.
/// </summary>
public async Task<string> RotateAdminPasswordAsync(string abbrev, string instanceUrl)
{
_logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev);
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var adminUsername = $"ots-admin-{abbrev}";
var newPassword = GeneratePassword(24);
// Log in using the stored OTS OAuth2 client credentials
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId))
throw new InvalidOperationException(
$"No OAuth credentials found for instance '{abbrev}'. Was post-init completed?");
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
// Look up the OTS admin user ID
var userId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, adminUsername);
await xibo.RotateUserPasswordAsync(instanceUrl, accessToken, userId, newPassword);
// Update Bitwarden secret
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(secretId))
{
await bws.UpdateInstanceSecretAsync(secretId,
key: $"{abbrev}/xibo-admin-password",
value: newPassword,
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
}
else
{
// Secret doesn't exist yet in Bitwarden — create it now
var newSecretId = await bws.CreateInstanceSecretAsync(
$"{abbrev}/xibo-admin-password", newPassword,
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
SettingsService.CatInstance, isSensitive: false);
}
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
return newPassword;
}
/// <summary>
/// Returns the stored admin password for an instance from Bitwarden.
/// </summary>
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
{
using var scope = _services.CreateScope();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var adminUsername = $"ots-admin-{abbrev}";
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
string? adminPassword = null;
string? oauthClientSecret = null;
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(adminSecretId))
{
try
{
var secret = await bws.GetSecretAsync(adminSecretId);
adminPassword = secret.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
}
}
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(oauthSecretId))
{
try
{
var secret = await bws.GetSecretAsync(oauthSecretId);
oauthClientSecret = secret.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
}
}
return new InstanceCredentials
{
AdminUsername = adminUsername,
AdminPassword = adminPassword,
OAuthClientId = oauthClientId,
OAuthClientSecret = oauthClientSecret,
};
}
// ─────────────────────────────────────────────────────────────────────────
// SAML configuration deployment
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Provisions a SAML application in Authentik, renders the settings-custom.php template,
/// and writes the rendered file to the instance's NFS-backed cms-custom volume.
/// The template is resolved from (a) the git repo cache, or (b) the local bundled
/// <c>templates/</c> directory shipped with the application.
/// Errors are logged but do not fail the overall deployment.
/// </summary>
public async Task DeploySamlConfigurationAsync(
string abbrev,
string instanceUrl,
SettingsService settings,
string? customerName = null,
CancellationToken ct = default)
{
try
{
_logger.LogInformation("[PostInit] Deploying SAML settings-custom.php for {Abbrev}", abbrev);
using var scope = _services.CreateScope();
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var docker = scope.ServiceProvider.GetRequiredService<IDockerCliService>();
// ── 1. Locate settings-custom.php.template ────────────────────────
string? templateContent = null;
// 1a. Try git repo cache first
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
if (!string.IsNullOrWhiteSpace(repoUrl))
{
try
{
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var gitPath = Path.Combine(templateConfig.CacheDir, "settings-custom.php.template");
if (File.Exists(gitPath))
{
templateContent = await File.ReadAllTextAsync(gitPath, ct);
_logger.LogInformation("[PostInit] Using template from git repo cache: {Path}", gitPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[PostInit] Could not fetch template from git — trying local fallback");
}
}
// 1b. Fall back to local templates/ directory (bundled with app)
if (templateContent == null)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "templates", "settings-custom.php.template"),
Path.Combine(Directory.GetCurrentDirectory(), "templates", "settings-custom.php.template"),
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
templateContent = await File.ReadAllTextAsync(candidate, ct);
_logger.LogInformation("[PostInit] Using local template: {Path}", candidate);
break;
}
}
}
if (templateContent == null)
{
_logger.LogWarning(
"[PostInit] settings-custom.php.template not found in git repo or local templates/ — skipping SAML deployment");
return;
}
// ── 2. Provision Authentik SAML application ───────────────────────
var samlBaseUrl = instanceUrl.TrimEnd('/') + "/saml";
Models.DTOs.AuthentikSamlConfig? samlConfig = null;
try
{
samlConfig = await authentik.ProvisionSamlAsync(abbrev, instanceUrl, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"[PostInit] Authentik provisioning failed for {Abbrev} — skipping SAML config deployment to avoid broken Xibo instance",
abbrev);
return; // Do NOT write a settings-custom.php with empty IdP values — it will crash Xibo
}
// ── 3. Render template ────────────────────────────────────────────
var rendered = templateContent
.Replace("{{SAML_BASE_URL}}", samlBaseUrl)
.Replace("{{SAML_SP_ENTITY_ID}}", $"{samlBaseUrl}/metadata")
.Replace("{{AUTHENTIK_IDP_ENTITY_ID}}", samlConfig?.IdpEntityId ?? "")
.Replace("{{AUTHENTIK_SSO_URL}}", samlConfig?.SsoUrlRedirect ?? "")
.Replace("{{AUTHENTIK_SLO_URL}}", samlConfig?.SloUrlRedirect ?? "")
.Replace("{{AUTHENTIK_IDP_X509_CERT}}", samlConfig?.IdpX509Cert ?? "");
// ── 4. Write rendered file to NFS volume ──────────────────────────
var nfsServer = await settings.GetAsync(SettingsService.NfsServer);
var nfsExport = await settings.GetAsync(SettingsService.NfsExport);
var nfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder);
if (string.IsNullOrWhiteSpace(nfsServer) || string.IsNullOrWhiteSpace(nfsExport))
throw new InvalidOperationException("NFS settings are not configured — cannot write SAML config to volume.");
// Path within the NFS export: {abbrev}/cms-custom/settings-custom.php
var nfsRelativePath = $"{abbrev}/cms-custom/settings-custom.php";
var (success, error) = await docker.WriteFileToNfsAsync(
nfsServer, nfsExport, nfsRelativePath, rendered, nfsExportFolder);
if (!success)
throw new InvalidOperationException($"Failed to write settings-custom.php to NFS: {error}");
_logger.LogInformation(
"[PostInit] SAML configuration deployed for {Abbrev}{ProviderInfo}",
abbrev,
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);
// ── 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)
{
_logger.LogError(ex, "[PostInit] SAML deployment failed for {Abbrev}: {Message}. " +
"Instance will continue without SAML — configure manually if needed.", abbrev, ex.Message);
// Don't rethrow — SAML failure should not block the rest of post-init
}
}
/// <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, 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>
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);
// ── 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));
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 (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);
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
// ─────────────────────────────────────────────────────────────────────────
private static string GeneratePassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
// ─────────────────────────────────────────────────────────────────────────
// Import existing instance secrets on startup
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Scans all Bitwarden secrets for existing instance-level credentials
/// (matching the <c>{abbrev}/xibo-admin-password</c> and <c>{abbrev}/xibo-oauth-secret</c>
/// naming convention) and imports their mappings into the config settings so
/// the app knows about them without a manual re-provisioning step.
/// Safe to call on every startup — existing mappings are never overwritten.
/// </summary>
public async Task ImportExistingInstanceSecretsAsync()
{
try
{
using var scope = _services.CreateScope();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
if (!await bws.IsConfiguredAsync())
{
_logger.LogDebug("[Import] Bitwarden not configured — skipping instance secret import");
return;
}
var allSecrets = await bws.ListSecretsAsync();
var imported = 0;
foreach (var summary in allSecrets)
{
// ── Admin password pattern: {abbrev}/xibo-admin-password ──
var adminMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-admin-password$");
if (adminMatch.Success)
{
var abbrev = adminMatch.Groups[1].Value;
var existing = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (string.IsNullOrWhiteSpace(existing))
{
await settings.SetAsync(
SettingsService.InstanceAdminPasswordSecretId(abbrev),
summary.Id, SettingsService.CatInstance);
imported++;
_logger.LogInformation(
"[Import] Imported admin password secret for instance {Abbrev} (id={Id})",
abbrev, summary.Id);
}
continue;
}
// ── OAuth secret pattern: {abbrev}/xibo-oauth-secret ──
var oauthMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-oauth-secret$");
if (oauthMatch.Success)
{
var abbrev = oauthMatch.Groups[1].Value;
var existing = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (string.IsNullOrWhiteSpace(existing))
{
await settings.SetAsync(
SettingsService.InstanceOAuthSecretId(abbrev),
summary.Id, SettingsService.CatInstance);
imported++;
_logger.LogInformation(
"[Import] Imported OAuth secret for instance {Abbrev} (id={Id})",
abbrev, summary.Id);
// Try to extract the OAuth client_id from the secret's Note field
try
{
var full = await bws.GetSecretAsync(summary.Id);
var cidMatch = Regex.Match(full.Note ?? "", @"ClientId:\s*(\S+)");
if (cidMatch.Success)
{
var existingCid = await settings.GetAsync(
SettingsService.InstanceOAuthClientId(abbrev));
if (string.IsNullOrWhiteSpace(existingCid))
{
await settings.SetAsync(
SettingsService.InstanceOAuthClientId(abbrev),
cidMatch.Groups[1].Value, SettingsService.CatInstance);
_logger.LogInformation(
"[Import] Imported OAuth client ID for instance {Abbrev}", abbrev);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"[Import] Could not fetch full OAuth secret for {Abbrev} to extract client ID",
abbrev);
}
}
}
}
if (imported > 0)
_logger.LogInformation("[Import] Imported {Count} instance secret mapping(s) from Bitwarden", imported);
else
_logger.LogDebug("[Import] No new instance secrets to import");
}
catch (Exception ex)
{
_logger.LogError(ex, "[Import] Failed to import existing instance secrets from Bitwarden");
}
}
}
/// <summary>Snapshot of provisioned credentials for a CMS instance.</summary>
public class InstanceCredentials
{
public string AdminUsername { get; set; } = string.Empty;
public string? AdminPassword { get; set; }
public string? OAuthClientId { get; set; }
public string? OAuthClientSecret { get; set; }
public bool HasAdminPassword => !string.IsNullOrWhiteSpace(AdminPassword);
public bool HasOAuthCredentials => !string.IsNullOrWhiteSpace(OAuthClientId) &&
!string.IsNullOrWhiteSpace(OAuthClientSecret);
}