feat(passenger-api): integrate @tria-plc IAM package (dual-ORM iam schema + package auth guard)

This commit is contained in:
Abubeker Yasin
2026-06-02 09:30:46 +03:00
parent 5059a1fe58
commit 092199f536
18 changed files with 2886 additions and 467 deletions

View File

@@ -2,17 +2,46 @@
NODE_ENV=development NODE_ENV=development
PORT=3002 PORT=3002
# Database (Prisma) # Database (Prisma) — owns the `passenger` schema in edr_database
DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_passenger?schema=edr_passenger DATABASE_URL=postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger
# Database (TypeORM / @tria-plc IAM) — shared `iam` schema in the SAME edr_database.
# These mirror the connection vars read by @tria-plc/api-common's TypeORM DataSource.
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=edr_database
DATABASE_USER=edr
DATABASE_PASSWORD=edr_secret
DATABASE_SCHEMA=iam
# RabbitMQ — the @tria-plc IAM/notification modules register RMQ clients (SMS/notifications).
# Connects lazily; a broker is only needed when those features actually send. Placeholder for dev.
RABBITMQ_URL=amqp://localhost:5672
# MinIO — the @tria-plc file/notification modules construct a MinIO client at boot (validates these).
# Placeholders for dev; only contacted when file upload/download features are actually used.
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=edr-dev
# CORS # CORS
FRONTEND_URL=http://localhost:5174 FRONTEND_URL=http://localhost:5174
BACK_OFFICE_URL=http://localhost:5184 BACK_OFFICE_URL=http://localhost:5184
# JWT # JWT (legacy passenger auth — being replaced by IAM)
JWT_SECRET=edr-platform-secret-change-in-production JWT_SECRET=edr-platform-secret-change-in-production
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
# @tria-plc IAM token contract — the package's JwtGuard/verifyToken + AuthService sign/verify with
# these. MUST match the IAM issuer's secret in shared deployments. (Expiry strings use jsonwebtoken/ms.)
JWT_ACCESS_TOKEN_SECRET=dev-iam-access-secret-change-me
JWT_ACCESS_TOKEN_EXPIRES=1h
JWT_REFRESH_TOKEN_SECRET=dev-iam-refresh-secret-change-me
JWT_REFRESH_TOKEN_EXPIRES=7d
# SendGrid # SendGrid
SENDGRID_API_KEY= SENDGRID_API_KEY=
SENDGRID_FROM_EMAIL=noreply@edr-platform.com SENDGRID_FROM_EMAIL=noreply@edr-platform.com

View File

