import { ForbiddenError } from '@/lib/errors'; import { type RolePermissions } from '@/lib/db/schema/users'; import type { AuthContext } from '@/lib/api/helpers'; export type { RolePermissions }; export type PermissionResource = keyof RolePermissions; export type PermissionAction = keyof RolePermissions[R]; /** * Canonical permission catalog — the SINGLE source of truth for which * `resource → action` leaves are valid across the app. * * Derived structurally from `RolePermissions` (the `satisfies` clause below * forces this literal to enumerate every resource and every action that the * type declares; adding a leaf to `RolePermissions` without adding it here is * a compile error). Both the role-creation validator * (`src/lib/validators/roles.ts`) and the per-user override allow-list * (`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts`) build their * accepted-key sets from this object, so the two can never diverge again * (audit finding L23). */ export const PERMISSION_CATALOG = { clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'], interests: [ 'view', 'create', 'edit', 'delete', 'change_stage', 'override_stage', 'generate_eoi', 'export', ], berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'], documents: [ 'view', 'create', 'edit', 'send_for_signing', 'upload_signed', 'delete', 'manage_folders', ], expenses: ['view', 'create', 'edit', 'delete', 'export', 'scan_receipt'], invoices: ['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export'], payments: ['view', 'record', 'delete'], files: ['view', 'upload', 'edit', 'delete', 'manage_folders'], email: ['view', 'send', 'configure_account'], reminders: ['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others'], calendar: ['connect', 'view_events'], reports: ['view_dashboard', 'view_analytics', 'export'], document_templates: ['view', 'generate', 'manage'], yachts: ['view', 'create', 'edit', 'delete', 'transfer'], companies: ['view', 'create', 'edit', 'delete'], memberships: ['view', 'manage'], tenancies: ['view', 'manage', 'cancel'], admin: [ 'manage_users', 'view_audit_log', 'manage_settings', 'manage_webhooks', 'manage_reports', 'manage_custom_fields', 'manage_forms', 'manage_tags', 'system_backup', 'permanently_delete_clients', ], residential_clients: ['view', 'create', 'edit', 'delete'], residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'], inquiries: ['view', 'manage'], client_groups: ['view', 'manage'], } as const satisfies { [R in PermissionResource]: ReadonlyArray & string>; }; /** Every valid resource key, in catalog order. */ export const PERMISSION_RESOURCES = Object.keys(PERMISSION_CATALOG) as PermissionResource[]; /** * Same catalog as `PERMISSION_CATALOG` but with each action list materialised * as a `Set` for O(1) membership tests. Consumed by the per-user override * allow-list to drop unknown resources/actions before persisting. */ export const ALLOWED_RESOURCE_ACTIONS: Record> = Object.fromEntries( Object.entries(PERMISSION_CATALOG).map(([resource, actions]) => [resource, new Set(actions)]), ); /** * Checks whether a permissions map grants a specific resource/action pair. * * Returns `true` automatically when `permissions` is `null`, which signals a * super-admin context that bypasses all permission checks. */ export function hasPermission( permissions: RolePermissions | null, resource: R, action: PermissionAction, ): boolean { // null = super admin; all permissions implicitly granted. if (permissions === null) return true; const resourcePerms = permissions[resource] as Record | undefined; if (!resourcePerms) return false; return resourcePerms[action as string] === true; } /** * Throws a `ForbiddenError` if the auth context does not have the required * resource/action permission. * * For use inside API route handlers or service functions after `withAuth` has * resolved the context. * * @example * requirePermission(ctx, 'clients', 'delete'); */ export function requirePermission( ctx: Pick, resource: R, action: PermissionAction, ): void { if (ctx.isSuperAdmin) return; if (!hasPermission(ctx.permissions, resource, action)) { throw new ForbiddenError('Insufficient permissions'); } }