dorfteich/apps/api/src/grants/grant-mappers.ts
Claude Opus 4.8 4d48d72c40
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add grant model and shared permission-resolution algorithm (#51)
The heart of the security model: one algorithm, implemented once, for API,
collab, and UI (permissions.md — authoritative).

- shared `permissions/`: pure resolution (`resolvePageCapability`) exactly per
  permissions.md — specificity page > label (incl. ancestor labels) > pond,
  deny wins within a level, default-closed, Site Admin bypass — plus the trash
  rule (`canAccessPage` / `canAccessTrashedPage`, ADR 0013). `grantValidationError`
  enforces the structural constraints. Documented, I/O-free signatures for
  API/collab reuse.
- prisma: `RoleGrant` (+ grant enums) per data-model.md, unique on
  (pond, subject, role, scope); migration adds a CHECK backstop that a
  POND_ADMIN grant is pond-scope + user-subject.
- api `grants/`: `GrantsService.createGrant` validates before insert (structural
  + no extra admin on a personal pond), rejects duplicates; `grantsForPond`
  returns the shared resolver model (what #52/#53 consume); enum mappers between
  the DB and the shared model. Interim "who may manage grants" stays until #52.
- tests: exhaustive table-driven resolver suite — every worked example from
  permissions.md §Resolution, edge cases (multi-label deny-wins, ancestor
  inheritance, anonymous/public, most-specific-allow-beats-less-specific-deny,
  trash) and a property test (a less-specific grant never overrides a
  more-specific decision); validation unit tests; grants db test proving
  write-time rejection of invalid grants.
- i18n: grant error codes (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 14:02:07 +02:00

80 lines
2.3 KiB
TypeScript

import { Grant, GrantEffect, GrantRole, GrantScopeType, GrantSubjectType } from '@dorfteich/shared';
import {
GrantEffect as PrismaEffect,
GrantRole as PrismaRole,
GrantScopeType as PrismaScopeType,
GrantSubjectType as PrismaSubjectType,
RoleGrant,
} from '@prisma/client';
/**
* Maps between the database's uppercase grant enums and the shared model's
* lowercase strings (issue #51), so the resolution algorithm in
* `@dorfteich/shared` sees one canonical shape regardless of storage.
*/
const SUBJECT_TO_DB: Record<GrantSubjectType, PrismaSubjectType> = {
user: 'USER',
authenticated: 'AUTHENTICATED',
public: 'PUBLIC',
};
const ROLE_TO_DB: Record<GrantRole, PrismaRole> = {
pond_admin: 'POND_ADMIN',
editor: 'EDITOR',
reader: 'READER',
};
const SCOPE_TO_DB: Record<GrantScopeType, PrismaScopeType> = {
pond: 'POND',
label: 'LABEL',
page: 'PAGE',
};
const EFFECT_TO_DB: Record<GrantEffect, PrismaEffect> = { allow: 'ALLOW', deny: 'DENY' };
const SUBJECT_FROM_DB: Record<PrismaSubjectType, GrantSubjectType> = {
USER: 'user',
AUTHENTICATED: 'authenticated',
PUBLIC: 'public',
};
const ROLE_FROM_DB: Record<PrismaRole, GrantRole> = {
POND_ADMIN: 'pond_admin',
EDITOR: 'editor',
READER: 'reader',
};
const SCOPE_FROM_DB: Record<PrismaScopeType, GrantScopeType> = {
POND: 'pond',
LABEL: 'label',
PAGE: 'page',
};
const EFFECT_FROM_DB: Record<PrismaEffect, GrantEffect> = { ALLOW: 'allow', DENY: 'deny' };
/** A stored grant row → the shared model shape. */
export function toGrant(row: RoleGrant): Grant {
return {
subjectType: SUBJECT_FROM_DB[row.subjectType],
subjectId: row.subjectId,
role: ROLE_FROM_DB[row.role],
scopeType: SCOPE_FROM_DB[row.scopeType],
scopeId: row.scopeId,
effect: EFFECT_FROM_DB[row.effect],
};
}
/** The shared grant shape → the Prisma enum columns (for writes). */
export function toGrantColumns(grant: Grant): {
subjectType: PrismaSubjectType;
subjectId: string | null;
role: PrismaRole;
scopeType: PrismaScopeType;
scopeId: string | null;
effect: PrismaEffect;
} {
return {
subjectType: SUBJECT_TO_DB[grant.subjectType],
subjectId: grant.subjectId,
role: ROLE_TO_DB[grant.role],
scopeType: SCOPE_TO_DB[grant.scopeType],
scopeId: grant.scopeId,
effect: EFFECT_TO_DB[grant.effect],
};
}