Files
edr-platform/apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs

237 lines
9.5 KiB
JavaScript

/**
* 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);
});