Files
edr-platform/packages/iam-seed/src/iam-baseline.seeder.ts
2026-07-27 08:42:27 +00:00

876 lines
26 KiB
TypeScript

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<number> {
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<string | undefined> {
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<string, KeyedRow>,
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<number> {
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<Map<string, KeyedRow>> {
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<Map<string, string>> {
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<Set<string>> {
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}`));
}
}