dorfteich/apps/api/src/admin/user-admin.service.ts
Claude Fable 5 6aac785841
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 3m0s
CI / Auth e2e pack (pull_request) Successful in 8m49s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m28s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m15s
CI / Import/export fidelity gate (push) Successful in 59s
#217: map IdP groups and roles onto the permission model
Declarative instance setting idpMapping.rules turns ID-token claims into
pond roles and the site-admin flag on every OIDC login — configuration,
not code. Mapped grants travel through the SAME GrantsService path as
manual ones (permission cache invalidated, collab access notify fires so
live sessions revalidate — asserted by test), never raw rows.

Ownership makes precedence explicit: role_grants.origin marks mapped
rows, users.is_site_admin_managed marks a mapping-set admin flag. The
mapping only creates and revokes what it owns — manual wins: hand-made
grants and hand-promoted admins are never revoked by a missing claim (a
manual toggle clears the marker and takes ownership). Removal of a claim
revokes the mapped grant and the managed flag on the next login. Every
mapping-driven change is audited with origin idp_mapping.

Failure containment: unknown pond slugs and the last-Pond-Admin
protection log-and-skip — a mapping problem must never become a login
lockout. Tests drive real OIDC logins against the fake IdP with group
claims: grant + working access, revocation incl. notify, manual-wins,
managed site-admin promote/demote/hands-off.

Documented in permissions.md (own section), ADR 0021, data-model.md and
the hardening guide (care rule: same PR).

Refs #217.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 13:09:11 +02:00

164 lines
5.6 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
AdminUserListQuery,
AdminUserListView,
AdminUserStatus,
AdminUserView,
} from '@dorfteich/shared';
import { Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuthService } from '../auth/auth.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { PseudonymizationService } from './pseudonymization.service';
/**
* Site-Admin user administration (issue #59): the searchable list plus the
* lifecycle actions (disable/enable, resend verification, delete, grant/revoke
* Site Admin). Guards protect the operator from locking the instance out —
* you cannot act on your own account, and the last Site Admin cannot be
* dropped. Every action is audit-logged with the actor; Site-Admin gating is
* the controller's job.
*/
@Injectable()
export class UserAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly pseudonymizer: PseudonymizationService,
private readonly auth: AuthService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(UserAdminService.name);
}
async list(query: AdminUserListQuery): Promise<AdminUserListView> {
const q = query.q?.trim();
const where: Prisma.UserWhereInput = q
? {
OR: [
{ username: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ displayName: { contains: q, mode: 'insensitive' } },
],
}
: {};
const [total, rows] = await Promise.all([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
const owned = await this.prisma.pond.groupBy({
by: ['ownerId'],
where: { ownerId: { in: rows.map((u) => u.id) }, deletedAt: null },
_count: { _all: true },
});
const pondCount = new Map(owned.map((o) => [o.ownerId, o._count._all]));
return {
users: rows.map((u) => this.viewOf(u, pondCount.get(u.id) ?? 0)),
total,
page: query.page,
pageSize: query.pageSize,
};
}
async setDisabled(actor: User, id: string, disabled: boolean): Promise<AdminUserView> {
await this.requireOther(actor, id);
const updated = await this.prisma.user.update({
where: { id },
data: { status: disabled ? 'DISABLED' : 'ACTIVE' },
});
// A disabled user is logged out everywhere; login then blocks with a
// distinct message (auth.service: account_disabled).
if (disabled) await this.prisma.session.deleteMany({ where: { userId: id } });
await this.audit.record({
action: 'user.disabled_set',
actorId: actor.id,
targetType: 'user',
targetId: id,
details: { disabled },
});
return this.viewOf(updated, await this.pondCountOf(id));
}
async resendVerification(actor: User, id: string): Promise<void> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException();
await this.auth.resendVerification(user.email); // no-op unless PENDING
await this.audit.record({
action: 'user.verification_resent',
actorId: actor.id,
targetType: 'user',
targetId: id,
});
}
async deleteUser(actor: User, id: string): Promise<void> {
const user = await this.requireOther(actor, id);
if (user.isSiteAdmin) await this.assertNotLastSiteAdmin();
await this.pseudonymizer.pseudonymize(id);
await this.audit.record({
action: 'user.deleted',
actorId: actor.id,
targetType: 'user',
targetId: id,
});
}
async setSiteAdmin(actor: User, id: string, value: boolean): Promise<AdminUserView> {
const user = await this.requireOther(actor, id); // not on self
if (!value && user.isSiteAdmin) await this.assertNotLastSiteAdmin();
const updated = await this.prisma.user.update({
where: { id },
// A manual toggle takes ownership of the flag: the IdP mapping
// (#217) may only revoke what it itself set.
data: { isSiteAdmin: value, isSiteAdminManaged: false },
});
await this.audit.record({
action: 'user.site_admin_set',
actorId: actor.id,
targetType: 'user',
targetId: id,
details: { isSiteAdmin: value },
});
return this.viewOf(updated, await this.pondCountOf(id));
}
private async requireOther(actor: User, id: string): Promise<User> {
if (id === actor.id) throw new BadRequestException({ code: 'cannot_modify_self' });
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new NotFoundException();
return user;
}
private async assertNotLastSiteAdmin(): Promise<void> {
const admins = await this.prisma.user.count({
where: { isSiteAdmin: true, status: { not: 'DISABLED' } },
});
if (admins <= 1) throw new BadRequestException({ code: 'last_site_admin' });
}
private async pondCountOf(id: string): Promise<number> {
return this.prisma.pond.count({ where: { ownerId: id, deletedAt: null } });
}
private viewOf(user: User, pondCount: number): AdminUserView {
return {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
status: user.status as AdminUserStatus,
isSiteAdmin: user.isSiteAdmin,
createdAt: user.createdAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
pondCount,
};
}
}