mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(<view key>) 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);
|
||||
|
||||
|
||||
@@ -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<CanActivate> {
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user