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
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
164 lines
5.6 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|