mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Merge branch 'dev' into passenger/feat/iam
This commit is contained in:
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma.module';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable, Inject, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
async log(input: {
|
||||
userId?: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
oldData?: any;
|
||||
newData?: any;
|
||||
}) {
|
||||
try {
|
||||
const ipAddress = this.getIpAddress();
|
||||
const userAgent = this.getUserAgent();
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
oldData: input.oldData,
|
||||
newData: input.newData,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit event:', error);
|
||||
// Don't throw - audit logging should not break main operations
|
||||
}
|
||||
}
|
||||
|
||||
private getIpAddress(): string {
|
||||
if (!this.request) return '';
|
||||
|
||||
return (
|
||||
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
this.request.headers['x-real-ip'] ||
|
||||
this.request.connection?.remoteAddress ||
|
||||
this.request.socket?.remoteAddress ||
|
||||
this.request.ip ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getUserAgent(): string {
|
||||
return this.request?.headers?.['user-agent'] || '';
|
||||
}
|
||||
|
||||
async getLogs(filters: any = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.action) {
|
||||
where.action = filters.action;
|
||||
}
|
||||
|
||||
if (filters.entityType) {
|
||||
where.entityType = filters.entityType;
|
||||
}
|
||||
|
||||
return this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500, // Limit to last 500 logs
|
||||
});
|
||||
}
|
||||
|
||||
async getLog(id: string) {
|
||||
return this.prisma.auditLog.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
// Global filter — also reached by non-HTTP (e.g. RabbitMQ) handlers. switchToHttp() would
|
||||
// yield no response object there, so re-throw and let the transport (golevelup) handle it
|
||||
// (nack/dead-letter) instead of crashing on response.status().
|
||||
if (host.getType() !== 'http') {
|
||||
throw exception;
|
||||
}
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { Request } from "express";
|
||||
|
||||
/**
|
||||
* Shared-secret guard for endpoints only the payment microservice may call
|
||||
* (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN the
|
||||
* payment service enforces on its own internal surface. A forged mark-paid must not be able
|
||||
* to confirm a booking without a real payment.
|
||||
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(ServiceAuthGuard.name);
|
||||
private readonly token = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
private warned = false;
|
||||
|
||||
constructor() {
|
||||
if (!this.token && process.env.NODE_ENV === "production") {
|
||||
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||
}
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (!this.token) {
|
||||
if (!this.warned) {
|
||||
this.logger.warn(
|
||||
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||
);
|
||||
this.warned = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const header = request.headers["x-service-token"];
|
||||
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
||||
const presented =
|
||||
(Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
|
||||
|
||||
const expected = Buffer.from(this.token);
|
||||
const actual = Buffer.from(presented);
|
||||
const valid =
|
||||
expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
if (!valid) throw new UnauthorizedException("Invalid service token");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,14 @@ import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, any> {
|
||||
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
|
||||
intercept(ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
|
||||
// Only wrap HTTP responses. This interceptor is global, so it also runs for RabbitMQ
|
||||
// message handlers (golevelup uses Nest's context system) — there, wrapping the return
|
||||
// value would corrupt the handler's contract (e.g. a returned Nack would be swallowed,
|
||||
// dropping a message instead of dead-lettering it). Let non-HTTP returns pass through.
|
||||
if (ctx.getType() !== 'http') {
|
||||
return next.handle();
|
||||
}
|
||||
return next.handle().pipe(
|
||||
map((data) => ({ success: true, data, timestamp: new Date().toISOString() })),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user