From 4d48d72c40a92fab4abe92ca6a86d703b0169b2a Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 14:02:07 +0200 Subject: [PATCH] Add grant model and shared permission-resolution algorithm (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY --- .../20260709115727_role_grants/migration.sql | 45 ++++ apps/api/prisma/schema.prisma | 51 ++++ apps/api/src/app.module.ts | 2 + apps/api/src/grants/grant-mappers.ts | 79 ++++++ apps/api/src/grants/grants.module.ts | 17 ++ apps/api/src/grants/grants.service.db.test.ts | 98 ++++++++ apps/api/src/grants/grants.service.ts | 83 +++++++ packages/shared/i18n/de/errors.json | 5 + packages/shared/i18n/en/errors.json | 5 + packages/shared/src/index.ts | 1 + packages/shared/src/permissions/index.ts | 3 + .../shared/src/permissions/resolve.test.ts | 228 ++++++++++++++++++ packages/shared/src/permissions/resolve.ts | 109 +++++++++ packages/shared/src/permissions/types.ts | 62 +++++ .../shared/src/permissions/validate.test.ts | 63 +++++ packages/shared/src/permissions/validate.ts | 49 ++++ 16 files changed, 900 insertions(+) create mode 100644 apps/api/prisma/migrations/20260709115727_role_grants/migration.sql create mode 100644 apps/api/src/grants/grant-mappers.ts create mode 100644 apps/api/src/grants/grants.module.ts create mode 100644 apps/api/src/grants/grants.service.db.test.ts create mode 100644 apps/api/src/grants/grants.service.ts create mode 100644 packages/shared/src/permissions/index.ts create mode 100644 packages/shared/src/permissions/resolve.test.ts create mode 100644 packages/shared/src/permissions/resolve.ts create mode 100644 packages/shared/src/permissions/types.ts create mode 100644 packages/shared/src/permissions/validate.test.ts create mode 100644 packages/shared/src/permissions/validate.ts diff --git a/apps/api/prisma/migrations/20260709115727_role_grants/migration.sql b/apps/api/prisma/migrations/20260709115727_role_grants/migration.sql new file mode 100644 index 0000000..9065fbf --- /dev/null +++ b/apps/api/prisma/migrations/20260709115727_role_grants/migration.sql @@ -0,0 +1,45 @@ +-- CreateEnum +CREATE TYPE "GrantSubjectType" AS ENUM ('USER', 'AUTHENTICATED', 'PUBLIC'); + +-- CreateEnum +CREATE TYPE "GrantRole" AS ENUM ('POND_ADMIN', 'EDITOR', 'READER'); + +-- CreateEnum +CREATE TYPE "GrantScopeType" AS ENUM ('POND', 'LABEL', 'PAGE'); + +-- CreateEnum +CREATE TYPE "GrantEffect" AS ENUM ('ALLOW', 'DENY'); + +-- DropIndex +DROP INDEX "page_content_cache_search_vector_idx"; + +-- CreateTable +CREATE TABLE "role_grants" ( + "id" TEXT NOT NULL, + "pond_id" TEXT NOT NULL, + "subject_type" "GrantSubjectType" NOT NULL, + "subject_id" TEXT, + "role" "GrantRole" NOT NULL, + "scope_type" "GrantScopeType" NOT NULL, + "scope_id" TEXT, + "effect" "GrantEffect" NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "role_grants_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "role_grants_pond_id_idx" ON "role_grants"("pond_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "role_grants_pond_id_subject_type_subject_id_role_scope_type_key" ON "role_grants"("pond_id", "subject_type", "subject_id", "role", "scope_type", "scope_id"); + +-- AddForeignKey +ALTER TABLE "role_grants" ADD CONSTRAINT "role_grants_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Structural backstop (permissions.md, issue #51): a POND_ADMIN grant is only +-- valid at pond scope for a specific user. The personal-pond single-admin rule +-- needs the pond type and is enforced in the service, not here. +ALTER TABLE "role_grants" ADD CONSTRAINT "role_grants_pond_admin_scope_check" + CHECK ("role" <> 'POND_ADMIN' OR ("scope_type" = 'POND' AND "subject_type" = 'USER')); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e800ffd..6951326 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -79,11 +79,62 @@ model Pond { pages Page[] attachments Attachment[] labels Label[] + grants RoleGrant[] @@index([ownerId]) @@map("ponds") } +enum GrantSubjectType { + USER + AUTHENTICATED + PUBLIC +} + +enum GrantRole { + POND_ADMIN + EDITOR + READER +} + +enum GrantScopeType { + POND + LABEL + PAGE +} + +enum GrantEffect { + ALLOW + DENY +} + +/// The permission table (permissions.md, data-model.md §role_grants, issue #51). +/// One grant `(subject, role, scope, effect)` inside a pond. The shared +/// resolution algorithm (`@dorfteich/shared` permissions) decides access from +/// these; the API/collab enforce it. Structural rules: `POND_ADMIN` only at +/// `POND` scope with a `USER` subject (a CHECK constraint backs this up, added +/// in the migration); personal ponds allow only their owner as admin (enforced +/// in the service, needs the pond type). `subjectId` is set only for `USER` +/// subjects; `scopeId` is the label/page id for `LABEL`/`PAGE` scopes. +model RoleGrant { + id String @id @default(uuid()) + pondId String @map("pond_id") + subjectType GrantSubjectType @map("subject_type") + subjectId String? @map("subject_id") + role GrantRole + scopeType GrantScopeType @map("scope_type") + scopeId String? @map("scope_id") + effect GrantEffect + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + + pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade) + + @@unique([pondId, subjectType, subjectId, role, scopeType, scopeId]) + @@index([pondId]) + @@map("role_grants") +} + /// A wiki page (data-model.md §pages). Carries a Yjs document from day one /// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is /// the merged state Y.Doc, decoded by the API to derive `PageContentCache` diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d96ee15..2a1fb3d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ import { CompactionModule } from './compaction/compaction.module'; import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; import { FilesModule } from './files/files.module'; +import { GrantsModule } from './grants/grants.module'; import { HealthModule } from './health/health.module'; import { LabelsModule } from './labels/labels.module'; import { LinksModule } from './links/links.module'; @@ -40,6 +41,7 @@ import { VersionsModule } from './versions/versions.module'; LabelsModule, LinksModule, SearchModule, + GrantsModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/grants/grant-mappers.ts b/apps/api/src/grants/grant-mappers.ts new file mode 100644 index 0000000..3880cc8 --- /dev/null +++ b/apps/api/src/grants/grant-mappers.ts @@ -0,0 +1,79 @@ +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 = { + user: 'USER', + authenticated: 'AUTHENTICATED', + public: 'PUBLIC', +}; +const ROLE_TO_DB: Record = { + pond_admin: 'POND_ADMIN', + editor: 'EDITOR', + reader: 'READER', +}; +const SCOPE_TO_DB: Record = { + pond: 'POND', + label: 'LABEL', + page: 'PAGE', +}; +const EFFECT_TO_DB: Record = { allow: 'ALLOW', deny: 'DENY' }; + +const SUBJECT_FROM_DB: Record = { + USER: 'user', + AUTHENTICATED: 'authenticated', + PUBLIC: 'public', +}; +const ROLE_FROM_DB: Record = { + POND_ADMIN: 'pond_admin', + EDITOR: 'editor', + READER: 'reader', +}; +const SCOPE_FROM_DB: Record = { + POND: 'pond', + LABEL: 'label', + PAGE: 'page', +}; +const EFFECT_FROM_DB: Record = { 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], + }; +} diff --git a/apps/api/src/grants/grants.module.ts b/apps/api/src/grants/grants.module.ts new file mode 100644 index 0000000..e9c2d79 --- /dev/null +++ b/apps/api/src/grants/grants.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { PondsModule } from '../ponds/ponds.module'; + +import { GrantsService } from './grants.service'; + +/** + * Permission grants (issue #51). Exports `GrantsService` so the API guards and + * collab token issuance (#52/#53) can resolve grants; the member-management + * controller/UI arrives with #54. + */ +@Module({ + imports: [PondsModule], + providers: [GrantsService], + exports: [GrantsService], +}) +export class GrantsModule {} diff --git a/apps/api/src/grants/grants.service.db.test.ts b/apps/api/src/grants/grants.service.db.test.ts new file mode 100644 index 0000000..f0e782c --- /dev/null +++ b/apps/api/src/grants/grants.service.db.test.ts @@ -0,0 +1,98 @@ +import { BadRequestException, ConflictException, INestApplication } from '@nestjs/common'; +import { Grant } from '@dorfteich/shared'; +import { PrismaClient, User } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { GrantsService } from './grants.service'; + +describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let grants: GrantsService; + const suffix = uniqueSuffix(); + let owner: User; + let shared: string; + let personal: string; + + const editorGrant: Grant = { + subjectType: 'user', + subjectId: 'target-user', + role: 'editor', + scopeType: 'pond', + scopeId: null, + effect: 'allow', + }; + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + grants = app.get(GrantsService); + + owner = await prisma.user.create({ + data: { + username: `grant-owner-${suffix}`, + email: `grant-owner-${suffix}@example.test`, + displayName: 'Grant Owner', + }, + }); + const s = await prisma.pond.create({ + data: { slug: `grant-shared-${suffix}`, name: 'Shared', type: 'SHARED', ownerId: owner.id }, + }); + shared = s.id; + const p = await prisma.pond.create({ + data: { + slug: `grant-personal-${suffix}`, + name: 'Personal', + type: 'PERSONAL', + ownerId: owner.id, + }, + }); + personal = p.id; + }); + + afterAll(async () => { + await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } }); + await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } }); + await prisma.user.deleteMany({ where: { id: owner.id } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('creates a valid grant and reads it back through the resolver model', async () => { + const created = await grants.createGrant(owner, shared, editorGrant); + expect(created).toMatchObject({ role: 'editor', scopeType: 'pond', effect: 'allow' }); + const all = await grants.grantsForPond(shared); + expect(all).toContainEqual(expect.objectContaining({ subjectId: 'target-user' })); + }); + + it('rejects a duplicate grant', async () => { + await expect(grants.createGrant(owner, shared, editorGrant)).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('rejects a pond_admin grant at label scope (structural)', async () => { + const bad: Grant = { + ...editorGrant, + role: 'pond_admin', + scopeType: 'label', + scopeId: 'some-label', + }; + await expect(grants.createGrant(owner, shared, bad)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('rejects a second admin on a personal pond', async () => { + const admin: Grant = { ...editorGrant, role: 'pond_admin' }; + await expect(grants.createGrant(owner, personal, admin)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + // The pond_admin-scope rule also has a DB CHECK backstop (in the migration); + // it is not exercised here because the test database is built with `db push`, + // which syncs tables/columns but not the raw CHECK constraint. +}); diff --git a/apps/api/src/grants/grants.service.ts b/apps/api/src/grants/grants.service.ts new file mode 100644 index 0000000..d3e9c2d --- /dev/null +++ b/apps/api/src/grants/grants.service.ts @@ -0,0 +1,83 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Grant, grantValidationError } from '@dorfteich/shared'; +import { Pond, User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { InterimAccessService } from '../ponds/interim-access.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { toGrant, toGrantColumns } from './grant-mappers'; + +/** + * Writes and reads over the permission table (`role_grants`, issue #51). Grants + * are validated against the shared structural rules before insertion (the DB + * CHECK is a backstop for the pond_admin-scope rule). Who may manage grants is + * still the interim rule (owner/Site Admin) until #52 wires the real Pond Admin + * check; the member-management API/UI arrives with #54. `grantsForPond` is what + * the resolver (#52/#53) reads. + */ +@Injectable() +export class GrantsService { + constructor( + private readonly prisma: PrismaService, + private readonly access: InterimAccessService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(GrantsService.name); + } + + /** All grants in a pond, as the shared resolver model (used by #52/#53). */ + async grantsForPond(pondId: string): Promise { + const rows = await this.prisma.roleGrant.findMany({ where: { pondId } }); + return rows.map(toGrant); + } + + private async requireModifiablePond(user: User, pondId: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + this.access.assertCanModify(user, pond); + return pond; + } + + /** + * Create a grant after validating its structural constraints (issue #51): + * pond_admin only at pond scope for a user subject, and no extra admins on a + * personal pond. Rejects duplicates. Returns the stored grant. + */ + async createGrant(user: User, pondId: string, grant: Grant): Promise { + const pond = await this.requireModifiablePond(user, pondId); + + const invalid = grantValidationError(grant, { + pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', + }); + if (invalid) throw new BadRequestException({ code: invalid }); + + const columns = toGrantColumns(grant); + const existing = await this.prisma.roleGrant.findFirst({ + where: { pondId, ...columns }, + select: { id: true }, + }); + if (existing) throw new ConflictException({ code: 'grant_exists' }); + + const created = await this.prisma.roleGrant.create({ + data: { pondId, createdBy: user.id, ...columns }, + }); + this.logger.info( + { grantId: created.id, pondId, userId: user.id, role: grant.role, scope: grant.scopeType }, + 'audit: grant created', + ); + return toGrant(created); + } + + /** Remove a grant by id (member-management detail; permission is interim). */ + async deleteGrant(user: User, grantId: string): Promise { + const grant = await this.prisma.roleGrant.findUnique({ where: { id: grantId } }); + if (!grant) throw new NotFoundException(); + await this.requireModifiablePond(user, grant.pondId); + await this.prisma.roleGrant.delete({ where: { id: grantId } }); + this.logger.info({ grantId, userId: user.id }, 'audit: grant deleted'); + } +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 2fdcacf..2dc6841 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -31,6 +31,11 @@ "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.", "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "network": "Der Server war nicht erreichbar.", + "grant_exists": "Diese Berechtigung existiert bereits.", + "grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.", + "grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.", + "grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.", + "grant_scope_id_mismatch": "Der Geltungsbereich der Berechtigung ist widersprüchlich.", "validation": { "required": "Dieses Feld ist erforderlich.", "taken": "Dieser Wert ist bereits vergeben.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 7eceb94..36993ff 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -31,6 +31,11 @@ "unsupported_file_type": "This file type is not supported.", "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "network": "The server could not be reached.", + "grant_exists": "This grant already exists.", + "grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.", + "grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.", + "grant_subject_id_mismatch": "The grant's subject is inconsistent.", + "grant_scope_id_mismatch": "The grant's scope is inconsistent.", "validation": { "required": "This field is required.", "taken": "This value is already taken.", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7a96d22..c793eb4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export * from './i18n-tools'; export * from './labels'; export * from './links'; export * from './pages'; +export * from './permissions'; export * from './search'; export * from './ponds'; export * from './quotas'; diff --git a/packages/shared/src/permissions/index.ts b/packages/shared/src/permissions/index.ts new file mode 100644 index 0000000..6b60019 --- /dev/null +++ b/packages/shared/src/permissions/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './resolve'; +export * from './validate'; diff --git a/packages/shared/src/permissions/resolve.test.ts b/packages/shared/src/permissions/resolve.test.ts new file mode 100644 index 0000000..14f6c2c --- /dev/null +++ b/packages/shared/src/permissions/resolve.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; + +import { canAccessPage, canAccessTrashedPage, resolvePageCapability } from './resolve'; +import type { + Grant, + GrantEffect, + GrantRole, + GrantScopeType, + PageResolutionContext, + PermissionViewer, +} from './types'; + +const UMA = 'uma'; +const PAGE = 'salaries'; + +/** Concise grant builder. */ +function grant( + role: GrantRole, + scopeType: GrantScopeType, + scopeId: string | null, + effect: GrantEffect, + subject: { type: 'user'; id: string } | { type: 'authenticated' } | { type: 'public' } = { + type: 'user', + id: UMA, + }, +): Grant { + return { + subjectType: subject.type, + subjectId: subject.type === 'user' ? subject.id : null, + role, + scopeType, + scopeId, + effect, + }; +} + +function ctx( + grants: Grant[], + options: { + viewer?: PermissionViewer; + pageLabelIds?: string[]; + labelParents?: Record; + } = {}, +): PageResolutionContext { + return { + viewer: options.viewer ?? { userId: UMA, isSiteAdmin: false }, + grants, + pageId: PAGE, + pageLabelIds: options.pageLabelIds ?? [], + labelParents: options.labelParents ?? {}, + }; +} + +describe('resolvePageCapability — worked examples (permissions.md §Resolution)', () => { + it('pond-scope allow: Uma edits the page', () => { + const c = ctx([grant('editor', 'pond', null, 'allow')]); + expect(resolvePageCapability('write', c)).toBe(true); + }); + + it('label-scope deny (more specific than pond) blocks the edit', () => { + const c = ctx( + [grant('editor', 'pond', null, 'allow'), grant('editor', 'label', 'confidential', 'deny')], + { pageLabelIds: ['confidential'] }, + ); + expect(resolvePageCapability('write', c)).toBe(false); + }); + + it('page-scope allow (most specific) beats the label deny', () => { + const c = ctx( + [ + grant('editor', 'pond', null, 'allow'), + grant('editor', 'label', 'confidential', 'deny'), + grant('editor', 'page', PAGE, 'allow'), + ], + { pageLabelIds: ['confidential'] }, + ); + expect(resolvePageCapability('write', c)).toBe(true); + }); + + it('two labels at the same level: deny wins', () => { + const c = ctx( + [ + grant('editor', 'pond', null, 'allow'), + grant('editor', 'label', 'confidential', 'deny'), + grant('editor', 'label', 'hr', 'allow'), + ], + { pageLabelIds: ['confidential', 'hr'] }, + ); + expect(resolvePageCapability('write', c)).toBe(false); + }); +}); + +describe('resolvePageCapability — edge cases', () => { + it('default-closed: no grants → deny', () => { + expect(resolvePageCapability('read', ctx([]))).toBe(false); + expect(resolvePageCapability('write', ctx([]))).toBe(false); + }); + + it('Site Admin bypasses resolution', () => { + const c = ctx([grant('reader', 'pond', null, 'deny')], { + viewer: { userId: 'admin', isSiteAdmin: true }, + }); + expect(resolvePageCapability('write', c)).toBe(true); + }); + + it('reader role covers read but not write', () => { + const c = ctx([grant('reader', 'pond', null, 'allow')]); + expect(resolvePageCapability('read', c)).toBe(true); + expect(resolvePageCapability('write', c)).toBe(false); + }); + + it('public subject: anonymous visitor may read a public-allow page', () => { + const anon: PermissionViewer = { userId: null, isSiteAdmin: false }; + const c = ctx([grant('reader', 'pond', null, 'allow', { type: 'public' })], { viewer: anon }); + expect(resolvePageCapability('read', c)).toBe(true); + }); + + it('authenticated subject does not match an anonymous visitor', () => { + const anon: PermissionViewer = { userId: null, isSiteAdmin: false }; + const c = ctx([grant('reader', 'pond', null, 'allow', { type: 'authenticated' })], { + viewer: anon, + }); + expect(resolvePageCapability('read', c)).toBe(false); + }); + + it('authenticated subject matches any logged-in user', () => { + const c = ctx([grant('reader', 'pond', null, 'allow', { type: 'authenticated' })], { + viewer: { userId: 'anyone', isSiteAdmin: false }, + }); + expect(resolvePageCapability('read', c)).toBe(true); + }); + + it('a grant on an ancestor label inherits to a page tagged with a descendant', () => { + // parent → child; the page carries `child`, the grant is on `parent`. + const c = ctx([grant('editor', 'label', 'parent', 'allow')], { + pageLabelIds: ['child'], + labelParents: { parent: null, child: 'parent' }, + }); + expect(resolvePageCapability('write', c)).toBe(true); + }); + + it('"only pages with label Y": no pond grant + label allow', () => { + const c = ctx([grant('editor', 'label', 'y', 'allow')], { pageLabelIds: ['y'] }); + expect(resolvePageCapability('write', c)).toBe(true); + // A page without label Y gets nothing. + expect(resolvePageCapability('write', ctx([grant('editor', 'label', 'y', 'allow')]))).toBe( + false, + ); + }); + + it('"all except label X": pond allow + label deny', () => { + const grants = [grant('editor', 'pond', null, 'allow'), grant('editor', 'label', 'x', 'deny')]; + // Page with X → denied. + expect(resolvePageCapability('write', ctx(grants, { pageLabelIds: ['x'] }))).toBe(false); + // Page without X → allowed. + expect(resolvePageCapability('write', ctx(grants, { pageLabelIds: ['other'] }))).toBe(true); + }); + + it('a deny for a different subject does not affect this user', () => { + const c = ctx([ + grant('editor', 'pond', null, 'allow'), + grant('editor', 'pond', null, 'deny', { type: 'user', id: 'someone-else' }), + ]); + expect(resolvePageCapability('write', c)).toBe(true); + }); +}); + +describe('trash rule (permissions.md §trash, ADR 0013)', () => { + const editor = ctx([grant('editor', 'pond', null, 'allow')]); + + it('a trashed page is not accessible in normal views', () => { + expect(canAccessPage('read', editor, true)).toBe(false); + expect(canAccessPage('write', editor, true)).toBe(false); + }); + + it('a live page follows the base capability', () => { + expect(canAccessPage('write', editor, false)).toBe(true); + }); + + it('Site Admin still sees trashed pages', () => { + const admin = ctx([], { viewer: { userId: 'a', isSiteAdmin: true } }); + expect(canAccessPage('read', admin, true)).toBe(true); + }); + + it('trash views/restore require write capability', () => { + expect(canAccessTrashedPage(editor)).toBe(true); + const reader = ctx([grant('reader', 'pond', null, 'allow')]); + expect(canAccessTrashedPage(reader)).toBe(false); + }); +}); + +describe('property: a less-specific grant never overrides a more-specific decision', () => { + const effects: GrantEffect[] = ['allow', 'deny']; + const lessSpecific: (Grant | null)[] = [ + null, + grant('editor', 'label', 'l', 'allow'), + grant('editor', 'label', 'l', 'deny'), + grant('editor', 'pond', null, 'allow'), + grant('editor', 'pond', null, 'deny'), + ]; + + it('a page-scope grant decides regardless of any label/pond grants', () => { + for (const pageEffect of effects) { + const expected = pageEffect === 'allow'; + for (const extra of lessSpecific) { + const grants = [grant('editor', 'page', PAGE, pageEffect), ...(extra ? [extra] : [])]; + const c = ctx(grants, { pageLabelIds: ['l'] }); + expect(resolvePageCapability('write', c)).toBe(expected); + } + } + }); + + it('a label-scope grant decides regardless of any pond grants', () => { + const pondGrants: (Grant | null)[] = [ + null, + grant('editor', 'pond', null, 'allow'), + grant('editor', 'pond', null, 'deny'), + ]; + for (const labelEffect of effects) { + const expected = labelEffect === 'allow'; + for (const extra of pondGrants) { + const grants = [grant('editor', 'label', 'l', labelEffect), ...(extra ? [extra] : [])]; + const c = ctx(grants, { pageLabelIds: ['l'] }); + expect(resolvePageCapability('write', c)).toBe(expected); + } + } + }); +}); diff --git a/packages/shared/src/permissions/resolve.ts b/packages/shared/src/permissions/resolve.ts new file mode 100644 index 0000000..f034828 --- /dev/null +++ b/packages/shared/src/permissions/resolve.ts @@ -0,0 +1,109 @@ +import type { Grant, PageResolutionContext, PermissionAction, PermissionViewer } from './types'; + +/** + * The permission resolution algorithm (issue #51), implemented exactly as + * permissions.md §Resolution specifies and used by API, collab, and UI. Pure: + * inputs are grants + labels + page + viewer, output is a boolean decision. + * + * Specificity, highest first: **page > label (incl. ancestor labels) > pond**. + * The first level with any matching grant decides; within it, a single `deny` + * denies (deny wins ties), otherwise it allows. No matching grant at any level + * → deny (default-closed). Site Admins bypass everything. + */ + +/** Does a grant's subject apply to the viewer? */ +function subjectMatches(grant: Grant, viewer: PermissionViewer): boolean { + switch (grant.subjectType) { + case 'public': + return true; + case 'authenticated': + return viewer.userId !== null; + case 'user': + return viewer.userId !== null && viewer.userId === grant.subjectId; + } +} + +/** Does a grant's role cover the action? Read is covered by any role; write + * needs editor or pond_admin. */ +function roleCovers(grant: Grant, action: PermissionAction): boolean { + return action === 'read' || grant.role !== 'reader'; +} + +/** Within one specificity level: deny wins, otherwise (some allow) allows. */ +function decide(levelGrants: Grant[]): boolean { + return !levelGrants.some((g) => g.effect === 'deny'); +} + +/** + * The page's "effective" label ids: its direct labels plus every ancestor of + * those labels — a grant on label L applies to L and all its descendants + * (permissions.md §label scope). Robust against malformed cycles. + */ +function effectiveLabelIds(ctx: PageResolutionContext): Set { + const result = new Set(); + for (const start of ctx.pageLabelIds) { + let current: string | null | undefined = start; + while (current && !result.has(current)) { + result.add(current); + current = ctx.labelParents[current] ?? null; + } + } + return result; +} + +/** + * May the viewer perform `action` on the page, per the grants? This is the base + * capability — it does NOT apply the trash rule (see {@link canAccessPage} / + * {@link canAccessTrashedPage}). Site Admin → always true; default-closed. + */ +export function resolvePageCapability( + action: PermissionAction, + ctx: PageResolutionContext, +): boolean { + if (ctx.viewer.isSiteAdmin) return true; + + const matching = ctx.grants.filter((g) => subjectMatches(g, ctx.viewer) && roleCovers(g, action)); + + // Page scope — grants on the page itself. + const pageGrants = matching.filter((g) => g.scopeType === 'page' && g.scopeId === ctx.pageId); + if (pageGrants.length > 0) return decide(pageGrants); + + // Label scope — grants on any label assigned to the page or an ancestor. + const effective = effectiveLabelIds(ctx); + const labelGrants = matching.filter( + (g) => g.scopeType === 'label' && g.scopeId !== null && effective.has(g.scopeId), + ); + if (labelGrants.length > 0) return decide(labelGrants); + + // Pond scope — grants on the whole pond. + const pondGrants = matching.filter((g) => g.scopeType === 'pond'); + if (pondGrants.length > 0) return decide(pondGrants); + + // No matching grant at any level → deny. + return false; +} + +/** + * May the viewer access the page in normal (non-trash) views for `action` + * (permissions.md §trash: a page or pond in trash is not accessible in normal + * views)? Site Admin bypasses. `trashed` = the page or its pond is in trash. + */ +export function canAccessPage( + action: PermissionAction, + ctx: PageResolutionContext, + trashed: boolean, +): boolean { + if (ctx.viewer.isSiteAdmin) return true; + if (trashed) return false; + return resolvePageCapability(action, ctx); +} + +/** + * May the viewer see or restore the page in trash views (permissions.md §trash, + * ADR 0013)? Only roles that could edit the page — write capability — may, + * whether or not it is currently trashed. Site Admin bypasses. + */ +export function canAccessTrashedPage(ctx: PageResolutionContext): boolean { + if (ctx.viewer.isSiteAdmin) return true; + return resolvePageCapability('write', ctx); +} diff --git a/packages/shared/src/permissions/types.ts b/packages/shared/src/permissions/types.ts new file mode 100644 index 0000000..bdc3883 --- /dev/null +++ b/packages/shared/src/permissions/types.ts @@ -0,0 +1,62 @@ +/** + * Permission model types (issue #51, permissions.md — authoritative). One + * grant is `(subject, role, scope, effect)` inside one pond. The resolution + * algorithm ({@link ./resolve}) is implemented once here and consumed by the + * API guards, the collab token issuance, and the frontend (UI affordances only + * — the client never enforces security). + */ + +/** Actions the model decides. `write` covers create/edit/delete/history/trash. */ +export type PermissionAction = 'read' | 'write'; + +/** A grant's role. `pond_admin` implies Editor everywhere in the pond. */ +export type GrantRole = 'pond_admin' | 'editor' | 'reader'; + +/** `deny` expresses the vision's "all pages except label X". */ +export type GrantEffect = 'allow' | 'deny'; + +/** Who a grant applies to. `authenticated` = any logged-in user; `public` = + * everyone including anonymous visitors. */ +export type GrantSubjectType = 'user' | 'authenticated' | 'public'; + +/** What a grant covers. `pond_admin` grants exist only at `pond` scope. */ +export type GrantScopeType = 'pond' | 'label' | 'page'; + +/** + * A single grant row (mirrors `role_grants`, data-model.md). `subjectId` is set + * only for `user` subjects; `scopeId` is the label/page id for `label`/`page` + * scopes and null for `pond` scope. + */ +export interface Grant { + subjectType: GrantSubjectType; + subjectId: string | null; + role: GrantRole; + scopeType: GrantScopeType; + scopeId: string | null; + effect: GrantEffect; +} + +/** The person (or anonymous visitor) whose access is being resolved. */ +export interface PermissionViewer { + /** The user's id, or null for an anonymous visitor. */ + userId: string | null; + /** Site Admins bypass resolution entirely (permissions.md). */ + isSiteAdmin: boolean; +} + +/** + * Everything the pure resolver needs to decide access to one page — no I/O. + * The caller gathers the pond's grants, the page's directly-assigned labels, + * and the pond's label parent map (for ancestor inheritance). + */ +export interface PageResolutionContext { + viewer: PermissionViewer; + /** All grants in the page's pond. */ + grants: Grant[]; + /** The page whose access is being resolved. */ + pageId: string; + /** Labels assigned directly to the page. */ + pageLabelIds: string[]; + /** Every label in the pond → its parent id (or null), for ancestor walks. */ + labelParents: Record; +} diff --git a/packages/shared/src/permissions/validate.test.ts b/packages/shared/src/permissions/validate.test.ts new file mode 100644 index 0000000..6c1d466 --- /dev/null +++ b/packages/shared/src/permissions/validate.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import type { Grant } from './types'; +import { grantValidationError } from './validate'; + +const base: Grant = { + subjectType: 'user', + subjectId: 'u1', + role: 'editor', + scopeType: 'pond', + scopeId: null, + effect: 'allow', +}; + +describe('grantValidationError (issue #51)', () => { + it('accepts a valid editor grant', () => { + expect(grantValidationError(base, { pondType: 'shared' })).toBeNull(); + }); + + it('accepts a valid pond_admin grant on a shared pond', () => { + expect( + grantValidationError({ ...base, role: 'pond_admin' }, { pondType: 'shared' }), + ).toBeNull(); + }); + + it('rejects pond_admin at label scope', () => { + const g: Grant = { ...base, role: 'pond_admin', scopeType: 'label', scopeId: 'l1' }; + expect(grantValidationError(g, { pondType: 'shared' })).toBe('grant_pond_admin_scope'); + }); + + it('rejects pond_admin with a non-user subject', () => { + const g: Grant = { ...base, role: 'pond_admin', subjectType: 'authenticated', subjectId: null }; + expect(grantValidationError(g, { pondType: 'shared' })).toBe('grant_pond_admin_scope'); + }); + + it('rejects a second admin on a personal pond', () => { + const g: Grant = { ...base, role: 'pond_admin' }; + expect(grantValidationError(g, { pondType: 'personal' })).toBe( + 'grant_pond_admin_personal_pond', + ); + }); + + it('rejects a user subject without an id, and a non-user subject with one', () => { + expect(grantValidationError({ ...base, subjectId: null }, { pondType: 'shared' })).toBe( + 'grant_subject_id_mismatch', + ); + expect( + grantValidationError( + { ...base, subjectType: 'public', subjectId: 'x' }, + { pondType: 'shared' }, + ), + ).toBe('grant_subject_id_mismatch'); + }); + + it('rejects a scoped grant without an id, and a pond grant with one', () => { + expect( + grantValidationError({ ...base, scopeType: 'label', scopeId: null }, { pondType: 'shared' }), + ).toBe('grant_scope_id_mismatch'); + expect(grantValidationError({ ...base, scopeId: 'p1' }, { pondType: 'shared' })).toBe( + 'grant_scope_id_mismatch', + ); + }); +}); diff --git a/packages/shared/src/permissions/validate.ts b/packages/shared/src/permissions/validate.ts new file mode 100644 index 0000000..5430461 --- /dev/null +++ b/packages/shared/src/permissions/validate.ts @@ -0,0 +1,49 @@ +import type { Grant } from './types'; + +/** Why a grant is invalid — a stable code the API maps to a localized message. */ +export type GrantValidationError = + | 'grant_pond_admin_scope' // pond_admin must be pond-scope + user-subject + | 'grant_subject_id_mismatch' // user subjects need an id; others must not have one + | 'grant_scope_id_mismatch' // label/page scopes need an id; pond must not have one + | 'grant_pond_admin_personal_pond'; // personal ponds' single admin is the owner + +/** The pond context a grant is validated against. */ +export interface GrantValidationContext { + pondType: 'personal' | 'shared'; +} + +/** + * Validates a grant's structural constraints (issue #51, permissions.md / + * data-model.md), returning the first violation or null when valid. Pure, so + * the API and any future caller reject invalid grants the same way. The DB also + * enforces the pond_admin-scope rule with a CHECK constraint as a backstop. + */ +export function grantValidationError( + grant: Grant, + context: GrantValidationContext, +): GrantValidationError | null { + // Subject id presence must match the subject type. + const hasSubjectId = grant.subjectId !== null && grant.subjectId !== undefined; + if ((grant.subjectType === 'user') !== hasSubjectId) { + return 'grant_subject_id_mismatch'; + } + + // Scope id presence must match the scope type. + const hasScopeId = grant.scopeId !== null && grant.scopeId !== undefined; + if ((grant.scopeType !== 'pond') !== hasScopeId) { + return 'grant_scope_id_mismatch'; + } + + if (grant.role === 'pond_admin') { + // Pond Admin exists only at pond scope for a specific user. + if (grant.scopeType !== 'pond' || grant.subjectType !== 'user') { + return 'grant_pond_admin_scope'; + } + // Personal ponds have exactly one Pond Admin — the owner; no others. + if (context.pondType === 'personal') { + return 'grant_pond_admin_personal_pond'; + } + } + + return null; +}