mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(passenger-api): integrate @tria-plc IAM package (dual-ORM iam schema + package auth guard)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
46
apps/edr-passenger-api/scripts/run-iam-migrations.cjs
Normal file
46
apps/edr-passenger-api/scripts/run-iam-migrations.cjs
Normal 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);
|
||||||
|
});
|
||||||
74
apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs
Normal file
74
apps/edr-passenger-api/scripts/seed-iam-dev-user.cjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
56
apps/edr-passenger-api/src/common/iam-typeorm.config.ts
Normal file
56
apps/edr-passenger-api/src/common/iam-typeorm.config.ts
Normal 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'],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {}
|
|
||||||
18
apps/edr-passenger-api/src/config/iam-database.config.ts
Normal file
18
apps/edr-passenger-api/src/config/iam-database.config.ts
Normal 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(),
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
2613
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user