@@ -11,6 +11,8 @@
"test": "jest", "test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed-complete.ts", "prisma:seed": "ts-node prisma/seed-complete.ts",
@@ -32,19 +34,27 @@
"@nestjs/platform-express": "^11.1.19", "@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^7.4.0", "@nestjs/swagger": "^7.4.0",
"@nestjs/typeorm": "^11.0.1",
"@sendgrid/mail": "^8.1.0", "@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "^0.1.4",
"@tria-plc/iamapi-common": "^0.1.6",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"dotenv": "^17.4.2",
"jose": "^5.10.0", "jose": "^5.10.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.21.0",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"tsconfig-paths": "^4.2.0" "tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30"
}, },
"devDependencies": { "devDependencies": {
"@edr/eslint-config": "workspace:*", "@edr/eslint-config": "workspace:*",

View File

@@ -0,0 +1,46 @@
/**
* Dev helper: run the @tria-plc/iamapi-common TypeORM migrations against the shared `iam` schema.
*
* The package ships its migration CLI assuming you run it from inside the package repo (it needs
* the package's devDeps). As a consumer we instead drive the shipped (compiled) migrations with the
* passenger app's own installed TypeORM.
*
* Reads the same DATABASE_* env vars as the app's IAM DataSource (see config/iam-database.config.ts).
* Run via: pnpm --filter @edr/passenger-api iam:migrate
* (the npm script loads .env with `node --env-file`).
*
* NOTE: in production the central IAM team owns/runs these migrations — this helper is for local dev.
*/
const path = require('path');
const { DataSource } = require('typeorm');
const iamDist = path
.dirname(require.resolve('@tria-plc/iamapi-common'))
.replace(/\\/g, '/');
const ds = new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT || 5432),
database: process.env.DATABASE_NAME,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
schema: process.env.DATABASE_SCHEMA || 'iam',
entities: [], // migrations are raw SQL — no entities needed to run them
migrations: [`${iamDist}/db/migrations/*.js`],
migrationsTableName: 'typeorm_migrations',
});
(async () => {
await ds.initialize();
// The IAM migrations rely on uuid_generate_v4() but never CREATE the extension themselves.
await ds.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
const applied = await ds.runMigrations({ transaction: 'each' });
console.log(`[iam-migrations] applied ${applied.length} migration(s)`);
applied.slice(-5).forEach((m) => console.log(' +', m.name));
await ds.destroy();
console.log('[iam-migrations] DONE');
})().catch((e) => {
console.error('[iam-migrations] FAIL:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
/**
* Dev helper: create a dev IAM user + an ACTIVE session, and print a ready-to-use Bearer token.
*
* Why this exists: in prod the central IAM service issues tokens (via password login at
* /v1/auth/login). For local dev of the passenger API (a token *consumer*), this seeds a session
* directly and mints a matching token with the package's own `generateToken`, so you can call
* protected routes immediately (paste the token into Swagger's Authorize box or `curl -H`).
*
* Run: pnpm --filter @edr/passenger-api iam:seed-dev-user
* Reads DATABASE_* + JWT_ACCESS_TOKEN_SECRET/EXPIRES from .env (loaded via `node --env-file`).
*/
const crypto = require('crypto');
const { DataSource } = require('typeorm');
const { generateToken } = require('@tria-plc/api-common/utils/token');
const DEV_EMAIL = process.env.DEV_IAM_EMAIL || 'dev@edr.local';
const ds = new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT || 5432),
database: process.env.DATABASE_NAME,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
});
(async () => {
await ds.initialize();
// Upsert the dev user (users.email is UNIQUE).
const name = { en: 'Dev User', am: 'የሙከራ ተጠቃሚ' };
const [user] = await ds.query(
`INSERT INTO iam.users (name, username, email, user_type, status, is_active)
VALUES ($1::jsonb, $2, $3, 'individual', 'accepted', true)
ON CONFLICT (email) DO UPDATE SET updated_at = now()
RETURNING id`,
[JSON.stringify(name), 'dev-user', DEV_EMAIL],
);
const userId = user.id;
// Fresh ACTIVE session; userInfo is the denormalized TCurrentUser the guard puts on req.user.
const sessionId = crypto.randomUUID();
const userInfo = {
id: userId,
email: DEV_EMAIL,
name,
username: 'dev-user',
userType: 'individual',
status: 'accepted',
roles: [],
permissions: [],
};
await ds.query(
`INSERT INTO iam.sessions (id, email, device, "userInfo", user_id, status, expiry_time)
VALUES ($1, $2, 'dev-seeder', $3::jsonb, $4, 'ACTIVE', now() + interval '7 days')`,
[sessionId, DEV_EMAIL, JSON.stringify(userInfo), userId],
);
// The package JwtGuard looks up the session by the token's `id` claim.
const token = generateToken({ id: sessionId });
console.log('\n=== IAM dev user seeded ===');
console.log('user id :', userId);
console.log('email :', DEV_EMAIL);
console.log('session id:', sessionId);
console.log('\nBearer token (valid 7 days):\n' + token);
console.log('\nTry it: curl -H "Authorization: Bearer <token>" http://localhost:3002/v1/auth/me');
console.log('(Run again any time for a fresh token/session.)\n');
await ds.destroy();
})().catch((e) => {
console.error('[seed-iam-dev-user] FAIL:', e.message);
process.exit(1);
});

View File

@@ -1,13 +1,17 @@
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; 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';
// Subpath import: the @tria-plc/iamapi-common barrel does not resolve under moduleResolution:"Node".
// Aliased to avoid clashing with the app's existing custom ./common/iam.module (remote IamGuard).
import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module';
import { PrismaModule } from './common/prisma.module'; import { PrismaModule } from './common/prisma.module';
import { I18nModule } from './common/i18n/i18n.module'; import { I18nModule } from './common/i18n/i18n.module';
import { IamModule } from './common/iam.module';
import { LocaleMiddleware } from './common/i18n/locale.middleware'; import { LocaleMiddleware } from './common/i18n/locale.middleware';
import appConfig from './config/app.config'; import appConfig from './config/app.config';
import dbConfig from './config/database.config'; import dbConfig from './config/database.config';
import iamDatabaseConfig from './config/iam-database.config';
import telebirrConfig from './config/telebirr.config'; import telebirrConfig from './config/telebirr.config';
import cbeConfig from './config/cbe.config'; import cbeConfig from './config/cbe.config';
import ebirrConfig from './config/ebirr.config'; import ebirrConfig from './config/ebirr.config';
@@ -46,6 +50,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
load: [ load: [
appConfig, appConfig,
dbConfig, dbConfig,
iamDatabaseConfig,
telebirrConfig, telebirrConfig,
cbeConfig, cbeConfig,
ebirrConfig, ebirrConfig,
@@ -56,10 +61,22 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
// TypeORM root DataSource for the shared `iam` schema (coexists with Prisma's `passenger`
// schema). Required so @tria-plc/api-common's JwtGuard can read `iam.sessions`, and so
// IamModule's forFeature repositories resolve. Options come from the `database` config
// namespace (see config/database.config.ts → iam-typeorm.config.ts).
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>('iamDatabase')!,
}),
// Mount the @tria-plc IAM module (auth/user/org-structure). Registers the IAM REST API under
// /v1/* (URI versioning). NOTE: SharedAuthModule (global JwtGuard) is intentionally added later
// in the route-protection phase, so public passenger routes are not 401'd before then.
TriaIamModule.forRoot(),
PrismaModule, PrismaModule,
I18nModule, I18nModule,
IamModule, // AuthModule,
AuthModule,
StationsModule, StationsModule,
FleetModule, FleetModule,
SchedulesModule, SchedulesModule,

View File

