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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue