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:
parent
c52996b559
commit
2ed9697ca6
7 changed files with 98 additions and 11 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 & 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>}
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue