dorfteich/apps/api/src/members/members.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

316 lines
11 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
AddMemberInput,
ChangeMemberRoleInput,
Grant,
MemberRole,
MemberView,
PondMembersView,
grantValidationError,
} from '@dorfteich/shared';
import { Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { toGrant, toGrantColumns } from '../grants/grant-mappers';
import { PermissionService } from '../permissions/permission.service';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { QuotaService, quotaExceeded } from '../quotas/quota.service';
import { UsersService } from '../users/users.service';
/** Highest capability wins when a user somehow holds several pond-scope grants. */
const ROLE_RANK: Record<MemberRole, number> = { reader: 0, editor: 1, pond_admin: 2 };
/**
* Pond membership management (issue #54): a member-centric view over the
* pond-scope user grants (#51/#52). Adding by exact username/e-mail keeps the
* user directory private; editor/reader seats are enforced against the
* per-pond quotas (#22). Personal-pond and last-admin rules come from the
* shared grant model and the grant table, respectively. Every change
* invalidates the pond's permission cache and notifies the collab server so
* live sessions re-validate at once (#39/#53).
*/
@Injectable()
export class MembersService {
constructor(
private readonly prisma: PrismaService,
private readonly users: UsersService,
private readonly quotas: QuotaService,
private readonly permissions: PermissionService,
private readonly permissionCache: PondPermissionCache,
private readonly accessNotifier: PondAccessNotifier,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(MembersService.name);
}
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;
}
private static pondScopeUserGrant(pondId: string, userId: string) {
return {
pondId,
subjectType: 'USER' as const,
subjectId: userId,
scopeType: 'POND' as const,
effect: 'ALLOW' as const,
};
}
private grantOf(role: MemberRole, userId: string): Grant {
return {
subjectType: 'user',
subjectId: userId,
role,
scopeType: 'pond',
scopeId: null,
effect: 'allow',
};
}
private static seatKey(role: 'editor' | 'reader'): 'editors_per_pond' | 'readers_per_pond' {
return role === 'editor' ? 'editors_per_pond' : 'readers_per_pond';
}
/** The member list, seat usage, and whether the caller may manage (issue #54). */
async list(user: User | null, pondId: string): Promise<PondMembersView> {
const pond = await this.requireLivePond(pondId);
const grants = await this.prisma.roleGrant.findMany({
where: { pondId, subjectType: 'USER', scopeType: 'POND', effect: 'ALLOW' },
});
// A user's effective role is the strongest grant they hold at pond scope.
const roleByUser = new Map<string, MemberRole>();
for (const row of grants) {
const userId = row.subjectId;
if (!userId) continue;
const role = toGrant(row).role;
const current = roleByUser.get(userId);
if (!current || ROLE_RANK[role] > ROLE_RANK[current]) roleByUser.set(userId, role);
}
const users = await this.prisma.user.findMany({
where: { id: { in: [...roleByUser.keys()] } },
select: { id: true, username: true, displayName: true },
});
const members: MemberView[] = users
.map((u) => ({
userId: u.id,
username: u.username,
displayName: u.displayName,
role: roleByUser.get(u.id)!,
isOwner: u.id === pond.ownerId,
}))
.sort(
(a, b) =>
ROLE_RANK[b.role] - ROLE_RANK[a.role] || a.displayName.localeCompare(b.displayName),
);
const editorLimit = await this.quotas.getEffective('editors_per_pond', {
pondId,
userId: pond.ownerId,
});
const readerLimit = await this.quotas.getEffective('readers_per_pond', {
pondId,
userId: pond.ownerId,
});
const canManage = await this.permissions.hasPondRole(user, pondId, 'pond_admin');
return {
members,
seats: {
editor: { used: members.filter((m) => m.role === 'editor').length, limit: editorLimit },
reader: { used: members.filter((m) => m.role === 'reader').length, limit: readerLimit },
},
pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared',
canManage,
};
}
/** Add a user (by exact username or e-mail) as a member with a role. */
async add(actor: User, pondId: string, input: AddMemberInput): Promise<MemberView> {
const pond = await this.requireLivePond(pondId);
const target = await this.users.findByUsernameOrEmail(input.usernameOrEmail);
if (!target) throw new BadRequestException({ code: 'member_not_found' });
const grant = this.grantOf(input.role, target.id);
this.assertGrantValid(grant, pond);
await this.prisma.$transaction(async (tx) => {
await this.lockMembership(tx, pondId);
const existing = await tx.roleGrant.count({
where: MembersService.pondScopeUserGrant(pondId, target.id),
});
if (existing > 0) throw new ConflictException({ code: 'member_exists' });
await this.assertSeatAvailable(tx, pond, input.role, null);
await tx.roleGrant.create({
data: { pondId, createdBy: actor.id, ...toGrantColumns(grant) },
});
});
await this.accessChanged(pondId);
await this.audit.record({
action: 'member.added',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { member: target.id, role: input.role },
});
return this.viewOf(target, input.role, pond);
}
/** Change a member's role, replacing their pond-scope grant. */
async changeRole(
actor: User,
pondId: string,
memberUserId: string,
input: ChangeMemberRoleInput,
): Promise<MemberView> {
const pond = await this.requireLivePond(pondId);
if (memberUserId === pond.ownerId) throw new ConflictException({ code: 'member_is_owner' });
const target = await this.prisma.user.findUnique({
where: { id: memberUserId },
select: { id: true, username: true, displayName: true },
});
if (!target) throw new NotFoundException({ code: 'member_not_a_member' });
const grant = this.grantOf(input.role, memberUserId);
this.assertGrantValid(grant, pond);
const changed = await this.prisma.$transaction(async (tx) => {
await this.lockMembership(tx, pondId);
const current = await tx.roleGrant.findMany({
where: MembersService.pondScopeUserGrant(pondId, memberUserId),
});
if (current.length === 0) throw new NotFoundException({ code: 'member_not_a_member' });
// Already exactly this role: nothing to do.
if (current.length === 1 && toGrant(current[0]!).role === input.role) return false;
// Demoting away from admin must leave at least one Pond Admin behind.
if (current.some((g) => g.role === 'POND_ADMIN') && input.role !== 'pond_admin') {
await this.assertNotLastAdmin(tx, pondId);
}
// The replaced grants free their own seat, so exclude this user from the count.
await this.assertSeatAvailable(tx, pond, input.role, memberUserId);
await tx.roleGrant.deleteMany({
where: MembersService.pondScopeUserGrant(pondId, memberUserId),
});
await tx.roleGrant.create({
data: { pondId, createdBy: actor.id, ...toGrantColumns(grant) },
});
return true;
});
if (changed) {
await this.accessChanged(pondId);
await this.audit.record({
action: 'member.role_changed',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { member: memberUserId, role: input.role },
});
}
return this.viewOf(target, input.role, pond);
}
/** Remove a member (drops their pond-scope grant). */
async remove(actor: User, pondId: string, memberUserId: string): Promise<void> {
const pond = await this.requireLivePond(pondId);
if (memberUserId === pond.ownerId) throw new ConflictException({ code: 'member_is_owner' });
await this.prisma.$transaction(async (tx) => {
await this.lockMembership(tx, pondId);
const current = await tx.roleGrant.findMany({
where: MembersService.pondScopeUserGrant(pondId, memberUserId),
});
if (current.length === 0) throw new NotFoundException({ code: 'member_not_a_member' });
if (current.some((g) => g.role === 'POND_ADMIN')) await this.assertNotLastAdmin(tx, pondId);
await tx.roleGrant.deleteMany({
where: MembersService.pondScopeUserGrant(pondId, memberUserId),
});
});
await this.accessChanged(pondId);
await this.audit.record({
action: 'member.removed',
actorId: actor.id,
targetType: 'pond',
targetId: pondId,
details: { member: memberUserId },
});
}
private assertGrantValid(grant: Grant, pond: Pond): void {
const invalid = grantValidationError(grant, {
pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared',
});
if (invalid) throw new BadRequestException({ code: invalid });
}
/** Serialize concurrent membership changes on one pond (seat counts can't race). */
private async lockMembership(tx: Prisma.TransactionClient, pondId: string): Promise<void> {
// ::text because Prisma cannot deserialize the function's void result.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${`members:${pondId}`}))::text`;
}
private async assertNotLastAdmin(tx: Prisma.TransactionClient, pondId: string): Promise<void> {
const admins = await tx.roleGrant.count({
where: { pondId, role: 'POND_ADMIN', effect: 'ALLOW' },
});
if (admins <= 1) throw new ConflictException({ code: 'grant_last_admin' });
}
/** Enforce editor/reader seat quotas; admins are unlimited. */
private async assertSeatAvailable(
tx: Prisma.TransactionClient,
pond: Pond,
role: MemberRole,
excludeUserId: string | null,
): Promise<void> {
if (role === 'pond_admin') return;
const key = MembersService.seatKey(role);
const limit = await this.quotas.getEffective(key, { pondId: pond.id, userId: pond.ownerId });
const used = await tx.roleGrant.count({
where: {
pondId: pond.id,
role: role === 'editor' ? 'EDITOR' : 'READER',
scopeType: 'POND',
subjectType: 'USER',
effect: 'ALLOW',
...(excludeUserId ? { subjectId: { not: excludeUserId } } : {}),
},
});
if (used >= limit) throw quotaExceeded(key, limit);
}
private viewOf(
user: { id: string; username: string; displayName: string },
role: MemberRole,
pond: Pond,
): MemberView {
return {
userId: user.id,
username: user.username,
displayName: user.displayName,
role,
isOwner: user.id === pond.ownerId,
};
}
private async accessChanged(pondId: string): Promise<void> {
this.permissionCache.invalidate(pondId);
await this.accessNotifier.notifyAccessChanged(pondId);
}
}