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

@ -4,12 +4,15 @@ import { one, query, run } from './db.js';
import { config } from './config.js';
import { randomToken, sha256, safeEqual } from './crypto.js';
import { forbidden, unauthorized } from './errors.js';
import { audit } from './audit.js';
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
export const COOKIE = 'kc_session';
const SESSION_HOURS = 12;
const IDLE_MINUTES = 120;
export const IMPERSONATION_MINUTES = 60;
export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string }
export interface AuthContext {
sessionId: string;
csrf: string;
@ -17,6 +20,10 @@ export interface AuthContext {
mfaEnrolled: boolean;
user: { id: string; email: string; name: string };
principal: Principal;
/** Gesetzt, während ein Staff-Mitglied "als Kunde" unterwegs ist (siehe customers-Modul, /admin/customers/:id/impersonate).
* principal ist in diesem Fall absichtlich auf 'customer' mit Mitgliedschaft in genau dieser Organisation umgeschaltet -
* echte Rechteeinschränkung, nicht nur eine andere Oberfläche. user bleibt die echte Staff-Identität (fürs Audit). */
impersonating: Impersonating | null;
}
declare module 'fastify' {
interface FastifyRequest { auth?: AuthContext; correlationId: string }
@ -39,7 +46,8 @@ async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
const token = req.cookies[COOKIE];
if (!token) return undefined;
const s = await one(
`SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
`SELECT s.id, s.csrf_token, s.pending_mfa, s.impersonating_org_id, s.impersonation_reason, s.impersonation_started_at, s.impersonation_expires_at,
u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
@ -47,12 +55,30 @@ async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
[sha256(token), IDLE_MINUTES],
);
if (!s || s.status !== 'active') return undefined;
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
let principal: Principal = { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: [] };
let impersonating: Impersonating | null = null;
if (s.impersonating_org_id) {
if (new Date(s.impersonation_expires_at as Date) <= new Date()) {
// Abgelaufen: Sitzung fällt automatisch auf die echte Staff-Identität zurück, kein harter Logout.
await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [s.id]);
await audit({ actorType: 'user', actorId: s.uid, orgId: s.impersonating_org_id, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: s.impersonating_org_id, after: { reason: 'expired' } }).catch(() => undefined);
} else {
const org = await one('SELECT name, customer_number FROM organizations WHERE id = ?', [s.impersonating_org_id]);
if (org) {
principal = { userId: s.uid, kind: 'customer', staffRole: null, memberships: [{ orgId: s.impersonating_org_id, role: 'owner' }] };
impersonating = { orgId: s.impersonating_org_id, orgName: org.name, customerNumber: org.customer_number, reason: s.impersonation_reason, startedAt: s.impersonation_started_at, expiresAt: s.impersonation_expires_at };
}
}
}
if (!impersonating) {
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
principal.memberships = ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole }));
}
return {
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
user: { id: s.uid, email: s.email, name: s.name },
principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) },
user: { id: s.uid, email: s.email, name: s.name }, principal, impersonating,
};
}

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 });

View file

