dorfteich/apps/api/src/grants/grants.service.ts
Claude Opus 4.8 406886c56c
Some checks failed
CD / Build and push images (push) Successful in 3m5s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Failing after 2m0s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s
Add label- and page-scope access rules UI including deny (#55)
Pond Admins configure the vision's fine-grained cases through a plain-language
surface, on top of the base roles from #54.

- shared: `AccessRuleView` (a grant enriched with subject/scope display names)
  and pure conflict helpers `scopeSpecificity`/`sameGrantSubject`/
  `isRuleShadowed` (unit-tested) for the client-side shadowed-rule hint. New
  `access` i18n namespace (de+en) with sentence templates (ADR 0012).
- api: `GET /ponds/:id/grants/access-rules` (Pond-Admin) returns the pond's
  grants enriched with each user's display name and each label/page scope's
  name, resolved in one batched query per kind.
- web `access/`: `AccessRulesManager` in Pond Settings — the pond's rules
  grouped by subject and rendered as readable de/en sentences ("Anna may not
  edit pages labeled “Confidential”"), an add form (subject = member or the
  `signed-in`/`public` pseudo-subjects; scope = label from the tree or a
  specific page; role; allow/deny) that warns when a rule would be shadowed by
  a more specific existing one (shared algorithm) and requires an explicit
  confirmation before granting anything to `public`. Semantics are the shared
  resolver's — the UI only reflects permissions.md.
- tests: shared `conflicts.test.ts`; an api db case for the enriched endpoint;
  a browser `access-rules` pack that configures BOTH vision patterns through
  the UI and verifies their effect end to end — "deny label X" (an editor
  loses a labelled page) and "only label Y" (a signed-in non-member, new
  `fixture-viewer`, reads only the labelled pages) — plus the shadow hint and
  the public confirmation, with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 21:45:31 +02:00

221 lines
8.0 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AccessRuleView, Grant, GrantView, grantValidationError } from '@dorfteich/shared';
import { Pond, RoleGrant, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
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`, 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 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 (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 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 } });
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));
}
/**
* A pond's grants enriched with the display names the access-rules UI renders
* as sentences (issue #55): each user subject's display name and each
* label/page scope's name, resolved in one batched query per kind so the
* client needs no id lookups.
*/
async listAccessRules(pondId: string): Promise<AccessRuleView[]> {
await this.requireLivePond(pondId);
const rows = await this.prisma.roleGrant.findMany({
where: { pondId },
orderBy: { createdAt: 'asc' },
});
const userIds = rows.filter((r) => r.subjectId).map((r) => r.subjectId!);
const labelIds = rows
.filter((r) => r.scopeType === 'LABEL' && r.scopeId)
.map((r) => r.scopeId!);
const pageIds = rows.filter((r) => r.scopeType === 'PAGE' && r.scopeId).map((r) => r.scopeId!);
const [users, labels, pages] = await Promise.all([
this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, displayName: true },
}),
this.prisma.label.findMany({
where: { id: { in: labelIds } },
select: { id: true, name: true },
}),
this.prisma.page.findMany({
where: { id: { in: pageIds } },
select: { id: true, title: true },
}),
]);
const userName = new Map(users.map((u) => [u.id, u.displayName]));
const labelName = new Map(labels.map((l) => [l.id, l.name]));
const pageTitle = new Map(pages.map((p) => [p.id, p.title]));
return rows.map((row) => {
const base = GrantsService.viewOf(row);
const scopeName =
row.scopeType === 'LABEL'
? (labelName.get(row.scopeId ?? '') ?? null)
: row.scopeType === 'PAGE'
? (pageTitle.get(row.scopeId ?? '') ?? null)
: null;
return {
...base,
subjectName: row.subjectId ? (userName.get(row.subjectId) ?? null) : null,
scopeName,
};
});
}
/** 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, no extra admins on a
* personal pond, and scope/subject must exist here. Rejects duplicates.
*/
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({
where: { pondId, ...columns },
select: { id: true },
});
if (existing) throw new ConflictException({ code: 'grant_exists' });
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,
subject: grant.subjectType,
subjectId: grant.subjectId,
role: grant.role,
scope: grant.scopeType,
scopeId: grant.scopeId,
effect: grant.effect,
},
'audit: grant created',
);
return GrantsService.viewOf(created);
}
/**
* 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();
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 } });
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);
}
}