From 79fec14c9ae7d50853a1d95a91a814a7beb83d27 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 07:31:08 +0000 Subject: [PATCH] feat(auth): deny by default with employee/customer audience guards FreightPermissionGuard now rejects non-employee user types before the key check, making every BookingStaff route staff-only in one place. Adds PortalCustomer and MixedAudience for the routes both audiences share, and stops ServiceAuthGuard failing open when SERVICE_AUTH_TOKEN is unset. --- apps/edr-freight-api/.env.example | 4 + .../src/common/booking-guards.ts | 30 +++++++- .../src/common/freight-permission.guard.ts | 73 ++++++++++++++++++- .../src/common/guards/service-auth.guard.ts | 10 ++- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 16ee9cd57..96af26034 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -122,3 +122,7 @@ FAYDA_SESSION_TTL_MINUTES=10 EXPIRATION_TIME=15 ALGORITHM=RS256 EMAIL_QUEUE=email_queue + +# Shared secret for service-to-service calls (payment microservice <-> freight). +# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev. +SERVICE_AUTH_TOKEN=change-me diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 854594ffc..852df2f15 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -1,7 +1,11 @@ import { applyDecorators, UseGuards } from '@nestjs/common'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; -import { FreightPermissionGuard } from './freight-permission.guard'; +import { + FreightPermissionGuard, + MixedAudienceGuard, + PortalCustomerGuard, +} from './freight-permission.guard'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; export const BookingStaff = (permission: string | string[]) => @@ -18,8 +22,30 @@ export const BookingStaff = (permission: string | string[]) => * Read-only reference data (yard dropdowns, search filters): any signed-in * staff. Menu/page visibility stays permission-gated in the frontend — this * only lets forms populate their lookups. + * Deprecated for new routes — it never checked the caller was staff. Prefer + * BookingStaff() or MixedAudience(); kept for routes not yet swept. */ -export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); +export const StaffReference = () => + applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([]))); + +/** Portal routes: customer accounts only; ownership scoping stays in services. */ +export const PortalCustomer = () => + applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard)); + +/** + * Routes both audiences call (sign, shared document reads, handover): staff + * need one of the given permissions, customers pass through to the service's + * ownership checks. + */ +export const MixedAudience = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + MixedAudienceGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index 68def6440..db6275c07 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -8,7 +8,19 @@ import { } from '@nestjs/common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { hasFreightPermission } from './freight-permission.util'; +import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; + +// String literals on purpose (same reasoning as login-audience.middleware.ts): +// the values are wire-format constants from iam.users.user_type, and importing +// the vendored enum couples us to its package layout for no gain. +const CUSTOMER_USER_TYPES = ['individual', 'external_organization']; + +const userTypeOf = (user: TCurrentUser): string | undefined => + (user as { userType?: string }).userType; + +/** Staff routes are employee-only; a missing userType (stale session) also fails. */ +const isEmployee = (user: TCurrentUser): boolean => + userTypeOf(user) === 'employee' || isSuperAdmin(user); export function FreightPermissionGuard( permissions: string[], @@ -19,11 +31,14 @@ export function FreightPermissionGuard( const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); const user = request.user; - if (!permissions?.length) return true; if (!user) { throw new UnauthorizedException('Authentication required'); } + if (!isEmployee(user)) { + throw new ForbiddenException('Staff account required'); + } + if (!permissions?.length) return true; if (permissions.some((p) => hasFreightPermission(user, p))) { return true; } @@ -36,3 +51,57 @@ export function FreightPermissionGuard( return FreightPermissionsGuard; } + +/** Portal routes: customer accounts only (individual / external organization). */ +@Injectable() +export class PortalCustomerGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + if (!CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) { + throw new ForbiddenException('Customer account required'); + } + return true; + } +} + +/** + * Routes both audiences legitimately call (contract sign, shared document + * reads, warehouse handover). Staff callers must hold one of the given + * permissions; customer callers pass here and are scoped by the service's + * ownership checks. + */ +export function MixedAudienceGuard(permissions: string[]): Type { + @Injectable() + class MixedAudiencesGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + if (CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) { + return true; + } + if (!isEmployee(user)) { + throw new ForbiddenException('Unrecognized account type'); + } + if ( + !permissions?.length || + permissions.some((p) => hasFreightPermission(user, p)) + ) { + return true; + } + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return MixedAudiencesGuard; +} diff --git a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts index 9165e54d5..2d2863dd5 100644 --- a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts +++ b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts @@ -20,8 +20,12 @@ export class ServiceAuthGuard implements CanActivate { private warned = false; constructor() { - if (!this.token && process.env.NODE_ENV === "production") { - throw new Error("SERVICE_AUTH_TOKEN must be set in production"); + // Fail closed everywhere: a missing secret must never silently open the + // internal payment surface. Local dev can opt out explicitly. + if (!this.token && process.env.ALLOW_UNAUTH_INTERNAL !== "true") { + throw new Error( + "SERVICE_AUTH_TOKEN must be set (or ALLOW_UNAUTH_INTERNAL=true for local dev)", + ); } } @@ -29,7 +33,7 @@ export class ServiceAuthGuard implements CanActivate { if (!this.token) { if (!this.warned) { this.logger.warn( - "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", + "ALLOW_UNAUTH_INTERNAL=true — internal endpoints are UNGUARDED (dev only)", ); this.warned = true; }