ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-audit-instrument-rate-limit-trips.md
Randall Stillwell 336a5890a8 feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.

audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.

Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:35:16 -05:00

42 lines
2.1 KiB
Markdown

---
kind: task
slug: audit-instrument-rate-limit-trips
title: Write audit-log rows on every rate-limit trip
plan_slug: multitenant-saas-hardening
epic_slug: tenant-lifecycle
status: draft
priority: P2
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-02"
---
# Task summary
Rate-limit trips currently only go to stdout (`console.warn`). The parent task spec called for an `audit_log` row on every trip so we can:
1. See in the audit view whether anyone's actually hitting the limits in practice.
2. Build abuse-signal dashboards on top of `audit_log` instead of scraping logs.
This is a small follow-up because we kept `Task-rate-limit-and-abuse-guardrails` scoped to "the wire-ins ship cleanly" and didn't want to churn `auth.ts` and `invites.ts` a second time inside the same task.
## Subtasks
- [ ] At every `if (!rl.allowed) { ... }` site, call `recordAudit(...)` before the rejection. For the Credentials path, the workspace context isn't known (the user isn't even authenticated yet), so we need a different write path:
- Option A: skip audit for sign-in trips and only log to stdout (current behavior).
- Option B: add a `system_audit_log` table for non-workspace events (sign-in attempts, rate-limit trips on unauthenticated routes).
- Option C: stamp `workspace_id = '00000000-0000-0000-0000-000000000000'` as a sentinel "system" workspace.
- Recommended: **A for v1** (no schema change), revisit when we have a real need for cross-workspace audit. For workspace-scoped trips (invite-create), use the regular `recordAudit()` flow.
- [ ] Wire `recordAudit` into the `invites.create` rate-limit branch. Action: `rate_limit.tripped`. Metadata: `{ key, retry_after_ms, limit, window_ms }`.
- [ ] Add the `rate_limit.tripped` action to the audit page's `ACTION_LABELS` map.
## Acceptance criteria
- [ ] Triggering the `invites.create` rate-limit produces a `rate_limit.tripped` row in the audit view.
- [ ] Sign-in trips remain logged to stdout (decision A above) until a `system_audit_log` table is justified.
## Links
- Parent: `./Task-rate-limit-and-abuse-guardrails.md`
- Epic: `./Epic-tenant-lifecycle.md`