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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { this.permissionCache.invalidate(pondId); await this.accessNotifier.notifyAccessChanged(pondId); } }