refactor: centralized the iam seeder

This commit is contained in:
Nathnael
2026-07-27 08:28:57 +00:00
parent 1fe10ee3c2
commit 03b8ca0ce7
19 changed files with 1339 additions and 592 deletions

View File

@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
| ---------------------- | ---------------------------------------------------------------------------------- | | ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums | | `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | | `@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/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | | `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations | | `@edr/tsconfig` | Shared TypeScript configurations |

View File

@@ -43,13 +43,15 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE= SUPER_ADMIN_PHONE=
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty. # Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
SUPER_ADMIN_PASSWORD= SUPER_ADMIN_DEFAULT_PASSWORD=
DEFAULT_PASSWORD=password@tria DEFAULT_PASSWORD=password@tria
# IAM baseline (roles, IAM app + permissions, position types, org/unit settings, # IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
# super-admin account). Local replacement for the package DataSeeder — # position types, organization types + default units, org/unit settings, super
# see src/seed/iam-baseline.seed.ts. # admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
SEED_IAM_BASELINE=false # 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) # Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true SEED_EDR_ORG=true

View File

@@ -35,12 +35,12 @@
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "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", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
}, },
"dependencies": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",
"@edr/iam-seed": "workspace:*",
"@edr/payment-providers": "workspace:*", "@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*", "@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0", "@golevelup/nestjs-rabbitmq": "^5.5.0",

View File

@@ -12,6 +12,7 @@ import {
ensurePostgresSchemas, ensurePostgresSchemas,
APPLICATION_SEARCH_PATH, APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas"; } from "./config/ensure-postgres-schemas";
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common"; import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -55,7 +56,6 @@ import {
EDR_FREIGHT_PERMISSIONS, EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed"; } from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { IamBaselineSeeder } from "./seed/iam-baseline.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
// Disabled seeds — imports commented out with their provider/injection/run below. // Disabled seeds — imports commented out with their provider/injection/run below.
// import { DemoUsersSeeder } from "./seed/demo-users.seeder"; // import { DemoUsersSeeder } from "./seed/demo-users.seeder";
@@ -154,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware";
applications: [EDR_FREIGHT_APPLICATION], applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS, 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, BookingsModule,
ContractsModule, ContractsModule,
SignaturesModule, SignaturesModule,
@@ -207,7 +219,6 @@ import { LoggerMiddleware } from "./logger.middleware";
AiModule, AiModule,
], ],
providers: [ providers: [
IamBaselineSeeder,
EdrOrgSeeder, EdrOrgSeeder,
FreightPositionsSeeder, FreightPositionsSeeder,
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
@@ -264,16 +275,15 @@ export class AppModule implements OnApplicationBootstrap {
// Permissions foundation — keep enabled: // Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys // freightPermissionKeyMigration → renames legacy permission keys
// edrOrgSeeder → seeds org/unit + the Permission catalog // edrOrgSeeder → seeds org/unit + the Permission catalog
// iamBaselineSeeder → seeds the IAM app, roles, permissions, // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
// position types, org/unit settings and the // position types, organization types +
// super-admin account. Local replacement for // default units, org/unit settings and the
// the package's DataSeeder (still exported as // super-admin account. Replaces the package's
// `DataSeeder` from @tria-plc/iamapi-common // DataSeeder, and is shared with
// and runnable via `pnpm iam:seed:run`) — // edr-passenger-api so one writer owns the
// see src/seed/iam-baseline.seed.ts for // `iam` schema. Runs after edrOrgSeeder
// what it seeds and what it drops. Runs after // because the super admin attaches to the
// edrOrgSeeder because the super admin is // edr_freight org/unit.
// attached to the edr_freight org/unit.
// Writes nothing unless SEED_IAM_BASELINE=true. // Writes nothing unless SEED_IAM_BASELINE=true.
// freightPositionsSeeder → seeds Position + PositionPermission rows // freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after) // (depends on edrOrgSeeder, must run after)

View File

@@ -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<ObjectLiteral>,
keys: string[],
errorPrefix: string,
): Promise<Map<string, string>> {
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;
}
}

View File

@@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
# Login endpoint for backoffice users: POST /v1/auth/login # Login endpoint for backoffice users: POST /v1/auth/login
SEED_EDR_PASSENGER_ORG=false SEED_EDR_PASSENGER_ORG=false
SEED_PASSENGER_STAFF=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. # Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset.
DEFAULT_PASSWORD=Admin@1234 DEFAULT_PASSWORD=Admin@1234

View File

@@ -27,6 +27,7 @@
"prisma:verify": "ts-node prisma/verify-backfill.ts" "prisma:verify": "ts-node prisma/verify-backfill.ts"
}, },
"dependencies": { "dependencies": {
"@edr/iam-seed": "workspace:*",
"@edr/types": "workspace:*", "@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0", "@golevelup/nestjs-rabbitmq": "^5.5.0",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",

View File

@@ -4,8 +4,8 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter"; import { EventEmitterModule } from "@nestjs/event-emitter";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; 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 { 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 { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import { import {
EDR_PASSENGER_APPLICATION, EDR_PASSENGER_APPLICATION,
@@ -103,6 +103,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
`Set your EDR Passenger password using this link: ${route}`, `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, SharedAuthModule,
PrismaModule, PrismaModule,
AuditModule, AuditModule,
@@ -151,18 +162,23 @@ import { EOtpType } from "@tria-plc/iamapi-common";
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
private readonly logger = new Logger(AppModule.name); private readonly logger = new Logger(AppModule.name);
constructor( constructor(
private readonly seeder: DataSeeder, private readonly iamBaselineSeeder: IamBaselineSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
private readonly segmentFareSeeder: SegmentFareSeeder, private readonly segmentFareSeeder: SegmentFareSeeder,
) {} ) {}
async onApplicationBootstrap() { 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 { try {
await this.seeder.run(); await this.iamBaselineSeeder.run();
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
"[DataSeeder] Seed failed (non-fatal):", "[IamBaselineSeeder] Seed failed (non-fatal):",
(err as Error).message, (err as Error).message,
); );
} }

