From 2ed9697ca6d3d673d10b87b13936ad1e388f8b8d Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 10:59:25 +0200 Subject: [PATCH] feat(customers): Impersonation ("als Kunde ansehen") mit Kennzeichnung und Audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/core/auth.ts | 34 ++++++++++++++++--- apps/api/src/modules/customers/index.ts | 19 ++++++++++- apps/api/src/modules/identity/index.ts | 20 +++++++++-- .../src/app/(app)/admin/kunden/[id]/page.tsx | 20 +++++++++-- apps/web/src/app/(app)/layout.tsx | 7 ++++ apps/web/src/lib/session.tsx | 2 ++ migrations/036_impersonation.sql | 7 ++++ 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 migrations/036_impersonation.sql diff --git a/apps/api/src/core/auth.ts b/apps/api/src/core/auth.ts index 5e87b9a..c668ef6 100644 --- a/apps/api/src/core/auth.ts +++ b/apps/api/src/core/auth.ts @@ -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 { 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 { [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, }; } diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts index b953353..89ace95 100644 --- a/apps/api/src/modules/customers/index.ts +++ b/apps/api/src/modules/customers/index.ts @@ -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[2]): Promise 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'); diff --git a/apps/api/src/modules/identity/index.ts b/apps/api/src/modules/identity/index.ts index 5a91382..98ff27f 100644 --- a/apps/api/src/modules/identity/index.ts +++ b/apps/api/src/modules/identity/index.ts @@ -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 }); diff --git a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx index 1100ffd..d02364a 100644 --- a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx +++ b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx @@ -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(null); const [orders, setOrders] = useState(null); const [ress, setRess] = useState(null); const [o, setO] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null); + const [impersonateOpen, setImpersonateOpen] = useState(false); const [impersonateBusy, setImpersonateBusy] = useState(false); const load = useCallback(() => api('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); useEffect(() => { void load(); api('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]); if (!o) return msg ? {msg.t} :

Wird geladen …

; @@ -37,6 +38,13 @@ export default function Kunde() { async function setStatus(status: string) { try { setO(await api('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) { + 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 (<>

← Alle Kunden

-

{o.name}

+

{o.name}

{can('customers.impersonate') && }

{o.customerNumber} · {o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}

{msg && {msg.t}} + {impersonateOpen &&
+

Als Kunde ansehen

+

Schaltet deine Sitzung für 60 Minuten auf die Sicht dieses Kunden um (sichtbarer Banner, jederzeit zurück möglich). Wird protokolliert.

+ +
+
}

Stammdaten & Rechnungsanschrift

{o.customerType === 'business' && } diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 5ba1d19..8a59b43 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -20,6 +20,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{title}
{items}
); 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) => {label}; return (
+ {me.impersonating && ( +
+ 👁 Ansicht als Kunde: {me.impersonating.orgName} ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason} + +
+ )}
Kundencenter
{nav}
{children}
{!enrollNeeded &&