diff --git a/apps/edr-passenger-api/src/common/passenger-guards.ts b/apps/edr-passenger-api/src/common/passenger-guards.ts index cadd56889..936ee73ef 100644 --- a/apps/edr-passenger-api/src/common/passenger-guards.ts +++ b/apps/edr-passenger-api/src/common/passenger-guards.ts @@ -11,4 +11,18 @@ export const PassengerStaff = (permission: string | string[]) => ), ); +/** + * Like {@link PassengerStaff} but the permission must be explicitly granted — + * super admins / org admins get no automatic bypass. + */ +export const PassengerStaffStrict = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission], { + strict: true, + }), + ), + ); + export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); diff --git a/apps/edr-passenger-api/src/common/passenger-permission.guard.ts b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts index a93b2060b..d101fb63b 100644 --- a/apps/edr-passenger-api/src/common/passenger-permission.guard.ts +++ b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts @@ -6,9 +6,25 @@ import { Type, UnauthorizedException, } from '@nestjs/common'; -import { hasPassengerPermission } from './passenger-permission.util'; +import { + hasPassengerPermission, + hasPassengerPermissionStrict, +} from './passenger-permission.util'; + +export type PassengerPermissionGuardOptions = { + /** + * When true, super admins and org admins do NOT bypass the check — the + * permission key must be explicitly granted to them like anyone else. + */ + strict?: boolean; +}; + +export function PassengerPermissionGuard( + permissions: string[], + options: PassengerPermissionGuardOptions = {}, +): Type { + const check = options.strict ? hasPassengerPermissionStrict : hasPassengerPermission; -export function PassengerPermissionGuard(permissions: string[]): Type { @Injectable() class PassengerPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { @@ -18,10 +34,11 @@ export function PassengerPermissionGuard(permissions: string[]): Type hasPassengerPermission(user, p))) return true; + if (permissions.some((p) => check(user, p))) return true; throw new ForbiddenException( - `Missing permission. Required one of: ${permissions.join(', ')}`, + `Missing permission. Required one of: ${permissions.join(', ')}` + + (options.strict ? ' (granted explicitly — admin role does not bypass)' : ''), ); } } diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts index 62df74603..d915ef86e 100644 --- a/apps/edr-passenger-api/src/common/passenger-permission.util.ts +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -65,6 +65,20 @@ export function hasPassengerPermission( return collectPermissionKeys(user).includes(permissionKey); } +/** + * Same check as {@link hasPassengerPermission} but WITHOUT the super-admin / + * org-admin bypass — the permission key must be explicitly granted, whether via + * a role or an employee position. Use for actions that must stay auditable to a + * deliberate grant (e.g. ticket generation, which can waive a fare). + */ +export function hasPassengerPermissionStrict( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + return collectPermissionKeys(user).includes(permissionKey); +} + export function assertPassengerPermission( user: MeLikeUser | null | undefined, permissionKey: string, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index e0eac4bc1..2c9010270 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -35,7 +35,7 @@ import { IssueReservationBookingDto, } from "./guest-booking.dto"; import { JwtGuard } from "../../common/jwt.guard"; -import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards"; +import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Booking") @@ -352,12 +352,12 @@ export class BookingsController { } @Post("reservations/:seatId/issue") - @PassengerAdmin() + @PassengerStaffStrict(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth("IAM-auth") @ApiOperation({ - summary: "Issue a booking from a reserved (blocked) seat — admin only", + summary: "Issue a booking from a reserved (blocked) seat — requires tickets:generate", description: - "Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Restricted to admins (edr_passenger_app:admin) because STAFF issuance waives the fare.", + "Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Because STAFF issuance waives the fare, this requires edr_passenger_app:tickets:generate to be explicitly granted — super admins and org admins do NOT bypass it.", }) @ApiBody({ type: IssueReservationBookingDto }) issueBookingFromReservation( diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 9b0a203ab..f1289d24a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { routesApi } from '@/lib/api/routes'; -import { usePermission } from '@/lib/use-permission'; +import { usePermissionStrict } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' @@ -44,9 +44,10 @@ export default function SeatsPage() { const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); - // Issuing a booking off a reserved seat is admin-only (POST /bookings/reservations/:seatId/issue - // is guarded by @PassengerAdmin) — hide the ticket action for everyone else. - const canIssueBooking = usePermission(PERMS.admin); + // Issuing a booking off a reserved seat needs edr_passenger_app:tickets:generate + // (POST /bookings/reservations/:seatId/issue is guarded by @PassengerStaffStrict). + // Strict: being an admin is not enough, the permission has to be granted. + const canIssueBooking = usePermissionStrict(PERMS.tickets.generate); const { data: schedulesData } = useQuery({ queryKey: ['schedules'], diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts index 4048e6d0e..05e47039d 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts @@ -23,6 +23,7 @@ interface AuthState { setUser: (user: AdminUser, token: string) => void; initialize: () => void; hasPermission: (key: string) => boolean; + hasPermissionStrict: (key: string) => boolean; } export const useAuthStore = create((set, get) => ({ @@ -123,4 +124,12 @@ export const useAuthStore = create((set, get) => ({ if (user.isSuperAdmin || user.isOrgAdmin) return true; return user.permissions.includes(key); }, + + // No super-admin / org-admin bypass — mirrors PassengerStaffStrict on the API, + // so we don't render actions that would 403. + hasPermissionStrict: (key: string) => { + const { user } = get(); + if (!user) return false; + return user.permissions.includes(key); + }, })); diff --git a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts index 0991a360e..d064c78f2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts @@ -13,3 +13,15 @@ import { useAuthStore } from './auth-store'; export function usePermission(key: string): boolean { return useAuthStore((s) => s.hasPermission(key)); } + +/** + * Same as usePermission but WITHOUT the super-admin / org-admin bypass — the + * permission must be explicitly granted. Use it wherever the API endpoint is + * guarded with PassengerStaffStrict, so the UI matches what the API allows. + * + * Usage: + * const canIssue = usePermissionStrict(PERMS.tickets.generate); + */ +export function usePermissionStrict(key: string): boolean { + return useAuthStore((s) => s.hasPermissionStrict(key)); +}