View File

@@ -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"
}
}

View File

@@ -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 * These rows started as a copy of the seed constants inside
* `@tria-plc/iamapi-common` (`dist/db/seed/role.seed`, * `@tria-plc/iamapi-common` (`dist/db/seed/*`) as of 0.7.12, and are owned here
* `organization-setting.seed`, `unit-setting.seed`) as of 0.7.12, and are now * now. Nothing is read from that package at runtime, so the apps sharing the
* owned here: edit, extend or delete anything below and the seeder follows. * `iam` schema stay consistent even while they resolve different versions of it
* Nothing in this file is imported from the package, so a version bump cannot * but a package upgrade will not hand you new IAM permissions either. Diff
* change what gets seeded it also will not hand you new IAM permissions, so * against `db/seed/role.seed` and `db/seed/org-type.seed` when upgrading.
* diff against those files when upgrading.
* *
* Ids are the package's originals. Keep them: the rows already in every * Ids are the package's originals. Keep them: rows in every existing
* environment carry these ids. * environment already carry them.
* *
* Deliberately NOT seeded (the package's DataSeeder does all of it): * Deliberately absent, because nothing in EDR reads them: the 11 Addis Ababa
* - TRIA super-admin organization + its `superadmin` user and password * sub-city organizations, the TRIA super-admin organization (`is_super_admin` is
* - 11 Addis Ababa sub-city organizations * only used to block edits to that row and hide it from one list query), and the
* - woreda / subcity / office / branch organization types + 65 default units * 7 non-IAM Smart Office applications.
* - the 7 non-IAM Smart Office applications (booking, bot, chronicle, dms,
* metabase, performance, record)
*/ */
export type LocalizedName = { am: string; en: string }; /** Applications permissions hang off. Each app seeds its own separately. */
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. */
const APPLICATIONS: SeedApplication[] = [ const APPLICATIONS: SeedApplication[] = [
{ {
id: "019bcb17-5470-7604-8708-7ed04d842b41", id: "019bcb17-5470-7604-8708-7ed04d842b41",
@@ -383,10 +350,10 @@ const PERMISSIONS: SeedPermission[] = [
]; ];
/** /**
* Role permission-key grants. Applied additively: the seeder only inserts * Role permission-key grants, applied additively: only missing pairs are
* missing pairs, so grants made through the IAM UI survive a reseed. * 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", roleKey: "super_admin",
permissionKeys: [ permissionKeys: [
@@ -518,13 +485,104 @@ const POSITION_TYPES: SeedPositionType[] = [
]; ];
/** /**
* PositionType permission-key grants. Empty because freight grants through * Organization types and the units an organization of that type is created
* Position/PositionPermission (see FreightPositionsSeeder), not position types. * 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: { const ORGANIZATION_TYPES: SeedOrganizationType[] = [
positionTypeKey: string; {
permissionKeys: string[]; 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. */ /** Seeded once per organization. All start unset; the IAM UI fills them in. */
const ORGANIZATION_SETTINGS: SettingDefault[] = [ const ORGANIZATION_SETTINGS: SettingDefault[] = [
@@ -569,43 +627,19 @@ const UNIT_SETTINGS: SettingDefault[] = [
]; ];
/** /**
* The super-admin account. Its access comes entirely from the `super_admin` * Defaults for every section except `superAdmin`, which has no sensible default
* UserRole: `isSuperAdmin()` in `common/freight-permission.util.ts` short-circuits * the account has to be attached to an organization only the consuming app
* `hasFreightPermission`, so this account passes every FreightPermissionGuard * knows about.
* 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.
*/ */
const SUPER_ADMIN: SeedSuperAdmin | null = { export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = {
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 = {
applications: APPLICATIONS, applications: APPLICATIONS,
roles: ROLES, roles: ROLES,
permissions: PERMISSIONS, permissions: PERMISSIONS,
rolePermissions: ROLE_PERMISSIONS, rolePermissions: ROLE_PERMISSIONS,
positionTypes: POSITION_TYPES, positionTypes: POSITION_TYPES,
positionTypePermissions: POSITION_TYPE_PERMISSIONS, positionTypePermissions: [],
organizationTypes: ORGANIZATION_TYPES,
organizationSettings: ORGANIZATION_SETTINGS, organizationSettings: ORGANIZATION_SETTINGS,
unitSettings: UNIT_SETTINGS, unitSettings: UNIT_SETTINGS,
superAdmin: SUPER_ADMIN, superAdmin: null,
}; };

View File

@@ -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<number> {
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<string | undefined> {
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<string, KeyedRow>,
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<number> {
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<Map<string, KeyedRow>> {
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<Map<string, string>> {
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<Set<string>> {
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}`));
}
}

View File

@@ -0,0 +1,2 @@
/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */
export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS");

View File

@@ -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],
};
}
}

View File

@@ -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<IamBaselineSeed> & {
/**
* Environment variable that must equal "true" for the seeder to write
* anything. Defaults to SEED_IAM_BASELINE.
*/
enableFlag?: string;
};

View File

@@ -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";

View File

@@ -1,9 +1,9 @@
/** /**
* Settings of `ownerId` that are not in `existingPairs` (`"<ownerId>:<key>"`) * Settings of `ownerId` that are not in `existingPairs` (`"<ownerId>:<key>"`)
* yet. This is what keeps the IAM baseline seed idempotent: `organization_settings` * 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 * and `unit_settings` have no unique index on (owner, key), so a plain re-insert
* INSERT (or the package seeder's upsert-on-id, which never supplies an id) * or the package seeder's upsert-on-id, which never supplies an id silently
* silently duplicates every row. * duplicates every row.
* *
* `value: null` becomes `undefined` so the column default applies on insert. * `value: null` becomes `undefined` so the column default applies on insert.
*/ */

View File

@@ -0,0 +1,11 @@
{
"extends": "@edr/tsconfig/nestjs.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts"]
}

43
pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
'@edr/api-common': '@edr/api-common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/api-common version: link:../../packages/api-common
'@edr/iam-seed':
specifier: workspace:*
version: link:../../packages/iam-seed
'@edr/payment-providers': '@edr/payment-providers':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/payment-providers version: link:../../packages/payment-providers
@@ -754,6 +757,9 @@ importers:
apps/edr-passenger-api: apps/edr-passenger-api:
dependencies: dependencies:
'@edr/iam-seed':
specifier: workspace:*
version: link:../../packages/iam-seed
'@edr/types': '@edr/types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/types version: link:../../packages/types
@@ -1292,6 +1298,43 @@ importers:
packages/config/tsconfig: {} 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: packages/payment-providers:
dependencies: dependencies:
'@edr/types': '@edr/types':