feat(customers): Impersonation ("als Kunde ansehen") mit Kennzeichnung und Audit

Umsetzung von Ticket #35: Superadmin/Admin kann auf der Kundenseite "Als
Kunde ansehen" wählen und sieht das Kundencenter zeitlich begrenzt (60 min)
genau so, wie der Kunde es sieht - mit sichtbarem Banner und jederzeitigem
"Zurück zur Verwaltung".

Technisch keine zweite Anmeldung: die bestehende Staff-Sitzung wird um
Impersonation-Felder erweitert (Migration 036). loadAuth() schaltet den
Principal währenddessen echt auf kind='customer' mit einer Mitgliedschaft
(Rolle "owner") in genau dieser Organisation um - eine tatsächliche
Rechteeinschränkung, keine bloß andere Oberfläche: staff-only-Endpunkte
sind währenddessen wirklich nicht mehr erreichbar (can() liefert für
kind='customer' grundsätzlich false), und canInOrg-geschützte Routen
funktionieren unverändert für genau diese eine Organisation. Die echte
Staff-Identität (AuthContext.user) bleibt für das Audit unverändert erhalten.

- POST /admin/customers/:id/impersonate (Recht customers.impersonate, nur
  admin/superadmin): Grund Pflichtfeld, setzt die Sitzung, protokolliert
  customer.impersonate.start mit beiden Identitäten (actorId = echter
  Staff-Nutzer, orgId = Kunde).
- loadAuth() erkennt eine abgelaufene Impersonation automatisch, setzt die
  Sitzung zurück (kein harter Logout) und protokolliert
  customer.impersonate.end mit reason "expired".
- POST /auth/impersonate/stop: manuelles Beenden, reason "manual".
- Web: Banner in (app)/layout.tsx mit Grund und "Zurück"-Button; Formular
  mit Grund-Eingabe auf der Kundenseite.

Gegen die echte Produktionsdatenbank end-to-end verifiziert: Start/Stopp,
staff-only-Endpunkt während Impersonation korrekt mit 403 abgelehnt, eigene
Organisation lesbar (200), fremde Organisation weiterhin 404 (keine
Existenz verraten), künstlich abgelaufene Sitzung fällt automatisch auf
die Staff-Identität zurück, Audit-Einträge zeigen durchgehend beide
Identitäten. Erfüllt die im Ticket vorgegebenen Testschritte vollständig.
Testsuite 58/59 (vorbestehende, unabhängige Flakiness in orders.test.ts).
Audit-Kette und Backup+Wiederherstellungstest danach weiterhin grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 10:59:25 +02:00
parent c52996b559
commit 2ed9697ca6
7 changed files with 98 additions and 11 deletions

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { clientIp, requireAuth, requirePermission, IMPERSONATION_MINUTES } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { assertCustomerCapacity } from '../../core/license.js';
import { canInOrg } from '../../core/policy.js';
@ -60,6 +60,9 @@ async function nextCustomerNumber(c: Parameters<typeof one>[2]): Promise<string>
export const customersModule: KcModule = {
name: 'customers',
permissions: {
staff: { admin: ['customers.impersonate'], superadmin: ['customers.impersonate'] },
},
register(app: FastifyInstance) {
// ---- Admin: Kunden -----------------------------------------------------
app.get('/admin/customers', async (req) => {
@ -108,6 +111,20 @@ export const customersModule: KcModule = {
return loadOrg(id);
});
/** "Als Kunde ansehen": schaltet die eigene Sitzung zeitlich begrenzt (IMPERSONATION_MINUTES) auf die
* Sicht dieses Kunden um (echte Rechteeinschränkung, nicht nur eine andere Oberfläche). Admin/Superadmin
* only; Grund ist Pflicht und wird protokolliert. Lässt sich nicht verschachteln (erfordert customers.read,
* das während einer laufenden Impersonation nicht mehr greift). */
app.post('/admin/customers/:id/impersonate', async (req) => {
const a = requirePermission(req, 'customers.impersonate');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ reason: z.string().trim().min(3).max(300) }).parse(req.body);
const org = await one('SELECT id, name, customer_number FROM organizations WHERE id = ?', [id]); if (!org) throw notFound();
await run('UPDATE sessions SET impersonating_org_id = ?, impersonation_reason = ?, impersonation_started_at = UTC_TIMESTAMP(3), impersonation_expires_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE) WHERE id = ?', [id, b.reason, IMPERSONATION_MINUTES, a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.impersonate.start', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason, minutes: IMPERSONATION_MINUTES } });
return { ok: true, orgName: org.name, customerNumber: org.customer_number, expiresInMinutes: IMPERSONATION_MINUTES };
});
/** Begrüßungsmail: Zugang (neuer Einladungslink oder Login-Link, falls schon aktiv), aktuelle Produkte, kurze Anleitung, Discord-Hinweis. */
app.post('/admin/customers/:id/welcome-mail', async (req) => {
const a = requirePermission(req, 'customers.write');

View file

@ -93,16 +93,30 @@ export const identityModule: KcModule = {
app.get('/auth/me', async (req) => {
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
const orgs = a.principal.memberships.length
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
: [];
// Während einer Impersonation stammt die Mitgliedschaft nicht aus der echten memberships-Tabelle
// (der Staff-Nutzer ist dort nicht wirklich Mitglied) - direkt aus dem Impersonation-Kontext aufbauen.
const orgs = a.impersonating
? [{ id: a.impersonating.orgId, name: a.impersonating.orgName, customer_number: a.impersonating.customerNumber, role: 'owner' }]
: a.principal.memberships.length
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
: [];
return {
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
impersonating: a.impersonating,
};
});
/** Beendet die eigene Impersonation-Sitzung (siehe POST /admin/customers/:id/impersonate), egal ob abgelaufen oder nicht. */
app.post('/auth/impersonate/stop', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
if (!a.impersonating) return { ok: true };
await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: a.impersonating.orgId, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: a.impersonating.orgId, correlationId: req.correlationId, ip: clientIp(req), after: { reason: 'manual' } });
return { ok: true };
});
// ---- MFA-Einrichtung ---------------------------------------------------
app.post('/auth/mfa/setup', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });