Skip to content

feat(account): read-only mode for abuse-paused accounts - #1049

Merged
jiashuoz merged 19 commits into
mainfrom
feat/paused-account-read-only
Sep 27, 2026
Merged

jiashuoz merged 19 commits into
mainfrom
feat/paused-account-read-only

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

An account whose sending is paused with pause_class = 'abuse' (columns from migration 122) is now read-only on every customer surface: no write of any kind succeeds. Pauses with the other classes (operator, billing, system) keep today's behaviour (sending refused, everything else works), because those are the automated detector's lever and may hit legitimate customers.

Motivation. The 2026-09 phishing operator, while paused, could still create brand-named agents, rename them, delete messages and register domains; only sending and permanent deletion were blocked.

Rule. State is exactly state='paused' AND pause_class='abuse' (identity.Store.AccountReadOnly); a resume lifts it immediately. Enforced at one guard per surface: internal/httpapi/read_only.go (/v1), internal/agent/read_only.go (legacy dashboard mux), mcp/src/tools/mutating.ts (per-tool flag), WebSocket by message type; agent access tokens and delegated tokens are refused for writes like any other credential. Fails closed on DB errors (503, no write).

Refused (403 account_read_only): agent create/update/rename/restore/trash; message send/reply/forward/schedule; review approve/reject; message delete/restore/label edits; domain register/verify/delete; API key create/delete; webhook create/update/delete/rotate/test; event redelivery; contacts + imports; templates; outreach upserts/deletes; protection settings; suppression create/delete; sending-access request; legacy dashboard writes; OAuth consent allow (mints a grant; checked in the handler after consent's own provider/authorize/session handling); HITL magic-link approve/reject; attaching a NEW external principal (403 account_read_only from POST /api/internal/users/external-principals/attach — TC side: a reconciler replay of an already-attached (issuer, subject) → user triple still returns 200; only a new principal for a paused account gets the new 403). The legacy account-write routes authenticate the session cookie only, and the guard resolves the caller the same way: an Authorization header on one of them is 400 ambiguous_credentials for everyone, and no valid session is 401 from the guard. A legacy write route missing from the classification table defaults to refuse. The HITL expiry sweep leaves a read-only account's approve-on-expiry holds pending_review (nothing released into the inbox/webhooks, nothing sent); reject-on-expiry holds still resolve.

Allowed: every read (incl. exports, metrics, events, attachment downloads, WS live-tail); POST /v1/templates/validate; sign-in/out, OAuth token exchange/revocation, /agent/identity; OAuth consent deny (fosite's access_denied redirect still reaches the client); account trash (DELETE /v1/account without permanent → mode: "trash"); the restore/erase interstitial (erase stays 409 erase_held). Operator local commands unaffected.

Decision: SES sender identities survive the trash of a read-only account. Trashing an account normally enqueues the SES sender-identity teardown for each owned domain. While the account is read-only (abuse pause) the trash skips that teardown: the provider-side identities are evidence for the abuse review, and deleting them at trash time would destroy it. The domains are still unverified, so nothing is sent from them. The purge at the end of the trash window is not held by a pause and still tears the identities down, so nothing is kept forever. Other pause classes and unpaused accounts are unchanged.

MCP: every tool advertises _meta["e2a/mutating"]; the classification is pinned to the HTTP methods of the /v1 operations each tool calls (walked against api/openapi.yaml) and every destructiveHint tool must be mutating. Raw (non-Huma) routes on the /v1 chi root are walked too: each non-GET one is a Huma operation or on an explicit exemption list.

Rate limiting: /v1 rate limiting runs before the read-only guard, so a refused write still counts against the request budget. Kept deliberately — running the guard's DB lookup first would let an over-the-limit caller drive unlimited lookups.

Clients: new vocabulary code account_read_only in openapi, both generated SDK models, TS/Python error mapping, CLI guidance (distinct exit code), MCP tool error text, web copy; a persistent dashboard banner plus disabled write controls (web/src/app/components/ReadOnlyBanner.tsx). Contract server seeds an abuse-paused account; conformance scenario: one representative write per resource family → 403, reads → 200, trash delete → 200.

Tests: spec-walk test classifies every openapi operation as read or write explicitly — an unclassified route fails the build; store/guard tests with real Postgres; -race on touched packages. Docs: docs/design/account-read-only.md, docs/api.md, docs/data-handling.md, docs/deployment.md.

Accepted window: the guards read the control row outside the handler transaction, so a write whose check passed milliseconds before a pause commits can complete; sending is re-checked at the sending gate. Documented in the design doc's Freshness section.

Operator note: -pause-account-sending -pause-class abuse now also freezes writes; -resume-account-sending lifts it.

Implementation by a Claude Code subagent; the final push and this PR were made by the main session after the subagent's connection dropped twice post-implementation. Review rounds to follow before merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW

jiashuoz and others added 19 commits September 27, 2026 10:55
An account whose sending is paused with pause class `abuse` is now
read-only. One enforcement point per surface:

- /v1: a Huma middleware (httpapi/read_only.go) classifies every
  operation (method rule; exceptions: validateTemplate reads,
  deleteAccount trash stays allowed, getInfo public) and refuses writes
  with 403 account_read_only. Uncached PK lookup per write; a failed
  lookup fails closed with 503. Covers every credential kind, since all
  resolve to a principal owned by the account.
- legacy mux: a gorilla middleware (agent/read_only.go) for the
  dashboard account routes and OAuth consent; the HITL magic links and
  the internal external-principal attach check in their handlers.
- GET /v1/account reports read_only; the operator pause readback
  prints read_only.
- notifications.support_email (falls back to reply_to) names the
  support contact in the message; the reason text is never shown.

Spec-walk test classifies every operation against an independent
derivation and drives each one over HTTP as a read-only account.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
- regenerate both SDK bases (ErrorBody vocabulary, AccountView.read_only)
- TS + Python map account_read_only to the permission family, never
  retryable
- CLI: new frozen exit code 10 (READ_ONLY) with guidance in the
  top-level error rendering, and whoami prints the read-only state
- MCP: per-tool mutating classification (tools/mutating.ts) pinned
  against the registered tools and the annotations; the /v1 guard stays
  the enforcement point, and a refused tool call surfaces
  [account_read_only] with structured code; whoami explains read_only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…unts

A persistent, non-dismissible banner in the app shell ("Your account is
read-only while sending is paused for abuse review. Contact support.")
driven by GET /v1/account read_only through the shared limits SWR entry.
The request helper turns a 403 account_read_only into that copy; the
Create key and Add domain controls are disabled and Create inbox is
hidden while read-only. The API error remains the backstop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The contract server seeds an abuse-paused account
(E2A_TEST_READONLY_API_KEY / {readonly_api_key}). The shared scenario
asserts read_only:true and reads return 200, one representative write
per resource family (agents, messages, domains, api keys, webhooks,
contacts, templates, suppressions, sending access) returns 403
account_read_only without the reason text, the permanent erase stays
409 erase_held, and the account trash still returns 200 mode=trash.
Wired through the Go, TypeScript and Python runners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The legacy guard resolved the caller with authenticatePrincipal (Authorization
header first, cookie only as fallback) and fell through on auth errors, while
every legacyAccountWrite handler authenticates the session cookie only. A
read-only session plus a junk bearer, or another account's valid key, bypassed
the guard: key create, agent rename/delete, profile PATCH, consent.

Session-only routes now resolve the caller exactly as the handlers do and
check read-only for that user; an Authorization header on such a route is
400 ambiguous_credentials, and a missing session is 401 from the guard.
Unclassified write routes default to refuse: every presented credential must
resolve and none may be read-only (anonymous requests, e.g. the SNS webhook
mounted on the same router, still pass).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The TTL sweep expire-approved holds with no read-only check, releasing an
abuse-paused account's suspicious inbound mail into the inbox and webhooks
(review_expired_approved) and resolving its held outbound mail. Approve-on-
expiry holds of a read-only account are now excluded from both candidate
queries, so they stay pending_review without sitting at the head of the
ordered, limited sweep and starving other accounts. Reject-on-expiry holds
still resolve; a resume makes the holds candidates again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…ccount

While an account is abuse-paused, moving it to the trash no longer runs the
per-domain sender-identity teardown, so the provider-side identities survive
as evidence for the review. The domains are still unverified (nothing is sent
from them) and the purge at the end of the trash window tears them down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
readOnlyGuard dereferenced p.User.ID unguarded; a resolver returning neither
an error nor an account now gets 503 auth_unavailable instead of a panic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…meta

Nothing enforced the per-tool mutating classification beyond readOnlyHint
agreement. TOOL_OPERATIONS now records the /v1 operation(s) each tool calls;
a test walks it against api/openapi.yaml and requires mutating exactly when
one of them is a write under the server's rule. Every destructiveHint tool
must be mutating, and each tool advertises the flag as _meta["e2a/mutating"].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Raw chi routes on the /v1 root bypass the Huma readOnlyGuard. A chi.Walk
test now fails on any non-GET route that is neither a Huma operation nor on
an explicit exemption list carrying its reason (unsubscribe, magic links,
trash interstitial).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…y account

The internal attach refused every call for an abuse-paused account, so the
reconciler's replays of already-made attaches started failing. The check now
runs in the store's attach transaction and only for a NEW mapping: a replay
of an attached (issuer, subject) -> user triple returns 200 as before; a new
principal for a read-only account stays 403 account_read_only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
… checks

The legacy guard refused consent up front, which swallowed deny (the client
never got fosite's access_denied redirect) and replaced consent's own
404/503/authorize errors with a guard 401 for unauthenticated callers. The
read-only check now runs in handleOAuthConsent after the provider, authorize
request and session checks, for allow only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…ndler

The legacy session-write guard and the handler behind it each looked up the
session. The guard now passes the user it checked (auth.WithSessionUser), so
there is one lookup and the handler acts for exactly the checked user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…limit order

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
@jiashuoz
jiashuoz merged commit 76eb56a into main Sep 27, 2026
47 of 48 checks passed
@jiashuoz
jiashuoz deleted the feat/paused-account-read-only branch September 27, 2026 06:28
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