refactor(permissions): the permission catalog and its decision rule live in shared

PERMISSION_ACTIONS, PermissionLevel, PermissionKey and evaluatePermission()
move to shared/src/permissions. PermissionsService decides through
evaluatePermission and re-exports the catalog for its importers, and
@RequirePermission takes a PermissionKey, so a misspelt action fails to
compile. Levels, defaults and every verdict stay as they were.
This commit is contained in:
Maurice
2026-10-10 14:47:56 +02:00
parent 8d716aec2f
commit 5e797463fa
7 changed files with 193 additions and 73 deletions
@@ -5,63 +5,16 @@ import { UnitOfWork } from '../database/unit-of-work';
import { logError } from '../audit/audit-log.logger';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { PERMISSION_ACTIONS, evaluatePermission, type PermissionAction, type PermissionLevel } from '@trek/shared';
import { PermissionsCacheStore, permissionsCacheSlot } from './permissions-cache';
/**
* Permission levels (hierarchical, higher includes lower):
* admin > trip_owner > trip_member > everybody
*
* "everybody" means any authenticated user with trip access.
* For trip_create, "everybody" means any authenticated user (no trip context).
*/
export type PermissionLevel = 'admin' | 'trip_owner' | 'trip_member' | 'everybody';
// The catalog (keys, levels, defaults) and the decision rule live in
// @trek/shared, so the admin screen and the client read the same table the
// server enforces. Re-exported here for the server code that imported them.
export { PERMISSION_ACTIONS };
export type { PermissionAction, PermissionLevel };
export interface PermissionAction {
key: string;
defaultLevel: PermissionLevel;
allowedLevels: PermissionLevel[];
}
// All configurable actions with their defaults matching upstream behavior
export const PERMISSION_ACTIONS: PermissionAction[] = [
// Trip management
{ key: 'trip_create', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
{ key: 'trip_edit', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_delete', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner'] },
{ key: 'trip_archive', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'trip_cover_upload', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
// Member management
{ key: 'member_manage', defaultLevel: 'trip_owner', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
// Files
{ key: 'file_upload', defaultLevel: 'trip_member', allowedLevels: ['admin', 'trip_owner', 'trip_member'] },
{ key: 'file_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
{ key: 'file_delete', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Places
{ key: 'place_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Budget
{ key: 'budget_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Packing
{ key: 'packing_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Reservations
{ key: 'reservation_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Day notes & schedule
{ key: 'day_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Collaboration (notes, polls, messages)
{ key: 'collab_edit', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
// Share link management
{ key: 'share_manage', defaultLevel: 'trip_owner', allowedLevels: ['trip_owner', 'trip_member'] },
];
const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
const ACTIONS_MAP = new Map<string, PermissionAction>(PERMISSION_ACTIONS.map(a => [a.key, a]));
// The cache is a PermissionsCacheStore (./permissions-cache), injected so a
// store shared between processes can be plugged in later. A hand-built
@@ -192,22 +145,11 @@ export class PermissionsService {
userId: number,
isMember: boolean
): Promise<boolean> {
// Admins always pass
// Admins always pass, without reading the configured level.
if (userRole === 'admin') return true;
const required = await this.getPermissionLevel(actionKey);
switch (required) {
case 'admin':
return false; // already checked above
case 'trip_owner':
return tripUserId !== null && tripUserId === userId;
case 'trip_member':
return (tripUserId !== null && tripUserId === userId) || isMember;
case 'everybody':
return true;
default:
return false;
}
const isOwner = tripUserId !== null && tripUserId === userId;
return evaluatePermission(required, { isAdmin: false, isOwner, isMember });
}
}
@@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata }
import { Reflector } from '@nestjs/core';
import { EntityManager } from '@mikro-orm/core';
import type { Request } from 'express';
import type { PermissionKey } from '@trek/shared';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripAccess } from '../../db/repositories/Trips.repository';
import { PermissionsService } from './permissions.service';
@@ -16,11 +17,12 @@ export const TRIP_PERMISSION_KEY = 'trekTripPermission';
/**
* Requires a permission on the trip the route is scoped to, on top of access.
*
* The action string is the same one the domain services pass to
* `PermissionsService.checkPermission` ('day_edit', 'budget_edit', …), so a route
* and its MCP counterpart cannot drift apart on which right they demand.
* The action is a key of the shared catalog (`PermissionKey` from `@trek/shared`),
* the same one the domain services pass to `PermissionsService.checkPermission`
* ('day_edit', 'budget_edit', …), so a route and its MCP counterpart cannot drift
* apart on which right they demand, and a misspelt key fails to compile.
*/
export const RequirePermission = (action: string) => SetMetadata(TRIP_PERMISSION_KEY, action);
export const RequirePermission = (action: PermissionKey) => SetMetadata(TRIP_PERMISSION_KEY, action);
type TripRequest = Request & { user?: User; [TRIP_REQUEST_KEY]?: TripAccess };