using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Quartz; using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; using OTSSignsOrchestrator.Server.Services; namespace OTSSignsOrchestrator.Server.Workers; /// /// Phase 2 provisioning pipeline — Xibo CMS bootstrap. Handles JobType = "bootstrap". /// Triggered after the stack deployed in Phase 1 becomes healthy. /// /// Steps: /// 1. xibo-health-check — Poll GET /about until 200 OK /// 2. create-ots-admin — POST /api/user (superAdmin) /// 3. create-ots-svc — POST /api/user (service account) /// 4. create-oauth2-app — POST /api/application — IMMEDIATELY capture secret /// 5. create-groups — POST /api/group (viewer, editor, admin, ots-it) /// 6. assign-group-acl — POST /api/group/{id}/acl per role /// 7. assign-service-accounts — Assign admin + svc to ots-it group /// 8. apply-theme — PUT /api/settings THEME_NAME=otssigns /// 9. delete-default-user — Delete xibo_admin (after safety check) /// 10. schedule-snapshot — Register Quartz DailySnapshotJob /// 11. authentik-invite — Create Authentik user + invitation, send welcome email /// public sealed class Phase2Pipeline : IProvisioningPipeline { public string HandlesJobType => "bootstrap"; private const int TotalSteps = 11; private readonly IServiceProvider _services; private readonly ILogger _logger; public Phase2Pipeline( IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } public async Task ExecuteAsync(Job job, CancellationToken ct) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var hub = scope.ServiceProvider.GetRequiredService>(); var xiboFactory = scope.ServiceProvider.GetRequiredService(); var authentikClient = scope.ServiceProvider.GetRequiredService(); var bws = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var emailService = scope.ServiceProvider.GetRequiredService(); var schedulerFactory = scope.ServiceProvider.GetRequiredService(); var ctx = await BuildContextAsync(job, db, ct); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); var abbrev = ctx.Abbreviation; var instanceUrl = ctx.InstanceUrl; // The initial Xibo CMS ships with xibo_admin / password credentials. // We use these to bootstrap via the API. IXiboApiClient? xiboClient = null; // Mutable state accumulated across steps var adminPassword = GenerateRandomPassword(24); var svcPassword = GenerateRandomPassword(24); int otsAdminUserId = 0; int otsSvcUserId = 0; string otsOAuthClientId = string.Empty; string otsOAuthClientSecret = string.Empty; var groupIds = new Dictionary(); // groupName → groupId // ── Step 1: xibo-health-check ─────────────────────────────────────── await runner.RunAsync("xibo-health-check", async () => { // Poll GET /about every 10 seconds, up to 5 minutes var timeout = TimeSpan.FromMinutes(5); var interval = TimeSpan.FromSeconds(10); var deadline = DateTimeOffset.UtcNow.Add(timeout); // Create a temporary client using default bootstrap credentials // xibo_admin/password → OAuth client_credentials from the seed application // The first call is to /about which doesn't need auth, use a raw HttpClient using var httpClient = new HttpClient { BaseAddress = new Uri(instanceUrl.TrimEnd('/')) }; while (DateTimeOffset.UtcNow < deadline) { ct.ThrowIfCancellationRequested(); try { var response = await httpClient.GetAsync("/api/about", ct); if (response.IsSuccessStatusCode) { return $"Xibo CMS at {instanceUrl} is healthy (status {(int)response.StatusCode})."; } } catch (HttpRequestException) { // Not ready yet } await Task.Delay(interval, ct); } throw new TimeoutException( $"Xibo CMS at {instanceUrl} did not return 200 OK from /about within {timeout.TotalMinutes} minutes."); }, ct); // Get a Refit client using the seed OAuth app credentials (xibo_admin bootstrap) // Parameters JSON should contain bootstrapClientId + bootstrapClientSecret var parameters = !string.IsNullOrEmpty(ctx.ParametersJson) ? JsonDocument.Parse(ctx.ParametersJson) : null; var bootstrapClientId = parameters?.RootElement.TryGetProperty("bootstrapClientId", out var bcid) == true ? bcid.GetString() ?? string.Empty : string.Empty; var bootstrapClientSecret = parameters?.RootElement.TryGetProperty("bootstrapClientSecret", out var bcs) == true ? bcs.GetString() ?? string.Empty : string.Empty; if (string.IsNullOrEmpty(bootstrapClientId) || string.IsNullOrEmpty(bootstrapClientSecret)) throw new InvalidOperationException( "Bootstrap OAuth credentials (bootstrapClientId, bootstrapClientSecret) must be provided in Job.Parameters."); xiboClient = await xiboFactory.CreateAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret); // ── Step 2: create-ots-admin ──────────────────────────────────────── await runner.RunAsync("create-ots-admin", async () => { var username = $"ots-admin-{abbrev}"; var email = $"ots-admin-{abbrev}@ots-signs.com"; var response = await xiboClient.CreateUserAsync(new CreateUserRequest( UserName: username, Email: email, Password: adminPassword, UserTypeId: 1, // SuperAdmin HomePageId: 1)); EnsureSuccess(response); var data = response.Content ?? throw new InvalidOperationException("CreateUser returned empty response."); otsAdminUserId = Convert.ToInt32(data["userId"]); return $"Created user '{username}' (userId={otsAdminUserId}, type=SuperAdmin)."; }, ct); // ── Step 3: create-ots-svc ────────────────────────────────────────── await runner.RunAsync("create-ots-svc", async () => { var username = $"ots-svc-{abbrev}"; var email = $"ots-svc-{abbrev}@ots-signs.com"; var response = await xiboClient.CreateUserAsync(new CreateUserRequest( UserName: username, Email: email, Password: svcPassword, UserTypeId: 1, // SuperAdmin — service account needs full API access HomePageId: 1)); EnsureSuccess(response); var data = response.Content ?? throw new InvalidOperationException("CreateUser returned empty response."); otsSvcUserId = Convert.ToInt32(data["userId"]); return $"Created user '{username}' (userId={otsSvcUserId}, type=SuperAdmin)."; }, ct); // ── Step 4: create-oauth2-app ─────────────────────────────────────── // CRITICAL: POST /api/application — NOT GET (that's blocked). // The OAuth2 client secret is returned ONLY in this response. Capture immediately. await runner.RunAsync("create-oauth2-app", async () => { var appName = $"OTS Orchestrator — {abbrev.ToUpperInvariant()}"; var response = await xiboClient.CreateApplicationAsync(new CreateApplicationRequest(appName)); EnsureSuccess(response); var data = response.Content ?? throw new InvalidOperationException("CreateApplication returned empty response."); // CRITICAL: Capture secret immediately — it cannot be retrieved again. otsOAuthClientId = data["key"]?.ToString() ?? throw new InvalidOperationException("OAuth application response missing 'key'."); otsOAuthClientSecret = data["secret"]?.ToString() ?? throw new InvalidOperationException("OAuth application response missing 'secret'."); // Store to Bitwarden CLI wrapper await bws.CreateInstanceSecretAsync( key: $"{abbrev}/xibo-oauth-secret", value: otsOAuthClientSecret, note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {otsOAuthClientId}"); // Store clientId ONLY in the database — NEVER store the secret in the DB db.OauthAppRegistries.Add(new OauthAppRegistry { Id = Guid.NewGuid(), InstanceId = ctx.InstanceId, ClientId = otsOAuthClientId, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(ct); return $"OAuth2 app '{appName}' created. ClientId={otsOAuthClientId}. Secret stored in Bitwarden (NEVER in DB)."; }, ct); // ── Step 5: create-groups ─────────────────────────────────────────── await runner.RunAsync("create-groups", async () => { var groupDefs = new[] { ($"{abbrev}-viewer", "Viewer role"), ($"{abbrev}-editor", "Editor role"), ($"{abbrev}-admin", "Admin role"), ($"ots-it-{abbrev}", "OTS IT internal"), }; foreach (var (name, description) in groupDefs) { var response = await xiboClient.CreateGroupAsync(new CreateGroupRequest(name, description)); EnsureSuccess(response); var data = response.Content ?? throw new InvalidOperationException($"CreateGroup '{name}' returned empty response."); groupIds[name] = Convert.ToInt32(data["groupId"]); } return $"Created {groupDefs.Length} groups: {string.Join(", ", groupIds.Keys)}."; }, ct); // ── Step 6: assign-group-acl ──────────────────────────────────────── // POST /api/group/{id}/acl — NOT /features await runner.RunAsync("assign-group-acl", async () => { var aclMap = new Dictionary { [$"{abbrev}-viewer"] = (XiboFeatureManifests.ViewerObjectIds, XiboFeatureManifests.ViewerPermissionIds), [$"{abbrev}-editor"] = (XiboFeatureManifests.EditorObjectIds, XiboFeatureManifests.EditorPermissionIds), [$"{abbrev}-admin"] = (XiboFeatureManifests.AdminObjectIds, XiboFeatureManifests.AdminPermissionIds), [$"ots-it-{abbrev}"] = (XiboFeatureManifests.OtsItObjectIds, XiboFeatureManifests.OtsItPermissionIds), }; var applied = new List(); foreach (var (groupName, (objectIds, permissionIds)) in aclMap) { if (!groupIds.TryGetValue(groupName, out var groupId)) throw new InvalidOperationException($"Group '{groupName}' ID not found."); var response = await xiboClient.SetGroupAclAsync(groupId, new SetAclRequest(objectIds, permissionIds)); EnsureSuccess(response); applied.Add($"{groupName} ({objectIds.Length} features)"); } return $"ACL assigned: {string.Join(", ", applied)}."; }, ct); // ── Step 7: assign-service-accounts ───────────────────────────────── await runner.RunAsync("assign-service-accounts", async () => { var otsItGroupName = $"ots-it-{abbrev}"; if (!groupIds.TryGetValue(otsItGroupName, out var otsItGroupId)) throw new InvalidOperationException($"Group '{otsItGroupName}' ID not found."); // Assign both ots-admin and ots-svc to the ots-it group var response = await xiboClient.AssignUserToGroupAsync( otsItGroupId, new AssignMemberRequest([otsAdminUserId, otsSvcUserId])); EnsureSuccess(response); return $"Assigned ots-admin-{abbrev} (id={otsAdminUserId}) and ots-svc-{abbrev} (id={otsSvcUserId}) to group '{otsItGroupName}'."; }, ct); // ── Step 8: apply-theme ───────────────────────────────────────────── await runner.RunAsync("apply-theme", async () => { var response = await xiboClient.UpdateSettingsAsync(new UpdateSettingsRequest( new Dictionary { ["THEME_NAME"] = "otssigns" })); EnsureSuccess(response); return "Theme set to 'otssigns'."; }, ct); // ── Step 9: delete-default-user ───────────────────────────────────── await runner.RunAsync("delete-default-user", async () => { // First verify ots-admin works via OAuth2 client_credentials test var testClient = await xiboFactory.CreateAsync(instanceUrl, otsOAuthClientId, otsOAuthClientSecret); var aboutResponse = await testClient.GetAboutAsync(); if (!aboutResponse.IsSuccessStatusCode) throw new InvalidOperationException( $"OAuth2 verification failed for ots-admin app (status {aboutResponse.StatusCode}). " + "Refusing to delete xibo_admin — it may be the only working admin account."); // Use GetAllPagesAsync to find xibo_admin user — default page size is only 10 var allUsers = await xiboClient.GetAllPagesAsync( (start, length) => xiboClient.GetUsersAsync(start, length)); var xiboAdminUser = allUsers.FirstOrDefault(u => u.TryGetValue("userName", out var name) && string.Equals(name?.ToString(), "xibo_admin", StringComparison.OrdinalIgnoreCase)); if (xiboAdminUser is null) return "xibo_admin user not found — may have already been deleted."; var xiboAdminId = Convert.ToInt32(xiboAdminUser["userId"]); // Safety check: never delete if it would be the last SuperAdmin var superAdminCount = allUsers.Count(u => u.TryGetValue("userTypeId", out var typeId) && Convert.ToInt32(typeId) == 1); if (superAdminCount <= 1) throw new InvalidOperationException( "Cannot delete xibo_admin — it is the only SuperAdmin. " + "Ensure ots-admin was created as SuperAdmin before retrying."); await xiboClient.DeleteUserAsync(xiboAdminId); return $"Deleted xibo_admin (userId={xiboAdminId}). Remaining SuperAdmins: {superAdminCount - 1}."; }, ct); // ── Step 10: schedule-snapshot ────────────────────────────────────── await runner.RunAsync("schedule-snapshot", async () => { var scheduler = await schedulerFactory.GetScheduler(ct); var jobKey = new JobKey($"snapshot-{abbrev}", "daily-snapshots"); var triggerKey = new TriggerKey($"snapshot-trigger-{abbrev}", "daily-snapshots"); var quartzJob = JobBuilder.Create() .WithIdentity(jobKey) .UsingJobData("abbrev", abbrev) .UsingJobData("instanceId", ctx.InstanceId.ToString()) .StoreDurably() .Build(); var trigger = TriggerBuilder.Create() .WithIdentity(triggerKey) .WithCronSchedule("0 0 2 * * ?") // 2:00 AM daily .Build(); await scheduler.ScheduleJob(quartzJob, trigger, ct); return $"DailySnapshotJob scheduled for instance '{abbrev}' — daily at 02:00 UTC."; }, ct); // ── Step 11: authentik-invite ─────────────────────────────────────── await runner.RunAsync("authentik-invite", async () => { var adminEmail = ctx.AdminEmail; var firstName = ctx.AdminFirstName; // Create Authentik user var userResponse = await authentikClient.CreateUserAsync(new CreateAuthentikUserRequest( Username: adminEmail, Name: $"{firstName}", Email: adminEmail, Groups: [$"customer-{abbrev}", $"customer-{abbrev}-viewer"])); EnsureSuccess(userResponse); // Create invitation with 7-day expiry var inviteResponse = await authentikClient.CreateInvitationAsync(new CreateFlowRequest( Name: $"invite-{abbrev}", SingleUse: true, Expires: DateTimeOffset.UtcNow.AddDays(7))); EnsureSuccess(inviteResponse); var inviteData = inviteResponse.Content; var invitationLink = inviteData?.TryGetValue("pk", out var pk) == true ? $"{instanceUrl}/if/flow/invite-{abbrev}/?itoken={pk}" : "(invitation link unavailable)"; // Send welcome email await emailService.SendWelcomeEmailAsync(adminEmail, firstName, instanceUrl, invitationLink); return $"Authentik user '{adminEmail}' created, assigned to customer-{abbrev} + customer-{abbrev}-viewer. " + $"Invitation link: {invitationLink}. Welcome email sent."; }, ct); _logger.LogInformation("Phase2Pipeline completed for job {JobId} (abbrev={Abbrev})", job.Id, abbrev); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private static async Task BuildContextAsync(Job job, OrchestratorDbContext db, CancellationToken ct) { var customer = await db.Customers .Include(c => c.Instances) .FirstOrDefaultAsync(c => c.Id == job.CustomerId, ct) ?? throw new InvalidOperationException($"Customer {job.CustomerId} not found for job {job.Id}."); var instance = customer.Instances.FirstOrDefault() ?? throw new InvalidOperationException($"No instance found for customer {job.CustomerId}."); var abbrev = customer.Abbreviation.ToLowerInvariant(); return new PipelineContext { JobId = job.Id, CustomerId = customer.Id, InstanceId = instance.Id, Abbreviation = abbrev, CompanyName = customer.CompanyName, AdminEmail = customer.AdminEmail, AdminFirstName = customer.AdminFirstName, InstanceUrl = instance.XiboUrl, DockerStackName = instance.DockerStackName, ParametersJson = job.Parameters, }; } private static string GenerateRandomPassword(int length) { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; return RandomNumberGenerator.GetString(chars, length); } private static void EnsureSuccess(Refit.IApiResponse response) { if (!response.IsSuccessStatusCode) throw new InvalidOperationException( $"API call failed with status {response.StatusCode}: {response.Error?.Content}"); } } /// /// Quartz job placeholder for daily instance snapshots. /// Registered per-instance by Phase2Pipeline step 10. /// [DisallowConcurrentExecution] public sealed class DailySnapshotJob : IJob { private readonly IServiceProvider _services; private readonly ILogger _logger; public DailySnapshotJob(IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } public async Task Execute(IJobExecutionContext context) { var abbrev = context.MergedJobDataMap.GetString("abbrev"); var instanceIdStr = context.MergedJobDataMap.GetString("instanceId"); _logger.LogInformation("DailySnapshotJob running for instance {Abbrev} (id={InstanceId})", abbrev, instanceIdStr); await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); if (!Guid.TryParse(instanceIdStr, out var instanceId)) { _logger.LogError("DailySnapshotJob: invalid instanceId '{InstanceId}'", instanceIdStr); return; } db.ScreenSnapshots.Add(new ScreenSnapshot { Id = Guid.NewGuid(), InstanceId = instanceId, SnapshotDate = DateOnly.FromDateTime(DateTime.UtcNow), ScreenCount = 0, CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); _logger.LogInformation("DailySnapshotJob completed for instance {Abbrev}", abbrev); } }