Skip to content

Revoking a connection is a state the row keeps, not a deletion - #302

Merged
czpython merged 1 commit into
mainfrom
revoke-is-a-state
Aug 22, 2026
Merged

Revoking a connection is a state the row keeps, not a deletion#302
czpython merged 1 commit into
mainfrom
revoke-is-a-state

Conversation

@czpython

@czpython czpython commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Deleting an oauth_connections row erased facts that were true: a consent happened, someone owned it, it had an identity. oauth.disconnected subscribers were told about a row they could no longer read, every consuming extension kept tombstone copies of the owner and identity on its own rows, and the Connections page could not show what was once authorized.

What changes

  • OauthConnection gains revoked_at (NULL = live) and revoked_reason (user, client_replaced, server_removed). Revoking clears the refresh token — nothing secret outlives the consent at rest — and evicts the Redis-cached access token. The row, its owner, identity, scopes, and connected_at survive forever. A second revoke keeps the first stamp.
  • Every delete site becomes a revoke: the per-connection revoke route (now idempotent), the replaced-client-credentials sweep ("a new client can never refresh the old client's tokens" stays true — revoke all, erase none), MCP grant disconnect, and MCP server removal. oauth.disconnected still publishes, and its subscribers can now read the row.
  • Reads default to live rows. The declaration handle (ScopedService.get / list_for_account) serves live connections only, so extension code changes nothing. get_access_token refuses a revoked connection with the existing OauthRefreshError. A revoke that lands mid-refresh always wins: the engine does not store the rotated token, and the enclosing transaction never rewrites the cleared one. The MCP door's live-only read raises the existing MissingGrantError. The audit page and the service detail ask for revoked rows explicitly and render them as history, with when and what revoked them.

The sub-decision: what a fresh sign-in of a known account does

A fresh sign-in always creates a new connection. Reconsent is the only way back to life.

One row per consent keeps the ledger exact: each grant's scopes, dates, and revocation stay on their own row, and provenance references point at the exact consent forever. There is no identity heuristic, so a wrong match can never attach a fresh consent to the wrong old row. Apps already adopt new connections through oauth.connected with reconsent=false — the same handler every first sign-in needs — and the old row stays readable for anything the app wants to carry over.

Reconsent (?connection=<id>) names the row, so it needs no guessing: it replaces the tokens of a live row and returns a revoked row to life under its old id. The Settings page shows a Reconnect button on a revoked connection for exactly this. MCP re-connects create a new grant like everyone else, so at most one live connection holds a (server, account) slot.

Migration

One Alembic revision adds the two columns. There is no backfill to do — deleted rows are gone; that is the point of this change.

Verification

  • ruff check / ruff format --check: clean
  • pytest backend/: 1304 passed on the rebased tree (deletion-asserting tests now assert revocation; new coverage for the revoked-mint refusal, the mid-refresh revoke, handle liveness filtering, the new-row rule on both doors, and reconsent-of-revoked)
  • frontend lint / test (117 passed) / build: clean

The declared-identity-key follow-up (opt-in dedup and resurrect per service) is tracked separately.

🤖 Generated with Claude Code

A revoked OauthConnection keeps its owner, identity, scopes, and dates;
only the refresh token is cleared, and the cached access token is
evicted. Every delete site is now a revoke, so oauth.disconnected
subscribers can read the row and extensions need no tombstone copies.
Reads default to live rows; the audit surfaces show revoked rows as
history. A fresh sign-in always creates a new connection; reconsent
(?connection=<id>) is the only way a revoked row returns to life.
@czpython
czpython merged commit c907c96 into main Aug 22, 2026
2 checks passed
@czpython
czpython deleted the revoke-is-a-state branch August 22, 2026 09:10
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