diff --git a/.env.example b/.env.example index 8a53804..0340cab 100644 --- a/.env.example +++ b/.env.example @@ -72,4 +72,19 @@ GATEWAY_JWT_SIGNING_KEY=CHANGE_ME_MINIMUM_32_BYTES # ---- gateway identity ---- GATEWAY_ID=00000000-0000-0000-0000-000000000000 GATEWAY_SITE_ID=00000000-0000-0000-0000-000000000000 -GATEWAY_DEPARTMENT=ICU \ No newline at end of file +GATEWAY_DEPARTMENT=ICU +GATEWAY_CODE=GW-ICU-1 +GATEWAY_SITE_CODE=SITE-01 +GATEWAY_SITE_NAME=Primary Site +# GATEWAY_SITE_ADDRESS= + +# ---- production bootstrap users (API seeds when missing; change before first deploy) ---- +SEED_ADMIN_USERNAME=admin +SEED_ADMIN_PASSWORD=CHANGE_ME +SEED_ADMIN_DISPLAY_NAME=System Admin +SEED_NURSE_USERNAME=nurse +SEED_NURSE_PASSWORD=CHANGE_ME +SEED_NURSE_DISPLAY_NAME=Charge Nurse +SEED_PHYSICIAN_USERNAME=physician +SEED_PHYSICIAN_PASSWORD=CHANGE_ME +SEED_PHYSICIAN_DISPLAY_NAME=Attending Physician diff --git a/VigilCareClinicalAPI/Configuration/SeedingOptions.cs b/VigilCareClinicalAPI/Configuration/SeedingOptions.cs new file mode 100644 index 0000000..8f19180 --- /dev/null +++ b/VigilCareClinicalAPI/Configuration/SeedingOptions.cs @@ -0,0 +1,33 @@ +public class SeedingOptions +{ + public const string Section = "Seeding"; + + /// + /// Demo patients, observations, well-known *.demo users, and demo site/gateway. + /// Must stay false in production. + /// + public bool EnableDemoData { get; set; } = true; + + public BootstrapUserOptions? Admin { get; set; } + public BootstrapUserOptions? Nurse { get; set; } + public BootstrapUserOptions? Physician { get; set; } + + /// + /// When SiteId + GatewayId are set, ensure matching clinical_sites / ward_gateways rows exist + /// so the edge gateway can heartbeat (same IDs as Gateway__* on the ward-gateway container). + /// + public Guid? SiteId { get; set; } + public string? SiteCode { get; set; } + public string? SiteName { get; set; } + public string? SiteAddress { get; set; } + public Guid? GatewayId { get; set; } + public string? GatewayCode { get; set; } + public string? GatewayDepartment { get; set; } +} + +public class BootstrapUserOptions +{ + public string? Username { get; set; } + public string? Password { get; set; } + public string? DisplayName { get; set; } +} diff --git a/VigilCareClinicalAPI/Data/Seed/BootstrapSeeder.cs b/VigilCareClinicalAPI/Data/Seed/BootstrapSeeder.cs new file mode 100644 index 0000000..00fadf3 --- /dev/null +++ b/VigilCareClinicalAPI/Data/Seed/BootstrapSeeder.cs @@ -0,0 +1,160 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +/// +/// Production-safe, idempotent bootstrap: default alert thresholds, optional role +/// accounts (passwords from config), and optional site/gateway matching env GUIDs. +/// Never creates patient PHI or demo credentials. +/// +public static class BootstrapSeeder +{ + public static async Task SeedAsync( + AppDbContext db, + IConnectionMultiplexer redis, + SeedingOptions options, + ILogger logger) + { + await DataSeeder.EnsureDefaultThresholdsAsync(db, redis); + + await EnsureBootstrapUserAsync(db, options.Admin, ClinicalRole.Admin, logger); + await EnsureBootstrapUserAsync(db, options.Nurse, ClinicalRole.Nurse, logger); + await EnsureBootstrapUserAsync(db, options.Physician, ClinicalRole.Physician, logger); + + await EnsureSiteAndGatewayAsync(db, options, logger); + } + + private static async Task EnsureBootstrapUserAsync( + AppDbContext db, + BootstrapUserOptions? user, + ClinicalRole role, + ILogger logger) + { + if (user is null + || string.IsNullOrWhiteSpace(user.Username) + || string.IsNullOrWhiteSpace(user.Password)) + { + return; + } + + var username = user.Username.Trim(); + if (await db.ClinicalUsers.AnyAsync(u => u.Username == username)) + { + logger.LogInformation( + "Bootstrap user '{Username}' ({Role}) already exists — skipping.", + username, role); + return; + } + + if (user.Password.Length < 8) + { + logger.LogWarning( + "Skipping bootstrap {Role} '{Username}': password must be at least 8 characters.", + role, username); + return; + } + + var displayName = string.IsNullOrWhiteSpace(user.DisplayName) + ? username + : user.DisplayName.Trim(); + + db.ClinicalUsers.Add(new ClinicalUser + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(user.Password), + DisplayName = displayName, + Role = role, + IsActive = true, + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + logger.LogInformation( + "Bootstrap user '{Username}' ({Role}) created.", + username, role); + } + + private static async Task EnsureSiteAndGatewayAsync( + AppDbContext db, + SeedingOptions options, + ILogger logger) + { + if (options.SiteId is null || options.SiteId == Guid.Empty + || options.GatewayId is null || options.GatewayId == Guid.Empty) + { + return; + } + + var siteId = options.SiteId.Value; + var gatewayId = options.GatewayId.Value; + var siteCode = string.IsNullOrWhiteSpace(options.SiteCode) ? "SITE-01" : options.SiteCode.Trim(); + var siteName = string.IsNullOrWhiteSpace(options.SiteName) ? "Primary Site" : options.SiteName.Trim(); + var gatewayCode = string.IsNullOrWhiteSpace(options.GatewayCode) ? "GW-ICU-1" : options.GatewayCode.Trim(); + var department = string.IsNullOrWhiteSpace(options.GatewayDepartment) ? "ICU" : options.GatewayDepartment.Trim(); + var address = string.IsNullOrWhiteSpace(options.SiteAddress) ? null : options.SiteAddress.Trim(); + + var existingGateway = await db.WardGateways + .AsNoTracking() + .FirstOrDefaultAsync(g => g.Id == gatewayId); + + if (existingGateway is not null) + { + if (existingGateway.SiteId != siteId + || !string.Equals(existingGateway.GatewayCode, gatewayCode, StringComparison.Ordinal) + || !string.Equals(existingGateway.Department, department, StringComparison.Ordinal)) + { + logger.LogWarning( + "Gateway id {GatewayId} already exists with different site/code/department — not modifying.", + gatewayId); + return; + } + + logger.LogInformation( + "Gateway '{GatewayCode}' (id={GatewayId}) already registered — skipping.", + existingGateway.GatewayCode, gatewayId); + return; + } + + var existingSite = await db.ClinicalSites.FirstOrDefaultAsync(s => s.Id == siteId); + if (existingSite is null) + { + if (await db.ClinicalSites.AnyAsync(s => s.SiteCode == siteCode)) + { + logger.LogWarning( + "Site code '{SiteCode}' exists under a different id — cannot bootstrap site {SiteId}.", + siteCode, siteId); + return; + } + + var site = new ClinicalSite(siteCode, siteName, address); + db.Entry(site).Property(nameof(ClinicalSite.Id)).CurrentValue = siteId; + db.ClinicalSites.Add(site); + logger.LogInformation("Bootstrap created site '{SiteCode}' (id={SiteId}).", siteCode, siteId); + } + else if (!string.Equals(existingSite.SiteCode, siteCode, StringComparison.Ordinal)) + { + logger.LogWarning( + "Site id {SiteId} already exists as code '{Existing}', not '{Requested}' — aborting gateway bootstrap.", + siteId, existingSite.SiteCode, siteCode); + return; + } + + if (await db.WardGateways.AnyAsync(g => g.SiteId == siteId && g.GatewayCode == gatewayCode)) + { + logger.LogWarning( + "Gateway code '{GatewayCode}' already exists for site {SiteId} under a different id.", + gatewayCode, siteId); + return; + } + + var gateway = new WardGateway(siteId, gatewayCode, department); + db.Entry(gateway).Property(nameof(WardGateway.Id)).CurrentValue = gatewayId; + db.WardGateways.Add(gateway); + await db.SaveChangesAsync(); + + logger.LogInformation( + "Bootstrap registered gateway '{GatewayCode}' (id={GatewayId}) on site {SiteId}.", + gatewayCode, gatewayId, siteId); + } +} diff --git a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs index b700a23..e291be8 100644 --- a/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/DataSeeder.cs @@ -48,8 +48,17 @@ public static class DataSeeder }; db.Encounters.AddRange(encounter1, encounter2); - var thresholds = BuildDefaultThresholds(); - db.AlertThresholds.AddRange(thresholds); + // Thresholds may already exist from BootstrapSeeder — unique on observation_code. + List thresholds; + if (!await db.AlertThresholds.AnyAsync()) + { + thresholds = BuildDefaultThresholds(); + db.AlertThresholds.AddRange(thresholds); + } + else + { + thresholds = await db.AlertThresholds.ToListAsync(); + } // Observations spanning normal, warning, and critical ranges for encounter1 var now = DateTimeOffset.UtcNow; @@ -108,6 +117,21 @@ public static class DataSeeder await CacheThresholdsAsync(redis, thresholds); } + /// + /// Idempotent: inserts the default threshold catalogue only when the table is empty, + /// then warms Redis. Safe for production bootstrap. + /// + public static async Task EnsureDefaultThresholdsAsync(AppDbContext db, IConnectionMultiplexer redis) + { + if (await db.AlertThresholds.AnyAsync()) + return; + + var thresholds = BuildDefaultThresholds(); + db.AlertThresholds.AddRange(thresholds); + await db.SaveChangesAsync(); + await CacheThresholdsAsync(redis, thresholds); + } + public static List BuildDefaultThresholds() => new() { new() { diff --git a/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs b/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs index dd98b76..9b7e862 100644 --- a/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/GatewayRegistrySeeder.cs @@ -1,5 +1,9 @@ using Microsoft.EntityFrameworkCore; +/// +/// Demo site + gateway with fixed GUIDs. Production uses BootstrapSeeder / +/// register-gateway with GATEWAY_ID / GATEWAY_SITE_ID from the environment. +/// public static class GatewayRegistrySeeder { public static readonly Guid DemoSiteId = diff --git a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs index a551b71..5a41187 100644 --- a/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs +++ b/VigilCareClinicalAPI/Data/Seed/UserSeeder.cs @@ -1,5 +1,10 @@ using Microsoft.EntityFrameworkCore; +/// +/// Demo-only users with well-known passwords. Invoked only when +/// Seeding:EnableDemoData is true (never in production). Production bootstrap +/// accounts come from BootstrapSeeder + Seeding:Admin/Nurse/Physician config. +/// public static class UserSeeder { public static readonly Guid SimulationRunnerUserId = diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 0d6b91b..e35c589 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -170,9 +170,15 @@ try builder.Services.Configure( builder.Configuration.GetSection(SimulationOptions.Section)); + builder.Services.Configure( + builder.Configuration.GetSection(SeedingOptions.Section)); + var simulationOptions = builder.Configuration .GetSection(SimulationOptions.Section).Get() ?? new(); + var seedingOptions = builder.Configuration.GetSection(SeedingOptions.Section).Get() + ?? new SeedingOptions(); + if (simulationOptions.Enabled) { builder.Services.AddHttpClient("simulation-loopback", c => @@ -358,8 +364,8 @@ try var app = builder.Build(); - // One-shot CLI verbs exit before hosting. Skip demo seeding for them so - // create-admin / register-gateway never race the demo UserSeeder. + // One-shot CLI verbs exit before hosting. Skip seeding for them so + // create-admin / register-gateway never race auto-bootstrap. var isCliCommand = args.Contains("encrypt-phi") || args.Contains("create-admin") || args.Contains("register-gateway"); @@ -375,10 +381,24 @@ try // must never be created in production. Seeding:EnableDemoData defaults to // true so local development and the existing verification scripts are // unaffected; appsettings.Production.json sets it to false. - var enableDemoData = builder.Configuration.GetValue("Seeding:EnableDemoData", true) + var enableDemoData = seedingOptions.EnableDemoData && !app.Environment.IsEnvironment("Testing") && !isCliCommand; + var runBootstrap = !app.Environment.IsEnvironment("Testing") && !isCliCommand; + + if (runBootstrap) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var redis = scope.ServiceProvider.GetRequiredService(); + var bootstrapLogger = scope.ServiceProvider + .GetRequiredService() + .CreateLogger("BootstrapSeeder"); + // Always: default thresholds (+ optional bootstrap users / site+gateway from config). + await BootstrapSeeder.SeedAsync(db, redis, seedingOptions, bootstrapLogger); + } + if (enableDemoData) { using var scope = app.Services.CreateScope(); diff --git a/VigilCareClinicalAPI/appsettings.json b/VigilCareClinicalAPI/appsettings.json index 35f40e1..3221545 100644 --- a/VigilCareClinicalAPI/appsettings.json +++ b/VigilCareClinicalAPI/appsettings.json @@ -227,7 +227,10 @@ "Enabled": true }, "Seeding": { - "EnableDemoData": true + "EnableDemoData": true, + "Admin": null, + "Nurse": null, + "Physician": null }, "Simulation": { "Enabled": false, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 745eca0..083d9c7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -51,6 +51,22 @@ services: Fhir__ApiKey: "${FHIR_API_KEY}" Dashboard__CorsOrigins__0: "${DASHBOARD_ORIGIN}" Seeding__EnableDemoData: "false" + Seeding__Admin__Username: "${SEED_ADMIN_USERNAME:-admin}" + Seeding__Admin__Password: "${SEED_ADMIN_PASSWORD}" + Seeding__Admin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-System Admin}" + Seeding__Nurse__Username: "${SEED_NURSE_USERNAME:-nurse}" + Seeding__Nurse__Password: "${SEED_NURSE_PASSWORD}" + Seeding__Nurse__DisplayName: "${SEED_NURSE_DISPLAY_NAME:-Charge Nurse}" + Seeding__Physician__Username: "${SEED_PHYSICIAN_USERNAME:-physician}" + Seeding__Physician__Password: "${SEED_PHYSICIAN_PASSWORD}" + Seeding__Physician__DisplayName: "${SEED_PHYSICIAN_DISPLAY_NAME:-Attending Physician}" + Seeding__SiteId: "${GATEWAY_SITE_ID}" + Seeding__SiteCode: "${GATEWAY_SITE_CODE:-SITE-01}" + Seeding__SiteName: "${GATEWAY_SITE_NAME:-Primary Site}" + Seeding__SiteAddress: "${GATEWAY_SITE_ADDRESS:-}" + Seeding__GatewayId: "${GATEWAY_ID}" + Seeding__GatewayCode: "${GATEWAY_CODE:-GW-ICU-1}" + Seeding__GatewayDepartment: "${GATEWAY_DEPARTMENT:-ICU}" Swagger__Enabled: "false" volumes: # CRITICAL: the Data Protection keyring encrypts patient PHI. If this