diff --git a/core/audit/audit.ts b/core/audit/audit.ts index b55d3d8..d1f042a 100644 --- a/core/audit/audit.ts +++ b/core/audit/audit.ts @@ -70,6 +70,16 @@ export const SENSITIVE_ACTIONS = [ "projection.activated", /** Administrative configuration was changed (or a change was refused). */ "admin.config_changed", + /** A maintainer opened a scoped support-access session. */ + "impersonation.session_started", + /** A maintainer closed a support-access session. */ + "impersonation.session_ended", + /** A support-access session expired before the maintainer closed it. */ + "impersonation.session_expired", + /** A permitted action ran inside a support-access session (or was refused). */ + "impersonation.action", + /** A dangerous mutation was blocked inside a read-only support session. */ + "impersonation.mutation_blocked", /** A delegated permission was granted (or a grant was refused). */ "authorization.granted", /** A delegated permission was renewed (or a renewal was refused). */ diff --git a/core/impersonation/__tests__/audit-trail.test.ts b/core/impersonation/__tests__/audit-trail.test.ts new file mode 100644 index 0000000..c67a2a8 --- /dev/null +++ b/core/impersonation/__tests__/audit-trail.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getAuditTrail, type AuditAction } from "@/core/audit"; +import { + canExecuteAction, + clearImpersonationAuditTrail, + createImpersonationSession, + endImpersonationSession +} from "../impersonation"; +import type { CreateImpersonationParams, ImpersonationSession } from "../types"; + +const fixedNow = new Date("2026-10-18T10:00:00.000Z"); +const mockClock = () => new Date(fixedNow.getTime()); +const laterClock = () => new Date(fixedNow.getTime() + 16 * 60 * 1000); + +function session(overrides: Partial = {}): ImpersonationSession { + const result = createImpersonationSession({ + maintainerId: "maintainer_alice", + targetUserId: "user_bob", + targetAddress: "GD5J...TEST", + reason: "Customer reported a sync failure", + clock: mockClock, + ...overrides + }); + if (!result.ok) throw new Error(result.code); + return result.value; +} + +function auditEvents(action: AuditAction) { + const page = getAuditTrail().query({ kind: "maintainer", id: "auditor" }, { action }); + if (!page.ok) throw new Error(page.code); + return page.value.events; +} + +describe("impersonation canonical audit trail", () => { + beforeEach(() => { + getAuditTrail().reset(); + clearImpersonationAuditTrail(); + }); + + it("records session creation with maintainer attribution and user target", () => { + const created = session(); + + const events = auditEvents("impersonation.session_started"); + expect(events).toHaveLength(1); + expect(events[0].actor).toEqual({ kind: "maintainer", id: "maintainer_alice" }); + expect(events[0].scope).toBe("maintainer"); + expect(events[0].target).toEqual({ kind: "user", id: "user_bob" }); + expect(events[0].outcome).toBe("allowed"); + expect(events[0].after?.sessionId).toBe(created.id); + }); + + it("records an allowed read action", () => { + const created = session(); + + const check = canExecuteAction(created, "ticket.view", { clock: mockClock }); + expect(check.ok).toBe(true); + + const events = auditEvents("impersonation.action"); + expect(events).toHaveLength(1); + expect(events[0].outcome).toBe("allowed"); + expect(events[0].after?.action).toBe("ticket.view"); + }); + + it("records a blocked dangerous mutation as a denied audit event", () => { + const created = session({ scopes: ["read:transfers"] }); + + const check = canExecuteAction(created, "ticket.transfer", { clock: mockClock }); + expect(check.ok).toBe(false); + + const events = auditEvents("impersonation.mutation_blocked"); + expect(events).toHaveLength(1); + expect(events[0].outcome).toBe("denied"); + expect(events[0].errorCode).toBe("impersonation_denied"); + expect(events[0].after?.action).toBe("ticket.transfer"); + }); + + it("records expiry when an action is attempted after the deadline", () => { + const created = session(); + + const check = canExecuteAction(created, "ticket.view", { clock: laterClock }); + expect(check.ok).toBe(false); + if (!check.ok) expect(check.code).toBe("impersonation_session_expired"); + + const events = auditEvents("impersonation.session_expired"); + expect(events).toHaveLength(1); + expect(events[0].outcome).toBe("denied"); + expect(events[0].after?.sessionId).toBe(created.id); + }); + + it("records session end", () => { + const created = session(); + + endImpersonationSession(created, "Resolved the customer issue", mockClock); + + const events = auditEvents("impersonation.session_ended"); + expect(events).toHaveLength(1); + expect(events[0].outcome).toBe("allowed"); + expect(events[0].after?.sessionId).toBe(created.id); + }); +}); diff --git a/core/impersonation/impersonation.ts b/core/impersonation/impersonation.ts index 874f113..716c825 100644 --- a/core/impersonation/impersonation.ts +++ b/core/impersonation/impersonation.ts @@ -11,6 +11,7 @@ * - Elevated Confirmation: dangerous mutations require explicit tokens if configured. */ +import { recordAudit, type AuditAction } from '@/core/audit/audit'; import { err, ok, type Result } from '@/core/result/result'; import { ACTION_SCOPE_MAP, @@ -18,6 +19,7 @@ import { DANGEROUS_MUTATIONS, ImpersonationAction, ImpersonationAuditEvent, + ImpersonationAuditEventType, ImpersonationBannerState, ImpersonationErrorCode, ImpersonationScope, @@ -318,9 +320,34 @@ export function getImpersonationBannerState( }; } -/** Records an audit event to the append-only trail */ +const CANONICAL_AUDIT_ACTIONS: Record = { + session_started: 'impersonation.session_started', + session_ended: 'impersonation.session_ended', + session_expired: 'impersonation.session_expired', + action_performed: 'impersonation.action', + mutation_blocked: 'impersonation.mutation_blocked' +}; + +/** Records an audit event to the local trail and the canonical audit trail */ function recordAuditEvent(event: ImpersonationAuditEvent): void { impersonationAuditTrail.push(Object.freeze(event)); + recordAudit({ + action: CANONICAL_AUDIT_ACTIONS[event.eventType], + actor: { kind: 'maintainer', id: event.maintainerId }, + scope: 'maintainer', + target: { kind: 'user', id: event.targetUserId || 'unknown' }, + reason: 'support_access', + outcome: event.outcome, + after: { + sessionId: event.sessionId, + eventType: event.eventType, + action: event.action ?? null, + reason: event.reason ?? null + }, + at: event.timestamp, + correlationId: event.sessionId, + ...(event.outcome === 'denied' ? { errorCode: 'impersonation_denied' } : {}) + }); } /** Returns the recorded impersonation audit trail (read-only copy) */ diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 96054be..a7f8ea7 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -55,6 +55,14 @@ cannot disagree about coverage: | `authorization.renewed` | `core/authorizations/store.ts` (`renew`) | | `authorization.revoked` | `core/authorizations/store.ts` (`revoke`) | | `authorization.cleaned_up` | `core/authorizations/store.ts` (`cleanup`) | +| `impersonation.session_started` | `core/impersonation/impersonation.ts` (`createImpersonationSession`) | +| `impersonation.session_ended` | `core/impersonation/impersonation.ts` (`endImpersonationSession`) | +| `impersonation.session_expired` | `core/impersonation/impersonation.ts` (`canExecuteAction`) | +| `impersonation.action` | `core/impersonation/impersonation.ts` (`canExecuteAction`) | +| `impersonation.mutation_blocked` | `core/impersonation/impersonation.ts` (`canExecuteAction`) | + +See [Support access](./SUPPORT_ACCESS.md) for the session model, scopes, +failure modes and rollback steps. ### Which lifecycle moves are audited diff --git a/docs/SUPPORT_ACCESS.md b/docs/SUPPORT_ACCESS.md new file mode 100644 index 0000000..e901aa1 --- /dev/null +++ b/docs/SUPPORT_ACCESS.md @@ -0,0 +1,160 @@ +# Support access (maintainer impersonation) + +## What this covers + +`core/impersonation/` gives maintainers a **time-bounded, scoped, audited** +support session for debugging a user's issue. It builds on the session model +introduced for issue #80 and closes the operational gaps in issue #229: every +session event is now also written to the canonical, tamper-evident audit trail. + +The default posture is **read-only**. A session can inspect tickets, transfer +history, events and diagnostics only when the corresponding scope was granted. +Dangerous mutations are unavailable unless the session explicitly allow-lists +them **and** the caller supplies an elevated confirmation token. + +## Guarantees + +| Property | Behaviour | +| --- | --- | +| Time bound | Default 15 minutes; minimum 1, maximum 60. Every action re-checks `expiresAt`. | +| Scoped | Scopes default to `read:tickets`, `inspect:events`, `support:diagnostics`. Actions not mapped to a granted scope are refused. | +| Read-only by default | `allowedMutations` is empty unless explicitly supplied. `ticket.transfer`, `ticket.redeem`, `ticket.burn`, `payout.withdraw`, `credential.rotate` and `event.cancel` are blocked. | +| Elevated confirmation | Allow-listed dangerous mutations still require a token of at least 8 characters when `elevatedConfirmationRequired` is true (the default). | +| User-safe notice | `getImpersonationBannerState()` returns the banner text, tone, remaining seconds, read-only flag and scopes. It never contains credentials. | +| No bypass | Every start, end, expiry, allowed action and blocked mutation is written to the local append-only trail **and** to `core/audit`. | + +## Usage + +```ts +import { + canExecuteAction, + createImpersonationSession, + endImpersonationSession, + getImpersonationBannerState +} from "@/core/impersonation"; + +const created = createImpersonationSession({ + maintainerId: "maintainer_alice", + targetUserId: "user_bob", + targetAddress: "GD5J...TEST", + reason: "Customer reported a sync failure", + durationMinutes: 20, + scopes: ["read:tickets", "inspect:events"], + clock: () => new Date() +}); + +if (!created.ok) throw new Error(created.code); + +const check = canExecuteAction(created.value, "ticket.view"); +if (!check.ok) return showSupportError(check.code); + +const banner = getImpersonationBannerState(created.value); +// banner.statusText -> "Impersonating user_bob (Read-Only)" + +const ended = endImpersonationSession( + created.value, + "Resolved the customer issue" +); +``` + +For a scoped action, pass the session's scopes when creating it, and pass an +elevated token only for an explicitly allow-listed mutation: + +```ts +const elevated = canExecuteAction(session, "ticket.transfer", { + elevatedConfirmationToken: process.env.SUPPORT_CONFIRMATION_TOKEN +}); +``` + +## Audit trail + +Every impersonation event is mirrored into `core/audit` with: + +- **actor** `{ kind: "maintainer", id: maintainerId }` +- **scope** `maintainer` +- **target** `{ kind: "user", id: targetUserId }` +- **reason** `support_access` +- **correlationId** the impersonation session id +- **outcome** `allowed` or `denied` + +| Local event | Canonical action | +| --- | --- | +| `session_started` | `impersonation.session_started` | +| `session_ended` | `impersonation.session_ended` | +| `session_expired` | `impersonation.session_expired` | +| `action_performed` | `impersonation.action` | +| `mutation_blocked` | `impersonation.mutation_blocked` | + +Refused actions are still recorded, with `outcome: "denied"` and +`errorCode: "impersonation_denied"`. A maintainer can review a session: + +```ts +import { getAuditTrail } from "@/core/audit"; + +const page = getAuditTrail().query( + { kind: "maintainer", id: "auditor" }, + { action: "impersonation.mutation_blocked", limit: 50 } +); +``` + +The local trail remains available through `getImpersonationAuditTrail()`; the +canonical event is the durable, cross-session record. The local trail is +in-memory and per tab, so do not use it as the only evidence after a restart. + +## Edge cases and failure modes + +- **Clock skew** — expiry is compared against the injected clock. Hosts must + keep NTP in sync; a large backwards jump keeps a session alive longer than + intended. Pass a monotonic clock in long-lived jobs where possible. +- **Expiry during work** — `canExecuteAction` checks the deadline before every + action. Do not start a long-running operation close to the deadline; the + next action will be refused even if the first one succeeded. +- **Inactive or expired session** — returns `impersonation_session_inactive` + or `impersonation_session_expired`; the refusal is audited. +- **Scope not granted** — returns `impersonation_scope_denied`; the refusal is + audited. +- **Dangerous mutation** — returns `impersonation_mutation_blocked` unless the + action is in `allowedMutations`; if allow-listed but the confirmation token + is missing or shorter than 8 characters, returns + `elevated_confirmation_required`. Neither path mutates user state. +- **Invalid request** — empty maintainer/target, a reason shorter than 5 + characters, or a duration outside 1–60 minutes returns the corresponding + error before a session is created. +- **Audit availability** — `recordAudit` is deliberately non-throwing. The + local trail is written first, so an action cannot silently bypass the local + evidence. If the canonical event is missing when reviewing a session, treat + that as an audit-pipeline incident and preserve the local trail while + investigating; do not delete either trail. +- **Multiple tabs** — each tab has its own in-memory session and local trail. + Persist only the session id in tab-scoped storage; never persist the elevated + confirmation token. Use the canonical trail to reconcile activity across tabs. +- **Read access** — a non-maintainer cannot read maintainer-scope audit events; + `getAuditTrail().query` returns `audit_denied`. + +## Rollback and recovery + +1. Call `endImpersonationSession()` for the affected session and discard the + in-memory object. Ending is audited even when the session already expired. +2. Revoke the maintainer's account/role through the normal identity process; + do not rely on session expiry for access removal. +3. Rotate the elevated confirmation token if one may have been exposed. +4. Review `impersonation.session_started`, `impersonation.action`, + `impersonation.mutation_blocked`, `impersonation.session_expired` and + `impersonation.session_ended` in `core/audit` for the session id. +5. If a dangerous mutation was allowed, verify the affected target through the + normal ledger/audit tooling. Do not edit or delete audit events. +6. Clear any tab-scoped session id. `clearImpersonationAuditTrail()` is a test + utility and must not be called from production code. + +## Verification + +```bash +npx vitest run core/impersonation +npx vitest run core/audit +``` + +The impersonation suite covers duration bounds, scope checks, dangerous +mutation blocking, elevated confirmation, expiry, banner state and the canonical +audit mapping. + +See [Audit trail](./AUDIT.md) for the shared audit rules and export/read API.