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
170 lines
6.0 KiB
TypeScript
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 } };
|
|
}
|
|
}
|