@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { SecretField } from '@/components/SecretField';
@ -14,9 +14,10 @@ interface CRow { id: string; number: string; status: string; productName: string
interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] }
interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean }
export default function Kunde() {
const { id } = useParams<{ id: string }>(); const { can } = useSession();
const { id } = useParams<{ id: string }>(); const { can, reload } = useSession(); const r = useRouter();
const [contracts, setContracts] = useState<CRow[] | null>(null); const [orders, setOrders] = useState<ORow[] | null>(null); const [ress, setRess] = useState<RRow[] | null>(null);
const [o, setO] = useState<Org | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState<string | null>(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null);
const [impersonateOpen, setImpersonateOpen] = useState(false); const [impersonateBusy, setImpersonateBusy] = useState(false);
const load = useCallback(() => api<Org>('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); api<CRow[]>('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api<ORow[]>('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api<RRow[]>('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]);
if (!o) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
@ -37,6 +38,13 @@ export default function Kunde() {
async function setStatus(status: string) {
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function impersonate(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setImpersonateBusy(true); const f = new FormData(e.currentTarget);
try {
await api('POST', `/admin/customers/${id}/impersonate`, { reason: String(f.get('reason') ?? '').trim() });
await reload(); r.push('/dashboard');
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setImpersonateBusy(false); }
}
const [welcomeBusy, setWelcomeBusy] = useState(false);
async function sendWelcome() {
setWelcomeBusy(true);
@ -45,9 +53,15 @@ export default function Kunde() {
}
return (<>
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
<div className="row between"><h1>{o.name}</h1><div className="row">{can('customers.impersonate') && <button className="btn" onClick={() => setImpersonateOpen(!impersonateOpen)} aria-expanded={impersonateOpen}>Als Kunde ansehen</button>}<Status value={o.status} /></div></div>
<p className="muted"><span className="mono">{o.customerNumber}</span> · <span className="badge info">{o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</span></p>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{impersonateOpen && <form className="card" onSubmit={impersonate} role="alertdialog" aria-labelledby="imp-h">
<h2 id="imp-h">Als Kunde ansehen</h2>
<p className="small muted">Schaltet deine Sitzung für 60 Minuten auf die Sicht dieses Kunden um (sichtbarer Banner, jederzeit zurück möglich). Wird protokolliert.</p>
<Field id="reason" label="Grund" hint="Pflichtfeld, erscheint im Audit-Protokoll"><input id="reason" name="reason" required minLength={3} maxLength={300} autoFocus /></Field>
<div className="row"><button className="btn primary" type="submit" disabled={impersonateBusy}>{impersonateBusy ? '…' : 'Ansicht wechseln'}</button><button className="btn" type="button" onClick={() => setImpersonateOpen(false)}>Abbrechen</button></div>
</form>}
<form className="card" onSubmit={save}><h2>Stammdaten &amp; Rechnungsanschrift</h2>
<div className="cols"><Field id="name" label={o.customerType === 'business' ? 'Firmenname' : 'Name'}><input id="name" name="name" defaultValue={o.name} disabled={!w} required /></Field>
{o.customerType === 'business' && <Field id="company" label="Firma (Rechnung)"><input id="company" name="company" defaultValue={o.billing.company ?? ''} disabled={!w} /></Field>}

View file

@ -20,6 +20,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<details className="group" open={inGroup(hrefs)}><summary>{title}</summary><div className="items">{items}</div></details>
);
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
async function stopImpersonation() { const orgId = me!.impersonating!.orgId; await api('POST', '/auth/impersonate/stop'); await reload(); r.replace(`/admin/kunden/${orgId}`); }
const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
const nav = (
@ -44,6 +45,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
);
const bottomLink = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
return (<div className="shell">
{me.impersonating && (
<div role="status" style={{ position: 'sticky', top: 0, zIndex: 100, background: 'var(--warn)', color: '#1a1200', padding: '8px 16px', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<span>👁 Ansicht als Kunde: <strong>{me.impersonating.orgName}</strong> ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason}</span>
<button className="btn small" onClick={() => void stopImpersonation()}>Zurück zur Verwaltung</button>
</div>
)}
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
{nav}<main id="main" className="main">{children}</main>
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">

View file

@ -2,10 +2,12 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { api, setCsrf, ApiError } from './api';
export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string }
export interface Me {
user: { id: string; email: string; name: string }; kind: 'customer' | 'staff'; staffRole: string | null; permissions: string[];
pendingMfa: boolean; mfaEnrolled: boolean; mfaEnrollRequired: boolean; csrf: string;
organizations: { id: string; name: string; customerNumber: string; role: string }[];
impersonating: Impersonating | null;
}
interface Ctx { me: Me | null; loading: boolean; reload: () => Promise<Me | null>; can: (p: string) => boolean }
const C = createContext<Ctx>({ me: null, loading: true, reload: async () => null, can: () => false });