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
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
227 lines
8.2 KiB
TypeScript
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);
|
|
}
|
|
}
|