95 lines
4 KiB
Markdown
95 lines
4 KiB
Markdown
|
|
---
|
||
|
|
kind: task
|
||
|
|
slug: workspace-soft-delete-and-audit
|
||
|
|
title: Workspace soft-delete (archive/restore) and append-only audit log
|
||
|
|
plan_slug: multitenant-saas-hardening
|
||
|
|
epic_slug: tenant-lifecycle
|
||
|
|
status: ready
|
||
|
|
priority: P1
|
||
|
|
tenant_id: global
|
||
|
|
owner: unassigned
|
||
|
|
cursor_todo_id: null
|
||
|
|
updated_at: "2026-06-01"
|
||
|
|
---
|
||
|
|
|
||
|
|
# Task summary
|
||
|
|
|
||
|
|
Two related changes shipped together because they share the "we need a paper trail" motivation: soft-delete for workspaces (and the major tenant-scoped tables) and an append-only `audit_log` that records who did what.
|
||
|
|
|
||
|
|
## Description
|
||
|
|
|
||
|
|
### Soft-delete
|
||
|
|
|
||
|
|
Add `archived_at timestamptz null` to:
|
||
|
|
|
||
|
|
- `workspaces`
|
||
|
|
- `objects`
|
||
|
|
- `markdown_backlog_items`
|
||
|
|
|
||
|
|
For each, add a partial index `where archived_at is null` on the same columns currently indexed (so the "active rows" filter stays fast).
|
||
|
|
|
||
|
|
**Read-side convention**: every workspace-scoped tRPC procedure adds `archived_at IS NULL` to its `where` clause by default. Add an `includeArchived: boolean` optional input to list-procedures that opt-in to showing archived rows (settings → archive view).
|
||
|
|
|
||
|
|
**Write-side**:
|
||
|
|
|
||
|
|
- `workspaces.archive({ workspaceSlug })` — owner only. Sets `archived_at`. Cascades to a *background job* that flips `archived_at` on all `objects` and `markdown_backlog_items` for that workspace. (For now, do it inline in the same transaction; revisit if it ever blocks.)
|
||
|
|
- `workspaces.restore({ workspaceSlug })` — owner only. Sets `archived_at = null` and cascades the unset.
|
||
|
|
|
||
|
|
Don't hard-delete anything via the UI yet. Hard-delete is a separate task and a separate set of risks.
|
||
|
|
|
||
|
|
### Audit log
|
||
|
|
|
||
|
|
New table `audit_log`:
|
||
|
|
|
||
|
|
- `id` uuid pk
|
||
|
|
- `workspace_id` uuid not null (indexed)
|
||
|
|
- `actor_user_id` uuid null (null = system actor, e.g. markdown importer)
|
||
|
|
- `action` varchar not null (e.g. `object.create`, `object.update`, `member.invite`, `workspace.archive`)
|
||
|
|
- `target_type` varchar not null (e.g. `object`, `workspace`, `workspace_member`)
|
||
|
|
- `target_id` uuid null (nullable because some actions don't target a single row)
|
||
|
|
- `metadata` jsonb null (small structured payload — keep it small, don't dump full row state here)
|
||
|
|
- `created_at` timestamptz default now (indexed `(workspace_id, created_at desc)`)
|
||
|
|
|
||
|
|
**Write path**: a tiny helper `recordAudit(db, { workspaceId, actorUserId, action, targetType, targetId, metadata })`. Call from every mutation procedure. Don't auto-instrument via Drizzle middleware — be explicit so it's grep-able which mutations are audited and which aren't.
|
||
|
|
|
||
|
|
**Read view**: minimal — `apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx` (new route) with a paginated table. Owner-only. Keep the UI dumb; this is a debugging surface, not a product feature.
|
||
|
|
|
||
|
|
### Anti-goals
|
||
|
|
|
||
|
|
- Don't try to write to the audit log from inside a non-procedure context (cron, importer) without an explicit `actor_user_id = null` or a synthetic "system" user. The point of the column is "who did this, for accountability" — fudging it defeats the purpose.
|
||
|
|
- Don't add row-level retention policies in this task. The table can grow; we'll partition or roll up later.
|
||
|
|
|
||
|
|
## Subtasks
|
||
|
|
|
||
|
|
- [ ] Add `archived_at` to the 3 tables + partial indexes.
|
||
|
|
- [ ] Add `audit_log` schema.
|
||
|
|
- [ ] Generate and commit migration.
|
||
|
|
- [ ] Add `archive` and `restore` procedures to the workspaces router.
|
||
|
|
- [ ] Implement `recordAudit` helper and call from every mutation in `objects`, `workspaces`, `invites`, and the markdown backlog import path.
|
||
|
|
- [ ] Add `archived_at IS NULL` to every existing list-procedure (audit existing routers).
|
||
|
|
- [ ] Add `/[workspaceSlug]/settings/audit` page (owner-only, paginated).
|
||
|
|
|
||
|
|
## Owner or assignee
|
||
|
|
|
||
|
|
Unassigned
|
||
|
|
|
||
|
|
## Status
|
||
|
|
|
||
|
|
ready
|
||
|
|
|
||
|
|
## Estimation
|
||
|
|
|
||
|
|
L
|
||
|
|
|
||
|
|
## Acceptance criteria
|
||
|
|
|
||
|
|
- [ ] Archived workspace stops appearing in the workspace switcher.
|
||
|
|
- [ ] Restoring an archived workspace makes its objects visible again.
|
||
|
|
- [ ] At least one `audit_log` row is written for every mutation in `objects` and `workspaces`.
|
||
|
|
- [ ] Audit view renders paginated rows scoped to the current workspace.
|
||
|
|
|
||
|
|
## Links to related Epic / Plan
|
||
|
|
|
||
|
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||
|
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|