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 { 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 { 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 { 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 { 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 { 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> { 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(); 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(); 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 { 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 { const ids = await this.visiblePondIds(user); return ids === null ? {} : { id: { in: ids } }; } }