dorfteich/apps/api/src/permissions/permission.service.ts
Claude Fable 5 0c6494f209
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
Enforce permissions in API guards and retire interim access (#52)
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
2026-07-09 16:31:41 +02:00

170 lines
6.0 KiB
TypeScript

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