From 03b8ca0ce7bb11aed1d1bbb7e02709333eff4b16 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 27 Jul 2026 08:28:57 +0000 Subject: [PATCH] refactor: centralized the iam seeder --- CLAUDE.md | 1 + apps/edr-freight-api/.env.example | 12 +- apps/edr-freight-api/package.json | 2 +- apps/edr-freight-api/src/app.module.ts | 34 +- .../src/seed/iam-baseline.seeder.ts | 468 ---------- apps/edr-passenger-api/.env.example | 11 + apps/edr-passenger-api/package.json | 1 + apps/edr-passenger-api/src/app.module.ts | 24 +- packages/iam-seed/package.json | 50 + .../iam-seed/src}/iam-baseline.seed.ts | 232 +++-- packages/iam-seed/src/iam-baseline.seeder.ts | 875 ++++++++++++++++++ packages/iam-seed/src/iam-seed.constants.ts | 2 + packages/iam-seed/src/iam-seed.module.ts | 39 + packages/iam-seed/src/iam-seed.types.ts | 100 ++ packages/iam-seed/src/index.ts | 20 + .../src}/missing-settings.util.spec.ts | 0 .../iam-seed/src}/missing-settings.util.ts | 6 +- packages/iam-seed/tsconfig.json | 11 + pnpm-lock.yaml | 43 + 19 files changed, 1339 insertions(+), 592 deletions(-) delete mode 100644 apps/edr-freight-api/src/seed/iam-baseline.seeder.ts create mode 100644 packages/iam-seed/package.json rename {apps/edr-freight-api/src/seed => packages/iam-seed/src}/iam-baseline.seed.ts (52%) create mode 100644 packages/iam-seed/src/iam-baseline.seeder.ts create mode 100644 packages/iam-seed/src/iam-seed.constants.ts create mode 100644 packages/iam-seed/src/iam-seed.module.ts create mode 100644 packages/iam-seed/src/iam-seed.types.ts create mode 100644 packages/iam-seed/src/index.ts rename {apps/edr-freight-api/src/seed => packages/iam-seed/src}/missing-settings.util.spec.ts (100%) rename {apps/edr-freight-api/src/seed => packages/iam-seed/src}/missing-settings.util.ts (84%) create mode 100644 packages/iam-seed/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index d67e6c3d5..b90d3b1dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | ---------------------- | ---------------------------------------------------------------------------------- | | `@edr/types` | Shared TypeScript interfaces and enums | | `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | | `@edr/ui-common` | Shared React components and theme | | `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | | `@edr/tsconfig` | Shared TypeScript configurations | diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 4df20a9a2..58fb60b18 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -43,13 +43,15 @@ JWT_REFRESH_TOKEN_EXPIRES=7d SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= # Super-admin password. Falls back to DEFAULT_PASSWORD when empty. -SUPER_ADMIN_PASSWORD= +SUPER_ADMIN_DEFAULT_PASSWORD= DEFAULT_PASSWORD=password@tria -# IAM baseline (roles, IAM app + permissions, position types, org/unit settings, -# super-admin account). Local replacement for the package DataSeeder — -# see src/seed/iam-baseline.seed.ts. -SEED_IAM_BASELINE=false +# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions, +# position types, organization types + default units, org/unit settings, super +# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see +# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only. +# Set to false to opt out. +SEED_IAM_BASELINE=true # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 66909a037..0531d056b 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -35,12 +35,12 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", + "@edr/iam-seed": "workspace:*", "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 2e2da880f..64df33f35 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,6 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; +import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; @@ -55,7 +56,6 @@ import { EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; -import { IamBaselineSeeder } from "./seed/iam-baseline.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; // Disabled seeds — imports commented out with their provider/injection/run below. // import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @@ -154,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware"; applications: [EDR_FREIGHT_APPLICATION], permissions: EDR_FREIGHT_PERMISSIONS, }), + // Replaces the package's DataSeeder. Shared with edr-passenger-api, which + // seeds the same `iam` schema — see packages/iam-seed. + IamSeedModule.forRoot({ + superAdmin: { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr_freight", + unitKey: "edr_freight_app", + fallbackEmail: "superadmin@tria.com", + }, + }), BookingsModule, ContractsModule, SignaturesModule, @@ -207,7 +219,6 @@ import { LoggerMiddleware } from "./logger.middleware"; AiModule, ], providers: [ - IamBaselineSeeder, EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, @@ -264,16 +275,15 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys // edrOrgSeeder → seeds org/unit + the Permission catalog - // iamBaselineSeeder → seeds the IAM app, roles, permissions, - // position types, org/unit settings and the - // super-admin account. Local replacement for - // the package's DataSeeder (still exported as - // `DataSeeder` from @tria-plc/iamapi-common - // and runnable via `pnpm iam:seed:run`) — - // see src/seed/iam-baseline.seed.ts for - // what it seeds and what it drops. Runs after - // edrOrgSeeder because the super admin is - // attached to the edr_freight org/unit. + // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions, + // position types, organization types + + // default units, org/unit settings and the + // super-admin account. Replaces the package's + // DataSeeder, and is shared with + // edr-passenger-api so one writer owns the + // `iam` schema. Runs after edrOrgSeeder + // because the super admin attaches to the + // edr_freight org/unit. // Writes nothing unless SEED_IAM_BASELINE=true. // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts b/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts deleted file mode 100644 index 9c718710a..000000000 --- a/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { hashPassword } from "@tria-plc/api-common/utils/argon"; -import { - Application, - EEmployeeStatus, - Employee, - EUserStatus, - EUserType, - Organization, - Permission, - PositionType, - PositionTypePermission, - Role, - RolePermission, - Unit, - UnitSetting, - User, - UserCredential, - UserRole, -} from "@tria-plc/iamapi-common"; -// Not re-exported from the package root, unlike UnitSetting. -import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; -import type { ESettingType } from "@tria-plc/iamapi-common/enums/setting-type.enum"; -import { - DataSource, - EntityManager, - EntityTarget, - In, - ObjectLiteral, -} from "typeorm"; - -import { IAM_BASELINE_SEED } from "./iam-baseline.seed"; -import { missingSettings } from "./missing-settings.util"; - -const SEED_FLAG = "SEED_IAM_BASELINE"; - -/** - * Local stand-in for `DataSeeder` from `@tria-plc/iamapi-common`, seeding only - * what `IAM_BASELINE_SEED` lists (see that file for what was deliberately left - * out). Differences from the upstream seeder, all deliberate: - * - * - Nothing is deleted. Upstream wipes every `position_type_permissions` row - * for the 6 system position types on each run and nulls their `unit_id`. - * - Settings are matched on (organization|unit, key). Upstream upserts them on - * `id`, which it never supplies, so every run inserts a duplicate set. - * - Ids are resolved from the database by key rather than from the seed - * constants, so rows that already exist under a different id still link up. - * - * Gated behind SEED_IAM_BASELINE=true so a normal boot never writes. - */ -@Injectable() -export class IamBaselineSeeder { - private readonly logger = new Logger(IamBaselineSeeder.name); - - constructor(private readonly dataSource: DataSource) {} - - async run() { - if (process.env[SEED_FLAG]?.trim().toLowerCase() !== "true") { - this.logger.log( - `Skipping IAM baseline seed because ${SEED_FLAG} is not enabled`, - ); - return; - } - - await this.dataSource.transaction(async (manager) => { - 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.seedOrganizationSettings(manager); - await this.seedUnitSettings(manager); - await this.seedSuperAdmin(manager); - }); - - this.logger.log("IAM baseline seed complete"); - } - - private async seedApplications(manager: EntityManager) { - const { applications } = IAM_BASELINE_SEED; - if (applications.length === 0) { - return; - } - - await manager.getRepository(Application).upsert( - applications.map((application) => ({ - id: application.id, - key: application.key, - name: { ...application.name }, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${applications.length} applications`); - } - - private async seedPermissions(manager: EntityManager) { - const { permissions } = IAM_BASELINE_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] : [], - ), - "missing_applications", - ); - - await manager.getRepository(Permission).upsert( - permissions.map((permission) => ({ - id: permission.id, - key: permission.key, - name: { ...permission.name }, - applicationId: permission.applicationKey - ? applicationIdByKey.get(permission.applicationKey) - : undefined, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${permissions.length} permissions`); - } - - private async seedRoles(manager: EntityManager) { - const { roles } = IAM_BASELINE_SEED; - if (roles.length === 0) { - return; - } - - await manager.getRepository(Role).upsert( - roles.map((role) => ({ - id: role.id, - key: role.key, - name: { ...role.name }, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${roles.length} roles`); - } - - private async seedRolePermissions(manager: EntityManager) { - const { rolePermissions } = IAM_BASELINE_SEED; - if (rolePermissions.length === 0) { - return; - } - - const roleIdByKey = await this.loadIdsByKey( - manager, - Role, - rolePermissions.map((mapping) => mapping.roleKey), - "missing_roles", - ); - const permissionIdByKey = await this.loadIdsByKey( - manager, - Permission, - rolePermissions.flatMap((mapping) => mapping.permissionKeys), - "missing_permissions", - ); - - const repository = manager.getRepository(RolePermission); - const roleIds = [...roleIdByKey.values()]; - const existing = await repository.find({ - where: { roleId: In(roleIds) }, - select: { roleId: true, permissionId: true }, - }); - const existingPairs = new Set( - existing.map((row) => `${row.roleId}:${row.permissionId}`), - ); - - const rows = rolePermissions.flatMap((mapping) => { - const roleId = roleIdByKey.get(mapping.roleKey) as string; - return mapping.permissionKeys - .map((key) => permissionIdByKey.get(key) as string) - .filter((permissionId) => !existingPairs.has(`${roleId}:${permissionId}`)) - .map((permissionId) => ({ roleId, permissionId })); - }); - - if (rows.length === 0) { - return; - } - - await repository.insert(rows); - this.logger.log(`Granted ${rows.length} role permissions`); - } - - private async seedPositionTypes(manager: EntityManager) { - const { positionTypes } = IAM_BASELINE_SEED; - if (positionTypes.length === 0) { - return; - } - - // unitId is intentionally left alone — upstream resets it to null here. - await manager.getRepository(PositionType).upsert( - positionTypes.map((positionType) => ({ - id: positionType.id, - key: positionType.key, - name: { ...positionType.name }, - isSystem: positionType.isSystem ?? true, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${positionTypes.length} position types`); - } - - private async seedPositionTypePermissions(manager: EntityManager) { - const { positionTypePermissions } = IAM_BASELINE_SEED; - if (positionTypePermissions.length === 0) { - return; - } - - const positionTypeIdByKey = await this.loadIdsByKey( - manager, - PositionType, - positionTypePermissions.map((mapping) => mapping.positionTypeKey), - "missing_position_types", - ); - const permissionIdByKey = await this.loadIdsByKey( - manager, - Permission, - positionTypePermissions.flatMap((mapping) => mapping.permissionKeys), - "missing_permissions", - ); - - const repository = manager.getRepository(PositionTypePermission); - const existing = await repository.find({ - where: { positionTypeId: In([...positionTypeIdByKey.values()]) }, - select: { positionTypeId: true, permissionId: true }, - }); - const existingPairs = new Set( - existing.map((row) => `${row.positionTypeId}:${row.permissionId}`), - ); - - const rows = positionTypePermissions.flatMap((mapping) => { - const positionTypeId = positionTypeIdByKey.get( - mapping.positionTypeKey, - ) as string; - return mapping.permissionKeys - .map((key) => permissionIdByKey.get(key) as string) - .filter( - (permissionId) => - !existingPairs.has(`${positionTypeId}:${permissionId}`), - ) - .map((permissionId) => ({ positionTypeId, permissionId })); - }); - - if (rows.length === 0) { - return; - } - - await repository.insert(rows); - this.logger.log(`Granted ${rows.length} position type permissions`); - } - - private async seedOrganizationSettings(manager: EntityManager) { - const defaults = IAM_BASELINE_SEED.organizationSettings; - if (defaults.length === 0) { - return; - } - - const organizations = await manager - .getRepository(Organization) - .find({ select: { id: true } }); - const existing = await manager - .getRepository(OrganizationSetting) - .find({ select: { organizationId: true, key: true } }); - const existingPairs = new Set( - existing.map((setting) => `${setting.organizationId}:${setting.key}`), - ); - - const rows = organizations.flatMap((organization) => - missingSettings(defaults, existingPairs, organization.id as string).map( - (setting) => ({ - ...setting, - type: setting.type as ESettingType, - organizationId: organization.id as string, - }), - ), - ); - - if (rows.length === 0) { - return; - } - - await manager.getRepository(OrganizationSetting).insert(rows); - this.logger.log( - `Seeded ${rows.length} organization settings across ${organizations.length} organizations`, - ); - } - - private async seedUnitSettings(manager: EntityManager) { - const defaults = IAM_BASELINE_SEED.unitSettings; - if (defaults.length === 0) { - return; - } - - const units = await manager.getRepository(Unit).find({ select: { id: true } }); - const existing = await manager - .getRepository(UnitSetting) - .find({ select: { unitId: true, key: true } }); - const existingPairs = new Set( - existing.map((setting) => `${setting.unitId}:${setting.key}`), - ); - - const rows = units.flatMap((unit) => - missingSettings(defaults, existingPairs, unit.id as string).map( - (setting) => ({ - ...setting, - type: setting.type as ESettingType, - unitId: unit.id as string, - }), - ), - ); - - if (rows.length === 0) { - return; - } - - await manager.getRepository(UnitSetting).insert(rows); - this.logger.log( - `Seeded ${rows.length} unit settings across ${units.length} units`, - ); - } - - /** - * User + credential + employee + `super_admin` UserRole. Every step is - * skip-if-present: an existing account keeps its password, its organization - * and any extra roles it was given through the IAM UI. - */ - private async seedSuperAdmin(manager: EntityManager) { - const seed = IAM_BASELINE_SEED.superAdmin; - if (!seed) { - return; - } - - const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail; - // SUPER_ADMIN_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. - const password = - process.env.SUPER_ADMIN_PASSWORD?.trim() || - process.env.DEFAULT_PASSWORD?.trim() || - seed.fallbackPassword; - const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || undefined; - - const roleIdByKey = await this.loadIdsByKey( - manager, - Role, - [seed.roleKey], - "missing_roles", - ); - - const organization = await manager.getRepository(Organization).findOne({ - where: { key: seed.organizationKey }, - select: { id: true }, - }); - if (!organization) { - throw new Error(`missing_organization:${seed.organizationKey}`); - } - - const unit = await manager.getRepository(Unit).findOne({ - where: { key: seed.unitKey, organizationId: organization.id }, - select: { id: true }, - }); - if (!unit) { - throw new Error(`missing_unit:${seed.unitKey}`); - } - - const userRepository = manager.getRepository(User); - const existingUser = await userRepository.findOne({ - where: [{ username: seed.username }, { email }], - select: { id: true }, - }); - - let userId = existingUser?.id as string | undefined; - if (!userId) { - const inserted = await userRepository.insert({ - username: seed.username, - email, - phoneNumber, - name: { ...seed.name }, - userType: EUserType.EMPLOYEE, - status: EUserStatus.ACCEPTED, - isActive: true, - hasSetPassword: true, - }); - userId = inserted.identifiers[0]?.id as string; - this.logger.log(`Seeded super admin user '${seed.username}' (${email})`); - } - - const credentialRepository = manager.getRepository(UserCredential); - if (!(await credentialRepository.existsBy({ userId }))) { - await credentialRepository.insert({ - userId, - password: await hashPassword(password), - isActive: true, - }); - this.logger.log(`Seeded super admin credential for '${seed.username}'`); - } - - const employeeRepository = manager.getRepository(Employee); - if ( - !(await employeeRepository.existsBy({ - userId, - organizationId: organization.id, - })) - ) { - await employeeRepository.insert({ - userId, - organizationId: organization.id, - unitId: unit.id, - isCurrent: true, - status: EEmployeeStatus.ACCEPTED, - name: { ...seed.name }, - }); - this.logger.log( - `Attached super admin to organization '${seed.organizationKey}'`, - ); - } - - // user_roles is UNIQUE (user_id, role_id). - await manager.getRepository(UserRole).upsert( - { - userId, - roleId: roleIdByKey.get(seed.roleKey) as string, - organizationId: organization.id, - unitId: unit.id, - }, - { conflictPaths: { userId: true, roleId: true } }, - ); - - this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`); - } - - /** - * Resolve `key → id` from the database for the given entity, throwing when a - * key the seed references has no row (the seed order is wrong, or the caller - * emptied the section that creates it). - */ - private async loadIdsByKey( - manager: EntityManager, - entity: EntityTarget, - keys: string[], - errorPrefix: string, - ): Promise> { - const wanted = [...new Set(keys)]; - const rows = (await manager - .getRepository(entity) - .find({ - where: { key: In(wanted) }, - select: { id: true, key: true }, - })) as { id?: string; key: string }[]; - - const idByKey = new Map(rows.map((row) => [row.key, row.id as string])); - - const missing = wanted.filter((key) => !idByKey.has(key)); - if (missing.length > 0) { - throw new Error(`${errorPrefix}:${missing.join(",")}`); - } - - return idByKey; - } -} diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 215490361..cf00cfe51 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN= # Login endpoint for backoffice users: POST /v1/auth/login SEED_EDR_PASSENGER_ORG=false SEED_PASSENGER_STAFF=false +# IAM baseline shared with edr-freight-api (roles, IAM app + permissions, position +# types, organization types + default units, org/unit settings, super admin). +# Replaces the seeder that used to ship inside @tria-plc/iamapi-common — see +# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only. +# Set to false to opt out. +SEED_IAM_BASELINE=true +# Super-admin account seeded by the above. Shared across the apps on this schema. +SUPER_ADMIN_EMAIL=superadmin@tria.com +SUPER_ADMIN_PHONE= +# Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_DEFAULT_PASSWORD= # Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset. DEFAULT_PASSWORD=Admin@1234 diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index c4587e98a..f58cd7bcf 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -27,6 +27,7 @@ "prisma:verify": "ts-node prisma/verify-backfill.ts" }, "dependencies": { + "@edr/iam-seed": "workspace:*", "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", "@nestjs/axios": "^4.0.1", diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index cce0f5e7d..014e4f5c6 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -4,8 +4,8 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; import { EventEmitterModule } from "@nestjs/event-emitter"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module"; -import { DataSeeder } from "@tria-plc/iamapi-common/db/seed/seeder"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import { EDR_PASSENGER_APPLICATION, @@ -103,6 +103,17 @@ import { EOtpType } from "@tria-plc/iamapi-common"; `Set your EDR Passenger password using this link: ${route}`, }, }), + // Replaces the package's DataSeeder. Shared with edr-freight-api, which + // seeds the same `iam` schema — see packages/iam-seed. + IamSeedModule.forRoot({ + superAdmin: { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr", + fallbackEmail: "superadmin@tria.com", + }, + }), SharedAuthModule, PrismaModule, AuditModule, @@ -151,18 +162,23 @@ import { EOtpType } from "@tria-plc/iamapi-common"; export class AppModule implements OnApplicationBootstrap { private readonly logger = new Logger(AppModule.name); constructor( - private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, private readonly segmentFareSeeder: SegmentFareSeeder, ) {} async onApplicationBootstrap() { + // Runs first so the roles it seeds exist before EdrPassengerOrgSeeder links + // super_admin permissions. Its own super-admin account attaches to the `edr` + // organization, which that seeder creates — so on a brand-new database the + // account lands on the next boot; it logs a warning and skips until then. + // Non-fatal internally, but the wrapper stays for symmetry with the rest. try { - await this.seeder.run(); + await this.iamBaselineSeeder.run(); } catch (err) { this.logger.error( - "[DataSeeder] Seed failed (non-fatal):", + "[IamBaselineSeeder] Seed failed (non-fatal):", (err as Error).message, ); } diff --git a/packages/iam-seed/package.json b/packages/iam-seed/package.json new file mode 100644 index 000000000..05c56670b --- /dev/null +++ b/packages/iam-seed/package.json @@ -0,0 +1,50 @@ +{ + "name": "@edr/iam-seed", + "version": "0.0.0", + "private": true, + "description": "Shared IAM baseline seeder for the apps that share the `iam` schema", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -w -p tsconfig.json", + "type-check": "tsc --noEmit", + "lint": "eslint src", + "test": "jest" + }, + "dependencies": { + "argon2": "^0.43.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "reflect-metadata": "^0.2.0", + "typeorm": "^0.3.20" + }, + "devDependencies": { + "@edr/eslint-config": "workspace:*", + "@edr/tsconfig": "workspace:*", + "@nestjs/common": "^11.0.0", + "@types/jest": "^29.5.13", + "@types/node": "^20.14.0", + "jest": "^29.7.0", + "reflect-metadata": "^0.2.2", + "ts-jest": "^29.2.5", + "typeorm": "^0.3.20", + "typescript": "^5.5.4" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testEnvironment": "node" + } +} diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seed.ts b/packages/iam-seed/src/iam-baseline.seed.ts similarity index 52% rename from apps/edr-freight-api/src/seed/iam-baseline.seed.ts rename to packages/iam-seed/src/iam-baseline.seed.ts index f55fa125c..01feac6c3 100644 --- a/apps/edr-freight-api/src/seed/iam-baseline.seed.ts +++ b/packages/iam-seed/src/iam-baseline.seed.ts @@ -1,68 +1,35 @@ +import { + IamBaselineSeed, + SeedApplication, + SeedOrganizationType, + SeedPermission, + SeedPositionType, + SeedRole, + SeedRolePermission, + SettingDefault, +} from "./iam-seed.types"; + /** - * IAM baseline seed data — the rows `IamBaselineSeeder` writes. + * The default IAM baseline: what `IamBaselineSeeder` writes when an app does not + * override a section via `IamSeedModule.forRoot()`. * - * These started as a copy of the seed constants inside - * `@tria-plc/iamapi-common` (`dist/db/seed/role.seed`, - * `organization-setting.seed`, `unit-setting.seed`) as of 0.7.12, and are now - * owned here: edit, extend or delete anything below and the seeder follows. - * Nothing in this file is imported from the package, so a version bump cannot - * change what gets seeded — it also will not hand you new IAM permissions, so - * diff against those files when upgrading. + * These rows started as a copy of the seed constants inside + * `@tria-plc/iamapi-common` (`dist/db/seed/*`) as of 0.7.12, and are owned here + * now. Nothing is read from that package at runtime, so the apps sharing the + * `iam` schema stay consistent even while they resolve different versions of it + * — but a package upgrade will not hand you new IAM permissions either. Diff + * against `db/seed/role.seed` and `db/seed/org-type.seed` when upgrading. * - * Ids are the package's originals. Keep them: the rows already in every - * environment carry these ids. + * Ids are the package's originals. Keep them: rows in every existing + * environment already carry them. * - * Deliberately NOT seeded (the package's DataSeeder does all of it): - * - TRIA super-admin organization + its `superadmin` user and password - * - 11 Addis Ababa sub-city organizations - * - woreda / subcity / office / branch organization types + 65 default units - * - the 7 non-IAM Smart Office applications (booking, bot, chronicle, dms, - * metabase, performance, record) + * Deliberately absent, because nothing in EDR reads them: the 11 Addis Ababa + * sub-city organizations, the TRIA super-admin organization (`is_super_admin` is + * only used to block edits to that row and hide it from one list query), and the + * 7 non-IAM Smart Office applications. */ -export type LocalizedName = { am: string; en: string }; - -export type SeedApplication = { id: string; key: string; name: LocalizedName }; - -export type SeedRole = { id: string; key: string; name: LocalizedName }; - -export type SeedPermission = { - id: string; - key: string; - /** Omitted for the 21 org/unit/location permissions the package leaves unlinked. */ - applicationKey?: string; - name: LocalizedName; -}; - -export type SeedPositionType = { - id: string; - key: string; - isSystem: boolean; - name: LocalizedName; -}; - -export type SeedSuperAdmin = { - username: string; - name: LocalizedName; - /** Key in `roles` — the grant that carries the access. */ - roleKey: string; - /** Existing organization/unit to attach the employee to. */ - organizationKey: string; - unitKey: string; - /** Used when SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD / DEFAULT_PASSWORD are all unset. */ - fallbackEmail: string; - fallbackPassword: string; -}; - -export type SettingDefault = { - key: string; - displayName: string; - /** Matches the package's ESettingType. */ - type: "value" | "file"; - value?: string | null; -}; - -/** Applications permissions hang off. `edr_freight_app` is seeded by EdrOrgSeeder. */ +/** Applications permissions hang off. Each app seeds its own separately. */ const APPLICATIONS: SeedApplication[] = [ { id: "019bcb17-5470-7604-8708-7ed04d842b41", @@ -383,10 +350,10 @@ const PERMISSIONS: SeedPermission[] = [ ]; /** - * Role → permission-key grants. Applied additively: the seeder only inserts - * missing pairs, so grants made through the IAM UI survive a reseed. + * Role → permission-key grants, applied additively: only missing pairs are + * inserted, so grants made through the IAM UI survive a reseed. */ -const ROLE_PERMISSIONS: { roleKey: string; permissionKeys: string[] }[] = [ +const ROLE_PERMISSIONS: SeedRolePermission[] = [ { roleKey: "super_admin", permissionKeys: [ @@ -518,13 +485,104 @@ const POSITION_TYPES: SeedPositionType[] = [ ]; /** - * PositionType → permission-key grants. Empty because freight grants through - * Position/PositionPermission (see FreightPositionsSeeder), not position types. + * Organization types and the units an organization of that type is created + * with. Both are read at runtime, which is why they are seeded: the New + * Organization form posts `organizationTypeId`, and + * `createOrganizationWithStructure` builds the new org's units from + * `default_units`. */ -const POSITION_TYPE_PERMISSIONS: { - positionTypeKey: string; - permissionKeys: string[]; -}[] = []; +const ORGANIZATION_TYPES: SeedOrganizationType[] = [ + { + key: "woreda", + name: { am: "ወረዳ", en: "Woreda" }, + defaultUnits: [ + { key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Chief Executive Office" } }, + { key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } }, + { key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } }, + { key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } }, + { key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Prosecutor's Office" } }, + { key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } }, + { key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Enforcement Office" } }, + { key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } }, + { key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Cooperative Office" } }, + { key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Business Office" } }, + { key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } }, + { key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } }, + { key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid waste office" } }, + { key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } }, + { key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } }, + { key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service Human Resource Management" } }, + { key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Government Building Office" } }, + { key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } }, + { key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } }, + { key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } }, + { key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } }, + { key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } }, + { key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } }, + { key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Supervision Office" } }, + { key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } }, + { key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } }, + { key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Charity Coordination Office" } }, + { key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } }, + { key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } }, + { key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Employment and Industry Development Office" } }, + { key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } }, + { key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } }, + ], + }, + { + key: "subcity", + name: { am: "ክፍለ ከተማ", en: "Sub City" }, + defaultUnits: [ + { key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Main Executive Office" } }, + { key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } }, + { key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } }, + { key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } }, + { key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Legal Affairs Office" } }, + { key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } }, + { key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Regulations Enforcement Office" } }, + { key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } }, + { key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Community Work Office" } }, + { key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Trade Office" } }, + { key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } }, + { key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } }, + { key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid Waste Management Office" } }, + { key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } }, + { key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } }, + { key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service and Human Resource Management" } }, + { key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Public Buildings Office" } }, + { key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } }, + { key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } }, + { key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } }, + { key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } }, + { key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } }, + { key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } }, + { key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Control Office" } }, + { key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } }, + { key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } }, + { key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Voluntarism Coordination Office" } }, + { key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } }, + { key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } }, + { key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Labor and Industry Development Office" } }, + { key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } }, + { key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } }, + ], + }, + { + key: "office", + name: { am: "ቢሮ", en: "Bureau" }, + defaultUnits: [ + { key: "ቢሮ", description: "ቢሮ", name: { am: "ቢሮ", en: "Bureau" } }, + ], + }, + { + key: "branch", + name: { am: "ቅርንጫፍ", en: "Branch" }, + defaultUnits: [ + + ], + }, +]; /** Seeded once per organization. All start unset; the IAM UI fills them in. */ const ORGANIZATION_SETTINGS: SettingDefault[] = [ @@ -569,43 +627,19 @@ const UNIT_SETTINGS: SettingDefault[] = [ ]; /** - * The super-admin account. Its access comes entirely from the `super_admin` - * UserRole: `isSuperAdmin()` in `common/freight-permission.util.ts` short-circuits - * `hasFreightPermission`, so this account passes every FreightPermissionGuard - * and every approval-step check without holding one freight permission row. - * - * Unlike the package seeder, the account lands in the freight organization - * (`edr_freight` / unit `edr_freight_app`) rather than a separate `tria` - * organization, so its employee context matches the app it administers. Both - * must already exist — EdrOrgSeeder creates them, and must run first. - * - * Read from env at seed time: SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE, and for the - * password SUPER_ADMIN_PASSWORD first, then DEFAULT_PASSWORD. The values below - * are the last resort. Set this to null to seed no account at all. + * Defaults for every section except `superAdmin`, which has no sensible default + * — the account has to be attached to an organization only the consuming app + * knows about. */ -const SUPER_ADMIN: SeedSuperAdmin | null = { - username: "superadmin", - name: { am: "ሱፐር አድሚን", en: "Super Admin" }, - roleKey: "super_admin", - organizationKey: "edr_freight", - unitKey: "edr_freight_app", - fallbackEmail: "superadmin@tria.com", - fallbackPassword: "password@tria", -}; - -/** - * What `IamBaselineSeeder` writes. Empty an array to skip that section - * entirely; the seeder never deletes rows, so emptying one leaves whatever is - * already in the database alone. - */ -export const IAM_BASELINE_SEED = { +export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = { applications: APPLICATIONS, roles: ROLES, permissions: PERMISSIONS, rolePermissions: ROLE_PERMISSIONS, positionTypes: POSITION_TYPES, - positionTypePermissions: POSITION_TYPE_PERMISSIONS, + positionTypePermissions: [], + organizationTypes: ORGANIZATION_TYPES, organizationSettings: ORGANIZATION_SETTINGS, unitSettings: UNIT_SETTINGS, - superAdmin: SUPER_ADMIN, + superAdmin: null, }; diff --git a/packages/iam-seed/src/iam-baseline.seeder.ts b/packages/iam-seed/src/iam-baseline.seeder.ts new file mode 100644 index 000000000..fe18e31a7 --- /dev/null +++ b/packages/iam-seed/src/iam-baseline.seeder.ts @@ -0,0 +1,875 @@ +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 { + 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 { + 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, + 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 { + 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> { + 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> { + 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> { + 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}`)); + } +} diff --git a/packages/iam-seed/src/iam-seed.constants.ts b/packages/iam-seed/src/iam-seed.constants.ts new file mode 100644 index 000000000..33664be79 --- /dev/null +++ b/packages/iam-seed/src/iam-seed.constants.ts @@ -0,0 +1,2 @@ +/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */ +export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS"); diff --git a/packages/iam-seed/src/iam-seed.module.ts b/packages/iam-seed/src/iam-seed.module.ts new file mode 100644 index 000000000..bc5d93f96 --- /dev/null +++ b/packages/iam-seed/src/iam-seed.module.ts @@ -0,0 +1,39 @@ +import { DynamicModule, Module } from "@nestjs/common"; + +import { IamBaselineSeeder } from "./iam-baseline.seeder"; +import { IAM_SEED_OPTIONS } from "./iam-seed.constants"; +import { IamSeedOptions } from "./iam-seed.types"; + +/** + * Provides `IamBaselineSeeder`. The app calls `run()` itself — usually from + * `onApplicationBootstrap`, after whatever seeder creates the organization the + * super admin attaches to. + * + * IamSeedModule.forRoot({ + * superAdmin: { + * username: "superadmin", + * name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + * roleKey: "super_admin", + * organizationKey: "edr_freight", + * unitKey: "edr_freight_app", + * fallbackEmail: "superadmin@tria.com", + * }, + * }) + * + * Any section left out keeps the value from `DEFAULT_IAM_BASELINE_SEED`. The + * seeder needs a TypeORM `DataSource` that can reach the `iam` schema; the app's + * default one is used, so `TypeOrmModule.forRoot*` must be registered. + */ +@Module({}) +export class IamSeedModule { + static forRoot(options: IamSeedOptions = {}): DynamicModule { + return { + module: IamSeedModule, + providers: [ + { provide: IAM_SEED_OPTIONS, useValue: options }, + IamBaselineSeeder, + ], + exports: [IamBaselineSeeder], + }; + } +} diff --git a/packages/iam-seed/src/iam-seed.types.ts b/packages/iam-seed/src/iam-seed.types.ts new file mode 100644 index 000000000..35007c25c --- /dev/null +++ b/packages/iam-seed/src/iam-seed.types.ts @@ -0,0 +1,100 @@ +export type LocalizedName = { am: string; en: string }; + +export type SeedApplication = { id: string; key: string; name: LocalizedName }; + +export type SeedRole = { id: string; key: string; name: LocalizedName }; + +export type SeedPermission = { + id: string; + key: string; + /** Omitted for the org/unit/location permissions the package leaves unlinked. */ + applicationKey?: string; + name: LocalizedName; +}; + +export type SeedPositionType = { + id: string; + key: string; + isSystem: boolean; + name: LocalizedName; +}; + +export type SeedRolePermission = { roleKey: string; permissionKeys: string[] }; + +export type SeedPositionTypePermission = { + positionTypeKey: string; + permissionKeys: string[]; +}; + +export type SeedDefaultUnit = { + key: string; + description: string; + name: LocalizedName; +}; + +export type SeedOrganizationType = { + key: string; + name: LocalizedName; + /** Units an organization of this type is created with. */ + defaultUnits: SeedDefaultUnit[]; +}; + +export type SettingDefault = { + key: string; + displayName: string; + /** Matches the package's ESettingType. */ + type: "value" | "file"; + value?: string | null; +}; + +/** + * The super-admin account. Its access comes from the `super_admin` role grant, + * so the account needs no permissions of its own. + * + * One account is shared by every app on this schema: whichever seeds first + * creates the user, the others find it and attach their own employee row for + * their organization. + * + * Email and phone are read from the environment at seed time — + * SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE — falling back to `fallbackEmail`. + * + * The password has NO fallback, by design. It comes from SUPER_ADMIN_DEFAULT_PASSWORD, + * or DEFAULT_PASSWORD if that is unset. With neither set, the account is still + * created and granted its role but gets no credential, and the seeder says so — + * the password is then set through the IAM reset flow. A seeded default would + * otherwise become a known password in whatever environment forgot to override + * it. + */ +export type SeedSuperAdmin = { + username: string; + name: LocalizedName; + /** Key in `roles` — the grant that carries the access. */ + roleKey: string; + /** Existing organization to attach the employee to. Must already exist. */ + organizationKey: string; + /** Optional unit within that organization; the employee is unit-less without it. */ + unitKey?: string; + fallbackEmail: string; +}; + +export type IamBaselineSeed = { + applications: SeedApplication[]; + roles: SeedRole[]; + permissions: SeedPermission[]; + rolePermissions: SeedRolePermission[]; + positionTypes: SeedPositionType[]; + positionTypePermissions: SeedPositionTypePermission[]; + organizationTypes: SeedOrganizationType[]; + organizationSettings: SettingDefault[]; + unitSettings: SettingDefault[]; + superAdmin: SeedSuperAdmin | null; +}; + +/** Per-section override passed to `IamSeedModule.forRoot()`. */ +export type IamSeedOptions = Partial & { + /** + * Environment variable that must equal "true" for the seeder to write + * anything. Defaults to SEED_IAM_BASELINE. + */ + enableFlag?: string; +}; diff --git a/packages/iam-seed/src/index.ts b/packages/iam-seed/src/index.ts new file mode 100644 index 000000000..04fa0d689 --- /dev/null +++ b/packages/iam-seed/src/index.ts @@ -0,0 +1,20 @@ +export { IamSeedModule } from "./iam-seed.module"; +export { IamBaselineSeeder } from "./iam-baseline.seeder"; +export { IAM_SEED_OPTIONS } from "./iam-seed.constants"; +export { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed"; +export { missingSettings } from "./missing-settings.util"; +export type { + IamBaselineSeed, + IamSeedOptions, + LocalizedName, + SeedApplication, + SeedDefaultUnit, + SeedOrganizationType, + SeedPermission, + SeedPositionType, + SeedPositionTypePermission, + SeedRole, + SeedRolePermission, + SeedSuperAdmin, + SettingDefault, +} from "./iam-seed.types"; diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts b/packages/iam-seed/src/missing-settings.util.spec.ts similarity index 100% rename from apps/edr-freight-api/src/seed/missing-settings.util.spec.ts rename to packages/iam-seed/src/missing-settings.util.spec.ts diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.ts b/packages/iam-seed/src/missing-settings.util.ts similarity index 84% rename from apps/edr-freight-api/src/seed/missing-settings.util.ts rename to packages/iam-seed/src/missing-settings.util.ts index 9ec4ccf5a..f3e7a36ca 100644 --- a/apps/edr-freight-api/src/seed/missing-settings.util.ts +++ b/packages/iam-seed/src/missing-settings.util.ts @@ -1,9 +1,9 @@ /** * Settings of `ownerId` that are not in `existingPairs` (`":"`) * yet. This is what keeps the IAM baseline seed idempotent: `organization_settings` - * and `unit_settings` have no unique index on (owner, key), so re-running an - * INSERT (or the package seeder's upsert-on-id, which never supplies an id) - * silently duplicates every row. + * and `unit_settings` have no unique index on (owner, key), so a plain re-insert + * — or the package seeder's upsert-on-id, which never supplies an id — silently + * duplicates every row. * * `value: null` becomes `undefined` so the column default applies on insert. */ diff --git a/packages/iam-seed/tsconfig.json b/packages/iam-seed/tsconfig.json new file mode 100644 index 000000000..6cf0770cb --- /dev/null +++ b/packages/iam-seed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "include": ["src"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b89d65b6..0423be502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@edr/api-common': specifier: workspace:* version: link:../../packages/api-common + '@edr/iam-seed': + specifier: workspace:* + version: link:../../packages/iam-seed '@edr/payment-providers': specifier: workspace:* version: link:../../packages/payment-providers @@ -754,6 +757,9 @@ importers: apps/edr-passenger-api: dependencies: + '@edr/iam-seed': + specifier: workspace:* + version: link:../../packages/iam-seed '@edr/types': specifier: workspace:* version: link:../../packages/types @@ -1292,6 +1298,43 @@ importers: packages/config/tsconfig: {} + packages/iam-seed: + dependencies: + argon2: + specifier: ^0.43.1 + version: 0.43.1 + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../config/tsconfig + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + ts-jest: + specifier: ^29.2.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + typeorm: + specifier: ^0.3.20 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/payment-providers: dependencies: '@edr/types':