Revoking a connection is a state the row keeps, not a deletion - #302
Merged
Conversation
czpython
force-pushed
the
revoke-is-a-state
branch
from
August 22, 2026 09:04
692bf93 to
9661ef4
Compare
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
force-pushed
the
revoke-is-a-state
branch
from
August 22, 2026 09:08
9661ef4 to
feceeae
Compare
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.
Deleting an
oauth_connectionsrow erased facts that were true: a consent happened, someone owned it, it had an identity.oauth.disconnectedsubscribers 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
OauthConnectiongainsrevoked_at(NULL = live) andrevoked_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, andconnected_atsurvive forever. A second revoke keeps the first stamp.oauth.disconnectedstill publishes, and its subscribers can now read the row.ScopedService.get/list_for_account) serves live connections only, so extension code changes nothing.get_access_tokenrefuses a revoked connection with the existingOauthRefreshError. 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 existingMissingGrantError. 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.connectedwithreconsent=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: cleanpytest 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)The declared-identity-key follow-up (opt-in dedup and resurrect per service) is tracked separately.
🤖 Generated with Claude Code