Replies: 1 comment
|
We need to have a proper stance on token revocation on declarative resource update. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Related Feature Issue
#4127
Problem Summary
An access token is a snapshot: it is minted with the scopes the principal held at that moment and
stays valid until it expires. Removing a user from a group, unassigning a role or trimming a role's
permissions changes what the next token carries and changes nothing about tokens already issued. We
already close this gap for application deletion and client secret regeneration through ADMINISTRATION
flows; this design extends the same machinery to authorization changes.
High-Level Approach
Group, role and scope changes all reduce to one question: which scopes did this principal just
lose, and at what time? A token carries
sub,audandscope, never role or group IDs, so thecriterion has to be expressed in those terms whatever the operator actually clicked.
entity.scope, plus a coarserscopedimension for scope deletion.CriteriaRevocationExecutor,the deny-list table, the SQL and both enforcement points unchanged.
A scope name alone does not identify a scope
The obvious encoding,
<entity>|license, is wrong. A permission string is unique only within itsresource server.
derivePermissionbuilds a root resource's permission from its handle alone, withno resource server prefix. The schema already says so:
ROLE_PERMISSIONis keyed on(ROLE_ID, DEPLOYMENT_ID, RESOURCE_SERVER_ID, PERMISSION), with the resource server in the keyprecisely because the permission alone identifies nothing.
This is not theoretical. In our own US-geography test deployment 7 of 18 scope names are defined in two resource servers each, and after adding actions, 40 of 60 action scopes collide:
licenseandlicense:suspendexist on both the California and Ohio DMV APIs,permits:approveon both Springfield servers,elections:certifyon both counties.Two roles named
State Identity Administratorexist, one per state OU, each grantinglicenseonits own DMV API. A criterion keyed on the scope name alone could not tell them apart, and neither
could one keyed on the role name.
The dimensions
plus a coarser one for when a scope stops existing at all:
ResolveAudienceBindingbinds token issuance to exactly one resource server, the access token'saudis set to that server's identifier, andDownscopeToResourceServerfilters the scopes to it.RESOURCE_SERVER.IDENTIFIERis unique per deployment. So(aud, scope)is globally unique and bothhalves are trusted claims.
Why hashed.
CRITERION_VALUEisVARCHAR(255), while an identifier isVARCHAR(2048)and a permission isVARCHAR(1000). The triple does not fit and cannot be widened without exceeding the PostgreSQL btree key limit on a column that backs the hot lookup. A SHA-256 digest is 64 characters and loses nothing, since every match here is an equality test.Each part is length-prefixed before hashing, rather than simply joined on
|. A resource server identifier carries no character allowlist, so a plain join lets("a|b", "c")and("a", "b|c")render identically and one revocation would match a scope it was never written for. Length-prefixing makes the rendering unambiguous whatever the parts contain.No plaintext column ships. An earlier draft of this design added a nullable, unindexed
CRITERION_DETAILcolumn carrying the plaintext triple for operator readability. It was dropped before merge: nothing in the application ever read it back (not the enforcement query, not the Resource Server snapshot, not any handler), so its only consumer was a human querying the table directly, at the cost of storing a second copy of data the digest already identifies. It may be proposed separately if an admin-facing view needs it.Where the scope ID does belong: the write path. It is the natural, unambiguous input to the
administration flow. The preparatory executor projects it once into
(aud, permission), on the adminpath where a catalogue lookup is free. IDs upstream, token-space in the row.
default-role-assignment-removal-flowrole_assignment_removeddefault-role-permission-removal-flowrole_permission_removeddefault-role-deletion-flowrole_deleteddefault-group-membership-removal-flowgroup_membership_removeddefault-group-deletion-flowgroup_membership_removeddefault-scope-deletion-flowscope_deletedConsent withdrawal and reducing an application's allowed scopes are natural follow-ons; both need a
third criterion shape,
<entity>|<client>|<scope>, so they are out of scope for the first pass.All six skip the
SessionRevocationExecutor. Losing a scope is not losing your identity: the SSOsession is still legitimately the user's, and they should get a fresh, correctly scoped token without
signing in again.
What the preparatory executor computes
The node revokes every scope carried by the path being cut, without simulating the post-change
state (see Alternative 3). Boundary mode makes the resulting over-revocation self-healing.
Fan-out is the real constraint. A role assigned to a group of 10,000 users with 5 scopes is
50,000 rows. This needs a configurable cap on how many entities one revocation may expand to, and a
clear refusal above it rather than a half-written plan.
Architecture Overview
Only the starred boxes are new. The write seam, the table, the Authorization Server funnel and the
Resource Server cache already carry
token_family,subjectandapp.key.flowchart TB subgraph Admin["Administration (write path)"] C[Console / Management API] --> F["ADMINISTRATION flow ⭑"] F --> P["Preparatory executor ⭑<br/>resolves the lost scopes<br/>publishes a trusted plan"] P --> R[CriteriaRevocationExecutor<br/>unchanged] R --> A["Action executor ⭑<br/>performs the change"] A --> S["Re-stamp node ⭑<br/>moves the cutoff forward"] end R -->|RevokeByCriteria| DB[("REVOCATION_CRITERIA<br/>type + value + reason<br/>+ revoked_at + expiry")] S -->|RevokeByCriteria| DB subgraph Enforce["Enforcement (read path)"] AS[Authorization Server<br/>token validator] RS[Resource Server<br/>revocationcache] end DB -->|one indexed lookup per request| AS DB -->|periodic snapshot| RS AS --> V[allow or reject] RS --> VA sample flow
flowchart LR S([START]) --> PV[PermissionValidator<br/>requiredScopes: system] PV --> PRE["PreRoleAssignmentRemovalExecutor<br/>mode: revoke_before_action"] PRE -->|"plan: criteria, mode,<br/>cutoff, reason, ttl"| REV[CriteriaRevocationExecutor] REV --> ACT[RoleAssignmentRemovalExecutor] ACT --> RS2[CriteriaRevocationExecutor<br/>re-stamp] RS2 --> E([COMPLETE])The plan travels in
SharedRuntimeData, written only by the preparatory node. Consumers refuse anempty plan that does not explicitly declare there was nothing to revoke, the same contract
PreDeleteExecutorandPreApplicationDeleteExecutoralready hold.New executors: one preparatory type parameterized per action (the
preApplicationActionExecutorpattern, one struct behind several registered names), and one action executor per management
operation.
CriteriaRevocationExecutor, the plan encoding, the store, the SQL and both enforcementpoints are reused as they stand.
What lands in the database
Removing
State Identity Administratorfroml.tranat 10:15, where the California-scoped rolecarries
licenseandvehicle-registration:entity.scope9f86d081...01a01978-b3a5...|https://api.dmv.ca.gov|licenserole_assignment_removedentity.scope2c624232...01a01978-b3a5...|https://api.dmv.ca.gov|vehicle-registrationrole_assignment_removedb.miller, who holds the Ohio-scoped role grantinglicenseonhttps://api.dmv.oh.gov, hashesdifferently and is untouched. That is the whole point of qualifying by
aud.Writes are idempotent. The unique index on
(DEPLOYMENT_ID, CRITERION_TYPE, CRITERION_VALUE)means re-revoking the same pair updates the reason and time bounds rather than inserting a duplicate.
One row per criterion regardless of administrative churn.
Sizing the expiry
EXPIRY_TIMEmust outlive every artifact the row could match, then the existing cleanup job sweepsit. The question is what sets that window.
An application criterion can be sized exactly, because it is scoped to one client:
resolveArtifactLifetimereads that client's own token config and takes the longest of itsuser-access, client-access and refresh validities, plus the authorization code window. A role, group
or scope criterion has no such client. It matches a token from any application that could mint that
scope for the entity, so there is nothing single to read.
Decision: size these rows from
oauth.refresh_token.validity_period, the deployment-wide refreshtoken validity.
This needs no new code.
resolveCriterionLifetimealready returnsmax(plan.TTL, tokenFamilyLifetime), andtokenFamilyLifetimeis wired from exactly that configvalue. So the preparatory node publishes no
TTLSecondsand the row lands atnow + oauth.refresh_token.validity_period, 86400 seconds by default.Two things to record alongside it:
clamps it:
ResolveTokenConfigoverrides rather than caps, and no validation rejects an over-longvalue. That client's tokens can outlive the row. Accepted for now, since the alternatives are a
deployment-wide maximum computed across every client or a real enforced ceiling, and neither exists
today.
authorization_code.validity_period, because a code issued justbefore a revocation can be redeemed just after and mint a token whose life starts later than
REVOKED_AT. Worth deciding whether these rows should match that.Sizing only has to cover artifacts outstanding at revocation time, not the rotation chains they
could have spawned. Revoking a refresh token kills its chain, and a rotated refresh token inherits
the original's absolute expiry rather than getting a fresh one, so a chain cannot outrun the window.
Time is part of the criterion
Group, role and scope assignments can be reverted, so a revocation cannot be a permanent verdict on a
value.
role_assignment_removed,role_permission_removedandgroup_membership_removedall jointhe existing boundary set alongside
application_secret_regenerated. A boundary row denies onlyartifacts established at or before
REVOKED_AT.role_deletedandscope_deletedshould beboundary too, since a scope may be regranted through another role or a name reused. That differs from
how
role_deletedis currently classified and should be confirmed.A revert cycle then works without special handling:
REVOKED_AT = T1iat <= T1dieiat = T3 > T1, passesREVOKED_AT = T4Two rules fall out of this and must be stated explicitly, because both are easy to get wrong:
Never delete the row on revert. Tokens issued before T1 are still live until they expire, so
deleting the criterion when the grant is restored would resurrect them. The row's lifetime belongs to
the tokens, not to the grant.
The cutoff moves forward, never back. The upsert takes
excluded.REVOKED_ATwhen the existingreason is a boundary one, so re-revocation advances the cutoff. A terminal reason is never downgraded
to a boundary one.
The window between revoke and act, and how to close it
The cutoff is stamped in the preparatory node, before the action. That ordering is deliberate and
should stay. But it leaves a window:
The window cannot be closed with a transaction: the grant lives in configdb and the deny list in
runtime_persistent. The fix is to re-stamp after the action, adding a second
CriteriaRevocationExecutornode that rewrites the same criteria withcutoff = now. The upsertmoves
REVOKED_ATforward, sweeping up anything minted in the window, while the first writepreserves the fail-safe property if the action fails. Two writes, one row, no schema change.
A smaller note:
iatis second-granularity whileREVOKED_ATis sub-second, and the comparison is<=, so a token minted in the same second as a revocation is treated as revoked. That errs towardover-revocation, which is the safe direction.
How the search runs at enforcement
Both enforcement points build a revocation identity from the token's trusted claims. Today an access
token contributes three criteria: token family, subject, and the OAuth client. This adds one
entity.scopeand onescopecriterion per scope in the token, each hashed with the audience. Areal token from our test deployment:
Authorization Server. Criteria are ORed into one indexed statement, as the existing query already
does. No extra round trip, only extra OR terms:
Each term is a probe on the unique index, so cost grows with the number of scopes in the token, not
with the size of the deny list. A six-scope token goes from three terms to fifteen. If that proves
material, the mitigation is a cheap per-deployment "any scope revocation exists" check that skips the
scope terms entirely.
Resource Server. The
revocationcachesnapshots the table periodically and answers from memory,so these become two more map lookups.
matchesEntryalready implements the same boundary rule as theSQL. The snapshot ignores criterion types it does not recognize, so an older Resource Server keeps
working against a newer writer.
Refresh tokens are covered. They carry
scope, the user inaccess_token_suband the audiencein
access_token_aud. The identity builder already applies the subject fallback; the audience needsthe same treatment.
Scopeless and OIDC-only tokens are not bound to a resource server at all, so their audience falls
back to the client id and these criteria never match them. That is the correct outcome.
Known gap: a mapped
sub. The examples above assumesubis the entity's resource ID, which isthe default. An application may map
subto a schema attribute throughsubjectAttribute, in whichcase its tokens carry that attribute (an email, say) while the write path keys the row on the entity
ID, and the two never match. This is inherited rather than introduced here: the existing
subjectdimension behind user deletion and session revocation has the same property. Closing it means emitting
an opaque, stable subject identifier on client-facing tokens and keying on that;
sub_idalreadyexists on the internal flow assertion but is deliberately not emitted to clients today. Tracked
separately, since it changes token contents and affects the existing dimension too.
Security Considerations
revocation succeeds and the action then fails we have over-revoked, costing a token refresh. The
reverse ordering would under-revoke, which is a security hole.
token minted between them escapes the boundary cutoff. Closed by re-stamping after the action.
already carries. Keying on catalogue IDs instead would mean resolving names to IDs at validation
time, and a stale mapping would resolve to "no match" and silently admit a revoked token.
cut without simulating the post-change state, and the
iatcomparison is<=against a sub-secondcutoff, so a token minted in the same second as a revocation is denied. Both err in the safe
direction.
until they expire; removing the criterion would resurrect them.
UpdateResourceServercan changeIDENTIFIER, and nothing rejects tokenscarrying the old
aud: locally issued tokens are verified with an empty expected audience, and athird-party server validates against a statically configured string. A criterion written after such
a change silently misses them. See the questions below.
denial-of-service surface against our own database and needs a cap with a clean refusal.
declarative resource server owns its definition from a file, so the change would be refused by the
service anyway. The preparatory node therefore refuses first, rather than writing deny-list rows for
a change that was never going to happen and leaving the principal's tokens dead for nothing.
revocationcacheis fed bydbSource, which readsruntime_persistent directly, so it only serves processes sharing our database. A genuine third-party
resource server has only
/oauth2/introspecttoday. See the questions below.Impacted Areas
internal/flow/executor: new preparatory, action and re-stamp executors;CriteriaRevocationExecutorand
PermissionValidatorreused unchanged.internal/revocation: newCriterionTypevalues and revocation reasons; boundary classification.internal/oauth/oauth2/revocation: criteria store and the deny-list SQL predicate.internal/oauth/oauth2/tokenservice: the Authorization Server revocation identity gains the scopedimensions and reads
aud(andaccess_token_audfor refresh tokens).internal/system/revocationcacheandinternal/system/security: the Resource Server cache andthe identity the middleware surfaces.
internal/role,internal/resource: admin providers exposing the validation and scope-resolution thepreparatory nodes need. The group operations live in
internal/rolerather thaninternal/group,because a group's scopes are the scopes of the roles it holds, so resolving them needs the role
service regardless.
REVOCATION_CRITERIA. The rows reuse the existing columns.internal/oauth/oauth2/revocation: a batched write path alongside the single-row one, so a plancarrying up to the fan-out cap costs a few statements rather than a round trip per row.
falling back to the native endpoint when none is configured.
Alternatives Considered
Alternative 1: key criteria on the scope UUID instead of
<entity>|<aud>|<scope>VARCHAR(255)as plaintext with no hashing.(aud, scope-string) -> IDper request. The Resource Server cache would need the config-databasecatalogue replicated into it, and a stale mapping fails open instead of closed.
(aud, permission)on the admin path, and recorded inCRITERION_DETAIL.Alternative 2: revoke by
subjectalone, with a boundary cutoffper (user, scope).
that removed one scope.
Alternative 3: compute the exact scope delta by simulating the post-change state
disagreement under-revokes, which is the unsafe failure.
Alternative 4: publish revocations per token via Token Status List
Expressing it would mean enumerating every matching outstanding token, which needs a full token
registry, precisely the state stateless JWTs exist to avoid.
Questions for Community Input
Fan-out cap. What is the ceiling on entities per revocation, and what happens above it: refuse,Resolved as shipped: the ceiling isor fall back to a coarse per-subject revocation?
oauth.revocation.criteria.max_criteria, 10,000 by default, and a change exceeding it is refusedbefore anything is written. Falling back to the coarser
scopedimension above the cap remains areasonable future option, since today the largest changes are the ones that end up on the native
endpoint, which revokes nothing.
role_deletedandscope_deletedclassification. Boundary, as argued above, or terminal?Resolved as shipped: both are boundary, as argued above.
UpdateResourceServercan changeIDENTIFIER. Outstanding tokens keep theold
aud, and nothing rejects them: locally issued tokens are verified with an empty expectedaudience, and a third-party server validates against a statically configured string. So a criterion
written after the change silently misses them. Options: make the identifier change an
administration flow that revokes on the old audience first, or refuse the change while non-expired
criteria reference that server. Separately, whether skipping audience validation for locally issued
tokens is intended at all is worth confirming against RFC 9068 section 4.
revocationcacheis fed bydbSource, which readsruntime_persistent directly, so it only serves processes sharing our database. A genuine
third-party resource server has only
/oauth2/introspecttoday; there is no Shared Signals, CAEP,webhook or Token Status List anywhere in the codebase. The hard part is specific to this design: a
criterion is a predicate over tokens that do not exist yet, not a flag on one that does. Per-token
mechanisms such as Token Status List cannot express it without enumerating every matching
outstanding token, which needs a full token registry, the very state stateless JWTs avoid. So the
answer cannot simply be "adopt Token Status List later". Unresolved, and out of scope here. One
thing in our favour:
syncSourceis already an interface withdbSourceas one implementation.actclaim has an actor distinct from the subject. Do werevoke on the actor's lost scopes as well?
Suggested order of delivery
entity.scopeandscopedimensions at both enforcement points, theCRITERION_DETAILcolumn, and the new reasons.
default-role-assignment-removal-flow, with its preparatory, action and re-stamp nodes, tovalidate the shape end to end.
default-group-membership-removal-flowanddefault-group-deletion-flow.default-role-permission-removal-flowanddefault-role-deletion-flow.default-scope-deletion-flow.configured flow handle, execute the flow, fall back to the native endpoint when none is configured.
All reactions