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 d80ce75c6..58fb60b18 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d # IAM seed defaults (used by @tria-plc/iamapi-common on first boot) SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= +# Super-admin password. Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_DEFAULT_PASSWORD= DEFAULT_PASSWORD=password@tria +# 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 SEED_FREIGHT_STAFF=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 aab9e0e4a..64df33f35 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,8 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +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"; import appConfig from "./config/app.config"; @@ -153,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, @@ -231,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -261,13 +274,22 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys - // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions // edrOrgSeeder → seeds org/unit + the Permission catalog + // 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) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. 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/packages/iam-seed/src/iam-baseline.seed.ts b/packages/iam-seed/src/iam-baseline.seed.ts new file mode 100644 index 000000000..01feac6c3 --- /dev/null +++ b/packages/iam-seed/src/iam-baseline.seed.ts @@ -0,0 +1,645 @@ +import { + IamBaselineSeed, + SeedApplication, + SeedOrganizationType, + SeedPermission, + SeedPositionType, + SeedRole, + SeedRolePermission, + SettingDefault, +} from "./iam-seed.types"; + +/** + * The default IAM baseline: what `IamBaselineSeeder` writes when an app does not + * override a section via `IamSeedModule.forRoot()`. + * + * 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: rows in every existing + * environment already carry them. + * + * 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. + */ + +/** Applications permissions hang off. Each app seeds its own separately. */ +const APPLICATIONS: SeedApplication[] = [ + { + id: "019bcb17-5470-7604-8708-7ed04d842b41", + key: "iam", + name: { am: "የስማርት ኦፊስ ማንነት እና መዳረሻ አስተዳደር", en: "Smart Office Identity and Access Management" }, + }, +]; + +const ROLES: SeedRole[] = [ + { + id: "520836ee-dc13-4c08-b572-8eced5bfd309", + key: "super_admin", + name: { am: "ዋና ተቆጣጣሪ", en: "Super Admin" }, + }, + { + id: "b2de1eae-ef93-4e90-8ec9-351f0dd8a6a9", + key: "organization_admin", + name: { am: "የመስሪያ ቤት ዋና ተቆጣጣሪ", en: "Organization Admin" }, + }, + { + id: "b3a9a5b5-9825-4290-8498-c62fb5925acd", + key: "unit_admin", + name: { am: "የመስሪያ ቤት ጽሕፈት ቤት ዋና ተቆጣጣሪ", en: "Organization Unit Admin" }, + }, + { + id: "ffe82427-ab16-4571-913c-553deb1b0f0f", + key: "guest", + name: { am: "ተጠቃሚ", en: "Guest" }, + }, +]; + +const PERMISSIONS: SeedPermission[] = [ + { + id: "019b5993-0000-0000-0000-000000000001", + key: "can:create:role", + applicationKey: "iam", + name: { am: "ሚና መፍጠር", en: "Create Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000002", + key: "can:update:role", + applicationKey: "iam", + name: { am: "ሚና ማሻሻል", en: "Update Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000003", + key: "can:delete:role", + applicationKey: "iam", + name: { am: "ሚና ማጥፋት", en: "Delete Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000006", + key: "can:update:permission", + applicationKey: "iam", + name: { am: "ፈቃድ ማሻሻል", en: "Update Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000007", + key: "can:delete:permission", + applicationKey: "iam", + name: { am: "ፈቃድ ማጥፋት", en: "Delete Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000009", + key: "can:create:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ መፍጠር", en: "Create Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000010", + key: "can:delete:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ ማጥፋት", en: "Delete Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000011", + key: "can:view:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ መመልከት", en: "View Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000012", + key: "can:create:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና መፍጠር", en: "Create User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000013", + key: "can:delete:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና ማጥፋት", en: "Delete User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000014", + key: "can:view:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና መመልከት", en: "View User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000015", + key: "can:create:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና መፍጠር", en: "Create Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000016", + key: "can:delete:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና ማጥፋት", en: "Delete Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000017", + key: "can:view:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና መመልከት", en: "View Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000018", + key: "can:find_all:organization", + name: { am: "ሁሉንም ድርጅቶች መፈለግ", en: "Find All Organizations" }, + }, + { + id: "019b5993-0000-0000-0000-000000000019", + key: "can:update:organization", + name: { am: "ድርጅት ማሻሻል", en: "Update Organization" }, + }, + { + id: "019b5993-0000-0000-0000-000000000020", + key: "can:delete:organization", + name: { am: "ድርጅት ማጥፋት", en: "Delete Organization" }, + }, + { + id: "019b5993-0000-0000-0000-000000000021", + key: "can:create:unit", + name: { am: "ክፍል መፍጠር", en: "Create Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000022", + key: "can:update:unit", + name: { am: "ክፍል ማሻሻል", en: "Update Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000023", + key: "can:delete:unit", + name: { am: "ክፍል ማጥፋት", en: "Delete Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000024", + key: "can:create:default_unit", + name: { am: "የዩኒት አይነት መፍጠር", en: "Create Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000025", + key: "can:update:default_unit", + name: { am: "የዩኒት አይነት ማሻሻል", en: "Update Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000026", + key: "can:delete:default_unit", + name: { am: "የዩኒት አይነት ማጥፋት", en: "Delete Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000027", + key: "can:create:default_position", + name: { am: "የስራ መደብ አይነት መፍጠር", en: "Create Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000028", + key: "can:update:default_position", + name: { am: "የስራ መደብ አይነት ማሻሻል", en: "Update Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000029", + key: "can:delete:default_position", + name: { am: "የስራ መደብ አይነት ማጥፋት", en: "Delete Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000030", + key: "can:create:organization_type", + name: { am: "የድርጅት አይነት መፍጠር", en: "Create Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000031", + key: "can:update:organization_type", + name: { am: "የድርጅት አይነት ማሻሻል", en: "Update Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000032", + key: "can:delete:organization_type", + name: { am: "የድርጅት አይነት ማጥፋት", en: "Delete Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000033", + key: "can:create:location_type", + name: { am: "የአካባቢ አይነት መፍጠር", en: "Create Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000034", + key: "can:update:location_type", + name: { am: "የአካባቢ አይነት ማሻሻል", en: "Update Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000035", + key: "can:delete:location_type", + name: { am: "የአካባቢ አይነት ማጥፋት", en: "Delete Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000036", + key: "can:create:location", + name: { am: "አካባቢ መፍጠር", en: "Create Location" }, + }, + { + id: "019b5993-0000-0000-0000-000000000037", + key: "can:update:location", + name: { am: "አካባቢ ማሻሻል", en: "Update Location" }, + }, + { + id: "019b5993-0000-0000-0000-000000000038", + key: "can:delete:location", + name: { am: "አካባቢ ማጥፋት", en: "Delete Location" }, + }, + { + id: "a9a7c0fa-e4fc-4c0e-b1c2-f74f9da40421", + key: "create:organization", + applicationKey: "iam", + name: { am: "የመስሪያ ቤት መፍጠር", en: "Create Organization" }, + }, + { + id: "c807691b-2693-4079-9dc5-1e080b67006c", + key: "activate:organization", + applicationKey: "iam", + name: { am: "የመስሪያ ቤት አስተካክል", en: "Activate Organization" }, + }, + { + id: "457a3659-ed96-456a-a6a1-2881226a86ed", + key: "can:debarOrganization", + applicationKey: "iam", + name: { am: "መቼት መቆጣጠር ይችላል", en: "Debar Organization" }, + }, + { + id: "2cd9e3c5-bd41-48f3-a849-f497b18b2906", + key: "manage:organizationAdmin", + applicationKey: "iam", + name: { am: "መቼት መቆጣጠር ይችላል", en: "Manage Organization Admin" }, + }, + { + id: "257d8c6e-ef30-4510-892f-d4a2ad4c814d", + key: "manage:unitAdmin", + applicationKey: "iam", + name: { am: "የጽሕፈት ቤት መቼት መቆጣጠር ይችላል", en: "Manage Unit Admin" }, + }, + { + id: "4052b7b8-e9f6-4bfe-9b93-eb59f9e4e576", + key: "can:createEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን መመደብ/መፍጠር ይችላሉ", en: "Can create employees" }, + }, + { + id: "965ec76d-bbdb-47a1-a916-07c52f609fa7", + key: "can:deactivateEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን ማባረር ይችላሉ", en: "Can deactivate employees" }, + }, + { + id: "019cbdc6-3d7a-73aa-ac57-51436dfa50e9", + key: "can:activateEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን መቀበል ይችላሉ", en: "Can activate employees" }, + }, + { + id: "ebd8cf49-243d-4887-b2c9-cbe61e86a17a", + key: "can:uploadUserCSV", + applicationKey: "iam", + name: { am: "የሰራተኞችን መረጃ መጫን ይችላል", en: "Can Upload User CSV" }, + }, + { + id: "68bebc03-c1a8-832f-ae5a-b66553e6bcef", + key: "can:exportUnitUsers", + applicationKey: "iam", + name: { am: "የተቀጣሪዎችን መረጃ ማውጣት ይችላሉ", en: "Can Export Unit Users" }, + }, + { + id: "c2566286-248e-4cb4-923d-b113d6a5d4ba", + key: "can:changeUsersProfile", + applicationKey: "iam", + name: { am: "ተጠቃሚዎች መግለጫ መቀየር ይችላል", en: "Can Change Users Profile" }, + }, + { + id: "14f438c6-9295-44a0-a4f3-4ac9855efc37", + key: "can:activateUser", + applicationKey: "iam", + name: { am: "ተጠቃሚዎችን መቆጣጠር ይችላል", en: "Can Activate/Deactivate User" }, + }, + { + id: "31a39f88-47c0-4322-80ef-f397a6791ff2", + key: "can:approveNewUser", + applicationKey: "iam", + name: { am: "አዲስ ተመዝጋቢ ማፅደቅ ይችላል", en: "Can Approve New User" }, + }, + { + id: "69694936-8b54-8330-b176-16ffc98c33a7", + key: "can:viewAllUsers", + applicationKey: "iam", + name: { am: "ሁሉንም ተጠቃሚዎች ማየት ይችላል", en: "Can View All Users" }, + }, + { + id: "68f1017f-4c60-8322-984d-e4317986e641", + key: "can:manageUsersAccountConfiguration", + applicationKey: "iam", + name: { am: "የተጠቃሚ መለያ አዋቂነት መቆጣጠር ይችላል", en: "Can Manage Users Account Configuration" }, + }, + { + id: "fceaa4c5-621f-45ce-b5a7-0ffc9e366fe1", + key: "can:setUserRequirementDocument", + applicationKey: "iam", + name: { am: "ተመዝጋቢዎች የሚያስገቡትን መረጃ መቆጣጠር ይችላል", en: "Can Set User Requirement Document" }, + }, +]; + +/** + * Role → permission-key grants, applied additively: only missing pairs are + * inserted, so grants made through the IAM UI survive a reseed. + */ +const ROLE_PERMISSIONS: SeedRolePermission[] = [ + { + roleKey: "super_admin", + permissionKeys: [ + "can:create:role", + "can:update:role", + "can:delete:role", + "can:update:permission", + "can:delete:permission", + "can:create:role_permission", + "can:delete:role_permission", + "can:view:role_permission", + "can:create:user_role", + "can:delete:user_role", + "can:view:user_role", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:find_all:organization", + "can:update:organization", + "can:delete:organization", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + "can:create:default_unit", + "can:update:default_unit", + "can:delete:default_unit", + "can:create:default_position", + "can:update:default_position", + "can:delete:default_position", + "can:create:organization_type", + "can:update:organization_type", + "can:delete:organization_type", + "can:create:location_type", + "can:update:location_type", + "can:delete:location_type", + "create:organization", + "activate:organization", + "can:debarOrganization", + "manage:organizationAdmin", + "manage:unitAdmin", + "can:activateUser", + "can:approveNewUser", + "can:viewAllUsers", + "can:setUserRequirementDocument", + "can:create:location", + "can:update:location", + "can:delete:location", + ], + }, + { + roleKey: "organization_admin", + permissionKeys: [ + "can:uploadUserCSV", + "can:changeUsersProfile", + "can:createEmployee", + "can:deactivateEmployee", + "can:exportUnitUsers", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + "manage:unitAdmin", + ], + }, + { + roleKey: "unit_admin", + permissionKeys: [ + "can:uploadUserCSV", + "can:createEmployee", + "can:exportUnitUsers", + "can:changeUsersProfile", + "can:deactivateEmployee", + "can:manageUsersAccountConfiguration", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + ], + }, + { + roleKey: "guest", + permissionKeys: [ + + ], + }, +]; + +const POSITION_TYPES: SeedPositionType[] = [ + { + id: "457a3659-ed96-456a-a6a1-2881226a86ec", + key: "employee", + isSystem: true, + name: { am: "ባለሙያ", en: "Employee" }, + }, + { + id: "34a7f69c-3f30-47a0-81c3-fcfc3087e456", + key: "teamLeader", + isSystem: true, + name: { am: "ቡድን መሪ", en: "Team Leader" }, + }, + { + id: "2cd9e3c5-bd41-48f3-a849-f497b18b2905", + key: "director", + isSystem: true, + name: { am: "ዳይሬክተር", en: "Director" }, + }, + { + id: "db310acf-7a78-40a3-83c7-9a9e9e6d1fc7", + key: "deputy", + isSystem: true, + name: { am: "ዘርፍ ኃላፊ", en: "Deputy" }, + }, + { + id: "1a4b4d40-e4fc-4f38-99a6-f81dc5fcff23", + key: "officeHead", + isSystem: true, + name: { am: "ቢሮ ኃላፊ", en: "Office Head" }, + }, + { + id: "83bc6cd3-119e-4a41-917c-c763fb3fd013", + key: "recordOfficer", + isSystem: true, + name: { am: "መዝገብ ቤት", en: "Record Officer" }, + }, +]; + +/** + * 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 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[] = [ + { key: "logoFileUrl", displayName: "Logo", type: "file" }, + { key: "faviconFileUrl", displayName: "Favicon", type: "file" }, + { key: "loginBackgroundFileUrl", displayName: "Login Background", type: "file" }, + { key: "stampImageFileUrl", displayName: "Stamp Image", type: "file" }, + { key: "customCssFileUrl", displayName: "Custom CSS", type: "file" }, + { key: "primaryColor", displayName: "Primary Color", type: "value" }, + { key: "secondaryColor", displayName: "Secondary Color", type: "value" }, + { key: "accentColor", displayName: "Accent Color", type: "value" }, + { key: "loginTitle", displayName: "Login Title", type: "value" }, + { key: "loginSubtitle", displayName: "Login Subtitle", type: "value" }, + { key: "sidebarColor", displayName: "Sidebar Color", type: "value" }, + { key: "headerColor", displayName: "Header Color", type: "value" }, + { key: "stampText", displayName: "Stamp Text", type: "value" }, + { key: "footerText", displayName: "Footer Text", type: "value" }, + { key: "supportEmail", displayName: "Support Email", type: "value" }, + { key: "supportPhone", displayName: "Support Phone", type: "value" }, +]; + +/** Seeded once per unit. */ +const UNIT_SETTINGS: SettingDefault[] = [ + { key: "isMultipleDelegationAllowed", displayName: "Is Multiple Delegation Allowed", type: "value" }, + { key: "internalSuffix", displayName: "Internal Suffix", type: "value" }, + { key: "internalPrefix", displayName: "Internal Prefix", type: "value" }, + { key: "internalSuffixCC", displayName: "Internal Suffix CC", type: "value" }, + { key: "internalPrefixCC", displayName: "Internal Prefix CC", type: "value" }, + { key: "referenceNumberPrefix", displayName: "Reference Number Prefix", type: "value" }, + { key: "externalReferenceNumberPrefix", displayName: "External Reference Number Prefix", type: "value" }, + { key: "internalMemoReferenceNumberPrefix", displayName: "Internal Memo Reference Number Prefix", type: "value" }, + { key: "escalationHour", displayName: "Escalation Hour", type: "value" }, + { key: "urgentLetterEscalationHour", displayName: "Urgent Letter Escalation Hour", type: "value" }, + { key: "onReviewLetterEscalationHour", displayName: "On Review Letter Escalation Hour", type: "value" }, + { key: "urgentOnReviewLetterEscalationHour", displayName: "Urgent On Review Letter Escalation Hour", type: "value" }, + { key: "shouldCollaboratorAlwaysSign", displayName: "Should Collaborator Always Sign", type: "value" }, + { key: "waitAllCollaboratorsBeforeAction", displayName: "Wait All Collaborators Before Action", type: "value" }, + { key: "shouldIncludeForYourReferenceInCC", displayName: "Should Include For Your Reference In CC", type: "value" }, + { key: "forwardWithTeeterSignature", displayName: "Forward With Teeter Signature", type: "value" }, + { key: "attachSignatureOnAttachment", displayName: "Attach Signature On Attachment", type: "value" }, + { key: "positionScopeToFetch", displayName: "Position Scope To Fetch", type: "value" }, +]; + +/** + * 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. + */ +export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = { + applications: APPLICATIONS, + roles: ROLES, + permissions: PERMISSIONS, + rolePermissions: ROLE_PERMISSIONS, + positionTypes: POSITION_TYPES, + positionTypePermissions: [], + organizationTypes: ORGANIZATION_TYPES, + organizationSettings: ORGANIZATION_SETTINGS, + unitSettings: UNIT_SETTINGS, + 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/packages/iam-seed/src/missing-settings.util.spec.ts b/packages/iam-seed/src/missing-settings.util.spec.ts new file mode 100644 index 000000000..a664117a5 --- /dev/null +++ b/packages/iam-seed/src/missing-settings.util.spec.ts @@ -0,0 +1,27 @@ +import { missingSettings } from "./missing-settings.util"; + +const defaults = [ + { key: "primaryColor", displayName: "Primary Color", value: null }, + { key: "logoFileUrl", displayName: "Logo", value: null }, +]; + +describe("missingSettings", () => { + it("returns every default when the owner has none", () => { + expect(missingSettings(defaults, new Set(), "org-1")).toHaveLength(2); + }); + + it("skips keys the owner has, but not the same key on another owner", () => { + const existing = new Set(["org-1:primaryColor"]); + + expect( + missingSettings(defaults, existing, "org-1").map((s) => s.key), + ).toEqual(["logoFileUrl"]); + expect(missingSettings(defaults, existing, "org-2")).toHaveLength(2); + }); + + it("drops nulls so the column default applies", () => { + expect( + missingSettings(defaults, new Set(), "org-1")[0].value, + ).toBeUndefined(); + }); +}); diff --git a/packages/iam-seed/src/missing-settings.util.ts b/packages/iam-seed/src/missing-settings.util.ts new file mode 100644 index 000000000..f3e7a36ca --- /dev/null +++ b/packages/iam-seed/src/missing-settings.util.ts @@ -0,0 +1,20 @@ +/** + * 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 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. + */ +export function missingSettings< + TSetting extends { key: string; value?: string | null }, +>( + defaults: TSetting[], + existingPairs: Set, + ownerId: string, +): (Omit & { value?: string })[] { + return defaults + .filter((setting) => !existingPairs.has(`${ownerId}:${setting.key}`)) + .map((setting) => ({ ...setting, value: setting.value ?? undefined })); +} 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':