/** * Create the three legacy backoffice roles and their users. * * Deliberately a standalone CLI, NOT part of the app lifecycle: nothing here runs on * `onApplicationBootstrap`, so a deploy can decide when accounts appear. Contrast * `EdrPassengerOrgSeeder` / `PassengerStaffUsersSeeder`, which run at boot behind * SEED_EDR_PASSENGER_ORG / SEED_PASSENGER_STAFF. * * pnpm --filter @edr/passenger-api iam:seed-legacy-users * pnpm --filter @edr/passenger-api iam:seed-legacy-users -- --dry-run * * The roles below carry ONLY the permission keys that existed before the granular * create/edit/delete change (47 of them). None of the newer narrow keys are granted. * That is the point: these three model how the app was used before, so running them * against the new guards proves the granular change stayed backwards-compatible. * * Idempotent, and safe to re-run: roles and users are upserted by their natural key, * and each role's permission links are synced to exactly the set declared here — * extras are pruned so the file stays the source of truth. * * Requires DATABASE_* in the environment (`node --env-file=.env` does this). * The password comes from SEED_USER_PASSWORD, falling back to DEFAULT_PASSWORD. * In production one of them MUST be set — there is no built-in default there. */ const { DataSource } = require('typeorm'); const { hashPassword } = require('@tria-plc/api-common/utils/argon'); const APP = 'edr_passenger_app'; const ORG_KEY = 'edr'; const DRY_RUN = process.argv.includes('--dry-run'); const p = (s) => `${APP}:${s}`; /** Every `:view` / `:view_all` key that existed before the granular change. */ const LEGACY_VIEW_SUFFIXES = [ 'agents:view', 'audit:view', 'bookings:view', 'classes:view', 'coaches:view', 'currencies:view', 'dashboard:view', 'fraud:view', 'inquiries:view', 'packages:view', 'passengers:view', 'payment_methods:view', 'payments:view', 'payments:view_all', 'reports:view', 'routes:view', 'schedules:view', 'seats:view', 'stations:view', 'tariff_rates:view', 'tickets:view', 'trains:view', ]; /** The rest of the pre-granular registry — 25 non-view keys. */ const LEGACY_WRITE_SUFFIXES = [ 'admin', 'agents:manage', 'bookings:cancel', 'bookings:manage', 'bookings:reschedule', 'classes:manage', 'coaches:manage', 'currencies:manage', 'fraud:manage', 'inquiries:manage', 'notifications:send', 'packages:manage', 'passengers:manage', 'payment_methods:manage', 'payments:manage', 'payments:manage_methods', 'payments:refund', 'routes:manage', 'schedules:manage', 'seats:manage', 'stations:manage', 'tariff_rates:manage', 'tickets:generate', 'tickets:manage', 'trains:manage', ]; const LEGACY_VIEW = LEGACY_VIEW_SUFFIXES.map(p); const LEGACY_ALL = [...LEGACY_VIEW_SUFFIXES, ...LEGACY_WRITE_SUFFIXES].map(p); /** * Withheld from the chief. `payment_methods:manage` and the legacy alias * `payments:manage_methods` both open POST/PATCH /payments/methods, so excluding only * one would leave the ability intact — both have to go for "no managing payment * methods" to actually hold. */ const CHIEF_EXCLUDED = [ p('payment_methods:manage'), p('payments:manage_methods'), p('tickets:generate'), p('admin'), ]; const ROLES = [ { key: 'old_ticketofficer', name: { en: 'Ticket Officer (legacy)', am: 'የቲኬት ኦፊሰር' }, email: 'old.ticketofficer@edr.local', username: 'old_ticketofficer', permissions: [ p('passengers:view'), p('bookings:view'), p('tickets:view'), p('tickets:manage'), p('payments:view'), p('payments:manage'), ], }, { key: 'old_passengerchief', name: { en: 'Passenger Chief (legacy)', am: 'የተሳፋሪ ኃላፊ' }, email: 'old.passengerchief@edr.local', username: 'old_passengerchief', permissions: LEGACY_ALL.filter((k) => !CHIEF_EXCLUDED.includes(k)), }, { key: 'old_passengerdirector', name: { en: 'Passenger Director (legacy)', am: 'የተሳፋሪ ዳይሬክተር' }, email: 'old.passengerdirector@edr.local', username: 'old_passengerdirector', permissions: LEGACY_VIEW, }, ]; function resolvePassword() { const pw = (process.env.SEED_USER_PASSWORD || process.env.DEFAULT_PASSWORD || '').trim(); if (pw) return pw; if (process.env.NODE_ENV === 'production') { throw new Error('SEED_USER_PASSWORD (or DEFAULT_PASSWORD) must be set in production'); } return '12345678'; } const ds = new DataSource({ type: 'postgres', host: process.env.DATABASE_HOST, port: Number(process.env.DATABASE_PORT || 5432), database: process.env.DATABASE_NAME, username: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, }); (async () => { const password = resolvePassword(); await ds.initialize(); // Fail before writing anything if a key is not seeded — a typo here would otherwise // create a role that silently grants less than intended. const wanted = [...new Set(ROLES.flatMap((r) => r.permissions))]; const found = await ds.query( `SELECT key FROM iam.permissions WHERE key = ANY($1::text[])`, [wanted], ); const missing = wanted.filter((k) => !found.some((f) => f.key === k)); if (missing.length) { throw new Error( `these permission keys are not in iam.permissions — run the app once with ` + `SEED_EDR_PASSENGER_ORG=true first:\n ${missing.join('\n ')}`, ); } const [org] = await ds.query(`SELECT id FROM iam.organizations WHERE key = $1`, [ORG_KEY]); if (!org) throw new Error(`missing_organization:${ORG_KEY}`); if (DRY_RUN) { console.log('\n=== DRY RUN — nothing written ==='); for (const r of ROLES) { console.log(`\n${r.key} (${r.email}) ${r.permissions.length} permissions`); for (const k of [...r.permissions].sort()) console.log(' ', k); } await ds.destroy(); return; } const hashed = await hashPassword(password); await ds.transaction(async (m) => { for (const r of ROLES) { const [role] = await m.query( `INSERT INTO iam.roles (id, key, name, created_at, updated_at) VALUES (gen_random_uuid(), $1, $2::jsonb, now(), now()) ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, updated_at = now() RETURNING id`, [r.key, JSON.stringify(r.name)], ); // Sync links to exactly this set: add what is missing, drop what is extra. await m.query( `INSERT INTO iam.role_permissions (id, role_id, permission_id, created_at, updated_at) SELECT gen_random_uuid(), $1, p.id, now(), now() FROM iam.permissions p WHERE p.key = ANY($2::text[]) AND NOT EXISTS (SELECT 1 FROM iam.role_permissions rp WHERE rp.role_id = $1 AND rp.permission_id = p.id)`, [role.id, r.permissions], ); // TypeORM's postgres driver returns `[rows, affectedCount]` for a DELETE ... RETURNING, // so the rows are at [0] — reading `.length` off the outer array would report 2 every time. const deleted = await m.query( `DELETE FROM iam.role_permissions rp USING iam.permissions p WHERE rp.permission_id = p.id AND rp.role_id = $1 AND NOT (p.key = ANY($2::text[])) RETURNING rp.id`, [role.id, r.permissions], ); const prunedCount = (Array.isArray(deleted[0]) ? deleted[0] : deleted).length; const [user] = await m.query( `INSERT INTO iam.users (id, name, username, email, user_type, status, is_active, has_set_password, created_at, updated_at) VALUES (gen_random_uuid(), $1::jsonb, $2, $3, 'individual', 'accepted', true, true, now(), now()) ON CONFLICT (email) DO UPDATE SET updated_at = now() RETURNING id`, [JSON.stringify(r.name), r.username, r.email], ); // Never overwrite a password that already exists — re-running must not reset a // credential someone has since changed. await m.query( `INSERT INTO iam.user_credentials (id, user_id, password, is_active, created_at, updated_at) SELECT gen_random_uuid(), $1, $2, true, now(), now() WHERE NOT EXISTS (SELECT 1 FROM iam.user_credentials WHERE user_id = $1 AND is_active = true)`, [user.id, hashed], ); await m.query( `INSERT INTO iam.user_roles (id, user_id, role_id, organization_id, created_at, updated_at) SELECT gen_random_uuid(), $1, $2, $3, now(), now() WHERE NOT EXISTS (SELECT 1 FROM iam.user_roles WHERE user_id = $1 AND role_id = $2)`, [user.id, role.id, org.id], ); await m.query( `INSERT INTO iam.employees (id, user_id, organization_id, is_current, name, created_at, updated_at) SELECT gen_random_uuid(), $1, $2, true, $3::jsonb, now(), now() WHERE NOT EXISTS (SELECT 1 FROM iam.employees WHERE user_id = $1 AND organization_id = $2 AND is_current = true)`, [user.id, org.id, JSON.stringify(r.name)], ); console.log( ` ${r.key.padEnd(24)} ${String(r.permissions.length).padStart(2)} permissions` + `${prunedCount ? ` (${prunedCount} stale link(s) pruned)` : ''} -> ${r.email}`, ); } }); console.log('\nDone. Sign in with the email above and the seeded password.'); console.log('Re-running is safe; an existing password is never overwritten.\n'); await ds.destroy(); })().catch((e) => { console.error('[seed-legacy-role-users] FAIL:', e.message); process.exit(1); });