Engineering · Part 18 · the v3 token model

Scope-less: consents, not scopes

architectureapi

Reference documentation for the v3 token model: why the OAuth scopes from Part 2 were deleted, what replaced them (nothing new — a thing we already had), and how one token.require(operation) now answers authorization for a browser session, a bearer token, and the in-app AI assistant alike.

The question a scope can't answer

Part 2 shipped classic api:read/api:write scopes: a token granted only api:read couldn't invoke a kind='write' operation. It worked, and it was still the wrong shape. The question a caller actually poses is never "may this client write, in general?" — it is "may this client run publish_post on this blog?". The system already held a precise answer to that question: the consent — the durable, user-editable grant that names, per blog, the policy (a named set of operations, Part 1) the client may borrow.

Next to that, a read/write scope pair is strictly redundant: coarser than the policy on one axis (it can't distinguish publish_post from delete_post — both "write"), and blind on the other (it says nothing about which blog). Every request had to pass both checks; only one of them ever carried information. Two authorization vocabularies means two places to review, two places to get wrong, and a consent screen that asks the user to approve a scope string and a policy — one of which is theatre.

Scope-less

So v3 deletes the scope layer. Wire tokens carry no scopes at all: the OAuth dance is unchanged (discovery, dynamic client registration, PKCE — Part 2 stands), but the token that comes out the other side is opaque and scope-less, and the consent row is the whole machine cap:

class Consent(models.Model):
    user   = models.ForeignKey(User)          # who is lending access
    client = models.ForeignKey(Client, null=True)   # null → the in-app assistant (below)
    blog   = models.ForeignKey(Blog)
    policy = models.ForeignKey(Policy)        # what the client may borrow HERE

One row per (client, blog): the user picked the policy on the consent screen, can edit it live on the Clients page, and deleting it revokes instantly — tokens are opaque, so every request re-reads the grant. There is no scope to display, because there is nothing a scope would add.

One token, two constructors

The second fold: the browser and the machine stopped being different code paths. Both wrap into a small token object, and the domain core sees only its interface:

CookieToken(user, blog)            # a session: the human, acting directly
ConsentToken(consent)              # a bearer: a client, acting for the human

def publish_post(token, post_id):
    token.require('publish_post')  # the ONE authorization line (Part 1's authorize, folded in)
    post = get_in_blog(Post, token.blog, post_id)
    ...

require computes the same thing in both cases — is this operation in the effective set? — but the effective set differs by construction:

  • CookieToken: the union of the policies the user's own membership holds on this blog. (Part 1, unchanged.)
  • ConsentToken: the consent's borrowed policy the lending user's own live membership. The intersection is the attenuation stack from Part 2, kept: a machine can never out-rank its human, and demoting the human instantly demotes every client they ever consented.

Because both constructors yield the same interface, every transport — HTML views, the JSON API, MCP, the LLM tool-call path — passes a token and forgets which kind it holds. There is no "if bearer then also check scope" branch anywhere, because there is no scope.

The assistant is a consent with no client

The in-app AI assistant used to be a special case: it acts for the user, in-process, with no OAuth client or token exchange. In v3 it is simply a consent row with client=NULL — the user still grants it a policy per blog on the same screen, the domain core still receives a ConsentToken, and the same intersection caps it. The most privileged-feeling caller in the system goes through the narrowest door, with zero special-case code.

What the deletion bought

  • One vocabulary. "What may X do here?" is always answered by a policy — for members (Part 1), for OAuth clients, for the assistant. The permissions screen, the consent screen and the Clients editor are three views of the same noun.
  • Honest consent. The consent screen asks exactly what it enforces: this client, this blog, this policy. Nothing displayed is theatre.
  • Less machinery. The scope-enforcement middleware, the scope column, the per-operation scope mapping and their tests are gone; the registry needs no per-operation metadata beyond kind, which survives only as a label (the read/write rows on the permissions screen), gating nothing.
  • Unchanged spec-compliance. OAuth 2.1 does not require scopes; clients that request them are tolerated and ignored. Discovery, DCR and PKCE all work exactly as in Part 2.

The general lesson mirrors Part 1's: resist inventing a second authorization vocabulary because a protocol offers you a field for one. If a precise per-tenant grant exists, a coarse global modifier on top of it is not defence in depth — it is a second thing to audit that can only ever agree with or contradict the first.


← All posts