@@ -1,264 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
import { of, throwError } from 'rxjs';
describe('IamGuard', () => {
let guard: IamGuard;
let httpService: HttpService;
let configService: ConfigService;
let reflector: Reflector;
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
IAM_API_URL: 'https://iam.test.com/api',
IAM_ENABLED: 'true',
IAM_API_KEY: 'test-api-key',
};
return config[key];
}),
};
const mockHttpService = {
post: jest.fn(),
};
const mockReflector = {
get: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IamGuard,
{ provide: ConfigService, useValue: mockConfigService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: Reflector, useValue: mockReflector },
],
}).compile();
guard = module.get<IamGuard>(IamGuard);
httpService = module.get<HttpService>(HttpService);
configService = module.get<ConfigService>(ConfigService);
reflector = module.get<Reflector>(Reflector);
jest.clearAllMocks();
});
const createMockContext = (token?: string, roles?: string[]): ExecutionContext => {
const request = {
headers: token ? { authorization: `Bearer ${token}` } : {},
user: undefined,
};
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
};
describe('canActivate', () => {
it('should allow access when IAM is disabled', async () => {
mockConfigService.get.mockReturnValueOnce('false'); // IAM_ENABLED
const context = createMockContext();
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw UnauthorizedException when no token provided', async () => {
const context = createMockContext();
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should validate token and allow access', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockHttpService.post).toHaveBeenCalledWith(
'https://iam.test.com/api/v1/auth/validate',
{ token: 'valid-token' },
expect.objectContaining({
headers: expect.objectContaining({
'X-API-Key': 'test-api-key',
}),
}),
);
});
it('should throw UnauthorizedException for invalid token', async () => {
const mockValidationResponse = {
data: {
valid: false,
error: 'Token expired',
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
const context = createMockContext('invalid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should check required roles', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'agent@test.com',
roles: ['AGENT'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
});
it('should allow access when user has required role', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(['ADMIN', 'SUPERVISOR']);
const context = createMockContext('valid-token');
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should handle HTTP errors gracefully', async () => {
mockHttpService.post.mockReturnValue(
throwError(() => new Error('Network error')),
);
const context = createMockContext('valid-token');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('should attach user to request', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('valid-token');
await guard.canActivate(context);
const request = context.switchToHttp().getRequest();
expect(request.user).toEqual({
userId: 'user-123',
email: 'admin@test.com',
roles: ['ADMIN'],
permissions: ['read', 'write'],
organizationId: 'org-456',
});
});
});
describe('token extraction', () => {
it('should extract token from Bearer header', async () => {
const mockValidationResponse = {
data: {
valid: true,
payload: {
sub: 'user-123',
email: 'test@test.com',
roles: [],
permissions: [],
exp: Date.now() + 3600000,
iat: Date.now(),
},
},
};
mockHttpService.post.mockReturnValue(of(mockValidationResponse));
mockReflector.get.mockReturnValue(null);
const context = createMockContext('my-token-123');
await guard.canActivate(context);
expect(mockHttpService.post).toHaveBeenCalledWith(
expect.any(String),
{ token: 'my-token-123' },
expect.any(Object),
);
});
it('should reject malformed authorization header', async () => {
const request = {
headers: { authorization: 'InvalidFormat token' },
};
const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => ({}),
} as ExecutionContext;
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
});
});

View File

@@ -1,144 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
/**
* IAM Adapter for @tria-plc corporate identity integration
*
* This adapter wraps the corporate IAM guards and provides a bridge
* between the corporate identity system and the EDR passenger API.
*
* For back-office roles (agent, supervisor, admin, staff), this guard
* validates tokens against the corporate IAM service.
*
* For passenger-facing routes, the existing JWT guard is used.
*/
export interface IamTokenPayload {
sub: string;
email: string;
roles: string[];
permissions: string[];
organizationId?: string;
exp: number;
iat: number;
}
export interface IamValidationResponse {
valid: boolean;
payload?: IamTokenPayload;
error?: string;
}
@Injectable()
export class IamGuard implements CanActivate {
private readonly iamApiUrl: string;
private readonly iamEnabled: boolean;
constructor(
private readonly reflector: Reflector,
private readonly config: ConfigService,
private readonly http: HttpService,
) {
this.iamApiUrl = this.config.get<string>('IAM_API_URL') || 'https://iam.tria-plc.com/api';
this.iamEnabled = this.config.get<string>('IAM_ENABLED') === 'true';
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.iamEnabled) {
// IAM disabled - allow access (for development)
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No authentication token provided');
}
const validation = await this.validateToken(token);
if (!validation.valid || !validation.payload) {
throw new UnauthorizedException(validation.error || 'Invalid token');
}
// Check required roles
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (requiredRoles && requiredRoles.length > 0) {
const hasRole = requiredRoles.some((role) => validation.payload!.roles.includes(role));
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
}
// Attach user to request
request.user = {
userId: validation.payload.sub,
email: validation.payload.email,
roles: validation.payload.roles,
permissions: validation.payload.permissions,
organizationId: validation.payload.organizationId,
};
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
return parts[1];
}
private async validateToken(token: string): Promise<IamValidationResponse> {
try {
const response = await firstValueFrom(
this.http.post<IamValidationResponse>(
`${this.iamApiUrl}/v1/auth/validate`,
{ token },
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.config.get<string>('IAM_API_KEY') || '',
},
timeout: 5000,
},
),
);
return response.data;
} catch (err) {
return {
valid: false,
error: err instanceof Error ? err.message : 'Token validation failed',
};
}
}
}
/**
* Decorator to mark routes as requiring IAM authentication
*/
export const UseIamAuth = () => {
// This is a marker decorator that can be used with @UseGuards(IamGuard)
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
// Marker only - actual guard is applied via @UseGuards
};
};
/**
* Decorator to specify required roles for IAM-protected routes
*/
export const IamRoles = (...roles: string[]) => {
return (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => {
if (descriptor) {
Reflect.defineMetadata('roles', roles, descriptor.value);
}
};
};

View File

@@ -0,0 +1,56 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import * as path from 'path';
/**
* TypeORM DataSource options for the shared `iam` schema.
*
* Context (see docs/iam-package-understanding-guide.md):
* - The `iam` schema is owned by `@tria-plc/iamapi-common` (TypeORM). Prisma owns the
* `passenger` schema. Both ORMs point at the same database (`edr_database`).
* - `@tria-plc/api-common`'s `JwtGuard` injects the *default* TypeORM `DataSource` and runs a
* raw `SELECT ... FROM iam.sessions`, so the app must expose a DataSource that can reach it.
*
* Connection env vars intentionally mirror the package's own migration DataSource
* (`@tria-plc/api-common/dist/modules/typeorm/typeorm.config.internal.js`) so the app and the
* package CLI read the same configuration:
* DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_SCHEMA
*
* This NEVER manages the schema: `synchronize: false` and `migrationsRun: false`. The `iam`
* schema is created by the IAM package migrations (dev: self-hosted; prod: central IAM team).
*/
function resolvePackageDist(pkg: string): string {
// Node honors each package's `exports` map at runtime even though TS `moduleResolution: "Node"`
// does not — so `require.resolve` on the barrel resolves to the package's dist `index.js`.
const resolved = require.resolve(pkg);
// Normalize to forward slashes so the glob works on Windows too.
return path.dirname(resolved).replace(/\\/g, '/');
}
export function buildIamTypeOrmOptions(): TypeOrmModuleOptions {
const iamDist = resolvePackageDist('@tria-plc/iamapi-common');
// Some IAM entities (e.g. PositionType) relate to the notification entities that physically
// live in @tria-plc/api-common (the IAM barrel only re-exports them), so BOTH dist trees must
// be registered or TypeORM throws "Entity metadata ... was not found".
const apiDist = resolvePackageDist('@tria-plc/api-common');
return {
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT ?? 5432),
database: process.env.DATABASE_NAME,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
schema: process.env.DATABASE_SCHEMA ?? 'iam',
// IAM entities live in the packages; registered so the same default DataSource also serves
// IamModule in the dev self-host phase (Phase 3). Harmless before the tables exist.
entities: [
`${iamDist}/entities/**/*.entity.{ts,js}`,
`${apiDist}/entities/**/*.entity.{ts,js}`,
],
synchronize: false, // schema is owned by IAM migrations — never auto-sync
migrationsRun: false, // migrations are run by the IAM package CLI (dev) / IAM team (prod)
autoLoadEntities: false,
migrationsTableName: 'typeorm_migrations',
retryAttempts: 0, // fail fast in dev if the iam schema / DB is unreachable
logging: ['error'],
};
}

