Engineering · Part 18 · the v3 token model
Scope-less: consents, not scopes
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.