Skip to content

admin: customer notes table + read-only impersonation JWT + RequireWritable middleware - #64

Merged
mastermanas805 merged 2 commits into
masterfrom
feat/admin-notes-impersonate-fresh
May 13, 2026
Merged

mastermanas805 merged 2 commits into
masterfrom
feat/admin-notes-impersonate-fresh

Conversation

@mastermanas805

Copy link
Copy Markdown
Member

Summary

Two related admin surfaces for the founder's customer-management drawer:

  • Notes — free-text per-team admin annotations. Migration 024 +
    three handlers behind RequireAdmin: GET/POST /admin/customers/:team_id/notes
    and DELETE /admin/notes/:note_id. Hard delete (notes are reversible
    by re-typing).
  • Impersonation — POST /admin/customers/:team_id/impersonate
    mints a 10-minute JWT carrying read_only=true + impersonated_by=<admin email>.
  • RequireWritable middleware — new gate that 403s POST/PATCH/PUT/DELETE
    on every /api/v1/* route, the /deploy group, and every top-level
    mutating provisioning endpoint (/db/new, /cache/new, …, /stacks/*)
    whenever the JWT carries read_only=true. GETs fall through —
    view-as-customer is the whole point.

Every impersonation issuance writes an audit_log row with
kind=admin.impersonation_started. /auth/me surfaces the impersonation
state so the dashboard can render the banner.

Read-only is irrevocable for the session lifetime. The
AgentActionReadOnlySession agent_action sentence satisfies the U3
contract and is exercised by TestAgentActionContract.

Test plan

  • make test-unit green
  • TestAgentActionContract passes with new AgentActionReadOnlySession
  • 7 new tests for admin notes (create/list/delete round-trip,
    newest-first, non-admin 403, empty body, unknown team, unknown
    note id, 8KB cap)
  • 10 new tests for impersonation (mints both claims, 10-min exp,
    audit row, non-admin 403, unknown team 404, empty team 409, GET
    works, POST blocked, real session still works, expired token
    rejected by RequireAuth)
  • 6 new tests for RequireWritable middleware (no-token passes,
    normal-token passes, GET under impersonation passes, POST/PUT/PATCH/DELETE
    under impersonation blocked, locals populated correctly)

Total: 23 new test functions, all green.

Coverage audit

grep on every POST/PATCH/PUT/DELETE route registration confirms
RequireWritable applies to:

  • app.Post("/db/new"), /cache/new, /nosql/new, /queue/new,
    /storage/new, /webhook/new — inline middleware on each
  • app.Post("/billing/checkout") (legacy top-level alias) — inline
  • app.Post("/stacks/new"), app.Delete("/stacks/:slug"),
    app.Patch("/stacks/:slug/env"), app.Post("/stacks/:slug/redeploy")
    — inline on each
  • deployGroup (POST /deploy/new, PATCH /deploy/:id/env,
    DELETE /deploy/:id, POST /deploy/:id/redeploy) — installed on the
    group middleware chain
  • api group (every POST/PATCH/PUT/DELETE under /api/v1/* —
    resources, team, billing, deployments, stacks, vault, audit, api-keys,
    experiments, invitations) — installed on the group middleware chain
  • adminGroup inherits RequireWritable from its api parent group;
    the impersonation-mint endpoint is the deliberate exemption (the
    admin minting holds a writable session, so the gate never fires
    there anyway).

Routes intentionally NOT gated: POST /claim, POST /webhook/receive/:token,
POST /auth/*, POST /razorpay/webhook, POST /api/v1/invitations/:token/accept
— none of these accept a session JWT, so no read_only flag can be present.

Pushback / open notes

None — the spec is internally consistent. The one tension between
"apply to /api/v1/*" and "test 5 says /db/new must 403" is resolved
by applying RequireWritable broadly (both group and inline), which is
what the test suite asserts.

🤖 Generated with Claude Code

mastermanas805 and others added 2 commits May 13, 2026 09:41
…itable middleware

Two related admin surfaces for the founder's customer-management drawer:

1. Free-text notes per team. Migration 024 adds admin_customer_notes (id,
   team_id, body, author_email, created_at). Three handlers behind
   RequireAdmin: GET/POST /admin/customers/:team_id/notes (nested under
   the customer URL so the dashboard's detail drawer renders cleanly) and
   DELETE /admin/notes/:note_id (hard delete — notes are reversible by
   re-typing, so the tombstone column buys nothing). author_email is
   sourced from the admin's JWT, never the request body, so a malicious
   admin cannot impersonate another admin in the ledger.

2. Read-only impersonation. POST /admin/customers/:team_id/impersonate
   mints a 10-minute JWT carrying read_only=true + impersonated_by=<admin
   email>. The token's uid is the target team's nominal owner so
   RequireAuth (which rejects empty uid) accepts it; every write attempt
   is blocked by RequireWritable before reaching the handler, so the
   target user accumulates no shadow writes.

   RequireWritable is a new middleware that 403s POST/PATCH/PUT/DELETE
   when the JWT carries read_only=true. GETs fall through unconditionally
   — view-as-customer is exactly what the impersonation surface enables.
   Installed on the /api/v1 group, the /deploy group, and every top-level
   mutating provisioning endpoint (POST /db/new, /cache/new, /nosql/new,
   /queue/new, /storage/new, /webhook/new, /stacks/*). The impersonation-
   mint endpoint itself is the one deliberate exemption — the admin
   holding a writable session never trips the gate.

   Read-only is irrevocable for the session lifetime (no downgrade-to-
   writable path within a token's validity). The agent_action sentence
   AgentActionReadOnlySession satisfies the U3 contract and is exercised
   by TestAgentActionContract.

Every impersonation issuance writes an audit_log row with
kind=admin.impersonation_started carrying the admin email, target team,
target user, issued_at, expires_at, and ttl_seconds — so a future BI
consumer can reconstruct who viewed which customer, when, for how long
without parsing the minted JWT after the fact.

/auth/me surfaces read_only + impersonated_by when the session carries
them, so the dashboard can render the "viewing as <customer>" banner.

Tests:
- internal/handlers/admin_customer_notes_test.go (7 tests): create-list-
  delete round-trip, newest-first ordering, non-admin gate, empty body,
  unknown team, unknown note id, 8KB body cap.
- internal/handlers/admin_impersonate_test.go (10 tests): mints the two
  claims, 10-minute exp, audit row written, non-admin 403, unknown team
  404, empty team 409, GET works, POST blocked, real session still works,
  expired token rejected by RequireAuth.
- internal/middleware/require_writable_test.go (6 tests): no-token
  passes, normal token passes, GET passes under impersonation, POST
  blocked under impersonation, all mutating methods blocked, locals
  populated correctly.
- TestAgentActionContract extended with AgentActionReadOnlySession.

23 new test functions across the three packages. `make test-unit` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…personate-fresh

# Conflicts:
#	internal/handlers/agent_action_contract_test.go
#	internal/router/router.go
#	internal/testhelpers/testhelpers.go
@mastermanas805
mastermanas805 merged commit 5c83a66 into master May 13, 2026
mastermanas805 added a commit that referenced this pull request May 14, 2026
…obby_plus copy (#107)

Wave FIX-H wraps up the BugBash B36 backup-correctness items so customer-
facing restore stops being one accidental retry away from a destroyed
database.

#57/#Q45 — restore replay guard
  Adds models.HasInflightRestore; a second POST /restore for the same
  resource while a prior row is pending/running returns 409
  restore_in_progress + AgentActionRestoreInflight. Fail-CLOSED on DB
  error (concurrent pg_restore --clean races itself).

#58/#A2 — restore to a new DB
  Accepts optional target_resource_id. Worker restores into the target
  (same team only); audit row carries both source + target ids. Skips
  the destructive-ack ceremony because the agent already opted into a
  fresh database.

#59 — backup integrity
  Migration 043 adds a nullable sha256 TEXT column to resource_backups.
  Worker stamps the digest at finalize; restore handler verifies before
  pg_restore. NULL on legacy rows is logged + accepted (fail-open on
  pre-043 data).

#64/#Q46 — cross-tenant 404
  GetBackupByIDForTeam joins resources to scope by team; cross-tenant
  backup_id guess now returns 404 backup_not_found instead of
  400 backup_resource_mismatch. Matches FIX-B tenant-isolation posture.

#65/#Q47 — refund quota on failure
  New internal endpoint POST /internal/teams/:id/backup-quota/refund
  (WORKER_INTERNAL_JWT_SECRET HS256, fail-closed when unset). The
  worker calls this when a MANUAL backup fails terminally so the
  team's daily manual-backups counter is credited back.

#66/#Q48 — hobby agent_action points to Hobby Plus
  AgentActionRestoreRequiresHobbyPlus added. The Hobby-tier restore 402
  now nudges Hobby Plus ($19/mo, restore enabled) instead of skipping
  the customer past the cheapest restore-enabled plan onto Pro ($49).

#67/#Q49 — destructive ack required for in-place restore
  In-place restore (no target_resource_id) now requires
  destructive_acknowledgment: true in the body. pg_restore --clean drops
  every table — refusing without an explicit ack prevents an agent
  testing a backup from wiping a live customer DB.

#Q50 — RPO/RTO on /capabilities
  Adds rpo_minutes + rto_minutes per tier in plans.yaml (anonymous/free
  = 0/0, hobby/hobby_plus = 1440/30, pro/team = 60/15) wired into the
  /api/v1/capabilities matrix via plans.Registry.RPOMinutes/RTOMinutes
  (added in instant.dev/common/plans#13).

Tests
  - +5 backup_test.go cases: ReplayBlocked, TargetNewDB,
    RequiresDestructiveAck, HobbyAgentActionPointsToHobbyPlus,
    CrossTenantBackupID_404
  - All prior backup tests updated to include destructive_acknowledgment
  - Contract test enforces the four new agent_action constants

DO NOT TOUCH list respected — no edits to email/, dpop.go, circuit/.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805 added a commit that referenced this pull request May 21, 2026
…obby_plus copy

Wave FIX-H wraps up the BugBash B36 backup-correctness items so customer-
facing restore stops being one accidental retry away from a destroyed
database.

#57/#Q45 — restore replay guard
  Adds models.HasInflightRestore; a second POST /restore for the same
  resource while a prior row is pending/running returns 409
  restore_in_progress + AgentActionRestoreInflight. Fail-CLOSED on DB
  error (concurrent pg_restore --clean races itself).

#58/#A2 — restore to a new DB
  Accepts optional target_resource_id. Worker restores into the target
  (same team only); audit row carries both source + target ids. Skips
  the destructive-ack ceremony because the agent already opted into a
  fresh database.

#59 — backup integrity
  Migration 043 adds a nullable sha256 TEXT column to resource_backups.
  Worker stamps the digest at finalize; restore handler verifies before
  pg_restore. NULL on legacy rows is logged + accepted (fail-open on
  pre-043 data).

#64/#Q46 — cross-tenant 404
  GetBackupByIDForTeam joins resources to scope by team; cross-tenant
  backup_id guess now returns 404 backup_not_found instead of
  400 backup_resource_mismatch. Matches FIX-B tenant-isolation posture.

#65/#Q47 — refund quota on failure
  New internal endpoint POST /internal/teams/:id/backup-quota/refund
  (WORKER_INTERNAL_JWT_SECRET HS256, fail-closed when unset). The
  worker calls this when a MANUAL backup fails terminally so the
  team's daily manual-backups counter is credited back.

#66/#Q48 — hobby agent_action points to Hobby Plus
  AgentActionRestoreRequiresHobbyPlus added. The Hobby-tier restore 402
  now nudges Hobby Plus ($19/mo, restore enabled) instead of skipping
  the customer past the cheapest restore-enabled plan onto Pro ($49).

#67/#Q49 — destructive ack required for in-place restore
  In-place restore (no target_resource_id) now requires
  destructive_acknowledgment: true in the body. pg_restore --clean drops
  every table — refusing without an explicit ack prevents an agent
  testing a backup from wiping a live customer DB.

#Q50 — RPO/RTO on /capabilities
  Adds rpo_minutes + rto_minutes per tier in plans.yaml (anonymous/free
  = 0/0, hobby/hobby_plus = 1440/30, pro/team = 60/15) wired into the
  /api/v1/capabilities matrix via plans.Registry.RPOMinutes/RTOMinutes
  (added in instant.dev/common/plans#13).

Tests
  - +5 backup_test.go cases: ReplayBlocked, TargetNewDB,
    RequiresDestructiveAck, HobbyAgentActionPointsToHobbyPlus,
    CrossTenantBackupID_404
  - All prior backup tests updated to include destructive_acknowledgment
  - Contract test enforces the four new agent_action constants

DO NOT TOUCH list respected — no edits to email/, dpop.go, circuit/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant