Merge pull request #993 from Tria-plc/alpha

fix: ( bookings ) require explicit tickets:generate to issue reservat…
This commit is contained in:
Abubeker Yasin
2026-07-28 12:19:47 +03:00
committed by GitHub
7 changed files with 79 additions and 12 deletions

View File

@@ -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);

View File

@@ -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<CanActivate> {
const check = options.strict ? hasPassengerPermissionStrict : hasPassengerPermission;
export function PassengerPermissionGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class PassengerPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
@@ -18,10 +34,11 @@ export function PassengerPermissionGuard(permissions: string[]): Type<CanActivat
if (!permissions?.length) return true;
if (!user) throw new UnauthorizedException('Authentication required');
if (permissions.some((p) => 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)' : ''),
);
}
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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'],

View File

@@ -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<AuthState>((set, get) => ({
@@ -123,4 +124,12 @@ export const useAuthStore = create<AuthState>((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);
},
}));

View File

@@ -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));
}