mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor: centralized the iam seeder
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -43,13 +43,15 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
|
||||
SUPER_ADMIN_PASSWORD=
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# IAM baseline (roles, IAM app + permissions, position types, org/unit settings,
|
||||
# super-admin account). Local replacement for the package DataSeeder —
|
||||
# see src/seed/iam-baseline.seed.ts.
|
||||
SEED_IAM_BASELINE=false
|
||||
# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
|
||||
# position types, organization types + default units, org/unit settings, super
|
||||
# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { IamBaselineSeeder } from "./seed/iam-baseline.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
// Disabled seeds — imports commented out with their provider/injection/run below.
|
||||
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
@@ -154,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr_freight",
|
||||
unitKey: "edr_freight_app",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
BookingsModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
@@ -207,7 +219,6 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
AiModule,
|
||||
],
|
||||
providers: [
|
||||
IamBaselineSeeder,
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
@@ -264,16 +275,15 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// iamBaselineSeeder → seeds the IAM app, roles, permissions,
|
||||
// position types, org/unit settings and the
|
||||
// super-admin account. Local replacement for
|
||||
// the package's DataSeeder (still exported as
|
||||
// `DataSeeder` from @tria-plc/iamapi-common
|
||||
// and runnable via `pnpm iam:seed:run`) —
|
||||
// see src/seed/iam-baseline.seed.ts for
|
||||
// what it seeds and what it drops. Runs after
|
||||
// edrOrgSeeder because the super admin is
|
||||
// attached to the edr_freight org/unit.
|
||||
// iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
|
||||
// position types, organization types +
|
||||
// default units, org/unit settings and the
|
||||
// super-admin account. Replaces the package's
|
||||
// DataSeeder, and is shared with
|
||||
// edr-passenger-api so one writer owns the
|
||||
// `iam` schema. Runs after edrOrgSeeder
|
||||
// because the super admin attaches to the
|
||||
// edr_freight org/unit.
|
||||
// Writes nothing unless SEED_IAM_BASELINE=true.
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN=<your-github-packages-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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
50
packages/iam-seed/package.json
Normal file
50
packages/iam-seed/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,35 @@
|
||||
import {
|
||||
IamBaselineSeed,
|
||||
SeedApplication,
|
||||
SeedOrganizationType,
|
||||
SeedPermission,
|
||||
SeedPositionType,
|
||||
SeedRole,
|
||||
SeedRolePermission,
|
||||
SettingDefault,
|
||||
} from "./iam-seed.types";
|
||||
|
||||
/**
|
||||
* IAM baseline seed data — the rows `IamBaselineSeeder` writes.
|
||||
* The default IAM baseline: what `IamBaselineSeeder` writes when an app does not
|
||||
* override a section via `IamSeedModule.forRoot()`.
|
||||
*
|
||||
* These started as a copy of the seed constants inside
|
||||
* `@tria-plc/iamapi-common` (`dist/db/seed/role.seed`,
|
||||
* `organization-setting.seed`, `unit-setting.seed`) as of 0.7.12, and are now
|
||||
* owned here: edit, extend or delete anything below and the seeder follows.
|
||||
* Nothing in this file is imported from the package, so a version bump cannot
|
||||
* change what gets seeded — it also will not hand you new IAM permissions, so
|
||||
* diff against those files when upgrading.
|
||||
* These rows started as a copy of the seed constants inside
|
||||
* `@tria-plc/iamapi-common` (`dist/db/seed/*`) as of 0.7.12, and are owned here
|
||||
* now. Nothing is read from that package at runtime, so the apps sharing the
|
||||
* `iam` schema stay consistent even while they resolve different versions of it
|
||||
* — but a package upgrade will not hand you new IAM permissions either. Diff
|
||||
* against `db/seed/role.seed` and `db/seed/org-type.seed` when upgrading.
|
||||
*
|
||||
* Ids are the package's originals. Keep them: the rows already in every
|
||||
* environment carry these ids.
|
||||
* Ids are the package's originals. Keep them: rows in every existing
|
||||
* environment already carry them.
|
||||
*
|
||||
* Deliberately NOT seeded (the package's DataSeeder does all of it):
|
||||
* - TRIA super-admin organization + its `superadmin` user and password
|
||||
* - 11 Addis Ababa sub-city organizations
|
||||
* - woreda / subcity / office / branch organization types + 65 default units
|
||||
* - the 7 non-IAM Smart Office applications (booking, bot, chronicle, dms,
|
||||
* metabase, performance, record)
|
||||
* Deliberately absent, because nothing in EDR reads them: the 11 Addis Ababa
|
||||
* sub-city organizations, the TRIA super-admin organization (`is_super_admin` is
|
||||
* only used to block edits to that row and hide it from one list query), and the
|
||||
* 7 non-IAM Smart Office applications.
|
||||
*/
|
||||
|
||||
export type LocalizedName = { am: string; en: string };
|
||||
|
||||
export type SeedApplication = { id: string; key: string; name: LocalizedName };
|
||||
|
||||
export type SeedRole = { id: string; key: string; name: LocalizedName };
|
||||
|
||||
export type SeedPermission = {
|
||||
id: string;
|
||||
key: string;
|
||||
/** Omitted for the 21 org/unit/location permissions the package leaves unlinked. */
|
||||
applicationKey?: string;
|
||||
name: LocalizedName;
|
||||
};
|
||||
|
||||
export type SeedPositionType = {
|
||||
id: string;
|
||||
key: string;
|
||||
isSystem: boolean;
|
||||
name: LocalizedName;
|
||||
};
|
||||
|
||||
export type SeedSuperAdmin = {
|
||||
username: string;
|
||||
name: LocalizedName;
|
||||
/** Key in `roles` — the grant that carries the access. */
|
||||
roleKey: string;
|
||||
/** Existing organization/unit to attach the employee to. */
|
||||
organizationKey: string;
|
||||
unitKey: string;
|
||||
/** Used when SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD / DEFAULT_PASSWORD are all unset. */
|
||||
fallbackEmail: string;
|
||||
fallbackPassword: string;
|
||||
};
|
||||
|
||||
export type SettingDefault = {
|
||||
key: string;
|
||||
displayName: string;
|
||||
/** Matches the package's ESettingType. */
|
||||
type: "value" | "file";
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
/** Applications permissions hang off. `edr_freight_app` is seeded by EdrOrgSeeder. */
|
||||
/** Applications permissions hang off. Each app seeds its own separately. */
|
||||
const APPLICATIONS: SeedApplication[] = [
|
||||
{
|
||||
id: "019bcb17-5470-7604-8708-7ed04d842b41",
|
||||
@@ -383,10 +350,10 @@ const PERMISSIONS: SeedPermission[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Role → permission-key grants. Applied additively: the seeder only inserts
|
||||
* missing pairs, so grants made through the IAM UI survive a reseed.
|
||||
* Role → permission-key grants, applied additively: only missing pairs are
|
||||
* inserted, so grants made through the IAM UI survive a reseed.
|
||||
*/
|
||||
const ROLE_PERMISSIONS: { roleKey: string; permissionKeys: string[] }[] = [
|
||||
const ROLE_PERMISSIONS: SeedRolePermission[] = [
|
||||
{
|
||||
roleKey: "super_admin",
|
||||
permissionKeys: [
|
||||
@@ -518,13 +485,104 @@ const POSITION_TYPES: SeedPositionType[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* PositionType → permission-key grants. Empty because freight grants through
|
||||
* Position/PositionPermission (see FreightPositionsSeeder), not position types.
|
||||
* Organization types and the units an organization of that type is created
|
||||
* with. Both are read at runtime, which is why they are seeded: the New
|
||||
* Organization form posts `organizationTypeId`, and
|
||||
* `createOrganizationWithStructure` builds the new org's units from
|
||||
* `default_units`.
|
||||
*/
|
||||
const POSITION_TYPE_PERMISSIONS: {
|
||||
positionTypeKey: string;
|
||||
permissionKeys: string[];
|
||||
}[] = [];
|
||||
const ORGANIZATION_TYPES: SeedOrganizationType[] = [
|
||||
{
|
||||
key: "woreda",
|
||||
name: { am: "ወረዳ", en: "Woreda" },
|
||||
defaultUnits: [
|
||||
{ key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Chief Executive Office" } },
|
||||
{ key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } },
|
||||
{ key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } },
|
||||
{ key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } },
|
||||
{ key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Prosecutor's Office" } },
|
||||
{ key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } },
|
||||
{ key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Enforcement Office" } },
|
||||
{ key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } },
|
||||
{ key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Cooperative Office" } },
|
||||
{ key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Business Office" } },
|
||||
{ key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } },
|
||||
{ key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } },
|
||||
{ key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid waste office" } },
|
||||
{ key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } },
|
||||
{ key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } },
|
||||
{ key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service Human Resource Management" } },
|
||||
{ key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Government Building Office" } },
|
||||
{ key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } },
|
||||
{ key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } },
|
||||
{ key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } },
|
||||
{ key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } },
|
||||
{ key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } },
|
||||
{ key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } },
|
||||
{ key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Supervision Office" } },
|
||||
{ key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } },
|
||||
{ key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } },
|
||||
{ key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Charity Coordination Office" } },
|
||||
{ key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } },
|
||||
{ key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } },
|
||||
{ key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Employment and Industry Development Office" } },
|
||||
{ key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } },
|
||||
{ key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "subcity",
|
||||
name: { am: "ክፍለ ከተማ", en: "Sub City" },
|
||||
defaultUnits: [
|
||||
{ key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Main Executive Office" } },
|
||||
{ key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } },
|
||||
{ key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } },
|
||||
{ key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } },
|
||||
{ key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Legal Affairs Office" } },
|
||||
{ key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } },
|
||||
{ key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Regulations Enforcement Office" } },
|
||||
{ key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } },
|
||||
{ key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Community Work Office" } },
|
||||
{ key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Trade Office" } },
|
||||
{ key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } },
|
||||
{ key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } },
|
||||
{ key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid Waste Management Office" } },
|
||||
{ key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } },
|
||||
{ key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } },
|
||||
{ key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service and Human Resource Management" } },
|
||||
{ key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Public Buildings Office" } },
|
||||
{ key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } },
|
||||
{ key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } },
|
||||
{ key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } },
|
||||
{ key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } },
|
||||
{ key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } },
|
||||
{ key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } },
|
||||
{ key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Control Office" } },
|
||||
{ key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } },
|
||||
{ key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } },
|
||||
{ key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Voluntarism Coordination Office" } },
|
||||
{ key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } },
|
||||
{ key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } },
|
||||
{ key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Labor and Industry Development Office" } },
|
||||
{ key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } },
|
||||
{ key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "office",
|
||||
name: { am: "ቢሮ", en: "Bureau" },
|
||||
defaultUnits: [
|
||||
{ key: "ቢሮ", description: "ቢሮ", name: { am: "ቢሮ", en: "Bureau" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "branch",
|
||||
name: { am: "ቅርንጫፍ", en: "Branch" },
|
||||
defaultUnits: [
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Seeded once per organization. All start unset; the IAM UI fills them in. */
|
||||
const ORGANIZATION_SETTINGS: SettingDefault[] = [
|
||||
@@ -569,43 +627,19 @@ const UNIT_SETTINGS: SettingDefault[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The super-admin account. Its access comes entirely from the `super_admin`
|
||||
* UserRole: `isSuperAdmin()` in `common/freight-permission.util.ts` short-circuits
|
||||
* `hasFreightPermission`, so this account passes every FreightPermissionGuard
|
||||
* and every approval-step check without holding one freight permission row.
|
||||
*
|
||||
* Unlike the package seeder, the account lands in the freight organization
|
||||
* (`edr_freight` / unit `edr_freight_app`) rather than a separate `tria`
|
||||
* organization, so its employee context matches the app it administers. Both
|
||||
* must already exist — EdrOrgSeeder creates them, and must run first.
|
||||
*
|
||||
* Read from env at seed time: SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE, and for the
|
||||
* password SUPER_ADMIN_PASSWORD first, then DEFAULT_PASSWORD. The values below
|
||||
* are the last resort. Set this to null to seed no account at all.
|
||||
* Defaults for every section except `superAdmin`, which has no sensible default
|
||||
* — the account has to be attached to an organization only the consuming app
|
||||
* knows about.
|
||||
*/
|
||||
const SUPER_ADMIN: SeedSuperAdmin | null = {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr_freight",
|
||||
unitKey: "edr_freight_app",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
fallbackPassword: "password@tria",
|
||||
};
|
||||
|
||||
/**
|
||||
* What `IamBaselineSeeder` writes. Empty an array to skip that section
|
||||
* entirely; the seeder never deletes rows, so emptying one leaves whatever is
|
||||
* already in the database alone.
|
||||
*/
|
||||
export const IAM_BASELINE_SEED = {
|
||||
export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = {
|
||||
applications: APPLICATIONS,
|
||||
roles: ROLES,
|
||||
permissions: PERMISSIONS,
|
||||
rolePermissions: ROLE_PERMISSIONS,
|
||||
positionTypes: POSITION_TYPES,
|
||||
positionTypePermissions: POSITION_TYPE_PERMISSIONS,
|
||||
positionTypePermissions: [],
|
||||
organizationTypes: ORGANIZATION_TYPES,
|
||||
organizationSettings: ORGANIZATION_SETTINGS,
|
||||
unitSettings: UNIT_SETTINGS,
|
||||
superAdmin: SUPER_ADMIN,
|
||||
superAdmin: null,
|
||||
};
|
||||
875
packages/iam-seed/src/iam-baseline.seeder.ts
Normal file
875
packages/iam-seed/src/iam-baseline.seeder.ts
Normal 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}`));
|
||||
}
|
||||
}
|
||||
2
packages/iam-seed/src/iam-seed.constants.ts
Normal file
2
packages/iam-seed/src/iam-seed.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */
|
||||
export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS");
|
||||
39
packages/iam-seed/src/iam-seed.module.ts
Normal file
39
packages/iam-seed/src/iam-seed.module.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
100
packages/iam-seed/src/iam-seed.types.ts
Normal file
100
packages/iam-seed/src/iam-seed.types.ts
Normal 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;
|
||||
};
|
||||
20
packages/iam-seed/src/index.ts
Normal file
20
packages/iam-seed/src/index.ts
Normal 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";
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Settings of `ownerId` that are not in `existingPairs` (`"<ownerId>:<key>"`)
|
||||
* yet. This is what keeps the IAM baseline seed idempotent: `organization_settings`
|
||||
* and `unit_settings` have no unique index on (owner, key), so re-running an
|
||||
* INSERT (or the package seeder's upsert-on-id, which never supplies an id)
|
||||
* silently duplicates every row.
|
||||
* and `unit_settings` have no unique index on (owner, key), so a plain re-insert
|
||||
* — or the package seeder's upsert-on-id, which never supplies an id — silently
|
||||
* duplicates every row.
|
||||
*
|
||||
* `value: null` becomes `undefined` so the column default applies on insert.
|
||||
*/
|
||||
11
packages/iam-seed/tsconfig.json
Normal file
11
packages/iam-seed/tsconfig.json
Normal 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
43
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user