View File

@@ -1,11 +0,0 @@
import { Module, Global } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { IamGuard } from './iam-adapter';
@Global()
@Module({
imports: [HttpModule.register({ timeout: 5000 })],
providers: [IamGuard],
exports: [IamGuard],
})
export class IamModule {}

View File

@@ -0,0 +1,18 @@
import { registerAs } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { buildIamTypeOrmOptions } from '../common/iam-typeorm.config';
/**
* Dedicated config namespace for the IAM **TypeORM** connection — the shared `iam` schema ONLY.
*
* This is intentionally separate from Prisma: Prisma remains the app's primary ORM and owns the
* `passenger` schema via `DATABASE_URL` (see prisma.service.ts). This second connection exists
* solely because `@tria-plc/api-common` / `@tria-plc/iamapi-common` are TypeORM-based and the
* `JwtGuard` reads `iam.sessions` through a TypeORM `DataSource`.
*
* Consumed by `TypeOrmModule.forRootAsync` in app.module.ts.
*/
export default registerAs(
'iamDatabase',
(): TypeOrmModuleOptions => buildIamTypeOrmOptions(),
);

View File

@@ -1,6 +1,10 @@
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
import "dotenv/config";
import "reflect-metadata"; import "reflect-metadata";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common"; import { ValidationPipe, VersioningType } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter"; import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
@@ -10,6 +14,11 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
app.enableVersioning({ type: VersioningType.URI });
app.enableCors({ app.enableCors({
origin: [ origin: [
process.env.PORTAL_URL ?? "http://localhost:5174", process.env.PORTAL_URL ?? "http://localhost:5174",

View File

@@ -2,39 +2,37 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter'; // IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
import { UserRole } from '@prisma/client'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents') @ApiTags('Agents')
@Controller('agents') @Controller('agents')
@UseGuards(IamGuard) // TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class AgentsController { export class AgentsController {
constructor(private service: AgentsService) {} constructor(private service: AgentsService) {}
@Post('bookings') @Post('bookings')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Create agent booking with cash payment' }) @ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) { createBooking(@Body() dto: CreateAgentBookingDto) {
return this.service.createAgentBooking(dto); return this.service.createAgentBooking(dto);
} }
@Post('shifts/open') @Post('shifts/open')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Open agent shift' }) @ApiOperation({ summary: 'Open agent shift' })
openShift(@Body() dto: OpenShiftDto) { openShift(@Body() dto: OpenShiftDto) {
return this.service.openShift(dto); return this.service.openShift(dto);
} }
@Post('shifts/close') @Post('shifts/close')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Close agent shift' }) @ApiOperation({ summary: 'Close agent shift' })
closeShift(@Body() dto: CloseShiftDto) { closeShift(@Body() dto: CloseShiftDto) {
return this.service.closeShift(dto); return this.service.closeShift(dto);
} }
@Get(':agentId/commissions') @Get(':agentId/commissions')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent commissions' }) @ApiOperation({ summary: 'Get agent commissions' })
getCommissions( getCommissions(
@Param('agentId') agentId: string, @Param('agentId') agentId: string,
@@ -49,7 +47,6 @@ export class AgentsController {
} }
@Get(':agentId/shifts') @Get(':agentId/shifts')
@IamRoles('AGENT', 'ADMIN')
@ApiOperation({ summary: 'Get agent shifts' }) @ApiOperation({ summary: 'Get agent shifts' })
getShifts(@Param('agentId') agentId: string) { getShifts(@Param('agentId') agentId: string) {
return this.service.getShifts(agentId); return this.service.getShifts(agentId);

View File

@@ -5,7 +5,6 @@ import { GuestBookingService } from './guest-booking.service';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Booking') @ApiTags('Booking')
@Controller('bookings') @Controller('bookings')

View File

@@ -1,12 +1,14 @@
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common'; import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service'; import { FraudService, FraudRuleConfig } from './fraud.service';
import { IamGuard, IamRoles } from '../../common/iam-adapter'; // IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
import { UserRole } from '@prisma/client'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Fraud Detection') @ApiTags('Fraud Detection')
@Controller('fraud') @Controller('fraud')
@UseGuards(IamGuard) // TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class FraudController { export class FraudController {
private readonly logger = new Logger(FraudController.name); private readonly logger = new Logger(FraudController.name);
@@ -17,7 +19,6 @@ export class FraudController {
* Get fraud alerts * Get fraud alerts
*/ */
@Get('alerts') @Get('alerts')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get fraud alerts' }) @ApiOperation({ summary: 'Get fraud alerts' })
async getAlerts( async getAlerts(
@Query('userId') userId?: string, @Query('userId') userId?: string,
@@ -32,7 +33,6 @@ export class FraudController {
* Get fraud rules * Get fraud rules
*/ */
@Get('rules') @Get('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Get fraud detection rules' }) @ApiOperation({ summary: 'Get fraud detection rules' })
async getRules() { async getRules() {
const rules = await this.fraudService.getRules(); const rules = await this.fraudService.getRules();
@@ -43,7 +43,6 @@ export class FraudController {
* Create or update fraud rule * Create or update fraud rule
*/ */
@Post('rules') @Post('rules')
@IamRoles('ADMIN')
@ApiOperation({ summary: 'Create or update fraud rule' }) @ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config); const rule = await this.fraudService.upsertRule(body.type, body.config);
@@ -54,7 +53,6 @@ export class FraudController {
* Block user temporarily * Block user temporarily
*/ */
@Post('actions/block') @Post('actions/block')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Block user temporarily' }) @ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { userId: string; durationMinutes: number }) { async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes); await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
@@ -65,7 +63,6 @@ export class FraudController {
* Unblock user * Unblock user
*/ */
@Post('actions/unblock') @Post('actions/unblock')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Unblock user' }) @ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { userId: string }) { async unblockUser(@Body() body: { userId: string }) {
await this.fraudService.unblockUser(body.userId); await this.fraudService.unblockUser(body.userId);

View File

@@ -2,7 +2,6 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard, IamRoles } from '../../common/iam-adapter';
import { TestNotificationDto } from './notifications.dto'; import { TestNotificationDto } from './notifications.dto';
@ApiTags('Notifications') @ApiTags('Notifications')
@@ -31,8 +30,8 @@ export class NotificationsController {
} }
@Post('test') @Post('test')
@UseGuards(IamGuard) // TODO(iam-authz): restrict to admin/staff via IAM PermissionGuard once role→permission mapping
@IamRoles('ADMIN', 'STAFF') // is confirmed. Currently protected by the class-level JwtGuard only.
@ApiOperation({ summary: 'Test notification delivery (Admin only)' }) @ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) { async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send( return this.service.send(

View File

@@ -3,7 +3,6 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@ne
import { PassengersService } from './passengers.service'; import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, RegisterInternationalPassengerDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
@ApiTags('Passenger') @ApiTags('Passenger')

View File

@@ -2,32 +2,31 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto'; import { GenerateReportDto } from './reports.dto';
import { IamGuard, IamRoles } from '../../common/iam-adapter'; // IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard.
import { UserRole } from '@prisma/client'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Reports') @ApiTags('Reports')
@Controller('reports') @Controller('reports')
@UseGuards(IamGuard) // TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM
// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only.
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
export class ReportsController { export class ReportsController {
constructor(private service: ReportsService) {} constructor(private service: ReportsService) {}
@Post('generate') @Post('generate')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Generate operational report' }) @ApiOperation({ summary: 'Generate operational report' })
generateReport(@Body() dto: GenerateReportDto) { generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto); return this.service.generateReport(dto);
} }
@Get(':reportId') @Get(':reportId')
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'Get report by ID' }) @ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) { getReport(@Param('reportId') reportId: string) {
return this.service.getReport(reportId); return this.service.getReport(reportId);
} }
@Get() @Get()
@IamRoles('ADMIN', 'SUPERVISOR')
@ApiOperation({ summary: 'List reports' }) @ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) { listReports(@Query('type') type?: string) {
return this.service.listReports(type); return this.service.listReports(type);

2613
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff