admin: customer notes table + read-only impersonation JWT + RequireWritable middleware - #64
Merged
Merged
Conversation
…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
5 of 6 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related admin surfaces for the founder's customer-management drawer:
three handlers behind
RequireAdmin:GET/POST /admin/customers/:team_id/notesand
DELETE /admin/notes/:note_id. Hard delete (notes are reversibleby re-typing).
POST /admin/customers/:team_id/impersonatemints a 10-minute JWT carrying
read_only=true+impersonated_by=<admin email>.on every
/api/v1/*route, the/deploygroup, and every top-levelmutating 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_logrow withkind=admin.impersonation_started./auth/mesurfaces the impersonationstate so the dashboard can render the banner.
Read-only is irrevocable for the session lifetime. The
AgentActionReadOnlySessionagent_action sentence satisfies the U3contract and is exercised by
TestAgentActionContract.Test plan
make test-unitgreenTestAgentActionContractpasses with newAgentActionReadOnlySessionnewest-first, non-admin 403, empty body, unknown team, unknown
note id, 8KB cap)
audit row, non-admin 403, unknown team 404, empty team 409, GET
works, POST blocked, real session still works, expired token
rejected by RequireAuth)
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
grepon everyPOST/PATCH/PUT/DELETEroute registration confirmsRequireWritableapplies to:app.Post("/db/new"),/cache/new,/nosql/new,/queue/new,/storage/new,/webhook/new— inline middleware on eachapp.Post("/billing/checkout")(legacy top-level alias) — inlineapp.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 thegroup middleware chain
apigroup (everyPOST/PATCH/PUT/DELETEunder/api/v1/*—resources, team, billing, deployments, stacks, vault, audit, api-keys,
experiments, invitations) — installed on the group middleware chain
adminGroupinheritsRequireWritablefrom itsapiparent 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_onlyflag 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/newmust 403" is resolvedby applying RequireWritable broadly (both group and inline), which is
what the test suite asserts.
🤖 Generated with Claude Code