Add grant model and shared permission-resolution algorithm (#51)
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

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
This commit is contained in:
Claude Opus 4.8 2026-07-09 14:02:07 +02:00
parent 19fb24c527
commit 4d48d72c40
16 changed files with 900 additions and 0 deletions

View File

@ -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'));

View File

@ -79,11 +79,62 @@ model Pond {
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
labels Label[] labels Label[]
grants RoleGrant[]
@@index([ownerId]) @@index([ownerId])
@@map("ponds") @@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 /// 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 /// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache` /// the merged state Y.Doc, decoded by the API to derive `PageContentCache`

View File

@ -9,6 +9,7 @@ import { CompactionModule } from './compaction/compaction.module';
import { AppConfig } from './config/app-config.service'; import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LabelsModule } from './labels/labels.module'; import { LabelsModule } from './labels/labels.module';
import { LinksModule } from './links/links.module'; import { LinksModule } from './links/links.module';
@ -40,6 +41,7 @@ import { VersionsModule } from './versions/versions.module';
LabelsModule, LabelsModule,
LinksModule, LinksModule,
SearchModule, SearchModule,
GrantsModule,
AuthModule, AuthModule,
AdminModule, AdminModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({

View File

@ -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<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],
};
}

View File

@ -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 {}

View File

@ -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.
});

View File

@ -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<Grant[]> {
const rows = await this.prisma.roleGrant.findMany({ where: { pondId } });
return rows.map(toGrant);
}
private async requireModifiablePond(user: User, pondId: string): Promise<Pond> {
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<Grant> {
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<void> {
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');
}
}

View File

@ -31,6 +31,11 @@
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.", "unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).", "file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
"network": "Der Server war nicht erreichbar.", "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": { "validation": {
"required": "Dieses Feld ist erforderlich.", "required": "Dieses Feld ist erforderlich.",
"taken": "Dieser Wert ist bereits vergeben.", "taken": "Dieser Wert ist bereits vergeben.",

View File

@ -31,6 +31,11 @@
"unsupported_file_type": "This file type is not supported.", "unsupported_file_type": "This file type is not supported.",
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).", "file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
"network": "The server could not be reached.", "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": { "validation": {
"required": "This field is required.", "required": "This field is required.",
"taken": "This value is already taken.", "taken": "This value is already taken.",

View File

@ -9,6 +9,7 @@ export * from './i18n-tools';
export * from './labels'; export * from './labels';
export * from './links'; export * from './links';
export * from './pages'; export * from './pages';
export * from './permissions';
export * from './search'; export * from './search';
export * from './ponds'; export * from './ponds';
export * from './quotas'; export * from './quotas';

View File

@ -0,0 +1,3 @@
export * from './types';
export * from './resolve';
export * from './validate';

View File

@ -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<string, string | null>;
} = {},
): 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);
}
}
});
});

View File

@ -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<string> {
const result = new Set<string>();
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);
}

View File

@ -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<string, string | null>;
}

View File

@ -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',
);
});
});

View File

@ -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;
}