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
+3
View File
@@ -111,3 +111,6 @@ export * from './vacay/school-holiday-catalog.schema';
// Money arithmetic in whole hundredths (sums, equal splits, currency precision).
export * from './money/money';
// Trip permission catalog (keys, levels, defaults) and the rule that decides a check.
export * from './permissions/permissions';
@@ -0,0 +1,60 @@
import {
PERMISSION_ACTIONS,
PERMISSION_LEVELS,
evaluatePermission,
isPermissionKey,
type PermissionLevel,
type PermissionSubject,
} from './permissions';
import { describe, expect, it } from 'vitest';
const stranger: PermissionSubject = { isAdmin: false, isOwner: false, isMember: false };
const member: PermissionSubject = { isAdmin: false, isOwner: false, isMember: true };
const owner: PermissionSubject = { isAdmin: false, isOwner: true, isMember: false };
const admin: PermissionSubject = { isAdmin: true, isOwner: false, isMember: false };
describe('PERMISSION_ACTIONS', () => {
it('lists every key once', () => {
const keys = PERMISSION_ACTIONS.map((a) => a.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('allows the default level of every action', () => {
for (const action of PERMISSION_ACTIONS) {
expect(action.allowedLevels as readonly PermissionLevel[]).toContain(action.defaultLevel);
}
});
it('only uses known levels', () => {
for (const action of PERMISSION_ACTIONS) {
for (const level of action.allowedLevels) expect(PERMISSION_LEVELS).toContain(level);
}
});
});
describe('isPermissionKey', () => {
it('knows the catalog and nothing else', () => {
expect(isPermissionKey('day_edit')).toBe(true);
expect(isPermissionKey('share_manage')).toBe(true);
expect(isPermissionKey('nonexistent_action')).toBe(false);
expect(isPermissionKey('')).toBe(false);
});
});
describe('evaluatePermission', () => {
it.each([
['admin', [false, false, false, true]],
['trip_owner', [false, false, true, true]],
['trip_member', [false, true, true, true]],
['everybody', [true, true, true, true]],
] as const)('%s: stranger, member, owner, admin', (level, expected) => {
expect([stranger, member, owner, admin].map((s) => evaluatePermission(level, s))).toEqual(expected);
});
it('refuses a level it does not know, unless the caller is an admin', () => {
const bogus = 'root' as unknown as PermissionLevel;
expect(evaluatePermission(bogus, owner)).toBe(false);
expect(evaluatePermission(bogus, admin)).toBe(true);
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* The catalog of configurable trip permissions and the one rule that decides
* them. The server enforces it (`PermissionsService`, `@RequirePermission`);
* the admin screen and the client's `can()` read the same keys and levels.
*
* Levels are hierarchical, higher includes lower:
* admin > trip_owner > trip_member > everybody
*
* "everybody" means any authenticated user with trip access; for
* `trip_create`, which has no trip, any authenticated user.
*/
export const PERMISSION_LEVELS = ['admin', 'trip_owner', 'trip_member', 'everybody'] as const;
export type PermissionLevel = (typeof PERMISSION_LEVELS)[number];
export interface PermissionAction {
readonly key: string;
readonly defaultLevel: PermissionLevel;
readonly allowedLevels: readonly PermissionLevel[];
}
/** Every configurable action with its default and the levels an admin may pick. */
export const PERMISSION_ACTIONS = [
// 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'] },
] as const satisfies readonly PermissionAction[];
/** The name of one configurable action, `'day_edit'`, `'budget_edit'`, … */
export type PermissionKey = (typeof PERMISSION_ACTIONS)[number]['key'];
const PERMISSION_KEYS: ReadonlySet<string> = new Set(PERMISSION_ACTIONS.map((a) => a.key));
/** Whether a string names a configurable action. */
export function isPermissionKey(value: string): value is PermissionKey {
return PERMISSION_KEYS.has(value);
}
/** Who is asking, relative to the trip the action is on. */
export interface PermissionSubject {
/** An instance admin passes every check. */
isAdmin: boolean;
/** Owns the trip. Always false for an action without a trip. */
isOwner: boolean;
/** A member of the trip who is not its owner. */
isMember: boolean;
}
/**
* Whether `subject` passes an action whose configured level is `level`. An
* admin always passes; an `admin` level otherwise refuses everyone; an
* unrecognised level refuses (fail closed).
*/
export function evaluatePermission(level: PermissionLevel, subject: PermissionSubject): boolean {
if (subject.isAdmin) return true;
switch (level) {
case 'admin':
return false;
case 'trip_owner':
return subject.isOwner;
case 'trip_member':
return subject.isOwner || subject.isMember;
case 'everybody':
return true;
default:
return false;
}
}