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
316 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|