import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; import * as argon2 from "argon2"; import { DataSource, EntityManager } from "typeorm"; import { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed"; import { IAM_SEED_OPTIONS } from "./iam-seed.constants"; import { IamBaselineSeed, IamSeedOptions, LocalizedName, SettingDefault, } from "./iam-seed.types"; import { missingSettings } from "./missing-settings.util"; /** Owned by @tria-plc/iamapi-common's migrations; never created here. */ const SCHEMA = "iam"; const DEFAULT_ENABLE_FLAG = "SEED_IAM_BASELINE"; /** * Advisory lock key, held for the seed transaction. Every app using this package * takes the same key, so two services — or two replicas of one — can never seed * concurrently. Arbitrary constant; nothing else uses it. */ const SEED_LOCK_KEY = 748_231_905; /** Postgres caps a statement at 65535 parameters; stay far below it. */ const INSERT_CHUNK = 500; type KeyedRow = { id: string; key: string }; /** * Seeds the IAM baseline shared by every app on the `iam` schema, replacing * `DataSeeder` from `@tria-plc/iamapi-common`. * * The schema has more than one writer, rows may already exist partially, and the * apps resolve different versions of the IAM package. Every write is built for * that: * * - **Insert-only.** A row that already exists by key is left exactly as it is — * no name overwrite, and above all no id rewrite, which would break foreign * keys other apps already point at. Drift is logged, not corrected. * - **Ids resolved from the database**, never assumed from the seed constants. * - **`ON CONFLICT DO NOTHING` on every insert**, so a row appearing between the * read and the write is a no-op rather than a crash. * - **An advisory lock** around the whole transaction, shared by all consumers. * - **Nothing is deleted.** The package seeder wipes every * `position_type_permissions` row for the system position types on each run * and nulls their `unit_id`; this one does not. * - **Never fatal.** A failure is logged and boot continues; missing * prerequisites skip that section with a warning. * * Raw SQL throughout, deliberately: importing the package's entity classes would * tie this package to one copy of `@tria-plc/iamapi-common`, and TypeORM matches * entity metadata by class identity — the apps would need the exact same * resolved version forever. Column names come from the package's own migrations. * * Apps call `run()` themselves so it can be ordered against their own seeders. * Runs unless the enable flag (default SEED_IAM_BASELINE) is explicitly turned * off — insert-only makes seeding the safe default. */ @Injectable() export class IamBaselineSeeder { private readonly logger = new Logger(IamBaselineSeeder.name); private readonly seed: IamBaselineSeed; private readonly enableFlag: string; constructor( private readonly dataSource: DataSource, @Optional() @Inject(IAM_SEED_OPTIONS) options?: IamSeedOptions, ) { const { enableFlag, ...overrides } = options ?? {}; this.enableFlag = enableFlag ?? DEFAULT_ENABLE_FLAG; this.seed = { ...DEFAULT_IAM_BASELINE_SEED, ...overrides }; } async run() { // Opt-out, not opt-in: an unset flag seeds. Every write is insert-only, so // the safe default is "keep the baseline current" — a new environment that // forgot the variable gets a working IAM rather than an empty one. const flag = process.env[this.enableFlag]?.trim().toLowerCase(); if (flag === "false" || flag === "0" || flag === "off") { this.logger.log( `Skipping IAM baseline seed because ${this.enableFlag}=${flag}`, ); return; } try { await this.dataSource.transaction(async (manager) => { // Never wait forever on a row another service holds: the seed is // optional, boot is not. await manager.query("SET LOCAL lock_timeout = '15s'"); // Try, don't wait — another service seeding right now is a reason to // skip, not to queue. Released on commit or rollback. const [{ locked }] = (await manager.query( "SELECT pg_try_advisory_xact_lock($1) AS locked", [SEED_LOCK_KEY], )) as [{ locked: boolean }]; if (!locked) { this.logger.log( "Skipping IAM baseline seed: another service holds the seed lock", ); return; } await this.seedApplications(manager); await this.seedPermissions(manager); await this.seedRoles(manager); await this.seedRolePermissions(manager); await this.seedPositionTypes(manager); await this.seedPositionTypePermissions(manager); await this.seedOrganizationTypes(manager); await this.seedOrganizationSettings(manager); await this.seedUnitSettings(manager); await this.seedSuperAdmin(manager); }); this.logger.log("IAM baseline seed complete"); } catch (error) { // Boot must not depend on the seed: the schema is shared, and a lock // timeout or a row another service wrote first is not worth an outage. this.logger.error( `IAM baseline seed failed, continuing boot: ${ error instanceof Error ? error.message : String(error) }`, ); } } private async seedApplications(manager: EntityManager) { const { applications } = this.seed; if (applications.length === 0) { return; } const existing = await this.loadByKey( manager, "application", applications.map((application) => application.key), ); this.warnOnIdDrift(applications, existing, "applications"); const inserted = await this.insertIgnoringConflicts( manager, "application", ["id", "key", "name::jsonb"], applications .filter((application) => !existing.has(application.key)) .map((application) => [ application.id, application.key, JSON.stringify(application.name), ]), ); this.logger.log( `Applications: ${inserted} inserted, ${applications.length - inserted} already present`, ); } private async seedPermissions(manager: EntityManager) { const { permissions } = this.seed; if (permissions.length === 0) { return; } // Permissions without an applicationKey stay unlinked (application_id null), // which is how the package ships the org/unit/location ones. const applicationIdByKey = await this.loadIdsByKey( manager, "application", permissions.flatMap((permission) => permission.applicationKey ? [permission.applicationKey] : [], ), "application", ); const existing = await this.loadByKey( manager, "permissions", permissions.map((permission) => permission.key), ); this.warnOnIdDrift(permissions, existing, "permissions"); const inserted = await this.insertIgnoringConflicts( manager, "permissions", ["id", "key", "name::jsonb", "application_id"], permissions .filter((permission) => !existing.has(permission.key)) .map((permission) => [ permission.id, permission.key, JSON.stringify(permission.name), permission.applicationKey ? (applicationIdByKey.get(permission.applicationKey) ?? null) : null, ]), ); this.logger.log( `Permissions: ${inserted} inserted, ${permissions.length - inserted} already present`, ); } private async seedRoles(manager: EntityManager) { const { roles } = this.seed; if (roles.length === 0) { return; } const existing = await this.loadByKey( manager, "roles", roles.map((role) => role.key), ); this.warnOnIdDrift(roles, existing, "roles"); const inserted = await this.insertIgnoringConflicts( manager, "roles", ["id", "key", "name::jsonb"], roles .filter((role) => !existing.has(role.key)) .map((role) => [role.id, role.key, JSON.stringify(role.name)]), ); this.logger.log( `Roles: ${inserted} inserted, ${roles.length - inserted} already present`, ); } private async seedRolePermissions(manager: EntityManager) { const { rolePermissions } = this.seed; if (rolePermissions.length === 0) { return; } const roleIdByKey = await this.loadIdsByKey( manager, "roles", rolePermissions.map((mapping) => mapping.roleKey), "role", ); const permissionIdByKey = await this.loadIdsByKey( manager, "permissions", rolePermissions.flatMap((mapping) => mapping.permissionKeys), "permission", ); const existingPairs = await this.loadPairs( manager, "role_permissions", "role_id", "permission_id", [...roleIdByKey.values()], ); const rows = rolePermissions.flatMap((mapping) => { const roleId = roleIdByKey.get(mapping.roleKey); if (!roleId) { return []; } return mapping.permissionKeys.flatMap((key) => { const permissionId = permissionIdByKey.get(key); if (!permissionId || existingPairs.has(`${roleId}:${permissionId}`)) { return []; } return [[roleId, permissionId]]; }); }); const inserted = await this.insertIgnoringConflicts( manager, "role_permissions", ["role_id", "permission_id"], rows, ); if (inserted > 0) { this.logger.log(`Granted ${inserted} role permissions`); } } private async seedPositionTypes(manager: EntityManager) { const { positionTypes } = this.seed; if (positionTypes.length === 0) { return; } const existing = await this.loadByKey( manager, "position_types", positionTypes.map((positionType) => positionType.key), ); this.warnOnIdDrift(positionTypes, existing, "position types"); // unit_id is intentionally left alone — the package seeder resets it to null. const inserted = await this.insertIgnoringConflicts( manager, "position_types", ["id", "key", "name::jsonb", "is_system"], positionTypes .filter((positionType) => !existing.has(positionType.key)) .map((positionType) => [ positionType.id, positionType.key, JSON.stringify(positionType.name), positionType.isSystem, ]), ); this.logger.log( `Position types: ${inserted} inserted, ${positionTypes.length - inserted} already present`, ); } private async seedPositionTypePermissions(manager: EntityManager) { const { positionTypePermissions } = this.seed; if (positionTypePermissions.length === 0) { return; } const positionTypeIdByKey = await this.loadIdsByKey( manager, "position_types", positionTypePermissions.map((mapping) => mapping.positionTypeKey), "position type", ); const permissionIdByKey = await this.loadIdsByKey( manager, "permissions", positionTypePermissions.flatMap((mapping) => mapping.permissionKeys), "permission", ); const existingPairs = await this.loadPairs( manager, "position_type_permissions", "position_type_id", "permission_id", [...positionTypeIdByKey.values()], ); const rows = positionTypePermissions.flatMap((mapping) => { const positionTypeId = positionTypeIdByKey.get(mapping.positionTypeKey); if (!positionTypeId) { return []; } return mapping.permissionKeys.flatMap((key) => { const permissionId = permissionIdByKey.get(key); if ( !permissionId || existingPairs.has(`${positionTypeId}:${permissionId}`) ) { return []; } return [[positionTypeId, permissionId]]; }); }); const inserted = await this.insertIgnoringConflicts( manager, "position_type_permissions", ["position_type_id", "permission_id"], rows, ); if (inserted > 0) { this.logger.log(`Granted ${inserted} position type permissions`); } } private async seedOrganizationTypes(manager: EntityManager) { const { organizationTypes } = this.seed; if (organizationTypes.length === 0) { return; } const existing = await this.loadByKey( manager, "organization_types", organizationTypes.map((organizationType) => organizationType.key), ); const insertedTypes = await this.insertIgnoringConflicts( manager, "organization_types", ["key", "name::jsonb"], organizationTypes .filter((organizationType) => !existing.has(organizationType.key)) .map((organizationType) => [ organizationType.key, JSON.stringify(organizationType.name), ]), ); // Re-read: ids are database-generated here, unlike the keyed catalogs above. const typeIdByKey = await this.loadIdsByKey( manager, "organization_types", organizationTypes.map((organizationType) => organizationType.key), "organization type", ); const existingUnits = await this.loadPairs( manager, "default_units", "organization_type_id", "key", [...typeIdByKey.values()], ); const unitRows = organizationTypes.flatMap((organizationType) => { const typeId = typeIdByKey.get(organizationType.key); if (!typeId) { return []; } return organizationType.defaultUnits .filter((unit) => !existingUnits.has(`${typeId}:${unit.key}`)) .map((unit) => [ unit.key, JSON.stringify(unit.name), unit.description, typeId, ]); }); const insertedUnits = await this.insertIgnoringConflicts( manager, "default_units", ["key", "name::jsonb", "description", "organization_type_id"], unitRows, ); this.logger.log( `Organization types: ${insertedTypes} inserted, ${insertedUnits} default units inserted`, ); } private async seedOrganizationSettings(manager: EntityManager) { const defaults = this.seed.organizationSettings; if (defaults.length === 0) { return; } const owners = (await manager.query( `SELECT id FROM ${SCHEMA}.organizations`, )) as { id: string }[]; const inserted = await this.insertOwnerSettings( manager, "organization_settings", "organization_id", owners, defaults, ); if (inserted > 0) { this.logger.log( `Seeded ${inserted} organization settings across ${owners.length} organizations`, ); } } private async seedUnitSettings(manager: EntityManager) { const defaults = this.seed.unitSettings; if (defaults.length === 0) { return; } const owners = (await manager.query(`SELECT id FROM ${SCHEMA}.units`)) as { id: string; }[]; const inserted = await this.insertOwnerSettings( manager, "unit_settings", "unit_id", owners, defaults, ); if (inserted > 0) { this.logger.log( `Seeded ${inserted} unit settings across ${owners.length} units`, ); } } /** Settings rows missing for each owner. Matched on (owner, key), never on id. */ private async insertOwnerSettings( manager: EntityManager, table: string, ownerColumn: string, owners: { id: string }[], defaults: SettingDefault[], ): Promise { if (owners.length === 0) { return 0; } const existing = (await manager.query( `SELECT "${ownerColumn}" AS owner, key FROM ${SCHEMA}.${table}`, )) as { owner: string; key: string }[]; const existingPairs = new Set( existing.map((row) => `${row.owner}:${row.key}`), ); const rows = owners.flatMap((owner) => missingSettings(defaults, existingPairs, owner.id).map((setting) => [ owner.id, setting.key, setting.displayName, setting.type, setting.value ?? null, ]), ); return this.insertIgnoringConflicts( manager, table, [ownerColumn, "key", "display_name", "type", "value"], rows, ); } /** * User + credential + employee + role grant. Every step is skip-if-present: an * existing account keeps its password, its organizations and any extra roles it * was given through the IAM UI. Apps sharing this schema share the account and * each attach their own employee row. */ private async seedSuperAdmin(manager: EntityManager) { const seed = this.seed.superAdmin; if (!seed) { return; } const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail; // SUPER_ADMIN_DEFAULT_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. There // is deliberately no hardcoded default — see SeedSuperAdmin. const password = process.env.SUPER_ADMIN_DEFAULT_PASSWORD?.trim() || process.env.DEFAULT_PASSWORD?.trim(); const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || null; const roleId = ( await this.loadIdsByKey(manager, "roles", [seed.roleKey], "role") ).get(seed.roleKey); if (!roleId) { this.logger.warn( `Skipping super admin: role '${seed.roleKey}' is not in the database`, ); return; } // The organization is optional. Access rides on the role grant, whose // organization_id is nullable, and login does not require an employee — so // the account is created either way and the employee row attaches on a later // run, once the app's own org seeder (gated on its own flag) has run. const [organization] = (await manager.query( `SELECT id FROM ${SCHEMA}.organizations WHERE key = $1 LIMIT 1`, [seed.organizationKey], )) as { id: string }[]; const organizationId = organization?.id ?? null; if (!organizationId) { this.logger.warn( `Organization '${seed.organizationKey}' does not exist yet: seeding the super admin without an employee record`, ); } let unitId: string | null = null; if (seed.unitKey && organizationId) { const [unit] = (await manager.query( `SELECT id FROM ${SCHEMA}.units WHERE key = $1 AND organization_id = $2 LIMIT 1`, [seed.unitKey, organizationId], )) as { id: string }[]; if (!unit) { this.logger.warn( `Super admin unit '${seed.unitKey}' not found, attaching without a unit`, ); } unitId = unit?.id ?? null; } const userId = await this.ensureSuperAdminUser(manager, { username: seed.username, email, phoneNumber, name: seed.name, }); if (!userId) { this.logger.warn("Skipping super admin: could not resolve the user row"); return; } const [credential] = (await manager.query( `SELECT id FROM ${SCHEMA}.user_credentials WHERE user_id = $1 LIMIT 1`, [userId], )) as { id: string }[]; if (!credential && !password) { this.logger.warn( `Super admin '${seed.username}' has no credential: set SUPER_ADMIN_DEFAULT_PASSWORD or DEFAULT_PASSWORD, or set the password through the IAM reset flow`, ); } if (!credential && password) { await this.insertIgnoringConflicts( manager, "user_credentials", ["user_id", "password", "is_active"], [[userId, await argon2.hash(password), true]], ); this.logger.log(`Seeded super admin credential for '${seed.username}'`); } const [employee] = organizationId ? ((await manager.query( `SELECT id FROM ${SCHEMA}.employees WHERE user_id = $1 AND organization_id = $2 LIMIT 1`, [userId, organizationId], )) as { id: string }[]) : [undefined]; if (organizationId && !employee) { await this.insertIgnoringConflicts( manager, "employees", [ "user_id", "organization_id", "unit_id", "is_current", "status", "name::jsonb", ], [ [ userId, organizationId, unitId, true, "accepted", JSON.stringify(seed.name), ], ], ); this.logger.log( `Attached super admin to organization '${seed.organizationKey}'`, ); } // user_roles is UNIQUE (user_id, role_id), so a concurrent grant is a no-op. await this.insertIgnoringConflicts( manager, "user_roles", ["user_id", "role_id", "organization_id", "unit_id"], [[userId, roleId, organizationId, unitId]], ); this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`); } /** * The super-admin user row, whether we create it or another service already * did. Matches on username OR email because either is enough to make the * insert fail on its unique index. */ private async ensureSuperAdminUser( manager: EntityManager, account: { username: string; email: string; phoneNumber: string | null; name: LocalizedName; }, ): Promise { const find = async () => { const [row] = (await manager.query( `SELECT id FROM ${SCHEMA}.users WHERE username = $1 OR email = $2 LIMIT 1`, [account.username, account.email], )) as { id: string }[]; return row?.id; }; const existing = await find(); if (existing) { return existing; } await this.insertIgnoringConflicts( manager, "users", [ "username", "email", "phone_number", "name::jsonb", "user_type", "status", "is_active", "has_set_password", ], [ [ account.username, account.email, account.phoneNumber, JSON.stringify(account.name), "employee", "accepted", true, true, ], ], ); // Re-read rather than trusting the insert: the row may have been skipped // because another service created it a moment earlier. const created = await find(); if (created) { this.logger.log( `Seeded super admin user '${account.username}' (${account.email})`, ); } return created; } /** * Report seed rows whose stored id differs from ours — a sign this environment * was seeded by something else, and the reason links are resolved by key. */ private warnOnIdDrift( rows: { id: string; key: string }[], existing: Map, label: string, ) { const drifted = rows.filter((row) => { const stored = existing.get(row.key); return stored && stored.id !== row.id; }); if (drifted.length > 0) { this.logger.warn( `${drifted.length} ${label} exist under a different id than the seed (left untouched): ${drifted .map((row) => row.key) .join(", ")}`, ); } } /** * `INSERT … ON CONFLICT DO NOTHING`, chunked. Every table here is written by * more than one service, so losing a race must cost nothing. * * A column spec may carry a cast for non-text types — `"name::jsonb"`. */ private async insertIgnoringConflicts( manager: EntityManager, table: string, columnSpecs: string[], rows: unknown[][], ): Promise { if (rows.length === 0) { return 0; } const columns = columnSpecs.map((spec) => spec.split("::")[0]); const casts = columnSpecs.map((spec) => { const [, cast] = spec.split("::"); return cast ? `::${cast}` : ""; }); const columnList = columns.map((column) => `"${column}"`).join(", "); let inserted = 0; for (let start = 0; start < rows.length; start += INSERT_CHUNK) { const chunk = rows.slice(start, start + INSERT_CHUNK); const params: unknown[] = []; const tuples = chunk.map((row) => { const placeholders = row.map((value, columnIndex) => { params.push(value); return `$${params.length}${casts[columnIndex]}`; }); return `(${placeholders.join(", ")})`; }); const result = (await manager.query( `INSERT INTO ${SCHEMA}.${table} (${columnList}) VALUES ${tuples.join(", ")} ON CONFLICT DO NOTHING RETURNING id`, params, )) as unknown[]; inserted += Array.isArray(result) ? result.length : 0; } return inserted; } /** Existing rows for the given keys, by key. */ private async loadByKey( manager: EntityManager, table: string, keys: string[], ): Promise> { const wanted = [...new Set(keys)]; if (wanted.length === 0) { return new Map(); } const rows = (await manager.query( `SELECT id, key FROM ${SCHEMA}.${table} WHERE key = ANY($1)`, [wanted], )) as KeyedRow[]; return new Map(rows.map((row) => [row.key, row])); } /** * Resolve `key → id`. Keys with no row are warned about and left out: whatever * references them is skipped rather than failing the whole seed, since another * service may own that row. */ private async loadIdsByKey( manager: EntityManager, table: string, keys: string[], label: string, ): Promise> { const wanted = [...new Set(keys)]; const rows = await this.loadByKey(manager, table, wanted); const idByKey = new Map( [...rows.values()].map((row) => [row.key, row.id] as [string, string]), ); const missing = wanted.filter((key) => !idByKey.has(key)); if (missing.length > 0) { this.logger.warn( `Unresolved ${label} keys, anything referencing them is skipped: ${missing.join(", ")}`, ); } return idByKey; } /** Existing `left:right` pairs of a link table, for the given left-hand ids. */ private async loadPairs( manager: EntityManager, table: string, leftColumn: string, rightColumn: string, leftIds: string[], ): Promise> { if (leftIds.length === 0) { return new Set(); } const rows = (await manager.query( `SELECT "${leftColumn}" AS left_value, "${rightColumn}" AS right_value FROM ${SCHEMA}.${table} WHERE "${leftColumn}" = ANY($1)`, [leftIds], )) as { left_value: string; right_value: string }[]; return new Set(rows.map((row) => `${row.left_value}:${row.right_value}`)); } }