diff --git a/apps/api/prisma/migrations/20260709140000_owner_admin_grants/migration.sql b/apps/api/prisma/migrations/20260709140000_owner_admin_grants/migration.sql new file mode 100644 index 0000000..41ccd53 --- /dev/null +++ b/apps/api/prisma/migrations/20260709140000_owner_admin_grants/migration.sql @@ -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' +); diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 9893705..fff0961 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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 { + 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 { const email = `${fixture.username}@dorfteich.test`; const user = await prisma.user.upsert({ @@ -104,7 +135,7 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise { select: { id: true }, }); if (!existing) { - await prisma.pond.create({ + const pond = await prisma.pond.create({ data: { slug: slugify(fixture.displayName) || fixture.username, name: fixture.displayName, @@ -112,6 +143,7 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise { ownerId: user.id, }, }); + await ensureOwnerAdminGrant(pond.id, user.id); } // The instance default for additional_ponds is 0 (ADR 0011) — give // the fixtures headroom so pond flows are exercisable in dev/e2e. @@ -217,6 +249,7 @@ async function seedContentFixtures(ownerId: string): Promise { }, }); } + await ensureOwnerAdminGrant(pond.id, ownerId); await prisma.quotaOverride.upsert({ where: { subjectType_subjectId_quotaKey: { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2a1fb3d..7fa47f7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,7 @@ import { LabelsModule } from './labels/labels.module'; import { LinksModule } from './links/links.module'; import { MailModule } from './mail/mail.module'; import { PagesModule } from './pages/pages.module'; +import { PermissionsModule } from './permissions/permissions.module'; import { PondsModule } from './ponds/ponds.module'; import { PrismaModule } from './prisma/prisma.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; @@ -32,6 +33,7 @@ import { VersionsModule } from './versions/versions.module'; MailModule, SettingsModule, UsersModule, + PermissionsModule, PondsModule, PagesModule, FilesModule, diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index ed18358..8d9af56 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -14,12 +14,14 @@ import type { Response } from 'express'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AppConfig } from '../config/app-config.service'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { RateLimit } from '../rate-limit/rate-limit.guard'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard'; import { AuthService } from './auth.service'; import { SessionsService } from './sessions.service'; +@AuthenticatedOnly() // routes reachable without a session opt out via @Public @Controller('auth') export class AuthController { constructor( diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts index 39c2e95..89c78eb 100644 --- a/apps/api/src/files/files.controller.ts +++ b/apps/api/src/files/files.controller.ts @@ -17,6 +17,10 @@ import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest } from '../auth/auth.guard'; +import { + RequiresAttachmentPermission, + RequiresPondRole, +} from '../permissions/permission.decorators'; import { FilesService } from './files.service'; @@ -26,6 +30,7 @@ export class FilesController { constructor(private readonly files: FilesService) {} @Post('ponds/:pondId/files') + @RequiresPondRole('editor', { idParam: 'pondId' }) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } })) async upload( @Param('pondId') pondId: string, @@ -38,6 +43,7 @@ export class FilesController { /** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */ @Get('media/:fileId') + @RequiresAttachmentPermission('read', { idParam: 'fileId' }) async download( @Param('fileId') fileId: string, @Req() request: AuthedRequest, @@ -55,6 +61,7 @@ export class FilesController { @Delete('files/:id') @HttpCode(204) + @RequiresAttachmentPermission('write', { idParam: 'id' }) async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { await this.files.remove(request.user!, id); } diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 378d80a..2342a22 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -11,7 +11,6 @@ import { AttachmentView } from '@dorfteich/shared'; import { Attachment, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; -import { InterimAccessService } from '../ponds/interim-access.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; @@ -28,7 +27,6 @@ export interface FileDownload { export class FilesService { constructor( private readonly prisma: PrismaService, - private readonly access: InterimAccessService, private readonly quotas: QuotaService, private readonly storage: FileStorageService, private readonly logger: PinoLogger, @@ -54,7 +52,7 @@ export class FilesService { file: { buffer: Buffer; size: number; originalname: string }, ): Promise { 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 — // catches a renamed .html-as-.png (ADR 0011 acceptance criterion). @@ -106,23 +104,15 @@ export class FilesService { } } - async download(user: User, id: string): Promise { - const attachment = await this.prisma.attachment.findFirst({ - where: { id }, - include: { pond: true }, - }); + async download(_user: User, id: string): Promise { + const attachment = await this.prisma.attachment.findFirst({ where: { id } }); if (!attachment) throw new NotFoundException(); - this.access.assertCanSee(user, attachment.pond); return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) }; } async remove(user: User, id: string): Promise { - const attachment = await this.prisma.attachment.findFirst({ - where: { id }, - include: { pond: true }, - }); + const attachment = await this.prisma.attachment.findFirst({ where: { id } }); if (!attachment) throw new NotFoundException(); - this.access.assertCanModify(user, attachment.pond); await this.prisma.attachment.delete({ where: { id: attachment.id } }); await this.storage.delete(attachment.pondId, attachment.id); diff --git a/apps/api/src/grants/grants.controller.ts b/apps/api/src/grants/grants.controller.ts new file mode 100644 index 0000000..aab7282 --- /dev/null +++ b/apps/api/src/grants/grants.controller.ts @@ -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 { + 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 { + 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 { + await this.grants.deleteGrant(request.user!, pondId, grantId); + } +} diff --git a/apps/api/src/grants/grants.module.ts b/apps/api/src/grants/grants.module.ts index e9c2d79..4eba288 100644 --- a/apps/api/src/grants/grants.module.ts +++ b/apps/api/src/grants/grants.module.ts @@ -2,15 +2,18 @@ import { Module } from '@nestjs/common'; import { PondsModule } from '../ponds/ponds.module'; +import { GrantsController } from './grants.controller'; import { GrantsService } from './grants.service'; /** - * Permission grants (issue #51). Exports `GrantsService` so the API guards and - * collab token issuance (#52/#53) can resolve grants; the member-management - * controller/UI arrives with #54. + * Permission grants (issues #51/#52): the management endpoints under + * `/ponds/:id/grants` (Pond-Admin-gated) and the service behind them. + * Resolution itself lives in PermissionsModule; the member-management UI + * arrives with #54. */ @Module({ imports: [PondsModule], + controllers: [GrantsController], providers: [GrantsService], exports: [GrantsService], }) diff --git a/apps/api/src/grants/grants.service.db.test.ts b/apps/api/src/grants/grants.service.db.test.ts index f0e782c..9499c37 100644 --- a/apps/api/src/grants/grants.service.db.test.ts +++ b/apps/api/src/grants/grants.service.db.test.ts @@ -13,17 +13,11 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => { let grants: GrantsService; const suffix = uniqueSuffix(); let owner: User; + let target: User; let shared: string; let personal: string; - - const editorGrant: Grant = { - subjectType: 'user', - subjectId: 'target-user', - role: 'editor', - scopeType: 'pond', - scopeId: null, - effect: 'allow', - }; + // Grants must point at an existing user (#52); filled in beforeAll. + let editorGrant: Grant; beforeAll(async () => { prisma = createTestPrisma(); @@ -37,6 +31,21 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => { 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({ 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 () => { await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } }); await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } }); - await prisma.user.deleteMany({ where: { id: owner.id } }); + await prisma.user.deleteMany({ where: { id: { in: [owner.id, target.id] } } }); await prisma.$disconnect(); await app.close(); }); @@ -64,7 +73,7 @@ describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => { const created = await grants.createGrant(owner, shared, editorGrant); expect(created).toMatchObject({ role: 'editor', scopeType: 'pond', effect: 'allow' }); const all = await grants.grantsForPond(shared); - expect(all).toContainEqual(expect.objectContaining({ subjectId: 'target-user' })); + expect(all).toContainEqual(expect.objectContaining({ subjectId: target.id })); }); it('rejects a duplicate grant', async () => { diff --git a/apps/api/src/grants/grants.service.ts b/apps/api/src/grants/grants.service.ts index d3e9c2d..aaee463 100644 --- a/apps/api/src/grants/grants.service.ts +++ b/apps/api/src/grants/grants.service.ts @@ -4,56 +4,107 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Grant, grantValidationError } from '@dorfteich/shared'; -import { Pond, User } from '@prisma/client'; +import { Grant, GrantView, grantValidationError } from '@dorfteich/shared'; +import { Pond, RoleGrant, User } from '@prisma/client'; 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 { toGrant, toGrantColumns } from './grant-mappers'; /** - * Writes and reads over the permission table (`role_grants`, issue #51). Grants - * are validated against the shared structural rules before insertion (the DB - * CHECK is a backstop for the pond_admin-scope rule). Who may manage grants is - * still the interim rule (owner/Site Admin) until #52 wires the real Pond Admin - * check; the member-management API/UI arrives with #54. `grantsForPond` is what - * the resolver (#52/#53) reads. + * Writes and reads over the permission table (`role_grants`, issues #51/#52). + * Who may manage grants is the guard's job (Pond Admin); this service + * validates the grant itself — the shared structural rules, plus that the + * scope and subject actually exist in this pond. Every mutation invalidates + * the pond's permission context and notifies the collab server so live + * sessions revalidate (issue #39; full revocation UX is #53), and leaves an + * audit log line. */ @Injectable() export class GrantsService { constructor( private readonly prisma: PrismaService, - private readonly access: InterimAccessService, + private readonly permissionCache: PondPermissionCache, + private readonly accessNotifier: PondAccessNotifier, private readonly logger: PinoLogger, ) { 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 { const rows = await this.prisma.roleGrant.findMany({ where: { pondId } }); return rows.map(toGrant); } - private async requireModifiablePond(user: User, pondId: string): Promise { + 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 { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); - this.access.assertCanModify(user, pond); + if (!pond) throw new NotFoundException(); return pond; } + /** A pond's grants for the management UI, newest last. */ + async listGrants(pondId: string): Promise { + 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 { + 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): - * pond_admin only at pond scope for a user subject, and no extra admins on a - * personal pond. Rejects duplicates. Returns the stored grant. + * pond_admin only at pond scope for a user subject, no extra admins on a + * personal pond, and scope/subject must exist here. Rejects duplicates. */ - async createGrant(user: User, pondId: string, grant: Grant): Promise { - const pond = await this.requireModifiablePond(user, pondId); + async createGrant(user: User, pondId: string, grant: Grant): Promise { + const pond = await this.requireLivePond(pondId); const invalid = grantValidationError(grant, { pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared', }); if (invalid) throw new BadRequestException({ code: invalid }); + await this.assertScopeAndSubjectExist(pondId, grant); const columns = toGrantColumns(grant); const existing = await this.prisma.roleGrant.findFirst({ @@ -65,19 +116,52 @@ export class GrantsService { const created = await this.prisma.roleGrant.create({ data: { pondId, createdBy: user.id, ...columns }, }); + await this.accessChanged(pondId); 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', ); - 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 { - const grant = await this.prisma.roleGrant.findUnique({ where: { id: grantId } }); + /** + * Remove a grant by id within its pond. The last remaining Pond Admin + * 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 { + const grant = await this.prisma.roleGrant.findFirst({ where: { id: grantId, pondId } }); 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 } }); - 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 { + this.permissionCache.invalidate(pondId); + await this.accessNotifier.notifyAccessChanged(pondId); } } diff --git a/apps/api/src/labels/labels.controller.ts b/apps/api/src/labels/labels.controller.ts index e8e22cd..0b93b20 100644 --- a/apps/api/src/labels/labels.controller.ts +++ b/apps/api/src/labels/labels.controller.ts @@ -25,11 +25,13 @@ import { import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; import { LabelsService } from './labels.service'; /** - * Hierarchical label management (issue #43). Access rules live in - * InterimAccessService, resolved per owning pond inside the service. + * Hierarchical label management (issue #43). Seeing a pond lets you read its + * 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() export class LabelsController { @@ -37,6 +39,7 @@ export class LabelsController { /** The pond's label hierarchy in one call — sidebar tree and pickers. */ @Get('ponds/:pondId/labels') + @RequiresPondRole('reader', { idParam: 'pondId' }) async list( @Param('pondId') pondId: string, @Req() request: AuthedRequest, @@ -45,6 +48,7 @@ export class LabelsController { } @Post('ponds/:pondId/labels') + @RequiresPondRole('pond_admin', { idParam: 'pondId' }) async create( @Param('pondId') pondId: string, @Body(new ZodValidationPipe(createLabelInputSchema)) input: CreateLabelInput, @@ -55,6 +59,7 @@ export class LabelsController { /** Rename and/or recolour a label. */ @Patch('labels/:id') + @RequiresPondRole('pond_admin', { labelParam: 'id' }) async update( @Param('id') id: string, @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. */ @Post('labels/:id/move') + @RequiresPondRole('pond_admin', { labelParam: 'id' }) async move( @Param('id') id: string, @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('labels/:id') @HttpCode(204) + @RequiresPondRole('pond_admin', { labelParam: 'id' }) async remove( @Param('id') id: string, @Query('force') force: string | undefined, @@ -86,6 +93,7 @@ export class LabelsController { /** A page's assigned labels. */ @Get('pages/:pageId/labels') + @RequiresPagePermission('read', { idParam: 'pageId' }) async pageLabels( @Param('pageId') pageId: string, @Req() request: AuthedRequest, @@ -95,6 +103,7 @@ export class LabelsController { /** Assign a label to a page (idempotent). */ @Post('pages/:pageId/labels') + @RequiresPagePermission('write', { idParam: 'pageId' }) async assign( @Param('pageId') pageId: string, @Body(new ZodValidationPipe(assignLabelInputSchema)) input: AssignLabelInput, @@ -106,6 +115,7 @@ export class LabelsController { /** Unassign a label from a page (idempotent). */ @Delete('pages/:pageId/labels/:labelId') @HttpCode(204) + @RequiresPagePermission('write', { idParam: 'pageId' }) async unassign( @Param('pageId') pageId: string, @Param('labelId') labelId: string, diff --git a/apps/api/src/labels/labels.service.db.test.ts b/apps/api/src/labels/labels.service.db.test.ts index 1084cc3..7ad4b9f 100644 --- a/apps/api/src/labels/labels.service.db.test.ts +++ b/apps/api/src/labels/labels.service.db.test.ts @@ -1,11 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { - BadRequestException, - ConflictException, - INestApplication, - NotFoundException, -} from '@nestjs/common'; +import { BadRequestException, ConflictException, INestApplication } from '@nestjs/common'; import { PrismaClient, User } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as Y from 'yjs'; @@ -20,7 +15,6 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => { let labels: LabelsService; const suffix = uniqueSuffix(); let owner: User; - let outsider: User; let pondId: string; let otherPondId: string; const pageIds: string[] = []; @@ -54,13 +48,6 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => { 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({ 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 } } }); await prisma.label.deleteMany({ where: { pondId: { 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 app.close(); }); @@ -191,10 +178,6 @@ describe.skipIf(!hasTestDb)('LabelsService (db, issue #43)', () => { await prisma.label.deleteMany({ where: { id: label.id } }); }); - it('hides labels from users who cannot see the pond', async () => { - await expect(labels.list(outsider, pondId)).rejects.toBeInstanceOf(NotFoundException); - await expect(labels.create(outsider, pondId, { name: 'Nope' })).rejects.toBeInstanceOf( - NotFoundException, - ); - }); + // Hiding labels from users who cannot see the pond moved to the route + // guard with #52 — covered by the permission e2e pack. }); diff --git a/apps/api/src/labels/labels.service.ts b/apps/api/src/labels/labels.service.ts index 8f24814..322cd89 100644 --- a/apps/api/src/labels/labels.service.ts +++ b/apps/api/src/labels/labels.service.ts @@ -20,7 +20,7 @@ import { import { Label, Pond, Prisma, User } from '@prisma/client'; 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 { SearchProvider } from '../search/search.provider'; @@ -28,11 +28,14 @@ import { SearchProvider } from '../search/search.provider'; type Tx = Prisma.TransactionClient; /** - * Hierarchical labels within a pond (issue #43, data-model.md §labels). Access - * is gated through {@link InterimAccessService} on the owning pond: seeing a - * pond lets you read its labels, modifying it lets you manage them. The tree - * logic (cycle prevention, depth limit) lives in `@dorfteich/shared` so the M5 - * permission resolver reuses the exact same hierarchy walk (permissions.md). + * Hierarchical labels within a pond (issue #43, data-model.md §labels). + * Permissions are enforced by the route guard (#52): seeing a pond lets you + * read its labels, Pond Admins manage the tree, label assignment is a page + * write. The tree logic (cycle prevention, depth limit) lives in + * `@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 * 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 { constructor( private readonly prisma: PrismaService, - private readonly access: InterimAccessService, + private readonly permissionCache: PondPermissionCache, private readonly logger: PinoLogger, private readonly search: SearchProvider, ) { @@ -84,29 +87,19 @@ export class LabelsService { }); } - private async requireVisiblePond(user: User, pondId: string): Promise { + private async requireLivePond(pondId: string): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); - this.access.assertCanSee(user, pond); + if (!pond) throw new NotFoundException(); return pond; } - private async requireModifiablePond(user: User, pondId: string): Promise { - const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); - this.access.assertCanModify(user, pond); - return pond; - } - - /** Loads a label and asserts the user may manage its pond; 404 if either is missing. */ - private async requireModifiableLabel( - user: User, - labelId: string, - ): Promise