using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; namespace OTSSignsOrchestrator.Server.Health.Checks; /// /// Verifies that both ots-admin-{abbrev} and ots-svc-{abbrev} exist /// with userTypeId == 1 (SuperAdmin). MUST use /// because Xibo paginates at 10 items by default. /// /// saml-usertypeid is JIT-only and does NOT maintain SuperAdmin on existing users — /// this check IS the ongoing enforcement mechanism. /// public sealed class AdminIntegrityHealthCheck : IHealthCheck { private readonly XiboClientFactory _clientFactory; private readonly IServiceProvider _services; private readonly ILogger _logger; public string CheckName => "AdminIntegrity"; public bool AutoRemediate => true; public AdminIntegrityHealthCheck( XiboClientFactory clientFactory, IServiceProvider services, ILogger logger) { _clientFactory = clientFactory; _services = services; _logger = logger; } public async Task RunAsync(Instance instance, CancellationToken ct) { var (client, abbrev) = await ResolveAsync(instance); if (client is null) return new HealthCheckResult(HealthStatus.Critical, "No OAuth app — cannot verify admin accounts"); var users = await client.GetAllPagesAsync( (start, length) => client.GetUsersAsync(start, length)); var adminName = $"ots-admin-{abbrev}"; var svcName = $"ots-svc-{abbrev}"; var problems = new List(); foreach (var expected in new[] { adminName, svcName }) { var user = users.FirstOrDefault(u => u.TryGetValue("userName", out var n) && string.Equals(n?.ToString(), expected, StringComparison.OrdinalIgnoreCase)); if (user is null) { problems.Add($"{expected} is MISSING"); continue; } if (user.TryGetValue("userTypeId", out var typeObj) && typeObj?.ToString() != "1") { problems.Add($"{expected} has userTypeId={typeObj} (expected 1)"); } } if (problems.Count == 0) return new HealthCheckResult(HealthStatus.Healthy, "Admin accounts intact"); return new HealthCheckResult( HealthStatus.Critical, $"Admin integrity issues: {string.Join("; ", problems)}", string.Join("\n", problems)); } public async Task RemediateAsync(Instance instance, CancellationToken ct) { var (client, abbrev) = await ResolveAsync(instance); if (client is null) return false; await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var users = await client.GetAllPagesAsync( (start, length) => client.GetUsersAsync(start, length)); var adminName = $"ots-admin-{abbrev}"; var svcName = $"ots-svc-{abbrev}"; var allFixed = true; foreach (var expected in new[] { adminName, svcName }) { var user = users.FirstOrDefault(u => u.TryGetValue("userName", out var n) && string.Equals(n?.ToString(), expected, StringComparison.OrdinalIgnoreCase)); if (user is null) { // Recreate missing account var email = $"{expected}@otssigns.internal"; var password = GenerateRandomPassword(32); var createResp = await client.CreateUserAsync(new CreateUserRequest( expected, email, password, UserTypeId: 1, HomePageId: 1)); if (!createResp.IsSuccessStatusCode) { _logger.LogError("Failed to recreate {User}: {Err}", expected, createResp.Error?.Content); allFixed = false; continue; } // Audit db.AuditLogs.Add(new AuditLog { Id = Guid.NewGuid(), InstanceId = instance.Id, Actor = "HealthCheckEngine:AdminIntegrity", Action = "RecreateUser", Target = expected, Outcome = "Success", Detail = "User was missing — recreated as SuperAdmin", OccurredAt = DateTime.UtcNow, }); } else { // Fix userTypeId if wrong if (user.TryGetValue("userTypeId", out var typeObj) && typeObj?.ToString() != "1") { var userId = int.Parse(user["userId"]?.ToString() ?? "0"); if (userId == 0) { allFixed = false; continue; } var updateResp = await client.UpdateUserAsync(userId, new UpdateUserRequest( UserName: null, Email: null, Password: null, UserTypeId: 1, HomePageId: null, Retired: null)); if (!updateResp.IsSuccessStatusCode) { _logger.LogError("Failed to fix userTypeId for {User}: {Err}", expected, updateResp.Error?.Content); allFixed = false; continue; } db.AuditLogs.Add(new AuditLog { Id = Guid.NewGuid(), InstanceId = instance.Id, Actor = "HealthCheckEngine:AdminIntegrity", Action = "FixUserType", Target = expected, Outcome = "Success", Detail = $"Changed userTypeId from {typeObj} to 1 (SuperAdmin)", OccurredAt = DateTime.UtcNow, }); } } } await db.SaveChangesAsync(ct); return allFixed; } private async Task<(IXiboApiClient? Client, string Abbrev)> ResolveAsync(Instance instance) { var abbrev = instance.Customer.Abbreviation; var oauthApp = instance.OauthAppRegistries.FirstOrDefault(); if (oauthApp is null) return (null, abbrev); var settings = _services.GetRequiredService(); var secret = await settings.GetAsync(Core.Services.SettingsService.InstanceOAuthSecretId(abbrev)); if (string.IsNullOrEmpty(secret)) return (null, abbrev); var client = await _clientFactory.CreateAsync(instance.XiboUrl, oauthApp.ClientId, secret); return (client, abbrev); } private static string GenerateRandomPassword(int length) { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; return System.Security.Cryptography.RandomNumberGenerator.GetString(chars, length); } }