#!/usr/bin/env node /** * Scoped, idempotent seeder for one module's IAM application + permissions + * roles + role_permissions. * * WHY THIS EXISTS rather than `pnpm run seed:hr`: * The vendor seeder (`@tria-plc/iamapi-common`'s seed.cli.js) re-seeds IAM's * ENTIRE baseline, and aborts the whole transaction when a baseline row it * wants to write already exists under a different id — which is the case on * every database restored from a dump taken at a different package version: * * Role seed conflicts with existing data, refusing to write: * "people_operation" is id 9f9c0c2e-… in the database but the seed assigns * it id 7bbf4e76-… — this would orphan every FK pointing at the old id * * That refusal is correct — it protects rows another product owns. But it also * means HR's and Finance's own rows can never be written on those databases. * This script writes ONLY the module's own rows, by the stable ids the module's * registry declares, and touches nothing it does not own. * * Usage (from the repo root): * DB_NAME=smart_office_e2e node scripts/seed-module-permissions.cjs hr * DB_NAME=smart_office_e2e node scripts/seed-module-permissions.cjs finance * * Env: DB_HOST DB_PORT DB_USER DB_PASSWORD DB_NAME (same convention as the apps). * Re-running is a no-op — every insert is ON CONFLICT DO NOTHING. */ const path = require("path"); const MODULES = { hr: { dist: "apps/edr-hr-api/dist/seed/hr-permissions.registry.js", application: "HR_APPLICATION", permissions: "HR_PERMISSIONS", roles: "HR_ROLES", rolePermissions: "HR_ROLE_PERMISSIONS", // `pg` resolves from the app that owns the registry. pgFrom: "apps/edr-hr-api", buildFilter: "@edr/hr-api", }, finance: { dist: "apps/finance-api/dist/seed/finance-permissions.registry.js", application: "FINANCE_APPLICATION", permissions: "FINANCE_PERMISSIONS", roles: "FINANCE_ROLES", rolePermissions: "FINANCE_ROLE_PERMISSIONS", pgFrom: "apps/finance-api", buildFilter: "@edr/finance-api", }, }; async function main() { const moduleName = process.argv[2]; const spec = MODULES[moduleName]; if (!spec) { console.error( `Usage: DB_NAME= node scripts/seed-module-permissions.cjs <${Object.keys(MODULES).join("|")}>`, ); process.exit(1); } const root = path.resolve(__dirname, ".."); const registryPath = path.join(root, spec.dist); let registry; try { registry = require(registryPath); } catch (err) { console.error( `Cannot load ${spec.dist}. Build the app first:\n` + ` pnpm turbo build --filter=${spec.buildFilter}\n${err.message}`, ); process.exit(1); } const { Client } = require( require.resolve("pg", { paths: [path.join(root, spec.pgFrom)] }), ); const application = registry[spec.application]; const permissions = registry[spec.permissions]; const roles = registry[spec.roles]; const rolePermissions = registry[spec.rolePermissions]; const dbName = process.env.DB_NAME; if (!dbName) { console.error("DB_NAME is required — refusing to guess which database to write to."); process.exit(1); } const client = new Client({ host: process.env.DB_HOST || "localhost", port: Number(process.env.DB_PORT || 5432), user: process.env.DB_USER || "postgres", password: process.env.DB_PASSWORD, database: dbName, }); await client.connect(); console.log(`[${moduleName}] → ${dbName}`); try { await client.query("BEGIN"); await client.query( `INSERT INTO iam.application (id, key, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`, [application.id, application.key, JSON.stringify(application.name)], ); // Guard: a key collision against a row owned by something else means the // registry's stable id and the database disagree. Writing would either fail // on the unique index or silently do nothing while the module keeps using // its own id — both worse than stopping. This has fired for real: a // hand-made `e2e_hr_employee` role in the e2e database had duplicated five // HR keys under ids the registry did not know. const keys = permissions.map((p) => p.key); const { rows: clashes } = await client.query( `SELECT id, key FROM iam.permissions WHERE key = ANY($1::text[]) AND id <> ALL($2::uuid[])`, [keys, permissions.map((p) => p.id)], ); if (clashes.length) { throw new Error( `Permission key(s) already exist under a different id — refusing to write:\n` + clashes.map((c) => ` ${c.key} is ${c.id}`).join("\n"), ); } for (const p of permissions) { await client.query( `INSERT INTO iam.permissions (id, key, name, application_id) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING`, [p.id, p.key, JSON.stringify(p.name), application.id], ); } for (const r of roles) { await client.query( `INSERT INTO iam.roles (id, key, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING`, [r.id, r.key, JSON.stringify(r.name)], ); } const permIdByKey = new Map(permissions.map((p) => [p.key, p.id])); const roleIdByKey = new Map(roles.map((r) => [r.key, r.id])); let grants = 0; for (const grant of rolePermissions) { const roleId = roleIdByKey.get(grant.roleKey); if (!roleId) throw new Error(`Unknown roleKey ${grant.roleKey}`); for (const permKey of grant.permissionKeys) { const permId = permIdByKey.get(permKey); if (!permId) throw new Error(`Unknown permission key ${permKey}`); await client.query( `INSERT INTO iam.role_permissions (role_id, permission_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [roleId, permId], ); grants++; } } await client.query("COMMIT"); console.log( ` ✓ application "${application.key}" · ${permissions.length} permissions · ` + `${roles.length} roles · ${grants} grants`, ); } catch (err) { await client.query("ROLLBACK"); console.error(` ✗ rolled back: ${err.message}`); process.exitCode = 1; } finally { await client.end(); } } main();