mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -64,3 +64,7 @@ The parity rule governs **existing** routes; it is not a license to mint new deb
|
||||
## Money (`src/money/money.ts`)
|
||||
|
||||
`toMinor`, `sumMinor`, `splitEqualShares` and `currencyDecimals` are the budget arithmetic both sides must agree on to the cent: amounts are netted in whole hundredths, equal splits hand the leftover hundredths out by rotation from `itemId % n`, and the shares always sum back to the total. The server's settlement uses these directly; `money.spec.ts` holds the share table that used to be duplicated between the server and client tests. The client still carries its own `splitEqualShares` (in euros, `CostsPanel.helpers.ts`) and `currencyDecimals` (`utils/formatters.ts`); folding them onto these is client work, not a second implementation to copy.
|
||||
|
||||
## Permissions (`src/permissions/permissions.ts`)
|
||||
|
||||
`PERMISSION_ACTIONS` is the catalog of configurable trip permissions (key, default level, the levels an admin may pick), `PermissionKey` the union of its keys and `evaluatePermission(level, { isAdmin, isOwner, isMember })` the one decision rule. The server's `PermissionsService` and `@RequirePermission(action: PermissionKey)` are built on them, so a new action starts here and a misspelt key fails to compile. The client still evaluates in its own `useCanDo` (with "not configured = allow"); moving it onto `evaluatePermission` is client work.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user