dorfteich/apps/api/src/grants/grants.service.ts
Claude Fable 5 c8aac13dfb
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m14s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m20s
CI / Import/export fidelity gate (push) Successful in 45s
Add Site-Admin system panel with persistent audit trail (#86)
New /admin/system panel (operations.md §Maintenance jobs): the maintenance
job list shows every registered job with truthful last-run data (new
Job.lastDurationMs recorded by the scheduler) and a manual trigger that
respects the run-mutex and is itself audit-logged; a backup card mirrors
the sidecar's status.json including the freshness verdict; an audit-log
viewer filters by actor, action, and time range with pagination; and a
storage overview lists the largest ponds. Auth events and admin actions
(grants, members, user/quota admin, plugins, settings, setup) now land in
a new audit_log table through a central AuditService — which keeps
emitting the established stdout log line — while content activity stays
log-only by design. All endpoints are Site-Admin-only; covered by API DB
tests and a Playwright pack in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 20:03:05 +02:00

227 lines
8.2 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 { AuditService } from '../audit/audit.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`, 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 audit: AuditService,
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);
await this.audit.record({
action: 'grant.created',
actorId: user.id,
targetType: 'pond',
targetId: pondId,
details: {
grantId: created.id,
subject: grant.subjectType,
subjectId: grant.subjectId,
role: grant.role,
scope: grant.scopeType,
scopeId: grant.scopeId,
effect: grant.effect,
},
});
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);
await this.audit.record({
action: 'grant.deleted',
actorId: user.id,
targetType: 'pond',
targetId: pondId,
details: { grantId, subjectId: grant.subjectId, role: grant.role },
});
}
/** 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);
}
}