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>
42 lines
2.1 KiB
Markdown
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`
|