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
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:
parent
4d48d72c40
commit
0c6494f209
@ -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'
|
||||
);
|
||||
@ -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> {
|
||||
const email = `${fixture.username}@dorfteich.test`;
|
||||
const user = await prisma.user.upsert({
|
||||
@ -104,7 +135,7 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
},
|
||||
});
|
||||
}
|
||||
await ensureOwnerAdminGrant(pond.id, ownerId);
|
||||
await prisma.quotaOverride.upsert({
|
||||
where: {
|
||||
subjectType_subjectId_quotaKey: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<void> {
|
||||
await this.files.remove(request.user!, id);
|
||||
}
|
||||
|
||||
@ -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<AttachmentView> {
|
||||
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<FileDownload> {
|
||||
const attachment = await this.prisma.attachment.findFirst({
|
||||
where: { id },
|
||||
include: { pond: true },
|
||||
});
|
||||
async download(_user: User, id: string): Promise<FileDownload> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
49
apps/api/src/grants/grants.controller.ts
Normal file
49
apps/api/src/grants/grants.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<Grant[]> {
|
||||
const rows = await this.prisma.roleGrant.findMany({ where: { pondId } });
|
||||
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 } });
|
||||
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<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):
|
||||
* 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<Grant> {
|
||||
const pond = await this.requireModifiablePond(user, pondId);
|
||||
async createGrant(user: User, pondId: string, grant: Grant): Promise<GrantView> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.permissionCache.invalidate(pondId);
|
||||
await this.accessNotifier.notifyAccessChanged(pondId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
});
|
||||
|
||||
@ -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<Pond> {
|
||||
private async requireLivePond(pondId: string): Promise<Pond> {
|
||||
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<Pond> {
|
||||
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<Label & { pond: Pond }> {
|
||||
/** Loads a label in a live pond; permission is the guard's job (#52). */
|
||||
private async requireLabel(labelId: string): Promise<Label & { pond: Pond }> {
|
||||
const label = await this.prisma.label.findUnique({
|
||||
where: { id: labelId },
|
||||
include: { pond: true },
|
||||
});
|
||||
if (!label || label.pond.deletedAt) throw new NotFoundException();
|
||||
this.access.assertCanModify(user, label.pond);
|
||||
return label;
|
||||
}
|
||||
|
||||
@ -132,13 +125,13 @@ export class LabelsService {
|
||||
|
||||
/** The whole label hierarchy of a pond in one call (issue #43 acceptance
|
||||
* criterion) — the sidebar tree and every label picker read this. */
|
||||
async list(user: User, pondId: string): Promise<LabelTreeNode[]> {
|
||||
await this.requireVisiblePond(user, pondId);
|
||||
async list(_user: User, pondId: string): Promise<LabelTreeNode[]> {
|
||||
await this.requireLivePond(pondId);
|
||||
return buildLabelTree(await this.allPondLabels(this.prisma, pondId));
|
||||
}
|
||||
|
||||
async create(user: User, pondId: string, input: CreateLabelInput): Promise<LabelView> {
|
||||
await this.requireModifiablePond(user, pondId);
|
||||
await this.requireLivePond(pondId);
|
||||
const name = input.name;
|
||||
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');
|
||||
return this.viewOf(label);
|
||||
}
|
||||
|
||||
/** Rename and/or recolour a label (issue #43). */
|
||||
async update(user: User, labelId: string, input: UpdateLabelInput): Promise<LabelView> {
|
||||
const existing = await this.requireModifiableLabel(user, labelId);
|
||||
async update(_user: User, labelId: string, input: UpdateLabelInput): Promise<LabelView> {
|
||||
const existing = await this.requireLabel(labelId);
|
||||
const pondId = existing.pondId;
|
||||
|
||||
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).
|
||||
* Rejects cycles and any move that would push the subtree past the depth limit. */
|
||||
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 parentId = input.parentId;
|
||||
|
||||
@ -219,6 +213,8 @@ export class LabelsService {
|
||||
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');
|
||||
return this.viewOf(label);
|
||||
}
|
||||
@ -231,7 +227,7 @@ export class LabelsService {
|
||||
* assignments and descendants.
|
||||
*/
|
||||
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 affectedPageIds = await this.withPondLock(pondId, async (tx) => {
|
||||
@ -253,33 +249,25 @@ export class LabelsService {
|
||||
return assignments.map((a) => a.pageId);
|
||||
});
|
||||
|
||||
this.permissionCache.invalidate(pondId);
|
||||
// Those pages lost a label → their search entries change (#49).
|
||||
for (const pageId of affectedPageIds) await this.search.indexPage(pageId);
|
||||
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). */
|
||||
private async requireModifiablePage(
|
||||
user: User,
|
||||
pageId: string,
|
||||
): Promise<{ id: string; pondId: string }> {
|
||||
/** Loads a live page (assignment ops); permission is the guard's job (#52). */
|
||||
private async requireLivePage(pageId: string): Promise<{ id: string; pondId: string }> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { pond: true },
|
||||
select: { id: true, pondId: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanModify(user, page.pond);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A page's assigned labels (issue #43) — read requires seeing the pond. */
|
||||
async pageLabels(user: User, pageId: string): Promise<LabelView[]> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { pond: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanSee(user, page.pond);
|
||||
/** A page's assigned labels (issue #43). */
|
||||
async pageLabels(_user: User, pageId: string): Promise<LabelView[]> {
|
||||
await this.requireLivePage(pageId);
|
||||
const rows = await this.prisma.pageLabel.findMany({
|
||||
where: { pageId },
|
||||
include: { label: true },
|
||||
@ -289,7 +277,7 @@ export class LabelsService {
|
||||
|
||||
/** Assign a label to a page (idempotent). Rejects labels from another pond. */
|
||||
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 } });
|
||||
if (!label || label.pondId !== page.pondId) {
|
||||
throw new BadRequestException({ code: 'label_wrong_pond' });
|
||||
@ -304,8 +292,8 @@ export class LabelsService {
|
||||
}
|
||||
|
||||
/** Remove a label from a page (idempotent). */
|
||||
async unassign(user: User, pageId: string, labelId: string): Promise<void> {
|
||||
await this.requireModifiablePage(user, pageId);
|
||||
async unassign(_user: User, pageId: string, labelId: string): Promise<void> {
|
||||
await this.requireLivePage(pageId);
|
||||
await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } });
|
||||
await this.search.indexPage(pageId); // labels are a search field (#49)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Controller, Get, Param, Req } from '@nestjs/common';
|
||||
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
import { LinksService } from './links.service';
|
||||
|
||||
/** 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). */
|
||||
@Get('pages/:id/backlinks')
|
||||
@RequiresPagePermission('read', { idParam: 'id' }) // sources are filtered per page
|
||||
async backlinks(@Param('id') id: string, @Req() request: AuthedRequest): Promise<BacklinkView[]> {
|
||||
return this.links.backlinks(request.user!, id);
|
||||
}
|
||||
|
||||
/** Referenced-but-missing pages in the pond, with their referrers. */
|
||||
@Get('ponds/:pondId/phantom-links')
|
||||
@RequiresPondRole('reader', { idParam: 'pondId' }) // referrers are filtered per page
|
||||
async phantomLinks(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { INestApplication, NotFoundException } from '@nestjs/common';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient, User } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { PagesService } from '../pages/pages.service';
|
||||
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';
|
||||
|
||||
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 },
|
||||
});
|
||||
pondId = pond.id;
|
||||
await grantOwnerAdmin(prisma, pondId, owner.id);
|
||||
// Headroom for the pages the create test makes.
|
||||
await prisma.quotaOverride.create({
|
||||
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}`);
|
||||
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 () => {
|
||||
|
||||
@ -2,15 +2,16 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BacklinkView, PhantomLinkView } from '@dorfteich/shared';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 —
|
||||
* seeing the pond (InterimAccessService) is therefore the read permission.
|
||||
* within a pond, so every link source lives in the same pond as its target;
|
||||
* 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). */
|
||||
const SNIPPET_LENGTH = 140;
|
||||
@ -27,7 +28,7 @@ type LinkSource = {
|
||||
export class LinksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: InterimAccessService,
|
||||
private readonly permissions: PermissionService,
|
||||
) {}
|
||||
|
||||
private static viewOf(source: LinkSource): BacklinkView {
|
||||
@ -41,10 +42,9 @@ export class LinksService {
|
||||
async backlinks(user: User, pageId: string): Promise<BacklinkView[]> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { pond: true },
|
||||
select: { id: true, pondId: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanSee(user, page.pond);
|
||||
|
||||
const links = await this.prisma.pageLink.findMany({
|
||||
where: { toPageId: pageId, fromPage: { deletedAt: null } },
|
||||
@ -60,10 +60,16 @@ export class LinksService {
|
||||
},
|
||||
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 backlinks: BacklinkView[] = [];
|
||||
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);
|
||||
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. */
|
||||
async phantomLinks(user: User, pondId: string): Promise<PhantomLinkView[]> {
|
||||
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({
|
||||
where: { toPageId: null, fromPage: { pondId, deletedAt: null } },
|
||||
@ -90,8 +96,15 @@ export class LinksService {
|
||||
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>();
|
||||
for (const row of rows) {
|
||||
if (!readable.has(row.fromPage.id)) continue;
|
||||
let entry = grouped.get(row.targetSlug);
|
||||
if (!entry) {
|
||||
entry = { targetSlug: row.targetSlug, referencedBy: [] };
|
||||
|
||||
@ -28,14 +28,20 @@ import type { Response } from 'express';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import {
|
||||
AuthenticatedOnly,
|
||||
RequiresPagePermission,
|
||||
RequiresPondRole,
|
||||
} from '../permissions/permission.decorators';
|
||||
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()
|
||||
export class PagesController {
|
||||
constructor(private readonly pages: PagesService) {}
|
||||
|
||||
@Post('ponds/:pondId/pages')
|
||||
@RequiresPondRole('editor', { idParam: 'pondId' })
|
||||
async create(
|
||||
@Param('pondId') pondId: string,
|
||||
@Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput,
|
||||
@ -45,6 +51,7 @@ export class PagesController {
|
||||
}
|
||||
|
||||
@Get('ponds/:pondId/pages')
|
||||
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page
|
||||
async list(
|
||||
@Param('pondId') pondId: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@ -53,12 +60,14 @@ export class PagesController {
|
||||
}
|
||||
|
||||
@Get('pages/:id')
|
||||
@RequiresPagePermission('read', { idParam: 'id' })
|
||||
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
|
||||
return this.pages.getState(request.user!, id);
|
||||
}
|
||||
|
||||
/** Short-lived collaboration token for the collab server (issue #34). */
|
||||
@Get('pages/:id/collab-token')
|
||||
@RequiresPagePermission('read', { idParam: 'id' }) // readers get an `ro` token
|
||||
async collabToken(
|
||||
@Param('id') id: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@ -68,6 +77,7 @@ export class PagesController {
|
||||
|
||||
/** Markdown export (issue #30) — downloads `<slug>.md`. */
|
||||
@Get('pages/:id/export/markdown')
|
||||
@RequiresPagePermission('read', { idParam: 'id' })
|
||||
async exportMarkdown(
|
||||
@Param('id') id: string,
|
||||
@Req() request: AuthedRequest,
|
||||
@ -83,6 +93,7 @@ export class PagesController {
|
||||
* navigates to (issue #25); the pond id must already be known to the caller
|
||||
* (e.g. from `GET /ponds/:slug`). */
|
||||
@Get('ponds/:pondId/pages/:slug')
|
||||
@RequiresPagePermission('read', { pondIdParam: 'pondId', slugParam: 'slug' })
|
||||
async getStateBySlug(
|
||||
@Param('pondId') pondId: 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.
|
||||
*/
|
||||
@Put('pages/:id/state')
|
||||
@AuthenticatedOnly() // always 410 — never touches the page
|
||||
saveState(): never {
|
||||
throw new GoneException({
|
||||
code: 'rest_state_write_retired',
|
||||
@ -107,6 +119,7 @@ export class PagesController {
|
||||
|
||||
/** Reposition a page in the manual sidebar order (issue #45). */
|
||||
@Patch('pages/:id/position')
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
async reposition(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput,
|
||||
@ -116,6 +129,7 @@ export class PagesController {
|
||||
}
|
||||
|
||||
@Patch('pages/:id')
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput,
|
||||
@ -126,6 +140,7 @@ export class PagesController {
|
||||
|
||||
@Delete('pages/:id')
|
||||
@HttpCode(204)
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||
await this.pages.softDelete(request.user!, id);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ import { generateKeyBetween } from 'fractional-indexing';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
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 { SearchProvider } from '../search/search.provider';
|
||||
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
||||
@ -43,7 +43,7 @@ const COLLAB_TOKEN_TTL_SECONDS = 60;
|
||||
export class PagesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: InterimAccessService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly logger: PinoLogger,
|
||||
private readonly config: AppConfig,
|
||||
private readonly search: SearchProvider,
|
||||
@ -86,23 +86,10 @@ export class PagesService {
|
||||
}
|
||||
}
|
||||
|
||||
private async findVisiblePage(user: User, id: string): Promise<Page> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: { pond: true },
|
||||
});
|
||||
/** Loads a live page; permission is the guard's job since #52. */
|
||||
private async findLivePage(id: string): Promise<Page> {
|
||||
const page = await this.prisma.page.findFirst({ where: { id, deletedAt: null } });
|
||||
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;
|
||||
}
|
||||
|
||||
@ -116,25 +103,35 @@ export class PagesService {
|
||||
};
|
||||
|
||||
/** 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[]> {
|
||||
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 pages = await this.prisma.page.findMany({
|
||||
where: { pondId, deletedAt: null },
|
||||
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
|
||||
include: { labels: { select: { labelId: true } } },
|
||||
});
|
||||
return pages.map((page) => ({
|
||||
...this.viewOf(page),
|
||||
labelIds: page.labels.map((l) => l.labelId),
|
||||
}));
|
||||
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),
|
||||
labelIds: page.labels.map((l) => l.labelId),
|
||||
}));
|
||||
}
|
||||
|
||||
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
|
||||
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 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})`;
|
||||
}
|
||||
|
||||
async getState(user: User, id: string): Promise<PageStateView> {
|
||||
const page = await this.findVisiblePage(user, id);
|
||||
async getState(_user: User, id: string): Promise<PageStateView> {
|
||||
const page = await this.findLivePage(id);
|
||||
return this.stateViewOf(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a collaboration token for a page (issue #34). The permission check
|
||||
* runs here, in the api — the collab server never sees session cookies
|
||||
* (ADR 0003). `mode` is `rw` for anyone who may modify the pond and `ro`
|
||||
* otherwise; under the interim access model seeing and modifying coincide,
|
||||
* 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).
|
||||
* runs in the api — the guard requires read access, and the collab server
|
||||
* never sees session cookies (ADR 0003). `mode` is `rw` for who may write
|
||||
* the page per the real grant resolution (issue #52) and `ro` otherwise.
|
||||
*/
|
||||
async issueCollabToken(user: User, id: string): Promise<CollabTokenResponse> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: { pond: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanSee(user, page.pond);
|
||||
const page = await this.findLivePage(id);
|
||||
|
||||
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(
|
||||
{ userId: user.id, pageId: page.id, mode },
|
||||
this.config.env.COLLAB_TOKEN_SECRET,
|
||||
@ -211,26 +202,15 @@ export class PagesService {
|
||||
return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS };
|
||||
}
|
||||
|
||||
async getStateBySlug(user: User, pondId: string, slug: string): Promise<PageStateView> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { pondId, slug },
|
||||
include: { pond: true },
|
||||
});
|
||||
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();
|
||||
}
|
||||
/** The trashed-page hint for editors (issue #31) moved into the guard. */
|
||||
async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
|
||||
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
|
||||
if (!page) throw new NotFoundException();
|
||||
return this.stateViewOf(page);
|
||||
}
|
||||
|
||||
async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> {
|
||||
const page = await this.findModifiablePage(user, id);
|
||||
async update(_user: User, id: string, input: UpdatePageInput): Promise<PageView> {
|
||||
const page = await this.findLivePage(id);
|
||||
|
||||
let slug = page.slug;
|
||||
if (input.slug !== undefined) {
|
||||
@ -267,8 +247,8 @@ export class PagesService {
|
||||
* 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).
|
||||
*/
|
||||
async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
||||
const page = await this.findModifiablePage(user, id);
|
||||
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
|
||||
const page = await this.findLivePage(id);
|
||||
const { afterId, beforeId } = input;
|
||||
if (afterId === id || beforeId === id) {
|
||||
throw new ConflictException({ code: 'bad_request' });
|
||||
@ -335,14 +315,14 @@ export class PagesService {
|
||||
* `page_content_cache.markdown` (refreshed on every state save, #23)
|
||||
* rather than re-decoding the Yjs state, so export always matches what
|
||||
* the app itself considers the page's current Markdown representation. */
|
||||
async exportMarkdown(user: User, id: string): Promise<{ slug: string; markdown: string }> {
|
||||
const page = await this.findVisiblePage(user, id);
|
||||
async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
|
||||
const page = await this.findLivePage(id);
|
||||
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||
return { slug: page.slug, markdown: cache?.markdown ?? '' };
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: page.id },
|
||||
data: { deletedAt: new Date(), deletedBy: user.id },
|
||||
|
||||
@ -3,7 +3,7 @@ import { PrismaClient, User } from '@prisma/client';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTestApp } from '../testing/test-app';
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { PagesService } from './pages.service';
|
||||
|
||||
describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => {
|
||||
@ -46,6 +46,7 @@ describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => {
|
||||
},
|
||||
});
|
||||
pondId = pond.id;
|
||||
await grantOwnerAdmin(prisma, pondId, owner.id);
|
||||
// Manual mode so `list` orders by sort_key.
|
||||
await prisma.pond.update({
|
||||
where: { id: pondId },
|
||||
|
||||
82
apps/api/src/permissions/permission.decorators.ts
Normal file
82
apps/api/src/permissions/permission.decorators.ts
Normal 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);
|
||||
}
|
||||
195
apps/api/src/permissions/permission.guard.ts
Normal file
195
apps/api/src/permissions/permission.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
apps/api/src/permissions/permission.service.ts
Normal file
169
apps/api/src/permissions/permission.service.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
388
apps/api/src/permissions/permissions.e2e.db.test.ts
Normal file
388
apps/api/src/permissions/permissions.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
apps/api/src/permissions/permissions.module.ts
Normal file
19
apps/api/src/permissions/permissions.module.ts
Normal 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 {}
|
||||
36
apps/api/src/permissions/pond-permission-cache.test.ts
Normal file
36
apps/api/src/permissions/pond-permission-cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
52
apps/api/src/permissions/pond-permission-cache.ts
Normal file
52
apps/api/src/permissions/pond-permission-cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
87
apps/api/src/permissions/route-permissions.e2e.db.test.ts
Normal file
87
apps/api/src/permissions/route-permissions.e2e.db.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -1,36 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Pond, Prisma, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* INTERIM access control, M2–M4 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();
|
||||
}
|
||||
}
|
||||
@ -23,14 +23,16 @@ import { Prisma } from '@prisma/client';
|
||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthenticatedOnly, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
import { PondsService } from './ponds.service';
|
||||
|
||||
/** Pond CRUD (issue #21). Access rules live in InterimAccessService. */
|
||||
/** Pond CRUD (issue #21; permission guard since #52). */
|
||||
@Controller('ponds')
|
||||
export class PondsController {
|
||||
constructor(private readonly ponds: PondsService) {}
|
||||
|
||||
@Post()
|
||||
@AuthenticatedOnly() // any signed-in user may create ponds; quotas gate how many
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createPondInputSchema)) input: CreatePondInput,
|
||||
@Req() request: AuthedRequest,
|
||||
@ -39,6 +41,7 @@ export class PondsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@AuthenticatedOnly() // the service filters to the caller's visible ponds
|
||||
async list(@Req() request: AuthedRequest): Promise<PondView[]> {
|
||||
return this.ponds.listVisible(request.user!);
|
||||
}
|
||||
@ -51,11 +54,13 @@ export class PondsController {
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
@RequiresPondRole('reader', { slugParam: 'slug' })
|
||||
async bySlug(@Param('slug') slug: string, @Req() request: AuthedRequest): Promise<PondView> {
|
||||
return this.ponds.getVisibleBySlug(request.user!, slug);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequiresPondRole('pond_admin', { idParam: 'id' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updatePondInputSchema)) input: UpdatePondInput,
|
||||
@ -66,6 +71,7 @@ export class PondsController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequiresPondRole('pond_admin', { idParam: 'id' })
|
||||
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||
await this.ponds.softDelete(request.user!, id);
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { QuotasModule } from '../quotas/quotas.module';
|
||||
|
||||
import { InterimAccessService } from './interim-access.service';
|
||||
import { PondAccessNotifier } from './pond-access-notifier.service';
|
||||
import { PondsController } from './ponds.controller';
|
||||
import { PondsService } from './ponds.service';
|
||||
@ -10,7 +9,7 @@ import { PondsService } from './ponds.service';
|
||||
@Module({
|
||||
imports: [QuotasModule],
|
||||
controllers: [PondsController],
|
||||
providers: [PondsService, InterimAccessService, PondAccessNotifier],
|
||||
exports: [PondsService, InterimAccessService, PondAccessNotifier],
|
||||
providers: [PondsService, PondAccessNotifier],
|
||||
exports: [PondsService, PondAccessNotifier],
|
||||
})
|
||||
export class PondsModule {}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
CreatePondInput,
|
||||
PondView,
|
||||
@ -6,19 +6,19 @@ import {
|
||||
pondSettingsSchema,
|
||||
slugify,
|
||||
} from '@dorfteich/shared';
|
||||
import { Pond, User } from '@prisma/client';
|
||||
import { Pond, Prisma, User } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QuotaService } from '../quotas/quota.service';
|
||||
import { InterimAccessService } from './interim-access.service';
|
||||
import { PondAccessNotifier } from './pond-access-notifier.service';
|
||||
|
||||
@Injectable()
|
||||
export class PondsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: InterimAccessService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quotas: QuotaService,
|
||||
private readonly accessNotifier: PondAccessNotifier,
|
||||
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> {
|
||||
const slug = await this.generateUniqueSlug(input.name, owner.username);
|
||||
// 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) => {
|
||||
await this.quotas.assertCanCreateSharedPond(tx, owner.id);
|
||||
return tx.pond.create({
|
||||
const created = await tx.pond.create({
|
||||
data: {
|
||||
slug,
|
||||
name: input.name,
|
||||
@ -78,6 +99,8 @@ export class PondsService {
|
||||
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');
|
||||
return this.viewOf(pond);
|
||||
@ -94,34 +117,35 @@ export class PondsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
const pond = await this.prisma.pond.create({
|
||||
data: {
|
||||
slug: await this.generateUniqueSlug(user.displayName, user.username),
|
||||
name: user.displayName,
|
||||
type: 'PERSONAL',
|
||||
ownerId: user.id,
|
||||
},
|
||||
const slug = await this.generateUniqueSlug(user.displayName, user.username);
|
||||
const pond = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.pond.create({
|
||||
data: { slug, name: user.displayName, 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');
|
||||
}
|
||||
|
||||
async listVisible(user: User): Promise<PondView[]> {
|
||||
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' },
|
||||
});
|
||||
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 } });
|
||||
this.access.assertCanSee(user, pond);
|
||||
if (!pond) throw new NotFoundException();
|
||||
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 } });
|
||||
this.access.assertCanModify(user, pond);
|
||||
if (!pond) throw new NotFoundException();
|
||||
const settings =
|
||||
input.sidebarSort === undefined
|
||||
? undefined
|
||||
@ -135,7 +159,7 @@ export class PondsService {
|
||||
|
||||
async softDelete(user: User, id: string): Promise<void> {
|
||||
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') {
|
||||
// The personal pond is the account's home — it cannot be trashed.
|
||||
throw new ForbiddenException({ code: 'personal_pond_undeletable' });
|
||||
@ -145,9 +169,8 @@ export class PondsService {
|
||||
data: { deletedAt: new Date(), deletedBy: user.id },
|
||||
});
|
||||
this.logger.info({ pondId: id, userId: user.id }, 'audit: pond trashed');
|
||||
// Trashing a pond is the interim access-relevant change: revalidate any
|
||||
// live collaboration sessions on its pages (issue #39). Real per-user
|
||||
// grant revocation reuses this same notification from M5 on (#53).
|
||||
// Revalidate any live collaboration sessions on the pond's pages
|
||||
// (issue #39); grant changes fire the same notification (#52/#53).
|
||||
await this.accessNotifier.notifyAccessChanged(id);
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from '@dorfteich/shared';
|
||||
import { Prisma, User } from '@prisma/client';
|
||||
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SearchProvider } from './search.provider';
|
||||
|
||||
@ -45,7 +46,10 @@ interface SearchRow {
|
||||
*/
|
||||
@Injectable()
|
||||
export class PostgresSearchProvider extends SearchProvider {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@ -99,6 +103,15 @@ export class PostgresSearchProvider extends SearchProvider {
|
||||
// A phrase that folds to nothing (e.g. only punctuation) matches nothing.
|
||||
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 labelFilter =
|
||||
query.labels && query.labels.length > 0
|
||||
@ -120,21 +133,43 @@ export class PostgresSearchProvider extends SearchProvider {
|
||||
JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL,
|
||||
websearch_to_tsquery('simple', ${normalized}) q
|
||||
WHERE c.search_vector @@ q
|
||||
AND (${user.isSiteAdmin}::boolean OR po.owner_id = ${user.id})
|
||||
${visibility}
|
||||
${scope}
|
||||
${labelFilter}
|
||||
ORDER BY ts_rank(c.search_vector, q) DESC, p.updated_at DESC
|
||||
LIMIT ${SEARCH_RESULT_LIMIT}`);
|
||||
|
||||
return rows.map((row) => ({
|
||||
pageId: row.pageId,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
pondId: row.pondId,
|
||||
pondSlug: row.pondSlug,
|
||||
pondName: row.pondName,
|
||||
labelIds: row.labelIds,
|
||||
snippet: row.snippet,
|
||||
}));
|
||||
// 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,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
pondId: row.pondId,
|
||||
pondSlug: row.pondSlug,
|
||||
pondName: row.pondName,
|
||||
labelIds: row.labelIds,
|
||||
snippet: row.snippet,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PondPermissionCache } from '../permissions/pond-permission-cache';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PostgresSearchProvider } from './postgres-search.provider';
|
||||
|
||||
@ -12,7 +14,10 @@ import { PostgresSearchProvider } from './postgres-search.provider';
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
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 {
|
||||
const count = await provider.reindexAll();
|
||||
console.log(`search:reindex — indexed ${count} page(s)`);
|
||||
|
||||
@ -2,6 +2,7 @@ import { BadRequestException, Controller, Get, Query, Req } from '@nestjs/common
|
||||
import { SearchQuery, SearchResultView, searchQuerySchema } from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||
import { SearchProvider } from './search.provider';
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
@Get('search')
|
||||
@AuthenticatedOnly() // results are permission-filtered inside the provider
|
||||
async query(
|
||||
@Query('q') q: string,
|
||||
@Query('pondId') pondId: string | undefined,
|
||||
|
||||
@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
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';
|
||||
|
||||
describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
||||
@ -69,6 +69,7 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
||||
},
|
||||
});
|
||||
pondId = pond.id;
|
||||
await grantOwnerAdmin(prisma, pondId, owner.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@ -116,6 +117,7 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => {
|
||||
ownerId: owner.id,
|
||||
},
|
||||
});
|
||||
await grantOwnerAdmin(prisma, other.id, owner.id);
|
||||
const otherPage = await prisma.page.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
|
||||
@ -15,3 +15,28 @@ export function createTestPrisma(): PrismaClient {
|
||||
export function uniqueSuffix(): string {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/com
|
||||
import { PageView } from '@dorfteich/shared';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
|
||||
|
||||
import { TrashService } from './trash.service';
|
||||
|
||||
@ -11,17 +12,20 @@ export class TrashController {
|
||||
constructor(private readonly trash: TrashService) {}
|
||||
|
||||
@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[]> {
|
||||
return this.trash.list(request.user!, pondId);
|
||||
}
|
||||
|
||||
@Post('pages/:id/restore')
|
||||
@RequiresPagePermission('write', { idParam: 'id', inTrash: true })
|
||||
async restore(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageView> {
|
||||
return this.trash.restore(request.user!, id);
|
||||
}
|
||||
|
||||
@Delete('pages/:id/purge')
|
||||
@HttpCode(204)
|
||||
@RequiresPagePermission('write', { idParam: 'id', inTrash: true })
|
||||
async purge(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
||||
await this.trash.purgeNow(request.user!, id);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { ClockService } from '../common/clock.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 { QuotaService } from '../quotas/quota.service';
|
||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||
@ -24,7 +24,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
export class TrashService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: InterimAccessService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly pages: PagesService,
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly quotas: QuotaService,
|
||||
@ -35,21 +35,27 @@ export class TrashService {
|
||||
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[]> {
|
||||
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({
|
||||
where: { pondId, deletedAt: { not: null } },
|
||||
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> {
|
||||
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();
|
||||
this.access.assertCanModify(user, page.pond);
|
||||
const restored = await this.prisma.page.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null, deletedBy: null },
|
||||
@ -60,9 +66,8 @@ export class TrashService {
|
||||
|
||||
/** Manual "purge single" (scope's third trash endpoint) — bypasses retention. */
|
||||
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();
|
||||
this.access.assertCanModify(user, page.pond);
|
||||
await this.purgePage(id);
|
||||
this.logger.info({ pageId: id, userId: user.id }, 'audit: page purged from trash');
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import { AuthedRequest, toCurrentUser } from '../auth/auth.guard';
|
||||
import { SessionsService } from '../auth/sessions.service';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
export interface SessionView {
|
||||
@ -30,7 +31,9 @@ export interface SessionView {
|
||||
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')
|
||||
export class UsersController {
|
||||
constructor(
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequiresPagePermission } from '../permissions/permission.decorators';
|
||||
import { VersionsService } from './versions.service';
|
||||
|
||||
/**
|
||||
@ -20,12 +21,14 @@ export class VersionsController {
|
||||
|
||||
/** List the page's versions, newest first. */
|
||||
@Get('pages/:id/versions')
|
||||
@RequiresPagePermission('write', { idParam: 'id' }) // history = write (ADR 0013)
|
||||
async list(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageVersionView[]> {
|
||||
return this.versions.list(request.user!, id);
|
||||
}
|
||||
|
||||
/** A single version rendered read-only, with Markdown for diffing. */
|
||||
@Get('pages/:id/versions/:versionId')
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
async getContent(
|
||||
@Param('id') id: string,
|
||||
@Param('versionId') versionId: string,
|
||||
@ -36,6 +39,7 @@ export class VersionsController {
|
||||
|
||||
/** Create a named version of the page. */
|
||||
@Post('pages/:id/versions')
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
async createNamed(
|
||||
@Param('id') id: string,
|
||||
@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). */
|
||||
@Post('pages/:id/versions/:versionId/restore')
|
||||
@RequiresPagePermission('write', { idParam: 'id' })
|
||||
async restore(
|
||||
@Param('id') id: string,
|
||||
@Param('versionId') versionId: string,
|
||||
|
||||
@ -15,7 +15,6 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
||||
let versions: VersionsService;
|
||||
const suffix = uniqueSuffix();
|
||||
let owner: User;
|
||||
let outsider: User;
|
||||
let pondId: string;
|
||||
const pageIds: string[] = [];
|
||||
|
||||
@ -48,13 +47,6 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
||||
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({
|
||||
data: {
|
||||
slug: `ver-pond-${suffix}`,
|
||||
@ -69,7 +61,7 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
||||
afterAll(async () => {
|
||||
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
|
||||
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 app.close();
|
||||
});
|
||||
@ -121,18 +113,8 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
||||
expect(typeof content.markdown).toBe('string');
|
||||
});
|
||||
|
||||
it('gates list, content, and restore on write access', async () => {
|
||||
const pageId = await createPage();
|
||||
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,
|
||||
);
|
||||
});
|
||||
// Write-access gating of every history route moved to the route guard
|
||||
// with #52 — covered by the permission e2e pack.
|
||||
|
||||
it('restore returns the target version and emits without mutating history', async () => {
|
||||
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 () => {
|
||||
const pageId = await createPage();
|
||||
const daysAgo = (days: number, hour: number): Date => {
|
||||
|
||||
@ -7,12 +7,11 @@ import {
|
||||
PageVersionTrigger,
|
||||
PageVersionView,
|
||||
} 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 * as Y from 'yjs';
|
||||
|
||||
import { deriveContent } from '../pages/yjs-content';
|
||||
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
@ -39,7 +38,6 @@ const TRIGGER_TO_VIEW: Record<PrismaTrigger, PageVersionTrigger> = {
|
||||
export class VersionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: InterimAccessService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
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
|
||||
* same permission as editing (permissions.md §UI obligations / ADR 0013), so
|
||||
* every history operation gates on `assertCanModify`.
|
||||
* Load a live page. Viewing history requires the same permission as
|
||||
* editing (ADR 0013) — the guard enforces write access on every history
|
||||
* route (#52); here only existence is checked.
|
||||
*/
|
||||
private async findModifiablePage(user: User, pageId: string): Promise<Page & { pond: Pond }> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { pond: true },
|
||||
});
|
||||
private async findLivePage(pageId: string): Promise<Page> {
|
||||
const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null } });
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanModify(user, page.pond);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** The page's versions, newest first (no snapshot bytes). Write access only. */
|
||||
async list(user: User, pageId: string): Promise<PageVersionView[]> {
|
||||
await this.findModifiablePage(user, pageId);
|
||||
async list(_user: User, pageId: string): Promise<PageVersionView[]> {
|
||||
await this.findLivePage(pageId);
|
||||
const versions = await this.prisma.pageVersion.findMany({
|
||||
where: { pageId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@ -85,8 +79,12 @@ export class VersionsService {
|
||||
}
|
||||
|
||||
/** A single version rendered read-only (HTML) with its Markdown for diffing. */
|
||||
async getContent(user: User, pageId: string, versionId: string): Promise<PageVersionContentView> {
|
||||
await this.findModifiablePage(user, pageId);
|
||||
async getContent(
|
||||
_user: User,
|
||||
pageId: string,
|
||||
versionId: string,
|
||||
): Promise<PageVersionContentView> {
|
||||
await this.findLivePage(pageId);
|
||||
const version = await this.prisma.pageVersion.findFirst({
|
||||
where: { id: versionId, pageId },
|
||||
});
|
||||
@ -104,7 +102,7 @@ export class VersionsService {
|
||||
* Returns the version being restored.
|
||||
*/
|
||||
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({
|
||||
where: { id: versionId, pageId },
|
||||
omit: { ydocSnapshot: true },
|
||||
@ -132,12 +130,7 @@ export class VersionsService {
|
||||
pageId: string,
|
||||
input: CreateVersionInput,
|
||||
): Promise<PageVersionView> {
|
||||
const page = await this.prisma.page.findFirst({
|
||||
where: { id: pageId, deletedAt: null },
|
||||
include: { pond: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
this.access.assertCanModify(user, page.pond);
|
||||
await this.findLivePage(pageId);
|
||||
|
||||
const snapshot = await this.reconstructSnapshot(pageId);
|
||||
|
||||
|
||||
@ -103,3 +103,10 @@ flowchart LR
|
||||
(see ADR 0012), with German and English translations added in the same
|
||||
change.
|
||||
- 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).
|
||||
|
||||
@ -36,6 +36,9 @@
|
||||
"grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.",
|
||||
"grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",
|
||||
"grant_scope_id_mismatch": "Der Geltungsbereich der Berechtigung ist widersprüchlich.",
|
||||
"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": {
|
||||
"required": "Dieses Feld ist erforderlich.",
|
||||
"taken": "Dieser Wert ist bereits vergeben.",
|
||||
|
||||
@ -36,6 +36,9 @@
|
||||
"grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.",
|
||||
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
||||
"grant_scope_id_mismatch": "The grant's scope is inconsistent.",
|
||||
"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": {
|
||||
"required": "This field is required.",
|
||||
"taken": "This value is already taken.",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './resolve';
|
||||
export * from './pond';
|
||||
export * from './schemas';
|
||||
export * from './validate';
|
||||
|
||||
107
packages/shared/src/permissions/pond.test.ts
Normal file
107
packages/shared/src/permissions/pond.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
packages/shared/src/permissions/pond.ts
Normal file
49
packages/shared/src/permissions/pond.ts
Normal 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));
|
||||
}
|
||||
@ -11,8 +11,9 @@ import type { Grant, PageResolutionContext, PermissionAction, PermissionViewer }
|
||||
* → deny (default-closed). Site Admins bypass everything.
|
||||
*/
|
||||
|
||||
/** Does a grant's subject apply to the viewer? */
|
||||
function subjectMatches(grant: Grant, viewer: PermissionViewer): boolean {
|
||||
/** Does a grant's subject apply to the viewer? (Shared with the pond-scope
|
||||
* resolution in {@link ./pond}.) */
|
||||
export function subjectMatches(grant: Grant, viewer: PermissionViewer): boolean {
|
||||
switch (grant.subjectType) {
|
||||
case 'public':
|
||||
return true;
|
||||
|
||||
47
packages/shared/src/permissions/schemas.ts
Normal file
47
packages/shared/src/permissions/schemas.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user