mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
237 lines
9.5 KiB
JavaScript
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);
|
|
});
|