Files
pn-new-crm/src/lib/auth/permissions.ts

131 lines
4.6 KiB
TypeScript
Raw Normal View History

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<R extends PermissionResource> = 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<PermissionAction<R> & 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<string, ReadonlySet<string>> = 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<R extends PermissionResource>(
permissions: RolePermissions | null,
resource: R,
action: PermissionAction<R>,
): boolean {
// null = super admin; all permissions implicitly granted.
if (permissions === null) return true;
const resourcePerms = permissions[resource] as Record<string, boolean> | 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<R extends PermissionResource>(
ctx: Pick<AuthContext, 'isSuperAdmin' | 'permissions'>,
resource: R,
action: PermissionAction<R>,
): void {
if (ctx.isSuperAdmin) return;
if (!hasPermission(ctx.permissions, resource, action)) {
throw new ForbiddenError('Insufficient permissions');
}
}