Enforce permissions in API guards and retire interim access (#52)
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 2m25s
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 1m12s
CD / Promote to Int (push) Successful in 12s

Every route now declares its access rule explicitly and is enforced
through the shared resolution algorithm (permissions.md):

- PermissionGuard + decorators (@RequiresPondRole, @RequiresPagePermission,
  @RequiresAttachmentPermission, @AuthenticatedOnly) applied to every
  route; a route-enumeration test proves full coverage alongside
  @Public()/Site-Admin-guarded routes.
- 404/403 policy (documented in README conventions): denied reads answer
  404 (existence hiding), denied writes on readable things answer 403;
  trash views need write capability (ADR 0013).
- PermissionService resolves page/pond questions via the shared resolver,
  with an in-process pond-context cache (grants + label parents) that is
  invalidated on every grant/label-tree change and TTL-bounded as a
  multi-process safety net. Grant changes also fire pond_access_changed
  for collab revalidation (#39/#53).
- shared: pond-scope resolution (hasPondRole, canSeePond) next to the
  page resolver; grant wire schemas + GrantView.
- Owner Pond-Admin grants: migration backfill for all existing ponds,
  created transactionally with every new pond (shared + personal + seed).
- Grant CRUD under /ponds/:id/grants (pond_admin-gated) with structural
  and referential validation, last-admin protection, audit logs.
- InterimAccessService deleted; page lists, search, backlinks, phantom
  links, and trash listings are filtered per page through the resolver;
  collab tokens are now truly ro for readers.
- Fixture-matrix e2e (reader/editor/pond admin/foreign, label-deny,
  authenticated-subject, revoke-then-immediate-deny cache test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
Claude Fable 5 2026-07-09 16:31:41 +02:00
parent 4d48d72c40
commit 0c6494f209
50 changed files with 1825 additions and 337 deletions

View File

@ -0,0 +1,19 @@
-- Seed the owner's Pond Admin grant for every existing pond (issue #52).
-- From M5 on, access is decided solely by role_grants (permissions.md);
-- without this backfill, owners of pre-M5 ponds would be locked out the
-- moment the interim owner-or-siteadmin rule is retired. Raw SQL on purpose:
-- the service-level rule "personal ponds get no additional pond_admin
-- grants" does not apply here — the owner IS the one admin being seeded.
-- Trashed ponds are included so a later restore restores access too.
INSERT INTO "role_grants"
("id", "pond_id", "subject_type", "subject_id", "role", "scope_type", "scope_id", "effect", "created_by")
SELECT gen_random_uuid()::text, p."id", 'USER', p."owner_id", 'POND_ADMIN', 'POND', NULL, 'ALLOW', p."owner_id"
FROM "ponds" p
WHERE NOT EXISTS (
SELECT 1 FROM "role_grants" g
WHERE g."pond_id" = p."id"
AND g."subject_type" = 'USER'
AND g."subject_id" = p."owner_id"
AND g."role" = 'POND_ADMIN'
AND g."scope_type" = 'POND'
);

View File

@ -66,6 +66,37 @@ const FIXTURES: FixtureUser[] = [
}, },
]; ];
/**
* Every pond needs its owner's Pond Admin grant access is decided solely
* by role_grants from M5 on (issue #52). Idempotent, matching the
* owner_admin_grants migration backfill.
*/
async function ensureOwnerAdminGrant(pondId: string, ownerId: string): Promise<void> {
const existing = await prisma.roleGrant.findFirst({
where: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
},
select: { id: true },
});
if (existing) return;
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
}
async function upsertFixtureUser(fixture: FixtureUser): Promise<string> { async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
const email = `${fixture.username}@dorfteich.test`; const email = `${fixture.username}@dorfteich.test`;
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
@ -104,7 +135,7 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
select: { id: true }, select: { id: true },
}); });
if (!existing) { if (!existing) {
await prisma.pond.create({ const pond = await prisma.pond.create({
data: { data: {
slug: slugify(fixture.displayName) || fixture.username, slug: slugify(fixture.displayName) || fixture.username,
name: fixture.displayName, name: fixture.displayName,
@ -112,6 +143,7 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
ownerId: user.id, ownerId: user.id,
}, },
}); });
await ensureOwnerAdminGrant(pond.id, user.id);
} }
// The instance default for additional_ponds is 0 (ADR 0011) — give // The instance default for additional_ponds is 0 (ADR 0011) — give
// the fixtures headroom so pond flows are exercisable in dev/e2e. // the fixtures headroom so pond flows are exercisable in dev/e2e.
@ -217,6 +249,7 @@ async function seedContentFixtures(ownerId: string): Promise<void> {
}, },
}); });
} }
await ensureOwnerAdminGrant(pond.id, ownerId);
await prisma.quotaOverride.upsert({ await prisma.quotaOverride.upsert({
where: { where: {
subjectType_subjectId_quotaKey: { subjectType_subjectId_quotaKey: {

View File

@ -15,6 +15,7 @@ import { LabelsModule } from './labels/labels.module';
import { LinksModule } from './links/links.module'; import { LinksModule } from './links/links.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { PagesModule } from './pages/pages.module'; import { PagesModule } from './pages/pages.module';
import { PermissionsModule } from './permissions/permissions.module';
import { PondsModule } from './ponds/ponds.module'; import { PondsModule } from './ponds/ponds.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { RateLimitModule } from './rate-limit/rate-limit.module'; import { RateLimitModule } from './rate-limit/rate-limit.module';
@ -32,6 +33,7 @@ import { VersionsModule } from './versions/versions.module';
MailModule, MailModule,
SettingsModule, SettingsModule,
UsersModule, UsersModule,
PermissionsModule,
PondsModule, PondsModule,
PagesModule, PagesModule,
FilesModule, FilesModule,

View File

@ -14,12 +14,14 @@ import type { Response } from 'express';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { RateLimit } from '../rate-limit/rate-limit.guard'; import { RateLimit } from '../rate-limit/rate-limit.guard';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard'; import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { SessionsService } from './sessions.service'; import { SessionsService } from './sessions.service';
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor( constructor(

View File

@ -17,6 +17,10 @@ import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import {
RequiresAttachmentPermission,
RequiresPondRole,
} from '../permissions/permission.decorators';
import { FilesService } from './files.service'; import { FilesService } from './files.service';
@ -26,6 +30,7 @@ export class FilesController {
constructor(private readonly files: FilesService) {} constructor(private readonly files: FilesService) {}
@Post('ponds/:pondId/files') @Post('ponds/:pondId/files')
@RequiresPondRole('editor', { idParam: 'pondId' })
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } }))
async upload( async upload(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@ -38,6 +43,7 @@ export class FilesController {
/** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */ /** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */
@Get('media/:fileId') @Get('media/:fileId')
@RequiresAttachmentPermission('read', { idParam: 'fileId' })
async download( async download(
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -55,6 +61,7 @@ export class FilesController {
@Delete('files/:id') @Delete('files/:id')
@HttpCode(204) @HttpCode(204)
@RequiresAttachmentPermission('write', { idParam: 'id' })
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> { async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.files.remove(request.user!, id); await this.files.remove(request.user!, id);
} }

View File

@ -11,7 +11,6 @@ import { AttachmentView } from '@dorfteich/shared';
import { Attachment, User } from '@prisma/client'; import { Attachment, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
@ -28,7 +27,6 @@ export interface FileDownload {
export class FilesService { export class FilesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
@ -54,7 +52,7 @@ export class FilesService {
file: { buffer: Buffer; size: number; originalname: string }, file: { buffer: Buffer; size: number; originalname: string },
): Promise<AttachmentView> { ): Promise<AttachmentView> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond); if (!pond) throw new NotFoundException();
// Bytes decide, not the client-declared Content-Type or extension — // Bytes decide, not the client-declared Content-Type or extension —
// catches a renamed .html-as-.png (ADR 0011 acceptance criterion). // catches a renamed .html-as-.png (ADR 0011 acceptance criterion).
@ -106,23 +104,15 @@ export class FilesService {
} }
} }
async download(user: User, id: string): Promise<FileDownload> { async download(_user: User, id: string): Promise<FileDownload> {
const attachment = await this.prisma.attachment.findFirst({ const attachment = await this.prisma.attachment.findFirst({ where: { id } });
where: { id },
include: { pond: true },
});
if (!attachment) throw new NotFoundException(); if (!attachment) throw new NotFoundException();
this.access.assertCanSee(user, attachment.pond);
return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) }; return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) };
} }
async remove(user: User, id: string): Promise<void> { async remove(user: User, id: string): Promise<void> {
const attachment = await this.prisma.attachment.findFirst({ const attachment = await this.prisma.attachment.findFirst({ where: { id } });
where: { id },
include: { pond: true },
});
if (!attachment) throw new NotFoundException(); if (!attachment) throw new NotFoundException();
this.access.assertCanModify(user, attachment.pond);
await this.prisma.attachment.delete({ where: { id: attachment.id } }); await this.prisma.attachment.delete({ where: { id: attachment.id } });
await this.storage.delete(attachment.pondId, attachment.id); await this.storage.delete(attachment.pondId, attachment.id);

View File

@ -0,0 +1,49 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common';
import {
CreateGrantInput,
GrantView,
createGrantInputSchema,
grantOfInput,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequiresPondRole } from '../permissions/permission.decorators';
import { GrantsService } from './grants.service';
/**
* Grant management (issue #52): a pond's grants are its Pond Admins'
* business members and their roles (permissions.md). The member-management
* UI on top of this arrives with #54.
*/
@Controller('ponds/:pondId/grants')
export class GrantsController {
constructor(private readonly grants: GrantsService) {}
@Get()
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async list(@Param('pondId') pondId: string): Promise<GrantView[]> {
return this.grants.listGrants(pondId);
}
@Post()
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async create(
@Param('pondId') pondId: string,
@Body(new ZodValidationPipe(createGrantInputSchema)) input: CreateGrantInput,
@Req() request: AuthedRequest,
): Promise<GrantView> {
return this.grants.createGrant(request.user!, pondId, grantOfInput(input));
}
@Delete(':grantId')
@HttpCode(204)
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async remove(
@Param('pondId') pondId: string,
@Param('grantId') grantId: string,
@Req() request: AuthedRequest,
): Promise<void> {
await this.grants.deleteGrant(request.user!, pondId, grantId);
}
}

View File

@ -2,15 +2,18 @@ import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { GrantsController } from './grants.controller';
import { GrantsService } from './grants.service'; import { GrantsService } from './grants.service';
/** /**
* Permission grants (issue #51). Exports `GrantsService` so the API guards and * Permission grants (issues #51/#52): the management endpoints under
* collab token issuance (#52/#53) can resolve grants; the member-management * `/ponds/:id/grants` (Pond-Admin-gated) and the service behind them.
* controller/UI arrives with #54. * Resolution itself lives in PermissionsModule; the member-management UI
* arrives with #54.
*/ */
@Module({ @Module({
imports: [PondsModule], imports: [PondsModule],
controllers: [GrantsController],
providers: [GrantsService], providers: [GrantsService],
exports: [GrantsService], exports: [GrantsService],
}) })

View File

@ -13,17 +13,11 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
let grants: GrantsService; let grants: GrantsService;
const suffix = uniqueSuffix(); const suffix = uniqueSuffix();
let owner: User; let owner: User;
let target: User;
let shared: string; let shared: string;
let personal: string; let personal: string;
// Grants must point at an existing user (#52); filled in beforeAll.
const editorGrant: Grant = { let editorGrant: Grant;
subjectType: 'user',
subjectId: 'target-user',
role: 'editor',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
};
beforeAll(async () => { beforeAll(async () => {
prisma = createTestPrisma(); prisma = createTestPrisma();
@ -37,6 +31,21 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
displayName: 'Grant Owner', displayName: 'Grant Owner',
}, },
}); });
target = await prisma.user.create({
data: {
username: `grant-target-${suffix}`,
email: `grant-target-${suffix}@example.test`,
displayName: 'Grant Target',
},
});
editorGrant = {
subjectType: 'user',
subjectId: target.id,
role: 'editor',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
};
const s = await prisma.pond.create({ const s = await prisma.pond.create({
data: { slug: `grant-shared-${suffix}`, name: 'Shared', type: 'SHARED', ownerId: owner.id }, data: { slug: `grant-shared-${suffix}`, name: 'Shared', type: 'SHARED', ownerId: owner.id },
}); });
@ -55,7 +64,7 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
afterAll(async () => { afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } }); await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } });
await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } }); await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } });
await prisma.user.deleteMany({ where: { id: owner.id } }); await prisma.user.deleteMany({ where: { id: { in: [owner.id, target.id] } } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
}); });
@ -64,7 +73,7 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
const created = await grants.createGrant(owner, shared, editorGrant); const created = await grants.createGrant(owner, shared, editorGrant);
expect(created).toMatchObject({ role: 'editor', scopeType: 'pond', effect: 'allow' }); expect(created).toMatchObject({ role: 'editor', scopeType: 'pond', effect: 'allow' });
const all = await grants.grantsForPond(shared); const all = await grants.grantsForPond(shared);
expect(all).toContainEqual(expect.objectContaining({ subjectId: 'target-user' })); expect(all).toContainEqual(expect.objectContaining({ subjectId: target.id }));
}); });
it('rejects a duplicate grant', async () => { it('rejects a duplicate grant', async () => {

View File

@ -4,56 +4,107 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Grant, grantValidationError } from '@dorfteich/shared'; import { Grant, GrantView, grantValidationError } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client'; import { Pond, RoleGrant, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { InterimAccessService } from '../ponds/interim-access.service'; import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { toGrant, toGrantColumns } from './grant-mappers'; import { toGrant, toGrantColumns } from './grant-mappers';
/** /**
* Writes and reads over the permission table (`role_grants`, issue #51). Grants * Writes and reads over the permission table (`role_grants`, issues #51/#52).
* are validated against the shared structural rules before insertion (the DB * Who may manage grants is the guard's job (Pond Admin); this service
* CHECK is a backstop for the pond_admin-scope rule). Who may manage grants is * validates the grant itself the shared structural rules, plus that the
* still the interim rule (owner/Site Admin) until #52 wires the real Pond Admin * scope and subject actually exist in this pond. Every mutation invalidates
* check; the member-management API/UI arrives with #54. `grantsForPond` is what * the pond's permission context and notifies the collab server so live
* the resolver (#52/#53) reads. * sessions revalidate (issue #39; full revocation UX is #53), and leaves an
* audit log line.
*/ */
@Injectable() @Injectable()
export class GrantsService { export class GrantsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly permissionCache: PondPermissionCache,
private readonly accessNotifier: PondAccessNotifier,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(GrantsService.name); this.logger.setContext(GrantsService.name);
} }
/** All grants in a pond, as the shared resolver model (used by #52/#53). */ /** All grants in a pond, as the shared resolver model (kept for callers
* that resolve rather than manage; PermissionService reads its own copy). */
async grantsForPond(pondId: string): Promise<Grant[]> { async grantsForPond(pondId: string): Promise<Grant[]> {
const rows = await this.prisma.roleGrant.findMany({ where: { pondId } }); const rows = await this.prisma.roleGrant.findMany({ where: { pondId } });
return rows.map(toGrant); return rows.map(toGrant);
} }
private async requireModifiablePond(user: User, pondId: string): Promise<Pond> { private static viewOf(row: RoleGrant): GrantView {
return {
...toGrant(row),
id: row.id,
pondId: row.pondId,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
};
}
private async requireLivePond(pondId: string): Promise<Pond> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond); if (!pond) throw new NotFoundException();
return pond; return pond;
} }
/** A pond's grants for the management UI, newest last. */
async listGrants(pondId: string): Promise<GrantView[]> {
await this.requireLivePond(pondId);
const rows = await this.prisma.roleGrant.findMany({
where: { pondId },
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => GrantsService.viewOf(row));
}
/** The grant must point at things that exist in this pond a label/page
* from elsewhere would silently never match during resolution. */
private async assertScopeAndSubjectExist(pondId: string, grant: Grant): Promise<void> {
if (grant.scopeType === 'label' && grant.scopeId) {
const label = await this.prisma.label.findFirst({
where: { id: grant.scopeId, pondId },
select: { id: true },
});
if (!label) throw new BadRequestException({ code: 'grant_scope_not_found' });
}
if (grant.scopeType === 'page' && grant.scopeId) {
const page = await this.prisma.page.findFirst({
where: { id: grant.scopeId, pondId },
select: { id: true },
});
if (!page) throw new BadRequestException({ code: 'grant_scope_not_found' });
}
if (grant.subjectType === 'user' && grant.subjectId) {
const user = await this.prisma.user.findUnique({
where: { id: grant.subjectId },
select: { id: true },
});
if (!user) throw new BadRequestException({ code: 'grant_subject_not_found' });
}
}
/** /**
* Create a grant after validating its structural constraints (issue #51): * 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 * pond_admin only at pond scope for a user subject, no extra admins on a
* personal pond. Rejects duplicates. Returns the stored grant. * personal pond, and scope/subject must exist here. Rejects duplicates.
*/ */
async createGrant(user: User, pondId: string, grant: Grant): Promise<Grant> { async createGrant(user: User, pondId: string, grant: Grant): Promise<GrantView> {
const pond = await this.requireModifiablePond(user, pondId); const pond = await this.requireLivePond(pondId);
const invalid = grantValidationError(grant, { const invalid = grantValidationError(grant, {
pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared',
}); });
if (invalid) throw new BadRequestException({ code: invalid }); if (invalid) throw new BadRequestException({ code: invalid });
await this.assertScopeAndSubjectExist(pondId, grant);
const columns = toGrantColumns(grant); const columns = toGrantColumns(grant);
const existing = await this.prisma.roleGrant.findFirst({ const existing = await this.prisma.roleGrant.findFirst({
@ -65,19 +116,52 @@ export class GrantsService {
const created = await this.prisma.roleGrant.create({ const created = await this.prisma.roleGrant.create({
data: { pondId, createdBy: user.id, ...columns }, data: { pondId, createdBy: user.id, ...columns },
}); });
await this.accessChanged(pondId);
this.logger.info( this.logger.info(
{ grantId: created.id, pondId, userId: user.id, role: grant.role, scope: grant.scopeType }, {
grantId: created.id,
pondId,
userId: user.id,
subject: grant.subjectType,
subjectId: grant.subjectId,
role: grant.role,
scope: grant.scopeType,
scopeId: grant.scopeId,
effect: grant.effect,
},
'audit: grant created', 'audit: grant created',
); );
return toGrant(created); return GrantsService.viewOf(created);
} }
/** Remove a grant by id (member-management detail; permission is interim). */ /**
async deleteGrant(user: User, grantId: string): Promise<void> { * Remove a grant by id within its pond. The last remaining Pond Admin
const grant = await this.prisma.roleGrant.findUnique({ where: { id: grantId } }); * grant is protected deleting it would leave the pond unmanageable
* (only a Site Admin could recover it).
*/
async deleteGrant(user: User, pondId: string, grantId: string): Promise<void> {
const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } });
if (!grant) throw new NotFoundException(); if (!grant) throw new NotFoundException();
await this.requireModifiablePond(user, grant.pondId);
if (grant.role === 'POND_ADMIN') {
const admins = await this.prisma.roleGrant.count({
where: { pondId, role: 'POND_ADMIN', effect: 'ALLOW' },
});
if (admins <= 1) throw new ConflictException({ code: 'grant_last_admin' });
}
await this.prisma.roleGrant.delete({ where: { id: grantId } }); await this.prisma.roleGrant.delete({ where: { id: grantId } });
this.logger.info({ grantId, userId: user.id }, 'audit: grant deleted'); await this.accessChanged(pondId);
this.logger.info(
{ grantId, pondId, userId: user.id, subjectId: grant.subjectId, role: grant.role },
'audit: grant deleted',
);
}
/** Revoked/added permission takes effect immediately: drop the cached pond
* context and tell the collab server to revalidate its sessions. */
private async accessChanged(pondId: string): Promise<void> {
this.permissionCache.invalidate(pondId);
await this.accessNotifier.notifyAccessChanged(pondId);
} }
} }

View File

@ -25,11 +25,13 @@ import {
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { LabelsService } from './labels.service'; import { LabelsService } from './labels.service';
/** /**
* Hierarchical label management (issue #43). Access rules live in * Hierarchical label management (issue #43). Seeing a pond lets you read its
* InterimAccessService, resolved per owning pond inside the service. * label tree; managing the tree is Pond Admin work; assigning labels to a
* page is editing that page (permissions.md, enforced by the guard, #52).
*/ */
@Controller() @Controller()
export class LabelsController { export class LabelsController {
@ -37,6 +39,7 @@ export class LabelsController {
/** The pond's label hierarchy in one call — sidebar tree and pickers. */ /** The pond's label hierarchy in one call — sidebar tree and pickers. */
@Get('ponds/:pondId/labels') @Get('ponds/:pondId/labels')
@RequiresPondRole('reader', { idParam: 'pondId' })
async list( async list(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -45,6 +48,7 @@ export class LabelsController {
} }
@Post('ponds/:pondId/labels') @Post('ponds/:pondId/labels')
@RequiresPondRole('pond_admin', { idParam: 'pondId' })
async create( async create(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Body(new ZodValidationPipe(createLabelInputSchema)) input: CreateLabelInput, @Body(new ZodValidationPipe(createLabelInputSchema)) input: CreateLabelInput,
@ -55,6 +59,7 @@ export class LabelsController {
/** Rename and/or recolour a label. */ /** Rename and/or recolour a label. */
@Patch('labels/:id') @Patch('labels/:id')
@RequiresPondRole('pond_admin', { labelParam: 'id' })
async update( async update(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(updateLabelInputSchema)) input: UpdateLabelInput, @Body(new ZodValidationPipe(updateLabelInputSchema)) input: UpdateLabelInput,
@ -65,6 +70,7 @@ export class LabelsController {
/** Move a label (with its subtree) to a new parent or to the root. */ /** Move a label (with its subtree) to a new parent or to the root. */
@Post('labels/:id/move') @Post('labels/:id/move')
@RequiresPondRole('pond_admin', { labelParam: 'id' })
async move( async move(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(moveLabelInputSchema)) input: MoveLabelInput, @Body(new ZodValidationPipe(moveLabelInputSchema)) input: MoveLabelInput,
@ -76,6 +82,7 @@ export class LabelsController {
/** Delete a label and its subtree; `?force=true` confirms detaching pages. */ /** Delete a label and its subtree; `?force=true` confirms detaching pages. */
@Delete('labels/:id') @Delete('labels/:id')
@HttpCode(204) @HttpCode(204)
@RequiresPondRole('pond_admin', { labelParam: 'id' })
async remove( async remove(
@Param('id') id: string, @Param('id') id: string,
@Query('force') force: string | undefined, @Query('force') force: string | undefined,
@ -86,6 +93,7 @@ export class LabelsController {
/** A page's assigned labels. */ /** A page's assigned labels. */
@Get('pages/:pageId/labels') @Get('pages/:pageId/labels')
@RequiresPagePermission('read', { idParam: 'pageId' })
async pageLabels( async pageLabels(
@Param('pageId') pageId: string, @Param('pageId') pageId: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -95,6 +103,7 @@ export class LabelsController {
/** Assign a label to a page (idempotent). */ /** Assign a label to a page (idempotent). */
@Post('pages/:pageId/labels') @Post('pages/:pageId/labels')
@RequiresPagePermission('write', { idParam: 'pageId' })
async assign( async assign(
@Param('pageId') pageId: string, @Param('pageId') pageId: string,
@Body(new ZodValidationPipe(assignLabelInputSchema)) input: AssignLabelInput, @Body(new ZodValidationPipe(assignLabelInputSchema)) input: AssignLabelInput,
@ -106,6 +115,7 @@ export class LabelsController {
/** Unassign a label from a page (idempotent). */ /** Unassign a label from a page (idempotent). */
@Delete('pages/:pageId/labels/:labelId') @Delete('pages/:pageId/labels/:labelId')
@HttpCode(204) @HttpCode(204)
@RequiresPagePermission('write', { idParam: 'pageId' })
async unassign( async unassign(
@Param('pageId') pageId: string, @Param('pageId') pageId: string,
@Param('labelId') labelId: string, @Param('labelId') labelId: string,

View File

@ -1,11 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { import { BadRequestException, ConflictException, INestApplication } from '@nestjs/common';
BadRequestException,
ConflictException,
INestApplication,
NotFoundException,
} from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client'; import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
@ -20,7 +15,6 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => {
let labels: LabelsService; let labels: LabelsService;
const suffix = uniqueSuffix(); const suffix = uniqueSuffix();
let owner: User; let owner: User;
let outsider: User;
let pondId: string; let pondId: string;
let otherPondId: string; let otherPondId: string;
const pageIds: string[] = []; const pageIds: string[] = [];
@ -54,13 +48,6 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => {
displayName: 'Label Owner', displayName: 'Label Owner',
}, },
}); });
outsider = await prisma.user.create({
data: {
username: `lbl-out-${suffix}`,
email: `lbl-out-${suffix}@example.test`,
displayName: 'Label Outsider',
},
});
const pond = await prisma.pond.create({ const pond = await prisma.pond.create({
data: { slug: `lbl-pond-${suffix}`, name: 'Label Pond', type: 'PERSONAL', ownerId: owner.id }, data: { slug: `lbl-pond-${suffix}`, name: 'Label Pond', type: 'PERSONAL', ownerId: owner.id },
}); });
@ -80,7 +67,7 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => {
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } }); if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
await prisma.label.deleteMany({ where: { pondId: { in: [pondId, otherPondId] } } }); await prisma.label.deleteMany({ where: { pondId: { in: [pondId, otherPondId] } } });
await prisma.pond.deleteMany({ where: { id: { in: [pondId, otherPondId] } } }); await prisma.pond.deleteMany({ where: { id: { in: [pondId, otherPondId] } } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } }); await prisma.user.deleteMany({ where: { id: owner.id } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
}); });
@ -191,10 +178,6 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => {
await prisma.label.deleteMany({ where: { id: label.id } }); await prisma.label.deleteMany({ where: { id: label.id } });
}); });
it('hides labels from users who cannot see the pond', async () => { // Hiding labels from users who cannot see the pond moved to the route
await expect(labels.list(outsider, pondId)).rejects.toBeInstanceOf(NotFoundException); // guard with #52 — covered by the permission e2e pack.
await expect(labels.create(outsider, pondId, { name: 'Nope' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
}); });

View File

@ -20,7 +20,7 @@ import {
import { Label, Pond, Prisma, User } from '@prisma/client'; import { Label, Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { InterimAccessService } from '../ponds/interim-access.service'; import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
@ -28,11 +28,14 @@ import { SearchProvider } from '../search/search.provider';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
/** /**
* Hierarchical labels within a pond (issue #43, data-model.md §labels). Access * Hierarchical labels within a pond (issue #43, data-model.md §labels).
* is gated through {@link InterimAccessService} on the owning pond: seeing a * Permissions are enforced by the route guard (#52): seeing a pond lets you
* pond lets you read its labels, modifying it lets you manage them. The tree * read its labels, Pond Admins manage the tree, label assignment is a page
* logic (cycle prevention, depth limit) lives in `@dorfteich/shared` so the M5 * write. The tree logic (cycle prevention, depth limit) lives in
* permission resolver reuses the exact same hierarchy walk (permissions.md). * `@dorfteich/shared` so the permission resolver uses the exact same
* hierarchy walk (permissions.md). Tree mutations invalidate the pond's
* permission context grants on a label cover its descendants, so a move or
* delete changes what those grants mean.
* *
* Writes that depend on the current tree (uniqueness, cycle, depth checks) run * Writes that depend on the current tree (uniqueness, cycle, depth checks) run
* inside a transaction holding a per-pond advisory lock, so check-then-write is * inside a transaction holding a per-pond advisory lock, so check-then-write is
@ -44,7 +47,7 @@ type Tx = Prisma.TransactionClient;
export class LabelsService { export class LabelsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly permissionCache: PondPermissionCache,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly search: SearchProvider, private readonly search: SearchProvider,
) { ) {
@ -84,29 +87,19 @@ export class LabelsService {
}); });
} }
private async requireVisiblePond(user: User, pondId: string): Promise<Pond> { private async requireLivePond(pondId: string): Promise<Pond> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond); if (!pond) throw new NotFoundException();
return pond; return pond;
} }
private async requireModifiablePond(user: User, pondId: string): Promise<Pond> { /** Loads a label in a live pond; permission is the guard's job (#52). */
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); private async requireLabel(labelId: string): Promise<Label & { pond: Pond }> {
this.access.assertCanModify(user, pond);
return pond;
}
/** Loads a label and asserts the user may manage its pond; 404 if either is missing. */
private async requireModifiableLabel(
user: User,
labelId: string,
): Promise<Label & { pond: Pond }> {
const label = await this.prisma.label.findUnique({ const label = await this.prisma.label.findUnique({
where: { id: labelId }, where: { id: labelId },
include: { pond: true }, include: { pond: true },
}); });
if (!label || label.pond.deletedAt) throw new NotFoundException(); if (!label || label.pond.deletedAt) throw new NotFoundException();
this.access.assertCanModify(user, label.pond);
return label; return label;
} }
@ -132,13 +125,13 @@ export class LabelsService {
/** The whole label hierarchy of a pond in one call (issue #43 acceptance /** The whole label hierarchy of a pond in one call (issue #43 acceptance
* criterion) the sidebar tree and every label picker read this. */ * criterion) the sidebar tree and every label picker read this. */
async list(user: User, pondId: string): Promise<LabelTreeNode[]> { async list(_user: User, pondId: string): Promise<LabelTreeNode[]> {
await this.requireVisiblePond(user, pondId); await this.requireLivePond(pondId);
return buildLabelTree(await this.allPondLabels(this.prisma, pondId)); return buildLabelTree(await this.allPondLabels(this.prisma, pondId));
} }
async create(user: User, pondId: string, input: CreateLabelInput): Promise<LabelView> { async create(user: User, pondId: string, input: CreateLabelInput): Promise<LabelView> {
await this.requireModifiablePond(user, pondId); await this.requireLivePond(pondId);
const name = input.name; const name = input.name;
const parentId = input.parentId ?? null; const parentId = input.parentId ?? null;
@ -163,13 +156,14 @@ export class LabelsService {
}); });
}); });
this.permissionCache.invalidate(pondId);
this.logger.info({ labelId: label.id, pondId, userId: user.id }, 'audit: label created'); this.logger.info({ labelId: label.id, pondId, userId: user.id }, 'audit: label created');
return this.viewOf(label); return this.viewOf(label);
} }
/** Rename and/or recolour a label (issue #43). */ /** Rename and/or recolour a label (issue #43). */
async update(user: User, labelId: string, input: UpdateLabelInput): Promise<LabelView> { async update(_user: User, labelId: string, input: UpdateLabelInput): Promise<LabelView> {
const existing = await this.requireModifiableLabel(user, labelId); const existing = await this.requireLabel(labelId);
const pondId = existing.pondId; const pondId = existing.pondId;
const label = await this.withPondLock(pondId, async (tx) => { const label = await this.withPondLock(pondId, async (tx) => {
@ -192,7 +186,7 @@ export class LabelsService {
/** Move a label (with its subtree) to a new parent or to the root (issue #43). /** Move a label (with its subtree) to a new parent or to the root (issue #43).
* Rejects cycles and any move that would push the subtree past the depth limit. */ * Rejects cycles and any move that would push the subtree past the depth limit. */
async move(user: User, labelId: string, input: MoveLabelInput): Promise<LabelView> { async move(user: User, labelId: string, input: MoveLabelInput): Promise<LabelView> {
const existing = await this.requireModifiableLabel(user, labelId); const existing = await this.requireLabel(labelId);
const pondId = existing.pondId; const pondId = existing.pondId;
const parentId = input.parentId; const parentId = input.parentId;
@ -219,6 +213,8 @@ export class LabelsService {
return tx.label.update({ where: { id: labelId }, data: { parentId } }); return tx.label.update({ where: { id: labelId }, data: { parentId } });
}); });
// Label grants cover descendants — a moved subtree changes their reach.
this.permissionCache.invalidate(pondId);
this.logger.info({ labelId, pondId, parentId, userId: user.id }, 'audit: label moved'); this.logger.info({ labelId, pondId, parentId, userId: user.id }, 'audit: label moved');
return this.viewOf(label); return this.viewOf(label);
} }
@ -231,7 +227,7 @@ export class LabelsService {
* assignments and descendants. * assignments and descendants.
*/ */
async remove(user: User, labelId: string, force: boolean): Promise<void> { async remove(user: User, labelId: string, force: boolean): Promise<void> {
const existing = await this.requireModifiableLabel(user, labelId); const existing = await this.requireLabel(labelId);
const pondId = existing.pondId; const pondId = existing.pondId;
const affectedPageIds = await this.withPondLock(pondId, async (tx) => { const affectedPageIds = await this.withPondLock(pondId, async (tx) => {
@ -253,33 +249,25 @@ export class LabelsService {
return assignments.map((a) => a.pageId); return assignments.map((a) => a.pageId);
}); });
this.permissionCache.invalidate(pondId);
// Those pages lost a label → their search entries change (#49). // Those pages lost a label → their search entries change (#49).
for (const pageId of affectedPageIds) await this.search.indexPage(pageId); for (const pageId of affectedPageIds) await this.search.indexPage(pageId);
this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted'); this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted');
} }
/** Loads a live page and asserts the user may edit it (for assignment ops). */ /** Loads a live page (assignment ops); permission is the guard's job (#52). */
private async requireModifiablePage( private async requireLivePage(pageId: string): Promise<{ id: string; pondId: string }> {
user: User,
pageId: string,
): Promise<{ id: string; pondId: string }> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { pond: true }, select: { id: true, pondId: true },
}); });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
return page; return page;
} }
/** A page's assigned labels (issue #43) — read requires seeing the pond. */ /** A page's assigned labels (issue #43). */
async pageLabels(user: User, pageId: string): Promise<LabelView[]> { async pageLabels(_user: User, pageId: string): Promise<LabelView[]> {
const page = await this.prisma.page.findFirst({ await this.requireLivePage(pageId);
where: { id: pageId, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
const rows = await this.prisma.pageLabel.findMany({ const rows = await this.prisma.pageLabel.findMany({
where: { pageId }, where: { pageId },
include: { label: true }, include: { label: true },
@ -289,7 +277,7 @@ export class LabelsService {
/** Assign a label to a page (idempotent). Rejects labels from another pond. */ /** Assign a label to a page (idempotent). Rejects labels from another pond. */
async assign(user: User, pageId: string, labelId: string): Promise<LabelView[]> { async assign(user: User, pageId: string, labelId: string): Promise<LabelView[]> {
const page = await this.requireModifiablePage(user, pageId); const page = await this.requireLivePage(pageId);
const label = await this.prisma.label.findUnique({ where: { id: labelId } }); const label = await this.prisma.label.findUnique({ where: { id: labelId } });
if (!label || label.pondId !== page.pondId) { if (!label || label.pondId !== page.pondId) {
throw new BadRequestException({ code: 'label_wrong_pond' }); throw new BadRequestException({ code: 'label_wrong_pond' });
@ -304,8 +292,8 @@ export class LabelsService {
} }
/** Remove a label from a page (idempotent). */ /** Remove a label from a page (idempotent). */
async unassign(user: User, pageId: string, labelId: string): Promise<void> { async unassign(_user: User, pageId: string, labelId: string): Promise<void> {
await this.requireModifiablePage(user, pageId); await this.requireLivePage(pageId);
await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } }); await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } });
await this.search.indexPage(pageId); // labels are a search field (#49) await this.search.indexPage(pageId); // labels are a search field (#49)
} }

View File

@ -2,6 +2,7 @@ import { Controller, Get, Param, Req } from '@nestjs/common';
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared'; import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { LinksService } from './links.service'; import { LinksService } from './links.service';
/** Wikilink index reads (issue #47): backlinks and a pond's phantom links. */ /** Wikilink index reads (issue #47): backlinks and a pond's phantom links. */
@ -11,12 +12,14 @@ export class LinksController {
/** Pages that link to this page (permission-filtered). */ /** Pages that link to this page (permission-filtered). */
@Get('pages/:id/backlinks') @Get('pages/:id/backlinks')
@RequiresPagePermission('read', { idParam: 'id' }) // sources are filtered per page
async backlinks(@Param('id') id: string, @Req() request: AuthedRequest): Promise<BacklinkView[]> { async backlinks(@Param('id') id: string, @Req() request: AuthedRequest): Promise<BacklinkView[]> {
return this.links.backlinks(request.user!, id); return this.links.backlinks(request.user!, id);
} }
/** Referenced-but-missing pages in the pond, with their referrers. */ /** Referenced-but-missing pages in the pond, with their referrers. */
@Get('ponds/:pondId/phantom-links') @Get('ponds/:pondId/phantom-links')
@RequiresPondRole('reader', { idParam: 'pondId' }) // referrers are filtered per page
async phantomLinks( async phantomLinks(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,

View File

@ -1,13 +1,13 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { INestApplication, NotFoundException } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client'; import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { PagesService } from '../pages/pages.service'; import { PagesService } from '../pages/pages.service';
import { createTestApp } from '../testing/test-app'; import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { LinksService } from './links.service'; import { LinksService } from './links.service';
describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => { describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
@ -70,6 +70,7 @@ describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
data: { slug: `lnk-pond-${suffix}`, name: 'Link Pond', type: 'PERSONAL', ownerId: owner.id }, data: { slug: `lnk-pond-${suffix}`, name: 'Link Pond', type: 'PERSONAL', ownerId: owner.id },
}); });
pondId = pond.id; pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
// Headroom for the pages the create test makes. // Headroom for the pages the create test makes.
await prisma.quotaOverride.create({ await prisma.quotaOverride.create({
data: { subjectType: 'USER', subjectId: owner.id, quotaKey: 'additional_ponds', value: 100 }, data: { subjectType: 'USER', subjectId: owner.id, quotaKey: 'additional_ponds', value: 100 },
@ -97,9 +98,13 @@ describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => {
]); ]);
}); });
it('hides backlinks from users who cannot see the pond', async () => { it('filters backlink sources to pages the requester may read (#52)', async () => {
// Route-level 404s for invisible ponds are the guard's job (#52, covered
// by the permission e2e pack); the service filters the link *sources*.
const target = await makePage(`hidden-${suffix}`); const target = await makePage(`hidden-${suffix}`);
await expect(links.backlinks(outsider, target)).rejects.toBeInstanceOf(NotFoundException); const source = await makePage(`hidden-src-${suffix}`, 'Hidden Source');
await link(source, `hidden-${suffix}`, target);
expect(await links.backlinks(outsider, target)).toEqual([]);
}); });
it('aggregates phantom links by target slug', async () => { it('aggregates phantom links by target slug', async () => {

View File

@ -2,15 +2,16 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared'; import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { InterimAccessService } from '../ponds/interim-access.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
* Reads over the wikilink index (`page_links`, issue #47). The index is written * Reads over the wikilink index (`page_links`, issue #47). The index is written
* by the collab server on every content change; here it is queried for a page's * by the collab server on every content change; here it is queried for a page's
* backlinks and a pond's phantom (missing-target) links. Wikilinks resolve only * backlinks and a pond's phantom (missing-target) links. Wikilinks resolve only
* within a pond, so every backlink lives in the same pond as its target * within a pond, so every link source lives in the same pond as its target;
* seeing the pond (InterimAccessService) is therefore the read permission. * sources are filtered to the pages the user may read (issue #52) so a link
* never leaks a title or snippet from a page outside the user's slice.
*/ */
/** Longest plain-text preview shown next to a backlink (issue #48). */ /** Longest plain-text preview shown next to a backlink (issue #48). */
const SNIPPET_LENGTH = 140; const SNIPPET_LENGTH = 140;
@ -27,7 +28,7 @@ type LinkSource = {
export class LinksService { export class LinksService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly permissions: PermissionService,
) {} ) {}
private static viewOf(source: LinkSource): BacklinkView { private static viewOf(source: LinkSource): BacklinkView {
@ -41,10 +42,9 @@ export class LinksService {
async backlinks(user: User, pageId: string): Promise<BacklinkView[]> { async backlinks(user: User, pageId: string): Promise<BacklinkView[]> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null }, where: { id: pageId, deletedAt: null },
include: { pond: true }, select: { id: true, pondId: true },
}); });
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
const links = await this.prisma.pageLink.findMany({ const links = await this.prisma.pageLink.findMany({
where: { toPageId: pageId, fromPage: { deletedAt: null } }, where: { toPageId: pageId, fromPage: { deletedAt: null } },
@ -60,10 +60,16 @@ export class LinksService {
}, },
orderBy: { fromPage: { title: 'asc' } }, orderBy: { fromPage: { title: 'asc' } },
}); });
const readable = await this.permissions.filterPages(
user,
page.pondId,
links.map((link) => ({ id: link.fromPage.id })),
'read',
);
const seen = new Set<string>(); const seen = new Set<string>();
const backlinks: BacklinkView[] = []; const backlinks: BacklinkView[] = [];
for (const link of links) { for (const link of links) {
if (seen.has(link.fromPage.id)) continue; if (seen.has(link.fromPage.id) || !readable.has(link.fromPage.id)) continue;
seen.add(link.fromPage.id); seen.add(link.fromPage.id);
backlinks.push(LinksService.viewOf(link.fromPage)); backlinks.push(LinksService.viewOf(link.fromPage));
} }
@ -73,7 +79,7 @@ export class LinksService {
/** Referenced-but-missing targets in a pond, with the pages referencing them. */ /** Referenced-but-missing targets in a pond, with the pages referencing them. */
async phantomLinks(user: User, pondId: string): Promise<PhantomLinkView[]> { async phantomLinks(user: User, pondId: string): Promise<PhantomLinkView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond); if (!pond) throw new NotFoundException();
const rows = await this.prisma.pageLink.findMany({ const rows = await this.prisma.pageLink.findMany({
where: { toPageId: null, fromPage: { pondId, deletedAt: null } }, where: { toPageId: null, fromPage: { pondId, deletedAt: null } },
@ -90,8 +96,15 @@ export class LinksService {
orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }], orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }],
}); });
const readable = await this.permissions.filterPages(
user,
pondId,
rows.map((row) => ({ id: row.fromPage.id })),
'read',
);
const grouped = new Map<string, PhantomLinkView>(); const grouped = new Map<string, PhantomLinkView>();
for (const row of rows) { for (const row of rows) {
if (!readable.has(row.fromPage.id)) continue;
let entry = grouped.get(row.targetSlug); let entry = grouped.get(row.targetSlug);
if (!entry) { if (!entry) {
entry = { targetSlug: row.targetSlug, referencedBy: [] }; entry = { targetSlug: row.targetSlug, referencedBy: [] };

View File

@ -28,14 +28,20 @@ import type { Response } from 'express';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import {
AuthenticatedOnly,
RequiresPagePermission,
RequiresPondRole,
} from '../permissions/permission.decorators';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
/** Page CRUD and Yjs state persistence (issue #23). */ /** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */
@Controller() @Controller()
export class PagesController { export class PagesController {
constructor(private readonly pages: PagesService) {} constructor(private readonly pages: PagesService) {}
@Post('ponds/:pondId/pages') @Post('ponds/:pondId/pages')
@RequiresPondRole('editor', { idParam: 'pondId' })
async create( async create(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput, @Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput,
@ -45,6 +51,7 @@ export class PagesController {
} }
@Get('ponds/:pondId/pages') @Get('ponds/:pondId/pages')
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page
async list( async list(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -53,12 +60,14 @@ export class PagesController {
} }
@Get('pages/:id') @Get('pages/:id')
@RequiresPagePermission('read', { idParam: 'id' })
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> { async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
return this.pages.getState(request.user!, id); return this.pages.getState(request.user!, id);
} }
/** Short-lived collaboration token for the collab server (issue #34). */ /** Short-lived collaboration token for the collab server (issue #34). */
@Get('pages/:id/collab-token') @Get('pages/:id/collab-token')
@RequiresPagePermission('read', { idParam: 'id' }) // readers get an `ro` token
async collabToken( async collabToken(
@Param('id') id: string, @Param('id') id: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -68,6 +77,7 @@ export class PagesController {
/** Markdown export (issue #30) — downloads `<slug>.md`. */ /** Markdown export (issue #30) — downloads `<slug>.md`. */
@Get('pages/:id/export/markdown') @Get('pages/:id/export/markdown')
@RequiresPagePermission('read', { idParam: 'id' })
async exportMarkdown( async exportMarkdown(
@Param('id') id: string, @Param('id') id: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -83,6 +93,7 @@ export class PagesController {
* navigates to (issue #25); the pond id must already be known to the caller * navigates to (issue #25); the pond id must already be known to the caller
* (e.g. from `GET /ponds/:slug`). */ * (e.g. from `GET /ponds/:slug`). */
@Get('ponds/:pondId/pages/:slug') @Get('ponds/:pondId/pages/:slug')
@RequiresPagePermission('read', { pondIdParam: 'pondId', slugParam: 'slug' })
async getStateBySlug( async getStateBySlug(
@Param('pondId') pondId: string, @Param('pondId') pondId: string,
@Param('slug') slug: string, @Param('slug') slug: string,
@ -98,6 +109,7 @@ export class PagesController {
* remain. Kept as an explicit 410 so any stale client gets a clear signal. * remain. Kept as an explicit 410 so any stale client gets a clear signal.
*/ */
@Put('pages/:id/state') @Put('pages/:id/state')
@AuthenticatedOnly() // always 410 — never touches the page
saveState(): never { saveState(): never {
throw new GoneException({ throw new GoneException({
code: 'rest_state_write_retired', code: 'rest_state_write_retired',
@ -107,6 +119,7 @@ export class PagesController {
/** Reposition a page in the manual sidebar order (issue #45). */ /** Reposition a page in the manual sidebar order (issue #45). */
@Patch('pages/:id/position') @Patch('pages/:id/position')
@RequiresPagePermission('write', { idParam: 'id' })
async reposition( async reposition(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput, @Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput,
@ -116,6 +129,7 @@ export class PagesController {
} }
@Patch('pages/:id') @Patch('pages/:id')
@RequiresPagePermission('write', { idParam: 'id' })
async update( async update(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput, @Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput,
@ -126,6 +140,7 @@ export class PagesController {
@Delete('pages/:id') @Delete('pages/:id')
@HttpCode(204) @HttpCode(204)
@RequiresPagePermission('write', { idParam: 'id' })
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> { async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.pages.softDelete(request.user!, id); await this.pages.softDelete(request.user!, id);
} }

View File

@ -17,7 +17,7 @@ import { generateKeyBetween } from 'fractional-indexing';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { InterimAccessService } from '../ponds/interim-access.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key'; import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
@ -43,7 +43,7 @@ const COLLAB_TOKEN_TTL_SECONDS = 60;
export class PagesService { export class PagesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly permissions: PermissionService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly search: SearchProvider, private readonly search: SearchProvider,
@ -86,23 +86,10 @@ export class PagesService {
} }
} }
private async findVisiblePage(user: User, id: string): Promise<Page> { /** Loads a live page; permission is the guard's job since #52. */
const page = await this.prisma.page.findFirst({ private async findLivePage(id: string): Promise<Page> {
where: { id, deletedAt: null }, const page = await this.prisma.page.findFirst({ where: { id, deletedAt: null } });
include: { pond: true },
});
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
return page;
}
private async findModifiablePage(user: User, id: string): Promise<Page> {
const page = await this.prisma.page.findFirst({
where: { id, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
return page; return page;
} }
@ -116,17 +103,27 @@ export class PagesService {
}; };
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26), /** Sidebar page list, ordered per the pond's persisted sort mode (issue #26),
* each with its assigned label ids for chips and filtering (issue #44). */ * each with its assigned label ids for chips and filtering (issue #44).
* Filtered to the pages the user may read (issue #52) a label- or
* page-scoped reader sees only their slice of the pond. */
async list(user: User, pondId: string): Promise<PageListItemView[]> { async list(user: User, pondId: string): Promise<PageListItemView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanSee(user, pond); if (!pond) throw new NotFoundException();
const settings = pondSettingsSchema.parse(pond.settings ?? {}); const settings = pondSettingsSchema.parse(pond.settings ?? {});
const pages = await this.prisma.page.findMany({ const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null }, where: { pondId, deletedAt: null },
orderBy: PagesService.SORT_ORDER[settings.sidebarSort], orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
include: { labels: { select: { labelId: true } } }, include: { labels: { select: { labelId: true } } },
}); });
return pages.map((page) => ({ const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
return pages
.filter((page) => readable.has(page.id))
.map((page) => ({
...this.viewOf(page), ...this.viewOf(page),
labelIds: page.labels.map((l) => l.labelId), labelIds: page.labels.map((l) => l.labelId),
})); }));
@ -134,7 +131,7 @@ export class PagesService {
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> { async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond); if (!pond) throw new NotFoundException();
const slug = await this.generateUniqueSlugInPond(pond.id, input.title); const slug = await this.generateUniqueSlugInPond(pond.id, input.title);
const last = await this.prisma.page.findFirst({ const last = await this.prisma.page.findFirst({
@ -179,28 +176,22 @@ export class PagesService {
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`; AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
} }
async getState(user: User, id: string): Promise<PageStateView> { async getState(_user: User, id: string): Promise<PageStateView> {
const page = await this.findVisiblePage(user, id); const page = await this.findLivePage(id);
return this.stateViewOf(page); return this.stateViewOf(page);
} }
/** /**
* Mint a collaboration token for a page (issue #34). The permission check * Mint a collaboration token for a page (issue #34). The permission check
* runs here, in the api the collab server never sees session cookies * runs in the api the guard requires read access, and the collab server
* (ADR 0003). `mode` is `rw` for anyone who may modify the pond and `ro` * never sees session cookies (ADR 0003). `mode` is `rw` for who may write
* otherwise; under the interim access model seeing and modifying coincide, * the page per the real grant resolution (issue #52) and `ro` otherwise.
* so callers who can see a page currently always get `rw` (real read-only
* grants arrive with #53, which this endpoint is already shaped for).
*/ */
async issueCollabToken(user: User, id: string): Promise<CollabTokenResponse> { async issueCollabToken(user: User, id: string): Promise<CollabTokenResponse> {
const page = await this.prisma.page.findFirst({ const page = await this.findLivePage(id);
where: { id, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
const mode = this.access.canModifyPond(user, page.pond) ? 'rw' : 'ro'; const canWrite = await this.permissions.canAccessPage(user, page, 'write');
const mode = canWrite ? 'rw' : 'ro';
const token = signCollabToken( const token = signCollabToken(
{ userId: user.id, pageId: page.id, mode }, { userId: user.id, pageId: page.id, mode },
this.config.env.COLLAB_TOKEN_SECRET, this.config.env.COLLAB_TOKEN_SECRET,
@ -211,26 +202,15 @@ export class PagesService {
return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS }; return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS };
} }
async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> { /** The trashed-page hint for editors (issue #31) moved into the guard. */
const page = await this.prisma.page.findFirst({ async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
where: { pondId, slug }, const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
include: { pond: true }, if (!page) throw new NotFoundException();
});
if (!page || !this.access.canSeePond(user, page.pond)) throw new NotFoundException();
if (page.deletedAt) {
// A plain 404 for anyone without edit rights — same as "does not
// exist" — but editors get a distinguishable code so the UI can
// offer a restore link instead of a dead end (issue #31).
if (this.access.canModifyPond(user, page.pond)) {
throw new NotFoundException({ code: 'page_trashed', details: { pageId: page.id } });
}
throw new NotFoundException();
}
return this.stateViewOf(page); return this.stateViewOf(page);
} }
async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> { async update(_user: User, id: string, input: UpdatePageInput): Promise<PageView> {
const page = await this.findModifiablePage(user, id); const page = await this.findLivePage(id);
let slug = page.slug; let slug = page.slug;
if (input.slug !== undefined) { if (input.slug !== undefined) {
@ -267,8 +247,8 @@ export class PagesService {
* same sequence. Requires write access; the sort mode does not have to be * same sequence. Requires write access; the sort mode does not have to be
* `manual` (the key is stored regardless, just not applied in other modes). * `manual` (the key is stored regardless, just not applied in other modes).
*/ */
async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> { async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
const page = await this.findModifiablePage(user, id); const page = await this.findLivePage(id);
const { afterId, beforeId } = input; const { afterId, beforeId } = input;
if (afterId === id || beforeId === id) { if (afterId === id || beforeId === id) {
throw new ConflictException({ code: 'bad_request' }); throw new ConflictException({ code: 'bad_request' });
@ -335,14 +315,14 @@ export class PagesService {
* `page_content_cache.markdown` (refreshed on every state save, #23) * `page_content_cache.markdown` (refreshed on every state save, #23)
* rather than re-decoding the Yjs state, so export always matches what * rather than re-decoding the Yjs state, so export always matches what
* the app itself considers the page's current Markdown representation. */ * the app itself considers the page's current Markdown representation. */
async exportMarkdown(user: User, id: string): Promise<{ slug: string; markdown: string }> { async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
const page = await this.findVisiblePage(user, id); const page = await this.findLivePage(id);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
return { slug: page.slug, markdown: cache?.markdown ?? '' }; return { slug: page.slug, markdown: cache?.markdown ?? '' };
} }
async softDelete(user: User, id: string): Promise<void> { async softDelete(user: User, id: string): Promise<void> {
const page = await this.findModifiablePage(user, id); const page = await this.findLivePage(id);
await this.prisma.page.update({ await this.prisma.page.update({
where: { id: page.id }, where: { id: page.id },
data: { deletedAt: new Date(), deletedBy: user.id }, data: { deletedAt: new Date(), deletedBy: user.id },

View File

@ -3,7 +3,7 @@ import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app'; import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { PagesService } from './pages.service'; import { PagesService } from './pages.service';
describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => { describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => {
@ -46,6 +46,7 @@ describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => {
}, },
}); });
pondId = pond.id; pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
// Manual mode so `list` orders by sort_key. // Manual mode so `list` orders by sort_key.
await prisma.pond.update({ await prisma.pond.update({
where: { id: pondId }, where: { id: pondId },

View File

@ -0,0 +1,82 @@
import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common';
import { PermissionAction } from '@dorfteich/shared';
import { PermissionGuard } from './permission.guard';
import { RequiredPondRole } from './permission.service';
/**
* Route permission declarations (issue #52). Every route must carry exactly
* one of these markers or `@Public()` / the Site-Admin guard so the
* route-enumeration test can prove no endpoint ships without an explicit
* access decision (default-closed, security.md). The markers with a
* requirement also attach the {@link PermissionGuard} that enforces it.
*
* 404/403 policy (docs/architecture/README.md §Conventions): a denied read
* is a 404 outsiders cannot probe what exists; a denied write on
* something the user may read is a 403.
*/
export const PERMISSION_KEY = 'permissionRequirement';
/** Where the guard finds the pond: a route param carrying its id or slug, or
* a label id whose pond is meant (`/labels/:id` routes). */
export interface PondParamSource {
idParam?: string;
slugParam?: string;
labelParam?: string;
}
/** Where the guard finds the page: its id param, or a pond-id + page-slug
* param pair (`/ponds/:pondId/pages/:slug`). */
export interface PageParamSource {
idParam?: string;
pondIdParam?: string;
slugParam?: string;
/** Trash routes: the page must BE trashed, and access = write capability
* on the page (ADR 0013); live pages 404 here. */
inTrash?: boolean;
}
export type PermissionRequirement =
| { kind: 'pond'; role: RequiredPondRole; source: PondParamSource }
| { kind: 'page'; action: PermissionAction; source: PageParamSource }
| { kind: 'attachment'; action: PermissionAction; source: { idParam: string } }
| { kind: 'authenticated' };
function requires(requirement: PermissionRequirement): MethodDecorator {
return applyDecorators(SetMetadata(PERMISSION_KEY, requirement), UseGuards(PermissionGuard));
}
/** The route needs `role` on the pond named by `source`. `reader` = may see
* the pond at all; `editor`/`pond_admin` are pond-wide roles. */
export function RequiresPondRole(role: RequiredPondRole, source: PondParamSource): MethodDecorator {
return requires({ kind: 'pond', role, source });
}
/** The route needs `action` on the page named by `source`. */
export function RequiresPagePermission(
action: PermissionAction,
source: PageParamSource,
): MethodDecorator {
return requires({ kind: 'page', action, source });
}
/** The route needs `action` on the attachment named by `source` permissions
* are inherited from its page, or from the pond for pond-level files
* (permissions.md §non-page objects). */
export function RequiresAttachmentPermission(
action: PermissionAction,
source: { idParam: string },
): MethodDecorator {
return requires({ kind: 'attachment', action, source });
}
/**
* The route needs a session but no pond/page permission either it only
* touches the caller's own data (profile, sessions, pond list) or all
* filtering happens inside the service (search). Marker only: the global
* AuthGuard already enforces authentication.
*/
export function AuthenticatedOnly(): MethodDecorator & ClassDecorator {
return SetMetadata(PERMISSION_KEY, { kind: 'authenticated' } satisfies PermissionRequirement);
}

View File

@ -0,0 +1,195 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionAction } from '@dorfteich/shared';
import { User } from '@prisma/client';
import { AuthedRequest } from '../auth/auth.guard';
import { PrismaService } from '../prisma/prisma.service';
import {
PERMISSION_KEY,
PageParamSource,
PermissionRequirement,
PondParamSource,
} from './permission.decorators';
import { PermissionService, RequiredPondRole } from './permission.service';
/** The columns the guard needs of a page under decision. */
interface GuardedPage {
id: string;
pondId: string;
deletedAt: Date | null;
pond: { deletedAt: Date | null };
}
/**
* Enforces the permission requirement a route declares through the
* decorators in {@link ./permission.decorators} (issue #52). It resolves the
* pond/page/attachment from the route params, asks {@link PermissionService}
* (which delegates to the shared resolver permissions.md), and applies the
* 404/403 policy: denied reads read as "not found" so existence is hidden;
* denied writes on things the user may read are 403.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requirement = this.reflector.getAllAndOverride<PermissionRequirement | undefined>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
// No requirement (or a marker-only one): nothing for this guard to do.
if (!requirement || requirement.kind === 'authenticated') return true;
const request = context.switchToHttp().getRequest<AuthedRequest>();
const user = request.user ?? null;
const params = request.params as Record<string, string | undefined>;
switch (requirement.kind) {
case 'pond':
return this.checkPond(user, requirement.role, requirement.source, params);
case 'page':
return this.checkPage(user, requirement.action, requirement.source, params);
case 'attachment':
return this.checkAttachment(user, requirement.action, requirement.source, params);
}
}
private async checkPond(
user: User | null,
role: RequiredPondRole,
source: PondParamSource,
params: Record<string, string | undefined>,
): Promise<boolean> {
const pond = await this.resolvePond(source, params);
if (!pond) throw new NotFoundException();
if (await this.permissions.hasPondRole(user, pond.id, role)) return true;
// Denied read → 404 (hide existence); denied write on a visible pond → 403.
if (role !== 'reader' && (await this.permissions.canSeePond(user, pond.id))) {
throw new ForbiddenException();
}
throw new NotFoundException();
}
/** The pond a pond-scoped route names — directly or via a label id. */
private async resolvePond(
source: PondParamSource,
params: Record<string, string | undefined>,
): Promise<{ id: string } | null> {
if (source.labelParam) {
const label = await this.prisma.label.findFirst({
where: { id: params[source.labelParam], pond: { deletedAt: null } },
select: { pondId: true },
});
return label ? { id: label.pondId } : null;
}
const where = source.idParam
? { id: params[source.idParam], deletedAt: null }
: { slug: params[source.slugParam!], deletedAt: null };
return this.prisma.pond.findFirst({ where, select: { id: true } });
}
private async checkPage(
user: User | null,
action: PermissionAction,
source: PageParamSource,
params: Record<string, string | undefined>,
): Promise<boolean> {
const where = source.idParam
? { id: params[source.idParam] }
: { pondId: params[source.pondIdParam!], slug: params[source.slugParam!] };
const page: GuardedPage | null = await this.prisma.page.findFirst({
where,
select: {
id: true,
pondId: true,
deletedAt: true,
pond: { select: { deletedAt: true } },
},
});
// A trashed pond hides its pages entirely (pond trash is Site-Admin land).
if (!page || page.pond.deletedAt) throw new NotFoundException();
if (source.inTrash) {
// Trash routes: only trashed pages, only for who could edit them
// (ADR 0013) — anyone else sees "not found", never "forbidden".
if (!page.deletedAt) throw new NotFoundException();
if (await this.permissions.canAccessTrashedPage(user, page)) return true;
throw new NotFoundException();
}
if (page.deletedAt) {
// Normal views never serve trashed pages; who could edit gets a
// distinguishable code so the UI can offer a restore link (issue #31).
if (await this.permissions.canAccessTrashedPage(user, page)) {
throw new NotFoundException({ code: 'page_trashed', details: { pageId: page.id } });
}
throw new NotFoundException();
}
if (!(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
}
if (action === 'write' && !(await this.permissions.canAccessPage(user, page, 'write'))) {
// Write attempt on a readable page → 403 (README §Conventions).
throw new ForbiddenException();
}
return true;
}
private async checkAttachment(
user: User | null,
action: PermissionAction,
source: { idParam: string },
params: Record<string, string | undefined>,
): Promise<boolean> {
const attachment = await this.prisma.attachment.findFirst({
where: { id: params[source.idParam] },
select: {
id: true,
pondId: true,
pageId: true,
pond: { select: { deletedAt: true } },
},
});
if (!attachment || attachment.pond.deletedAt) throw new NotFoundException();
// Attachments inherit their page's permissions (permissions.md §non-page
// objects). Deliberately without the trash rule: files of a soft-deleted
// page stay reachable until the purge removes them (issue #27).
if (attachment.pageId) {
const page = { id: attachment.pageId, pondId: attachment.pondId };
if (!(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
}
if (action === 'write' && !(await this.permissions.canAccessPage(user, page, 'write'))) {
throw new ForbiddenException();
}
return true;
}
// Pond-level files (uploads not yet tied to a page): read follows pond
// visibility, write needs a pond-wide editor.
if (!(await this.permissions.canSeePond(user, attachment.pondId))) {
throw new NotFoundException();
}
if (
action === 'write' &&
!(await this.permissions.hasPondRole(user, attachment.pondId, 'editor'))
) {
throw new ForbiddenException();
}
return true;
}
}

View File

@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import {
PermissionAction,
PermissionViewer,
PondRole,
canSeePond,
hasPondRole,
resolvePageCapability,
} from '@dorfteich/shared';
import { Prisma, User } from '@prisma/client';
import { toGrant } from '../grants/grant-mappers';
import { PrismaService } from '../prisma/prisma.service';
import { PondPermissionCache, PondPermissionContext } from './pond-permission-cache';
/** The role a permission decorator can require at pond level. `reader` means
* "may see the pond at all" any allow grant, at any scope (shared
* `canSeePond`); `editor`/`pond_admin` are pond-wide roles. */
export type RequiredPondRole = 'reader' | PondRole;
/** What page filtering needs to know about one page; `labelIds` may be
* preloaded by the caller (one grouped query otherwise). */
export interface FilterablePage {
id: string;
labelIds?: string[];
}
/**
* The API-side entry point for every permission question (issue #52). Pure
* resolution lives in `@dorfteich/shared` (permissions.md is normative); this
* service loads the inputs grants and label hierarchy per pond (cached, see
* {@link PondPermissionCache}) and the page's labels (fresh) and delegates.
* Nothing outside this module may answer an access question itself.
*/
@Injectable()
export class PermissionService {
constructor(
private readonly prisma: PrismaService,
private readonly cache: PondPermissionCache,
) {}
static viewerOf(user: User | null | undefined): PermissionViewer {
return { userId: user?.id ?? null, isSiteAdmin: user?.isSiteAdmin ?? false };
}
/** The pond's grants and label hierarchy, from cache or two indexed queries. */
async pondContext(pondId: string): Promise<PondPermissionContext> {
const cached = this.cache.get(pondId);
if (cached) return cached;
const [grantRows, labels] = await Promise.all([
this.prisma.roleGrant.findMany({ where: { pondId } }),
this.prisma.label.findMany({ where: { pondId }, select: { id: true, parentId: true } }),
]);
const context: PondPermissionContext = {
grants: grantRows.map(toGrant),
labelParents: Object.fromEntries(labels.map((l) => [l.id, l.parentId])),
};
this.cache.set(pondId, context);
return context;
}
/** May the user see that the pond exists (metadata, shell)? */
async canSeePond(user: User | null, pondId: string): Promise<boolean> {
const { grants } = await this.pondContext(pondId);
return canSeePond(PermissionService.viewerOf(user), grants);
}
/** Does the user hold `role` pond-wide? (`reader` = may see the pond.) */
async hasPondRole(user: User | null, pondId: string, role: RequiredPondRole): Promise<boolean> {
if (role === 'reader') return this.canSeePond(user, pondId);
const { grants } = await this.pondContext(pondId);
return hasPondRole(role, PermissionService.viewerOf(user), grants);
}
/**
* May the user perform `action` on one live page? (Trash views go through
* {@link canAccessTrashedPage}.) The page's own labels are loaded fresh.
*/
async canAccessPage(
user: User | null,
page: { id: string; pondId: string },
action: PermissionAction,
): Promise<boolean> {
const allowed = await this.filterPages(user, page.pondId, [{ id: page.id }], action);
return allowed.has(page.id);
}
/** May the user see/restore/purge the page in trash views (= write capability,
* ADR 0013)? The trashed flag itself is the caller's routing decision. */
async canAccessTrashedPage(
user: User | null,
page: { id: string; pondId: string },
): Promise<boolean> {
return this.canAccessPage(user, page, 'write');
}
/**
* Resolve `action` for many pages of one pond at once the page-list,
* search, backlink, and trash filters. Returns the ids the user may access.
* Pages without preloaded `labelIds` get them in one grouped query.
*/
async filterPages(
user: User | null,
pondId: string,
pages: FilterablePage[],
action: PermissionAction,
): Promise<Set<string>> {
if (pages.length === 0) return new Set();
const viewer = PermissionService.viewerOf(user);
if (viewer.isSiteAdmin) return new Set(pages.map((p) => p.id));
const { grants, labelParents } = await this.pondContext(pondId);
const missing = pages.filter((p) => p.labelIds === undefined).map((p) => p.id);
const labelsByPage = new Map<string, string[]>();
if (missing.length > 0) {
const rows = await this.prisma.pageLabel.findMany({
where: { pageId: { in: missing } },
select: { pageId: true, labelId: true },
});
for (const row of rows) {
const list = labelsByPage.get(row.pageId) ?? [];
list.push(row.labelId);
labelsByPage.set(row.pageId, list);
}
}
const allowed = new Set<string>();
for (const page of pages) {
const pageLabelIds = page.labelIds ?? labelsByPage.get(page.id) ?? [];
const ok = resolvePageCapability(action, {
viewer,
grants,
pageId: page.id,
pageLabelIds,
labelParents,
});
if (ok) allowed.add(page.id);
}
return allowed;
}
/**
* The ponds the user may see, or `null` for "all" (Site Admin). Visibility
* is `canSeePond`: any matching allow grant, which this single indexed
* query expresses exactly (deny grants never *create* visibility).
*/
async visiblePondIds(user: User): Promise<string[] | null> {
if (user.isSiteAdmin) return null;
const rows = await this.prisma.roleGrant.findMany({
where: {
effect: 'ALLOW',
OR: [
{ subjectType: 'USER', subjectId: user.id },
{ subjectType: 'AUTHENTICATED' },
{ subjectType: 'PUBLIC' },
],
},
select: { pondId: true },
distinct: ['pondId'],
});
return rows.map((row) => row.pondId);
}
/** Prisma `where` fragment restricting pond queries to visible rows. */
async visiblePondsWhere(user: User): Promise<Prisma.PondWhereInput> {
const ids = await this.visiblePondIds(user);
return ids === null ? {} : { id: { in: ids } };
}
}

View File

@ -0,0 +1,388 @@
import { INestApplication } from '@nestjs/common';
import { CreateGrantInput } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondsService } from '../ponds/ponds.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* The fixture matrix for the real permission model (issue #52 acceptance
* criteria): reader / editor / pond admin / foreign user against one shared
* pond, exercised through HTTP so the guard, the resolver, the 404/403
* policy, and the cache invalidation are all proven end to end.
*/
describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'berechtigungen sind kein zufall 1';
const userIds: Record<string, string> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pondSlug: string;
let pageId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const users = app.get(UsersService);
const username = `perm-${handle}-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: `Perm ${handle}`,
password,
locale: 'en',
});
userIds[handle] = user.id;
await users.markEmailVerified(user.id);
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
cookies[handle] = sessionCookieOf(res);
}
function grantInput(overrides: Partial<CreateGrantInput>): CreateGrantInput {
return {
subjectType: 'user',
subjectId: undefined,
role: 'reader',
scopeType: 'pond',
scopeId: undefined,
effect: 'allow',
...overrides,
};
}
async function createGrant(input: CreateGrantInput, as = 'owner'): Promise<string> {
const res = await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies[as]!)
.send(input)
.expect(201);
return (res.body as { id: string }).id;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
for (const handle of ['owner', 'reader', 'editor', 'admin2', 'foreign']) {
await makeUser(handle);
}
// markEmailVerified bypasses the verification flow, so the personal pond
// (for the personal-pond grant rules below) and the shared-pond quota
// headroom are created explicitly.
await app
.get(PondsService)
.ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }));
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: userIds.owner!,
quotaKey: 'additional_ponds',
value: 10,
},
});
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', cookies.owner!)
.send({ name: `Perm Pond ${suffix}` })
.expect(201);
pondId = (pond.body as { id: string }).id;
pondSlug = (pond.body as { slug: string }).slug;
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: 'Guarded Page' })
.expect(201);
pageId = (page.body as { id: string }).id;
await createGrant(grantInput({ subjectId: userIds.reader!, role: 'reader' }));
await createGrant(grantInput({ subjectId: userIds.editor!, role: 'editor' }));
await createGrant(grantInput({ subjectId: userIds.admin2!, role: 'pond_admin' }));
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.pageLabel.deleteMany({ where: { page: { pondId } } });
await prisma.label.deleteMany({ where: { pondId } });
await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } });
await prisma.pageUpdate.deleteMany({ where: { page: { pondId } } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
// Personal ponds (and their grants) before their users.
const ids = Object.values(userIds);
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } });
await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } });
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
await app.close();
});
it('owner (pond admin) sees and edits everything, including grants', async () => {
await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.owner!).expect(200);
await api()
.patch(`/api/v1/pages/${pageId}`)
.set('Cookie', cookies.owner!)
.send({ title: 'Renamed by owner' })
.expect(200);
const grants = await api()
.get(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.expect(200);
// Owner admin grant + the three fixture grants.
expect((grants.body as unknown[]).length).toBeGreaterThanOrEqual(4);
});
it('reader reads but cannot write (403 on readable things)', async () => {
await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.reader!).expect(200);
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200);
// Write on a readable page → 403 (README §Conventions).
await api()
.patch(`/api/v1/pages/${pageId}`)
.set('Cookie', cookies.reader!)
.send({ title: 'Nope' })
.expect(403);
// Page creation needs a pond-wide editor.
await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.reader!)
.send({ title: 'Nope' })
.expect(403);
// History is gated like editing (ADR 0013).
await api().get(`/api/v1/pages/${pageId}/versions`).set('Cookie', cookies.reader!).expect(403);
});
it('reader gets a read-only collab token, editor a rw one (issue #52)', async () => {
const ro = await api()
.get(`/api/v1/pages/${pageId}/collab-token`)
.set('Cookie', cookies.reader!)
.expect(200);
expect((ro.body as { mode: string }).mode).toBe('ro');
const rw = await api()
.get(`/api/v1/pages/${pageId}/collab-token`)
.set('Cookie', cookies.editor!)
.expect(200);
expect((rw.body as { mode: string }).mode).toBe('rw');
});
it('editor edits pages but cannot manage members or labels', async () => {
await api()
.patch(`/api/v1/pages/${pageId}`)
.set('Cookie', cookies.editor!)
.send({ title: 'Renamed by editor' })
.expect(200);
await api().get(`/api/v1/ponds/${pondId}/grants`).set('Cookie', cookies.editor!).expect(403);
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.editor!)
.send(grantInput({ subjectId: userIds.editor!, role: 'pond_admin' }))
.expect(403);
await api()
.post(`/api/v1/ponds/${pondId}/labels`)
.set('Cookie', cookies.editor!)
.send({ name: 'Nope' })
.expect(403);
// Pond settings are Pond Admin work too.
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.editor!)
.send({ name: 'Nope' })
.expect(403);
});
it('a second pond admin manages grants and labels', async () => {
const id = await createGrant(
grantInput({ subjectType: 'authenticated', role: 'reader' }),
'admin2',
);
await api()
.delete(`/api/v1/ponds/${pondId}/grants/${id}`)
.set('Cookie', cookies.admin2!)
.expect(204);
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.admin2!)
.send({ name: `Perm Pond ${suffix}` })
.expect(200);
});
it('foreign users consistently see 404 — existence stays hidden', async () => {
await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.foreign!).expect(404);
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.foreign!).expect(404);
await api()
.patch(`/api/v1/pages/${pageId}`)
.set('Cookie', cookies.foreign!)
.send({ title: 'Nope' })
.expect(404);
await api().get(`/api/v1/ponds/${pondId}/grants`).set('Cookie', cookies.foreign!).expect(404);
await api().get(`/api/v1/ponds/${pondId}/trash`).set('Cookie', cookies.foreign!).expect(404);
// Not in the pond list either.
const list = await api().get('/api/v1/ponds').set('Cookie', cookies.foreign!).expect(200);
expect((list.body as { id: string }[]).map((p) => p.id)).not.toContain(pondId);
});
it('an authenticated-subject grant opens the pond to signed-in users', async () => {
const id = await createGrant(grantInput({ subjectType: 'authenticated', role: 'reader' }));
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.foreign!).expect(200);
await api()
.delete(`/api/v1/ponds/${pondId}/grants/${id}`)
.set('Cookie', cookies.owner!)
.expect(204);
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.foreign!).expect(404);
});
it('a label-scope deny beats the pond-scope allow (most specific wins)', async () => {
const label = await api()
.post(`/api/v1/ponds/${pondId}/labels`)
.set('Cookie', cookies.owner!)
.send({ name: `confidential-${suffix}` })
.expect(201);
const labelId = (label.body as { id: string }).id;
await api()
.post(`/api/v1/pages/${pageId}/labels`)
.set('Cookie', cookies.owner!)
.send({ labelId })
.expect(201);
const denyId = await createGrant(
grantInput({
subjectId: userIds.reader!,
role: 'reader',
scopeType: 'label',
scopeId: labelId,
effect: 'deny',
}),
);
// The page reads as nonexistent for the denied reader; the pond stays visible.
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(404);
await api().get(`/api/v1/ponds/${pondSlug}`).set('Cookie', cookies.reader!).expect(200);
// …and it disappears from the sidebar list.
const pages = await api()
.get(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.reader!)
.expect(200);
expect((pages.body as { id: string }[]).map((p) => p.id)).not.toContain(pageId);
await api()
.delete(`/api/v1/ponds/${pondId}/grants/${denyId}`)
.set('Cookie', cookies.owner!)
.expect(204);
await api()
.delete(`/api/v1/pages/${pageId}/labels/${labelId}`)
.set('Cookie', cookies.owner!)
.expect(204);
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200);
});
it('revoking a grant denies the very next request (cache invalidation)', async () => {
// Fill the cache with a granted read…
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200);
// …revoke…
const grants = await api()
.get(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.expect(200);
const readerGrant = (grants.body as { id: string; subjectId: string | null }[]).find(
(g) => g.subjectId === userIds.reader,
);
expect(readerGrant).toBeDefined();
await api()
.delete(`/api/v1/ponds/${pondId}/grants/${readerGrant!.id}`)
.set('Cookie', cookies.owner!)
.expect(204);
// …and the immediately following request is already denied.
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(404);
// Restore for any later cases.
await createGrant(grantInput({ subjectId: userIds.reader!, role: 'reader' }));
await api().get(`/api/v1/pages/${pageId}`).set('Cookie', cookies.reader!).expect(200);
});
it('trash requires write capability, per page (ADR 0013)', async () => {
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.editor!)
.send({ title: 'Trash me' })
.expect(201);
const trashedId = (page.body as { id: string }).id;
await api().delete(`/api/v1/pages/${trashedId}`).set('Cookie', cookies.editor!).expect(204);
// Readers see an empty trash and cannot restore; editors can.
const readerTrash = await api()
.get(`/api/v1/ponds/${pondId}/trash`)
.set('Cookie', cookies.reader!)
.expect(200);
expect(readerTrash.body).toEqual([]);
await api()
.post(`/api/v1/pages/${trashedId}/restore`)
.set('Cookie', cookies.reader!)
.expect(404);
const editorTrash = await api()
.get(`/api/v1/ponds/${pondId}/trash`)
.set('Cookie', cookies.editor!)
.expect(200);
expect((editorTrash.body as { id: string }[]).map((p) => p.id)).toContain(trashedId);
await api()
.post(`/api/v1/pages/${trashedId}/restore`)
.set('Cookie', cookies.editor!)
.expect(201);
await api().delete(`/api/v1/pages/${trashedId}`).set('Cookie', cookies.editor!).expect(204);
await api()
.delete(`/api/v1/pages/${trashedId}/purge`)
.set('Cookie', cookies.editor!)
.expect(204);
});
it('grant validation: personal ponds refuse extra admins, last admin stays', async () => {
const personal = await prisma.pond.findFirstOrThrow({
where: { ownerId: userIds.owner!, type: 'PERSONAL' },
});
await api()
.post(`/api/v1/ponds/${personal.id}/grants`)
.set('Cookie', cookies.owner!)
.send(grantInput({ subjectId: userIds.editor!, role: 'pond_admin' }))
.expect(400);
// The personal pond's only admin grant (the owner's) cannot be deleted.
const grants = await api()
.get(`/api/v1/ponds/${personal.id}/grants`)
.set('Cookie', cookies.owner!)
.expect(200);
const adminGrant = (grants.body as { id: string; role: string }[]).find(
(g) => g.role === 'pond_admin',
);
expect(adminGrant).toBeDefined();
await api()
.delete(`/api/v1/ponds/${personal.id}/grants/${adminGrant!.id}`)
.set('Cookie', cookies.owner!)
.expect(409);
});
it('rejects grants pointing at foreign or missing scopes and subjects', async () => {
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.send(grantInput({ subjectId: userIds.reader!, scopeType: 'label', scopeId: 'missing' }))
.expect(400);
await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.send(grantInput({ subjectId: 'no-such-user' }))
.expect(400);
});
});

View File

@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { PermissionGuard } from './permission.guard';
import { PermissionService } from './permission.service';
import { PondPermissionCache } from './pond-permission-cache';
/**
* The permission layer (issue #52, permissions.md): the guard + decorators
* every route declares its access rule through, the service that answers
* permission questions via the shared resolver, and the pond-context cache.
* Global so `@UseGuards(PermissionGuard)` (applied by the decorators) can
* resolve its dependencies from any feature module without import churn.
*/
@Global()
@Module({
providers: [PermissionService, PondPermissionCache, PermissionGuard],
exports: [PermissionService, PondPermissionCache, PermissionGuard],
})
export class PermissionsModule {}

View File

@ -0,0 +1,36 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PondPermissionCache, PondPermissionContext } from './pond-permission-cache';
const CTX: PondPermissionContext = { grants: [], labelParents: {} };
describe('PondPermissionCache (issue #52)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('serves a stored context and drops it on invalidate', () => {
const cache = new PondPermissionCache();
cache.set('pond-1', CTX);
expect(cache.get('pond-1')).toBe(CTX);
cache.invalidate('pond-1');
expect(cache.get('pond-1')).toBeUndefined();
});
it('invalidating one pond leaves others untouched', () => {
const cache = new PondPermissionCache();
cache.set('pond-1', CTX);
cache.set('pond-2', CTX);
cache.invalidate('pond-1');
expect(cache.get('pond-2')).toBe(CTX);
});
it('expires entries after the TTL (multi-process staleness bound)', () => {
const cache = new PondPermissionCache();
cache.set('pond-1', CTX);
vi.advanceTimersByTime(PondPermissionCache.TTL_MS - 1);
expect(cache.get('pond-1')).toBe(CTX);
vi.advanceTimersByTime(2);
expect(cache.get('pond-1')).toBeUndefined();
});
});

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { Grant } from '@dorfteich/shared';
/** Everything pond-wide that permission resolution needs (permissions.md
* §Performance): the pond's grants and its label hierarchy. Page labels are
* *not* cached they change with everyday editing and are one indexed query. */
export interface PondPermissionContext {
grants: Grant[];
/** Every label in the pond → its parent id (or null), for ancestor walks. */
labelParents: Record<string, string | null>;
}
interface CacheEntry {
context: PondPermissionContext;
expiresAt: number;
}
/**
* In-process cache for {@link PondPermissionContext} (issue #52). Explicitly
* invalidated whenever grants or the label tree change (GrantsService,
* LabelsService), so a revoked permission takes effect with the very next
* request. The short TTL is a safety net only it bounds staleness if the
* API ever runs in multiple processes, where another instance's mutations
* cannot invalidate this map (collab sessions are separately revalidated via
* the `pond_access_changed` notification, issue #39).
*/
@Injectable()
export class PondPermissionCache {
private readonly entries = new Map<string, CacheEntry>();
/** Staleness bound for changes this process did not see (see class docs). */
static readonly TTL_MS = 30_000;
get(pondId: string): PondPermissionContext | undefined {
const entry = this.entries.get(pondId);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
this.entries.delete(pondId);
return undefined;
}
return entry.context;
}
set(pondId: string, context: PondPermissionContext): void {
this.entries.set(pondId, { context, expiresAt: Date.now() + PondPermissionCache.TTL_MS });
}
/** Drop a pond's context — call after any grant or label-tree mutation. */
invalidate(pondId: string): void {
this.entries.delete(pondId);
}
}

View File

@ -0,0 +1,87 @@
import 'reflect-metadata';
import { INestApplication } from '@nestjs/common';
// Nest's own metadata keys — the same ones its router reads.
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { ModulesContainer } from '@nestjs/core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { createTestApp } from '../testing/test-app';
import { hasTestDb } from '../testing/test-db';
import { PERMISSION_KEY } from './permission.decorators';
/**
* Every route must carry an explicit access declaration (issue #52 acceptance
* criterion, default-closed security.md): a permission decorator from
* `permission.decorators.ts`, `@Public()`, or the Site-Admin guard, on the
* handler or its controller. A route added without one fails here, so no
* endpoint can ship with access control forgotten rather than decided.
*/
const IS_PUBLIC_KEY = 'isPublic'; // auth.guard.ts
type Handler = (...args: unknown[]) => unknown;
function hasOwnOrClassMetadata(key: string, handler: Handler, controller: object): boolean {
return (
Reflect.getMetadata(key, handler) !== undefined ||
Reflect.getMetadata(key, controller) !== undefined
);
}
function hasSiteAdminGuard(handler: Handler, controller: object): boolean {
const guards: unknown[] = [
...((Reflect.getMetadata(GUARDS_METADATA, handler) as unknown[] | undefined) ?? []),
...((Reflect.getMetadata(GUARDS_METADATA, controller) as unknown[] | undefined) ?? []),
];
return guards.includes(SiteAdminGuard);
}
describe.skipIf(!hasTestDb)('route permission coverage (issue #52)', () => {
let app: INestApplication;
beforeAll(async () => {
app = await createTestApp();
});
afterAll(async () => {
await app.close();
});
it('every route declares its access rule explicitly', () => {
const modules = app.get(ModulesContainer);
const uncovered: string[] = [];
let routes = 0;
for (const module of modules.values()) {
for (const wrapper of module.controllers.values()) {
const controller = wrapper.metatype as (new () => unknown) | undefined;
if (!controller) continue;
const prototype = controller.prototype as Record<string, unknown>;
for (const name of Object.getOwnPropertyNames(prototype)) {
if (name === 'constructor') continue;
const handler = prototype[name];
if (typeof handler !== 'function') continue;
// Only actual routes: Nest stores the HTTP verb on the handler.
if (Reflect.getMetadata(METHOD_METADATA, handler) === undefined) continue;
routes += 1;
const covered =
hasOwnOrClassMetadata(PERMISSION_KEY, handler as Handler, controller) ||
hasOwnOrClassMetadata(IS_PUBLIC_KEY, handler as Handler, controller) ||
hasSiteAdminGuard(handler as Handler, controller);
if (!covered) {
const base = Reflect.getMetadata(PATH_METADATA, controller) as string;
const path = Reflect.getMetadata(PATH_METADATA, handler) as string;
uncovered.push(`${controller.name}.${name} (${base}/${path})`);
}
}
}
}
// Sanity: the enumeration actually saw the app's routes.
expect(routes).toBeGreaterThan(40);
expect(uncovered).toEqual([]);
});
});

View File

@ -1,36 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Pond, Prisma, User } from '@prisma/client';
/**
* INTERIM access control, M2M4 only: a pond is visible and editable for
* its owner and for Site Admins, nobody else. The real role model
* (docs/architecture/permissions.md) arrives with M5 and replaces this
* service. Until then, every access question about ponds and their
* content MUST be asked here and nowhere else that keeps the M5 swap
* confined to this one file (issue #21 acceptance criterion).
*/
@Injectable()
export class InterimAccessService {
canSeePond(user: User, pond: Pond): boolean {
return user.isSiteAdmin || pond.ownerId === user.id;
}
canModifyPond(user: User, pond: Pond): boolean {
// Interim rule: seeing and modifying coincide until M5 separates roles.
return this.canSeePond(user, pond);
}
/** Prisma `where` fragment restricting pond queries to visible rows. */
visiblePondsWhere(user: User): Prisma.PondWhereInput {
return user.isSiteAdmin ? {} : { ownerId: user.id };
}
/** 404 — not 403 — so outsiders cannot probe which slugs exist. */
assertCanSee(user: User, pond: Pond | null): asserts pond is Pond {
if (!pond || !this.canSeePond(user, pond)) throw new NotFoundException();
}
assertCanModify(user: User, pond: Pond | null): asserts pond is Pond {
if (!pond || !this.canModifyPond(user, pond)) throw new NotFoundException();
}
}

View File

@ -23,14 +23,16 @@ import { Prisma } from '@prisma/client';
import { SiteAdminGuard } from '../admin/site-admin.guard'; import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly, RequiresPondRole } from '../permissions/permission.decorators';
import { PondsService } from './ponds.service'; import { PondsService } from './ponds.service';
/** Pond CRUD (issue #21). Access rules live in InterimAccessService. */ /** Pond CRUD (issue #21; permission guard since #52). */
@Controller('ponds') @Controller('ponds')
export class PondsController { export class PondsController {
constructor(private readonly ponds: PondsService) {} constructor(private readonly ponds: PondsService) {}
@Post() @Post()
@AuthenticatedOnly() // any signed-in user may create ponds; quotas gate how many
async create( async create(
@Body(new ZodValidationPipe(createPondInputSchema)) input: CreatePondInput, @Body(new ZodValidationPipe(createPondInputSchema)) input: CreatePondInput,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
@ -39,6 +41,7 @@ export class PondsController {
} }
@Get() @Get()
@AuthenticatedOnly() // the service filters to the caller's visible ponds
async list(@Req() request: AuthedRequest): Promise<PondView[]> { async list(@Req() request: AuthedRequest): Promise<PondView[]> {
return this.ponds.listVisible(request.user!); return this.ponds.listVisible(request.user!);
} }
@ -51,11 +54,13 @@ export class PondsController {
} }
@Get(':slug') @Get(':slug')
@RequiresPondRole('reader', { slugParam: 'slug' })
async bySlug(@Param('slug') slug: string, @Req() request: AuthedRequest): Promise<PondView> { async bySlug(@Param('slug') slug: string, @Req() request: AuthedRequest): Promise<PondView> {
return this.ponds.getVisibleBySlug(request.user!, slug); return this.ponds.getVisibleBySlug(request.user!, slug);
} }
@Patch(':id') @Patch(':id')
@RequiresPondRole('pond_admin', { idParam: 'id' })
async update( async update(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(updatePondInputSchema)) input: UpdatePondInput, @Body(new ZodValidationPipe(updatePondInputSchema)) input: UpdatePondInput,
@ -66,6 +71,7 @@ export class PondsController {
@Delete(':id') @Delete(':id')
@HttpCode(204) @HttpCode(204)
@RequiresPondRole('pond_admin', { idParam: 'id' })
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> { async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.ponds.softDelete(request.user!, id); await this.ponds.softDelete(request.user!, id);
} }

View File

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { QuotasModule } from '../quotas/quotas.module'; import { QuotasModule } from '../quotas/quotas.module';
import { InterimAccessService } from './interim-access.service';
import { PondAccessNotifier } from './pond-access-notifier.service'; import { PondAccessNotifier } from './pond-access-notifier.service';
import { PondsController } from './ponds.controller'; import { PondsController } from './ponds.controller';
import { PondsService } from './ponds.service'; import { PondsService } from './ponds.service';
@ -10,7 +9,7 @@ import { PondsService } from './ponds.service';
@Module({ @Module({
imports: [QuotasModule], imports: [QuotasModule],
controllers: [PondsController], controllers: [PondsController],
providers: [PondsService, InterimAccessService, PondAccessNotifier], providers: [PondsService, PondAccessNotifier],
exports: [PondsService, InterimAccessService, PondAccessNotifier], exports: [PondsService, PondAccessNotifier],
}) })
export class PondsModule {} export class PondsModule {}

View File

@ -1,4 +1,4 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { import {
CreatePondInput, CreatePondInput,
PondView, PondView,
@ -6,19 +6,19 @@ import {
pondSettingsSchema, pondSettingsSchema,
slugify, slugify,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client'; import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
import { InterimAccessService } from './interim-access.service';
import { PondAccessNotifier } from './pond-access-notifier.service'; import { PondAccessNotifier } from './pond-access-notifier.service';
@Injectable() @Injectable()
export class PondsService { export class PondsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly permissions: PermissionService,
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
private readonly accessNotifier: PondAccessNotifier, private readonly accessNotifier: PondAccessNotifier,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
@ -63,13 +63,34 @@ export class PondsService {
} }
} }
/**
* The owner's Pond Admin grant, created with the pond itself (issue #52)
* access is decided solely by grants, so a pond without this row would be
* invisible even to its owner. Written directly (not through GrantsService)
* because the "no pond_admin grants on personal ponds" rule is about
* *additional* admins; the owner is the one admin every pond starts with.
*/
private static ownerAdminGrant(pondId: string, ownerId: string): Prisma.RoleGrantCreateInput {
return {
pond: { connect: { id: pondId } },
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
};
}
async createShared(owner: User, input: CreatePondInput): Promise<PondView> { async createShared(owner: User, input: CreatePondInput): Promise<PondView> {
const slug = await this.generateUniqueSlug(input.name, owner.username); const slug = await this.generateUniqueSlug(input.name, owner.username);
// Quota check and create share one transaction — the advisory lock in // Quota check and create share one transaction — the advisory lock in
// the check makes concurrent creations by the same user race-safe. // the check makes concurrent creations by the same user race-safe. The
// owner grant joins it so no pond ever exists without its admin.
const pond = await this.prisma.$transaction(async (tx) => { const pond = await this.prisma.$transaction(async (tx) => {
await this.quotas.assertCanCreateSharedPond(tx, owner.id); await this.quotas.assertCanCreateSharedPond(tx, owner.id);
return tx.pond.create({ const created = await tx.pond.create({
data: { data: {
slug, slug,
name: input.name, name: input.name,
@ -78,6 +99,8 @@ export class PondsService {
ownerId: owner.id, ownerId: owner.id,
}, },
}); });
await tx.roleGrant.create({ data: PondsService.ownerAdminGrant(created.id, owner.id) });
return created;
}); });
this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created'); this.logger.info({ pondId: pond.id, ownerId: owner.id }, 'audit: pond created');
return this.viewOf(pond); return this.viewOf(pond);
@ -94,34 +117,35 @@ export class PondsService {
select: { id: true }, select: { id: true },
}); });
if (existing) return; if (existing) return;
const pond = await this.prisma.pond.create({ const slug = await this.generateUniqueSlug(user.displayName, user.username);
data: { const pond = await this.prisma.$transaction(async (tx) => {
slug: await this.generateUniqueSlug(user.displayName, user.username), const created = await tx.pond.create({
name: user.displayName, data: { slug, name: user.displayName, type: 'PERSONAL', ownerId: user.id },
type: 'PERSONAL', });
ownerId: user.id, await tx.roleGrant.create({ data: PondsService.ownerAdminGrant(created.id, user.id) });
}, return created;
}); });
this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created'); this.logger.info({ pondId: pond.id, ownerId: user.id }, 'audit: personal pond created');
} }
async listVisible(user: User): Promise<PondView[]> { async listVisible(user: User): Promise<PondView[]> {
const ponds = await this.prisma.pond.findMany({ const ponds = await this.prisma.pond.findMany({
where: { ...this.access.visiblePondsWhere(user), deletedAt: null }, where: { ...(await this.permissions.visiblePondsWhere(user)), deletedAt: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
return ponds.map((pond) => this.viewOf(pond)); return ponds.map((pond) => this.viewOf(pond));
} }
async getVisibleBySlug(user: User, slug: string): Promise<PondView> { /** Existence/permission are the guard's job (#52); this only loads. */
async getVisibleBySlug(_user: User, slug: string): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { slug, deletedAt: null } });
this.access.assertCanSee(user, pond); if (!pond) throw new NotFoundException();
return this.viewOf(pond); return this.viewOf(pond);
} }
async update(user: User, id: string, input: UpdatePondInput): Promise<PondView> { async update(_user: User, id: string, input: UpdatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
this.access.assertCanModify(user, pond); if (!pond) throw new NotFoundException();
const settings = const settings =
input.sidebarSort === undefined input.sidebarSort === undefined
? undefined ? undefined
@ -135,7 +159,7 @@ export class PondsService {
async softDelete(user: User, id: string): Promise<void> { async softDelete(user: User, id: string): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
this.access.assertCanModify(user, pond); if (!pond) throw new NotFoundException();
if (pond.type === 'PERSONAL') { if (pond.type === 'PERSONAL') {
// The personal pond is the account's home — it cannot be trashed. // The personal pond is the account's home — it cannot be trashed.
throw new ForbiddenException({ code: 'personal_pond_undeletable' }); throw new ForbiddenException({ code: 'personal_pond_undeletable' });
@ -145,9 +169,8 @@ export class PondsService {
data: { deletedAt: new Date(), deletedBy: user.id }, data: { deletedAt: new Date(), deletedBy: user.id },
}); });
this.logger.info({ pondId: id, userId: user.id }, 'audit: pond trashed'); this.logger.info({ pondId: id, userId: user.id }, 'audit: pond trashed');
// Trashing a pond is the interim access-relevant change: revalidate any // Revalidate any live collaboration sessions on the pond's pages
// live collaboration sessions on its pages (issue #39). Real per-user // (issue #39); grant changes fire the same notification (#52/#53).
// grant revocation reuses this same notification from M5 on (#53).
await this.accessNotifier.notifyAccessChanged(id); await this.accessNotifier.notifyAccessChanged(id);
} }

View File

@ -9,6 +9,7 @@ import {
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Prisma, User } from '@prisma/client'; import { Prisma, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from './search.provider'; import { SearchProvider } from './search.provider';
@ -45,7 +46,10 @@ interface SearchRow {
*/ */
@Injectable() @Injectable()
export class PostgresSearchProvider extends SearchProvider { export class PostgresSearchProvider extends SearchProvider {
constructor(private readonly prisma: PrismaService) { constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {
super(); super();
} }
@ -99,6 +103,15 @@ export class PostgresSearchProvider extends SearchProvider {
// A phrase that folds to nothing (e.g. only punctuation) matches nothing. // A phrase that folds to nothing (e.g. only punctuation) matches nothing.
if (normalized.trim() === '') return []; if (normalized.trim() === '') return [];
// Pond-level prefilter (visible ponds only) keeps the LIMIT meaningful;
// the exact per-page resolution happens below (issue #52, ADR 0010).
const visiblePondIds = await this.permissions.visiblePondIds(user);
if (visiblePondIds !== null && visiblePondIds.length === 0) return [];
const visibility =
visiblePondIds === null
? Prisma.empty
: Prisma.sql`AND p.pond_id = ANY(${visiblePondIds}::text[])`;
const scope = query.pondId ? Prisma.sql`AND p.pond_id = ${query.pondId}` : Prisma.empty; const scope = query.pondId ? Prisma.sql`AND p.pond_id = ${query.pondId}` : Prisma.empty;
const labelFilter = const labelFilter =
query.labels && query.labels.length > 0 query.labels && query.labels.length > 0
@ -120,13 +133,35 @@ export class PostgresSearchProvider extends SearchProvider {
JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL, JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL,
websearch_to_tsquery('simple', ${normalized}) q websearch_to_tsquery('simple', ${normalized}) q
WHERE c.search_vector @@ q WHERE c.search_vector @@ q
AND (${user.isSiteAdmin}::boolean OR po.owner_id = ${user.id}) ${visibility}
${scope} ${scope}
${labelFilter} ${labelFilter}
ORDER BY ts_rank(c.search_vector, q) DESC, p.updated_at DESC ORDER BY ts_rank(c.search_vector, q) DESC, p.updated_at DESC
LIMIT ${SEARCH_RESULT_LIMIT}`); LIMIT ${SEARCH_RESULT_LIMIT}`);
return rows.map((row) => ({ // Per-page resolution (label-/page-scope grants, deny-wins) per pond.
const rowsByPond = new Map<string, SearchRow[]>();
for (const row of rows) {
const group = rowsByPond.get(row.pondId) ?? [];
group.push(row);
rowsByPond.set(row.pondId, group);
}
const readableByPond = new Map<string, Set<string>>();
for (const [pondId, group] of rowsByPond) {
readableByPond.set(
pondId,
await this.permissions.filterPages(
user,
pondId,
group.map((row) => ({ id: row.pageId, labelIds: row.labelIds })),
'read',
),
);
}
return rows
.filter((row) => readableByPond.get(row.pondId)?.has(row.pageId))
.map((row) => ({
pageId: row.pageId, pageId: row.pageId,
title: row.title, title: row.title,
slug: row.slug, slug: row.slug,

View File

@ -1,3 +1,5 @@
import { PermissionService } from '../permissions/permission.service';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PostgresSearchProvider } from './postgres-search.provider'; import { PostgresSearchProvider } from './postgres-search.provider';
@ -12,7 +14,10 @@ import { PostgresSearchProvider } from './postgres-search.provider';
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
const prisma = new PrismaService(); const prisma = new PrismaService();
const provider = new PostgresSearchProvider(prisma); // Reindexing never asks permission questions, but the provider's search
// path needs the service — wire it manually like everything here.
const permissions = new PermissionService(prisma, new PondPermissionCache());
const provider = new PostgresSearchProvider(prisma, permissions);
try { try {
const count = await provider.reindexAll(); const count = await provider.reindexAll();
console.log(`search:reindex — indexed ${count} page(s)`); console.log(`search:reindex — indexed ${count} page(s)`);

View File

@ -2,6 +2,7 @@ import { BadRequestException, Controller, Get, Query, Req } from '@nestjs/common
import { SearchQuery, SearchResultView, searchQuerySchema } from '@dorfteich/shared'; import { SearchQuery, SearchResultView, searchQuerySchema } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { SearchProvider } from './search.provider'; import { SearchProvider } from './search.provider';
/** Full-text search (issue #49, ADR 0010). */ /** Full-text search (issue #49, ADR 0010). */
@ -14,6 +15,7 @@ export class SearchController {
* `labels` is a comma-separated list; scope defaults to all readable ponds. * `labels` is a comma-separated list; scope defaults to all readable ponds.
*/ */
@Get('search') @Get('search')
@AuthenticatedOnly() // results are permission-filtered inside the provider
async query( async query(
@Query('q') q: string, @Query('q') q: string,
@Query('pondId') pondId: string | undefined, @Query('pondId') pondId: string | undefined,

View File

@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { createTestApp } from '../testing/test-app'; import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { SearchProvider } from './search.provider'; import { SearchProvider } from './search.provider';
describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => { describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
@ -69,6 +69,7 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
}, },
}); });
pondId = pond.id; pondId = pond.id;
await grantOwnerAdmin(prisma, pondId, owner.id);
}); });
afterAll(async () => { afterAll(async () => {
@ -116,6 +117,7 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
ownerId: owner.id, ownerId: owner.id,
}, },
}); });
await grantOwnerAdmin(prisma, other.id, owner.id);
const otherPage = await prisma.page.create({ const otherPage = await prisma.page.create({
data: { data: {
id: randomUUID(), id: randomUUID(),

View File

@ -15,3 +15,28 @@ export function createTestPrisma(): PrismaClient {
export function uniqueSuffix(): string { export function uniqueSuffix(): string {
return Math.random().toString(36).slice(2, 10); return Math.random().toString(36).slice(2, 10);
} }
/**
* The owner's Pond Admin grant for a pond created directly through Prisma.
* Production paths create it with the pond (PondsService, issue #52);
* fixtures that bypass the service need it too, or the owner cannot see
* their own pond under the grant-based resolution.
*/
export async function grantOwnerAdmin(
prisma: PrismaClient,
pondId: string,
ownerId: string,
): Promise<void> {
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: ownerId,
role: 'POND_ADMIN',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
}

View File

@ -2,6 +2,7 @@ import { Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/com
import { PageView } from '@dorfteich/shared'; import { PageView } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { TrashService } from './trash.service'; import { TrashService } from './trash.service';
@ -11,17 +12,20 @@ export class TrashController {
constructor(private readonly trash: TrashService) {} constructor(private readonly trash: TrashService) {}
@Get('ponds/:pondId/trash') @Get('ponds/:pondId/trash')
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters to editable pages
async list(@Param('pondId') pondId: string, @Req() request: AuthedRequest): Promise<PageView[]> { async list(@Param('pondId') pondId: string, @Req() request: AuthedRequest): Promise<PageView[]> {
return this.trash.list(request.user!, pondId); return this.trash.list(request.user!, pondId);
} }
@Post('pages/:id/restore') @Post('pages/:id/restore')
@RequiresPagePermission('write', { idParam: 'id', inTrash: true })
async restore(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageView> { async restore(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageView> {
return this.trash.restore(request.user!, id); return this.trash.restore(request.user!, id);
} }
@Delete('pages/:id/purge') @Delete('pages/:id/purge')
@HttpCode(204) @HttpCode(204)
@RequiresPagePermission('write', { idParam: 'id', inTrash: true })
async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> { async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.trash.purgeNow(request.user!, id); await this.trash.purgeNow(request.user!, id);
} }

View File

@ -5,7 +5,7 @@ import { PinoLogger } from 'nestjs-pino';
import { ClockService } from '../common/clock.service'; import { ClockService } from '../common/clock.service';
import { PagesService } from '../pages/pages.service'; import { PagesService } from '../pages/pages.service';
import { InterimAccessService } from '../ponds/interim-access.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QuotaService } from '../quotas/quota.service'; import { QuotaService } from '../quotas/quota.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
@ -24,7 +24,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
export class TrashService { export class TrashService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService, private readonly permissions: PermissionService,
private readonly pages: PagesService, private readonly pages: PagesService,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly quotas: QuotaService, private readonly quotas: QuotaService,
@ -35,21 +35,27 @@ export class TrashService {
this.logger.setContext(TrashService.name); this.logger.setContext(TrashService.name);
} }
/** A pond's trash — same access rule as restoring/purging from it. */ /** A pond's trash: the trashed pages the user could edit trash access is
* write capability (ADR 0013), resolved per page (issue #52). */
async list(user: User, pondId: string): Promise<PageView[]> { async list(user: User, pondId: string): Promise<PageView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond); if (!pond) throw new NotFoundException();
const pages = await this.prisma.page.findMany({ const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: { not: null } }, where: { pondId, deletedAt: { not: null } },
orderBy: { deletedAt: 'desc' }, orderBy: { deletedAt: 'desc' },
}); });
return pages.map((page) => this.pages.viewOf(page)); const editable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id })),
'write',
);
return pages.filter((page) => editable.has(page.id)).map((page) => this.pages.viewOf(page));
} }
async restore(user: User, id: string): Promise<PageView> { async restore(user: User, id: string): Promise<PageView> {
const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } }); const page = await this.prisma.page.findFirst({ where: { id } });
if (!page || !page.deletedAt) throw new NotFoundException(); if (!page || !page.deletedAt) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
const restored = await this.prisma.page.update({ const restored = await this.prisma.page.update({
where: { id }, where: { id },
data: { deletedAt: null, deletedBy: null }, data: { deletedAt: null, deletedBy: null },
@ -60,9 +66,8 @@ export class TrashService {
/** Manual "purge single" (scope's third trash endpoint) — bypasses retention. */ /** Manual "purge single" (scope's third trash endpoint) — bypasses retention. */
async purgeNow(user: User, id: string): Promise<void> { async purgeNow(user: User, id: string): Promise<void> {
const page = await this.prisma.page.findFirst({ where: { id }, include: { pond: true } }); const page = await this.prisma.page.findFirst({ where: { id } });
if (!page || !page.deletedAt) throw new NotFoundException(); if (!page || !page.deletedAt) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
await this.purgePage(id); await this.purgePage(id);
this.logger.info({ pageId: id, userId: user.id }, 'audit: page purged from trash'); this.logger.info({ pageId: id, userId: user.id }, 'audit: page purged from trash');
} }

View File

@ -20,6 +20,7 @@ import {
import { AuthedRequest, toCurrentUser } from '../auth/auth.guard'; import { AuthedRequest, toCurrentUser } from '../auth/auth.guard';
import { SessionsService } from '../auth/sessions.service'; import { SessionsService } from '../auth/sessions.service';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
export interface SessionView { export interface SessionView {
@ -30,7 +31,9 @@ export interface SessionView {
current: boolean; current: boolean;
} }
/** Self-service endpoints for the signed-in account (issues #17, #18). */ /** Self-service endpoints for the signed-in account (issues #17, #18)
* every route only touches the caller's own data. */
@AuthenticatedOnly()
@Controller('users/me') @Controller('users/me')
export class UsersController { export class UsersController {
constructor( constructor(

View File

@ -8,6 +8,7 @@ import {
import { AuthedRequest } from '../auth/auth.guard'; import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequiresPagePermission } from '../permissions/permission.decorators';
import { VersionsService } from './versions.service'; import { VersionsService } from './versions.service';
/** /**
@ -20,12 +21,14 @@ export class VersionsController {
/** List the page's versions, newest first. */ /** List the page's versions, newest first. */
@Get('pages/:id/versions') @Get('pages/:id/versions')
@RequiresPagePermission('write', { idParam: 'id' }) // history = write (ADR 0013)
async list(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageVersionView[]> { async list(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageVersionView[]> {
return this.versions.list(request.user!, id); return this.versions.list(request.user!, id);
} }
/** A single version rendered read-only, with Markdown for diffing. */ /** A single version rendered read-only, with Markdown for diffing. */
@Get('pages/:id/versions/:versionId') @Get('pages/:id/versions/:versionId')
@RequiresPagePermission('write', { idParam: 'id' })
async getContent( async getContent(
@Param('id') id: string, @Param('id') id: string,
@Param('versionId') versionId: string, @Param('versionId') versionId: string,
@ -36,6 +39,7 @@ export class VersionsController {
/** Create a named version of the page. */ /** Create a named version of the page. */
@Post('pages/:id/versions') @Post('pages/:id/versions')
@RequiresPagePermission('write', { idParam: 'id' })
async createNamed( async createNamed(
@Param('id') id: string, @Param('id') id: string,
@Body(new ZodValidationPipe(createVersionInputSchema)) input: CreateVersionInput, @Body(new ZodValidationPipe(createVersionInputSchema)) input: CreateVersionInput,
@ -46,6 +50,7 @@ export class VersionsController {
/** Restore the page to an earlier version (creates a pre-restore snapshot). */ /** Restore the page to an earlier version (creates a pre-restore snapshot). */
@Post('pages/:id/versions/:versionId/restore') @Post('pages/:id/versions/:versionId/restore')
@RequiresPagePermission('write', { idParam: 'id' })
async restore( async restore(
@Param('id') id: string, @Param('id') id: string,
@Param('versionId') versionId: string, @Param('versionId') versionId: string,

View File

@ -15,7 +15,6 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
let versions: VersionsService; let versions: VersionsService;
const suffix = uniqueSuffix(); const suffix = uniqueSuffix();
let owner: User; let owner: User;
let outsider: User;
let pondId: string; let pondId: string;
const pageIds: string[] = []; const pageIds: string[] = [];
@ -48,13 +47,6 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
displayName: 'Version Owner', displayName: 'Version Owner',
}, },
}); });
outsider = await prisma.user.create({
data: {
username: `ver-out-${suffix}`,
email: `ver-out-${suffix}@example.test`,
displayName: 'Version Outsider',
},
});
const pond = await prisma.pond.create({ const pond = await prisma.pond.create({
data: { data: {
slug: `ver-pond-${suffix}`, slug: `ver-pond-${suffix}`,
@ -69,7 +61,7 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
afterAll(async () => { afterAll(async () => {
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } }); if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
await prisma.pond.deleteMany({ where: { id: pondId } }); await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } }); await prisma.user.deleteMany({ where: { id: owner.id } });
await prisma.$disconnect(); await prisma.$disconnect();
await app.close(); await app.close();
}); });
@ -121,18 +113,8 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
expect(typeof content.markdown).toBe('string'); expect(typeof content.markdown).toBe('string');
}); });
it('gates list, content, and restore on write access', async () => { // Write-access gating of every history route moved to the route guard
const pageId = await createPage(); // with #52 — covered by the permission e2e pack.
const version = await versions.createNamed(owner, pageId, { label: 'v' });
await expect(versions.list(outsider, pageId)).rejects.toBeInstanceOf(NotFoundException);
await expect(versions.getContent(outsider, pageId, version.id)).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(versions.restore(outsider, pageId, version.id)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('restore returns the target version and emits without mutating history', async () => { it('restore returns the target version and emits without mutating history', async () => {
const pageId = await createPage(); const pageId = await createPage();
@ -150,14 +132,6 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
); );
}); });
it('refuses a named version for a user without write access', async () => {
const pageId = await createPage();
await expect(versions.createNamed(outsider, pageId, { label: 'nope' })).rejects.toBeInstanceOf(
NotFoundException,
);
expect(await prisma.pageVersion.count({ where: { pageId } })).toBe(0);
});
it('thins auto versions beyond the window to the newest per day, keeping the rest', async () => { it('thins auto versions beyond the window to the newest per day, keeping the rest', async () => {
const pageId = await createPage(); const pageId = await createPage();
const daysAgo = (days: number, hour: number): Date => { const daysAgo = (days: number, hour: number): Date => {

View File

@ -7,12 +7,11 @@ import {
PageVersionTrigger, PageVersionTrigger,
PageVersionView, PageVersionView,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Page, PageVersion, PageVersionTrigger as PrismaTrigger, Pond, User } from '@prisma/client'; import { Page, PageVersion, PageVersionTrigger as PrismaTrigger, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { deriveContent } from '../pages/yjs-content'; import { deriveContent } from '../pages/yjs-content';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@ -39,7 +38,6 @@ const TRIGGER_TO_VIEW: Record<PrismaTrigger, PageVersionTrigger> = {
export class VersionsService { export class VersionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(VersionsService.name); this.logger.setContext(VersionsService.name);
@ -58,23 +56,19 @@ export class VersionsService {
} }
/** /**
* Load a page and assert the user may edit it. Viewing history requires the * Load a live page. Viewing history requires the same permission as
* same permission as editing (permissions.md §UI obligations / ADR 0013), so * editing (ADR 0013) the guard enforces write access on every history
* every history operation gates on `assertCanModify`. * route (#52); here only existence is checked.
*/ */
private async findModifiablePage(user: User, pageId: string): Promise<Page & { pond: Pond }> { private async findLivePage(pageId: string): Promise<Page> {
const page = await this.prisma.page.findFirst({ const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null } });
where: { id: pageId, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
return page; return page;
} }
/** The page's versions, newest first (no snapshot bytes). Write access only. */ /** The page's versions, newest first (no snapshot bytes). Write access only. */
async list(user: User, pageId: string): Promise<PageVersionView[]> { async list(_user: User, pageId: string): Promise<PageVersionView[]> {
await this.findModifiablePage(user, pageId); await this.findLivePage(pageId);
const versions = await this.prisma.pageVersion.findMany({ const versions = await this.prisma.pageVersion.findMany({
where: { pageId }, where: { pageId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@ -85,8 +79,12 @@ export class VersionsService {
} }
/** A single version rendered read-only (HTML) with its Markdown for diffing. */ /** A single version rendered read-only (HTML) with its Markdown for diffing. */
async getContent(user: User, pageId: string, versionId: string): Promise<PageVersionContentView> { async getContent(
await this.findModifiablePage(user, pageId); _user: User,
pageId: string,
versionId: string,
): Promise<PageVersionContentView> {
await this.findLivePage(pageId);
const version = await this.prisma.pageVersion.findFirst({ const version = await this.prisma.pageVersion.findFirst({
where: { id: versionId, pageId }, where: { id: versionId, pageId },
}); });
@ -104,7 +102,7 @@ export class VersionsService {
* Returns the version being restored. * Returns the version being restored.
*/ */
async restore(user: User, pageId: string, versionId: string): Promise<PageVersionView> { async restore(user: User, pageId: string, versionId: string): Promise<PageVersionView> {
await this.findModifiablePage(user, pageId); await this.findLivePage(pageId);
const version = await this.prisma.pageVersion.findFirst({ const version = await this.prisma.pageVersion.findFirst({
where: { id: versionId, pageId }, where: { id: versionId, pageId },
omit: { ydocSnapshot: true }, omit: { ydocSnapshot: true },
@ -132,12 +130,7 @@ export class VersionsService {
pageId: string, pageId: string,
input: CreateVersionInput, input: CreateVersionInput,
): Promise<PageVersionView> { ): Promise<PageVersionView> {
const page = await this.prisma.page.findFirst({ await this.findLivePage(pageId);
where: { id: pageId, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
const snapshot = await this.reconstructSnapshot(pageId); const snapshot = await this.reconstructSnapshot(pageId);

View File

@ -103,3 +103,10 @@ flowchart LR
(see ADR 0012), with German and English translations added in the same (see ADR 0012), with German and English translations added in the same
change. change.
- Every story lists the ADRs it depends on; read them before starting. - Every story lists the ADRs it depends on; read them before starting.
- Every API route declares its access rule explicitly (a permission
decorator, `@Public()`, or the Site-Admin guard — issue #52); permission
checks run only through the shared resolution (permissions.md), never ad
hoc. **404/403 policy:** a denied _read_ answers `404` so the existence
of ponds and pages is not revealed; a denied _write_ on something the
user may read answers `403`. Trash views require write capability and
answer `404` on denial (ADR 0013).

View File

@ -36,6 +36,9 @@
"grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.", "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_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",
"grant_scope_id_mismatch": "Der Geltungsbereich der Berechtigung ist widersprüchlich.", "grant_scope_id_mismatch": "Der Geltungsbereich der Berechtigung ist widersprüchlich.",
"grant_scope_not_found": "Das Label oder die Seite, auf die sich diese Berechtigung bezieht, existiert in diesem Teich nicht.",
"grant_subject_not_found": "Diese Person existiert nicht.",
"grant_last_admin": "Die letzte Teich-Admin-Berechtigung kann nicht entfernt werden.",
"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

@ -36,6 +36,9 @@
"grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.", "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_subject_id_mismatch": "The grant's subject is inconsistent.",
"grant_scope_id_mismatch": "The grant's scope is inconsistent.", "grant_scope_id_mismatch": "The grant's scope is inconsistent.",
"grant_scope_not_found": "The label or page this grant points to does not exist in this pond.",
"grant_subject_not_found": "This user does not exist.",
"grant_last_admin": "The last Pond Admin cannot be removed.",
"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

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

View File

@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { canSeePond, hasPondRole } from './pond';
import type { Grant, GrantEffect, GrantRole, GrantScopeType, PermissionViewer } from './types';
const UMA = 'uma';
const VIEWER: PermissionViewer = { userId: UMA, isSiteAdmin: false };
const ADMIN: PermissionViewer = { userId: 'root', isSiteAdmin: true };
const ANONYMOUS: PermissionViewer = { userId: null, isSiteAdmin: false };
/** Concise grant builder (mirrors resolve.test.ts). */
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,
};
}
describe('hasPondRole (issue #52)', () => {
it('is default-closed: no grants → no role', () => {
expect(hasPondRole('editor', VIEWER, [])).toBe(false);
expect(hasPondRole('pond_admin', VIEWER, [])).toBe(false);
});
it('a pond-scope editor grant answers the editor question', () => {
const grants = [grant('editor', 'pond', null, 'allow')];
expect(hasPondRole('editor', VIEWER, grants)).toBe(true);
expect(hasPondRole('pond_admin', VIEWER, grants)).toBe(false);
});
it('pond_admin implies editor', () => {
const grants = [grant('pond_admin', 'pond', null, 'allow')];
expect(hasPondRole('editor', VIEWER, grants)).toBe(true);
expect(hasPondRole('pond_admin', VIEWER, grants)).toBe(true);
});
it('a pond-scope reader grant gives no pond-wide editor capability', () => {
expect(hasPondRole('editor', VIEWER, [grant('reader', 'pond', null, 'allow')])).toBe(false);
});
it('only pond-scope grants count — a label-scoped editor is not pond-wide', () => {
expect(hasPondRole('editor', VIEWER, [grant('editor', 'label', 'docs', 'allow')])).toBe(false);
});
it('deny wins among matching pond-scope grants', () => {
const grants = [grant('editor', 'pond', null, 'allow'), grant('editor', 'pond', null, 'deny')];
expect(hasPondRole('editor', VIEWER, grants)).toBe(false);
});
it('grants for other subjects are ignored', () => {
const grants = [grant('editor', 'pond', null, 'allow', { type: 'user', id: 'somebody-else' })];
expect(hasPondRole('editor', VIEWER, grants)).toBe(false);
});
it('authenticated-subject grants apply to logged-in viewers only', () => {
const grants = [grant('editor', 'pond', null, 'allow', { type: 'authenticated' })];
expect(hasPondRole('editor', VIEWER, grants)).toBe(true);
expect(hasPondRole('editor', ANONYMOUS, grants)).toBe(false);
});
it('Site Admin bypasses', () => {
expect(hasPondRole('pond_admin', ADMIN, [])).toBe(true);
});
});
describe('canSeePond (issue #52)', () => {
it('is default-closed: no grants → invisible', () => {
expect(canSeePond(VIEWER, [])).toBe(false);
});
it('any allow grant makes the pond visible, even label- or page-scoped', () => {
expect(canSeePond(VIEWER, [grant('reader', 'label', 'docs', 'allow')])).toBe(true);
expect(canSeePond(VIEWER, [grant('editor', 'page', 'p1', 'allow')])).toBe(true);
expect(canSeePond(VIEWER, [grant('reader', 'pond', null, 'allow')])).toBe(true);
});
it('deny grants alone do not make a pond visible', () => {
expect(canSeePond(VIEWER, [grant('reader', 'label', 'docs', 'deny')])).toBe(false);
});
it('allow grants for other subjects do not', () => {
const grants = [grant('reader', 'pond', null, 'allow', { type: 'user', id: 'somebody-else' })];
expect(canSeePond(VIEWER, grants)).toBe(false);
});
it('public-subject grants make the pond visible to anonymous viewers', () => {
const grants = [grant('reader', 'pond', null, 'allow', { type: 'public' })];
expect(canSeePond(ANONYMOUS, grants)).toBe(true);
});
it('Site Admin bypasses', () => {
expect(canSeePond(ADMIN, [])).toBe(true);
});
});

View File

@ -0,0 +1,49 @@
import { subjectMatches } from './resolve';
import type { Grant, PermissionViewer } from './types';
/**
* Pond-scope permission questions (issue #52). Page access is decided by
* {@link ./resolve}; the questions answered here are about the pond as a
* whole: "may the viewer see that this pond exists?" and "does the viewer
* hold a pond-wide role?" (creating pages, managing the pond). Both are pure
* and share the grant model and deny-wins semantics of the page resolver.
*/
/** Roles a pond-wide capability check can ask for. `pond_admin` implies
* `editor`; `editor` implies nothing further here (pond-wide read is
* {@link canSeePond}, which any grant satisfies). */
export type PondRole = 'editor' | 'pond_admin';
/** Does a grant's role satisfy a pond-role question? Admin covers editor. */
function roleSatisfies(grantRole: Grant['role'], asked: PondRole): boolean {
if (asked === 'pond_admin') return grantRole === 'pond_admin';
return grantRole === 'editor' || grantRole === 'pond_admin';
}
/**
* Does the viewer hold `role` pond-wide? Only pond-scope grants count a
* label- or page-scoped editor may edit those pages but has no pond-wide
* capability (creating pages, managing the pond). Deny wins among matching
* grants; no matching grant false (default-closed). Site Admin bypasses.
*/
export function hasPondRole(role: PondRole, viewer: PermissionViewer, grants: Grant[]): boolean {
if (viewer.isSiteAdmin) return true;
const matching = grants.filter(
(g) => g.scopeType === 'pond' && subjectMatches(g, viewer) && roleSatisfies(g.role, role),
);
if (matching.length === 0) return false;
return !matching.some((g) => g.effect === 'deny');
}
/**
* May the viewer see that the pond exists (its metadata, label tree, page
* list individual pages still resolve per page)? Any `allow` grant whose
* subject matches makes the pond visible, at any scope and for any role: a
* label-scoped reader needs the pond shell to reach their pages. A viewer
* with only `deny` grants (or none) sees nothing (default-closed). Site
* Admin bypasses.
*/
export function canSeePond(viewer: PermissionViewer, grants: Grant[]): boolean {
if (viewer.isSiteAdmin) return true;
return grants.some((g) => g.effect === 'allow' && subjectMatches(g, viewer));
}

View File

@ -11,8 +11,9 @@ import type { Grant, PageResolutionContext, PermissionAction, PermissionViewer }
* deny (default-closed). Site Admins bypass everything. * deny (default-closed). Site Admins bypass everything.
*/ */
/** Does a grant's subject apply to the viewer? */ /** Does a grant's subject apply to the viewer? (Shared with the pond-scope
function subjectMatches(grant: Grant, viewer: PermissionViewer): boolean { * resolution in {@link ./pond}.) */
export function subjectMatches(grant: Grant, viewer: PermissionViewer): boolean {
switch (grant.subjectType) { switch (grant.subjectType) {
case 'public': case 'public':
return true; return true;

View File

@ -0,0 +1,47 @@
import { z } from 'zod';
import type { Grant } from './types';
/**
* Wire schemas and views for the grant-management API (`/ponds/:id/grants`,
* issue #52). The structural rules beyond shape (pond_admin only at pond
* scope, subject/scope id presence, personal-pond single admin) live in
* {@link ./validate} and are applied by the service after parsing.
*/
export const GRANT_SUBJECT_TYPES = ['user', 'authenticated', 'public'] as const;
export const GRANT_ROLES = ['pond_admin', 'editor', 'reader'] as const;
export const GRANT_SCOPE_TYPES = ['pond', 'label', 'page'] as const;
export const GRANT_EFFECTS = ['allow', 'deny'] as const;
export const createGrantInputSchema = z.object({
subjectType: z.enum(GRANT_SUBJECT_TYPES),
/** Required for `user` subjects, absent otherwise (validate.ts checks). */
subjectId: z.string().min(1).nullish(),
role: z.enum(GRANT_ROLES),
scopeType: z.enum(GRANT_SCOPE_TYPES),
/** The label/page id for those scopes, absent at pond scope. */
scopeId: z.string().min(1).nullish(),
effect: z.enum(GRANT_EFFECTS),
});
export type CreateGrantInput = z.infer<typeof createGrantInputSchema>;
/** A stored grant as the API serves it. */
export interface GrantView extends Grant {
id: string;
pondId: string;
createdBy: string;
createdAt: string;
}
/** The parsed input as the resolver's grant shape (nullish → null). */
export function grantOfInput(input: CreateGrantInput): Grant {
return {
subjectType: input.subjectType,
subjectId: input.subjectId ?? null,
role: input.role,
scopeType: input.scopeType,
scopeId: input.scopeId ?? null,
effect: input.effect,
};
}