Lesson 21 / 23
Scopes and Permissions
Keeping a token's scope narrower than its owner's authority, the decision produced by the intersection of the scope and permission layers, responses that diverge by the reason for denial, measuring the access surface, and the narrowing rule on renewal.
Contents
The previous four lessons bound the decision to the person: who, in which role, with which attributes, and in what relationship to the object. The answer to these questions is fixed for a person. But the one making the request is often not the person themselves: the library’s mobile app, a reporting tool, a third-party reading-list service the person has authorized.
Granting these clients the person’s entire authority is unnecessary. The reading-list service has no need to delete a loan record; the reporting tool has no need to read a member’s address. Scope is the mechanism that keeps a token’s authority narrower than its owner’s authority.
A Two-Layer Decision
Scope does not replace the authorization models from the previous lessons; it sits on top of them. A request must pass two questions at once: can the person do this, and has this client been granted this authority. The two come from separate sources — the first from user management, the second from the consent given when the token was issued.
The decision is therefore an intersection. Scope cannot widen authority: a token
carrying the fine:write scope means nothing if its owner has no permission to collect
fines. The reverse also holds: an authorized clerk’s token cannot reach an endpoint
outside its scope.
The computation below runs these two layers over a mapping table.
cat > scope.mjs <<'EOF' // scope.mjs — mapping token scopes onto endpoints and the two-layer decision // 1. Endpoint -> required scope mapping. Part of the contract. const ENDPOINTS = [ { method: "GET", path: "/loans", scope: "loan:read" }, { method: "POST", path: "/loans", scope: "loan:write" }, { method: "DELETE", path: "/loans/{id}", scope: "loan:write" }, { method: "GET", path: "/members/{id}", scope: "member:read" }, { method: "PATCH", path: "/members/{id}", scope: "member:write" }, { method: "GET", path: "/members/{id}/fines", scope: "fine:read" }, { method: "POST", path: "/fines/{id}/collect", scope: "fine:write" }, { method: "GET", path: "/reports/overdue", scope: "report:read" }, ]; // 2. Subject permissions (from the RBAC lesson): what the person can do. const PERMISSIONS = { "U-1001": new Set(["loan:read", "loan:write", "member:read", "fine:read"]), "P-2001": new Set(["loan:read", "loan:write", "member:read", "member:write", "fine:read", "fine:write"]), "Y-3001": new Set(["loan:read", "loan:write", "member:read", "member:write", "fine:read", "fine:write", "report:read"]), }; // 3. Tokens: the scope set granted to the client. Not the same thing as the person's permissions. const TOKENS = [ { name: "mobile-app", subject: "U-1001", scopes: ["loan:read", "loan:write", "member:read"] }, { name: "reading-list", subject: "U-1001", scopes: ["loan:read"] }, { name: "reporting-tool", subject: "P-2001", scopes: ["report:read", "loan:read"] }, { name: "desk-app", subject: "Y-3001", scopes: ["loan:read", "loan:write", "member:write", "fine:write"] }, ]; // 4. Decision: both layers must pass. Scope bounds the client's authority, permission the person's. function decision(token, endpoint) { const hasScope = token.scopes.includes(endpoint.scope); const hasPermission = PERMISSIONS[token.subject].has(endpoint.scope); if (!hasScope && !hasPermission) return "deny: no scope and no permission"; if (!hasScope) return "deny: token lacks scope"; if (!hasPermission) return "deny: subject lacks permission"; return "allow"; } const pad = (s, n) => String(s).padEnd(n); console.log("token endpoint result"); for (const t of TOKENS) { for (const endpoint of ENDPOINTS) { const s = decision(t, endpoint); if (s === "allow") continue; // show only the denied ones if (!PERMISSIONS[t.subject].has(endpoint.scope) && !t.scopes.includes(endpoint.scope)) continue; console.log(`${pad(t.name, 18)} ${pad(endpoint.method + " " + endpoint.path, 33)} ${s}`); } } // 5. The scope as an access surface: how many endpoints can each token reach? console.log("\ntoken scopes allowed endpoints surface"); for (const t of TOKENS) { const allowed = ENDPOINTS.filter((endpoint) => decision(t, endpoint) === "allow"); const percent = ((100 * allowed.length) / ENDPOINTS.length).toFixed(0); console.log(`${pad(t.name, 18)} ${pad(t.scopes.length, 7)} ${pad(allowed.length + " / " + ENDPOINTS.length, 16)} %${percent}`); } // 6. The effect of scope granularity: coarse scope surface vs fine scope surface. const COARSE = ["loan:*", "member:*"]; const coarseMatches = (endpoint) => COARSE.some((k) => endpoint.scope.startsWith(k.slice(0, -1))); const coarseSurface = ENDPOINTS.filter(coarseMatches).length; const fineSurface = ENDPOINTS.filter((endpoint) => ["loan:read", "member:read"].includes(endpoint.scope)).length; console.log(`\ncoarse scope ${JSON.stringify(COARSE)} -> ${coarseSurface} / ${ENDPOINTS.length} endpoints`); console.log(`fine scope ["loan:read","member:read"] -> ${fineSurface} / ${ENDPOINTS.length} endpoints`); console.log(`granting a coarse scope to a client that wants read access opens ${coarseSurface - fineSurface} extra endpoints.`); // 7. Narrowing on renewal: the client can request less scope, never more. function renew(token, requested) { const outside = requested.filter((k) => !token.scopes.includes(k)); return outside.length ? { result: "deny", reason: "requested scope outside the original token: " + outside.join(", ") } : { result: "accept", scopes: requested }; } const mobile = TOKENS[0]; console.log("\nrenewal attempts (original scope: " + JSON.stringify(mobile.scopes) + ")"); for (const requested of [["loan:read"], ["loan:read", "member:read"], ["loan:read", "fine:write"]]) { const r = renew(mobile, requested); console.log(` ${pad(JSON.stringify(requested), 34)} ${r.result}${r.reason ? " — " + r.reason : ""}`); } EOF node scope.mjs
token endpoint result
mobile-app GET /members/{id}/fines deny: token lacks scope
reading-list POST /loans deny: token lacks scope
reading-list DELETE /loans/{id} deny: token lacks scope
reading-list GET /members/{id} deny: token lacks scope
reading-list GET /members/{id}/fines deny: token lacks scope
reporting-tool POST /loans deny: token lacks scope
reporting-tool DELETE /loans/{id} deny: token lacks scope
reporting-tool GET /members/{id} deny: token lacks scope
reporting-tool PATCH /members/{id} deny: token lacks scope
reporting-tool GET /members/{id}/fines deny: token lacks scope
reporting-tool POST /fines/{id}/collect deny: token lacks scope
reporting-tool GET /reports/overdue deny: subject lacks permission
desk-app GET /members/{id} deny: token lacks scope
desk-app GET /members/{id}/fines deny: token lacks scope
desk-app GET /reports/overdue deny: token lacks scope
token scopes allowed endpoints surface
mobile-app 3 4 / 8 %50
reading-list 1 1 / 8 %13
reporting-tool 2 1 / 8 %13
desk-app 4 5 / 8 %63
coarse scope ["loan:*","member:*"] -> 5 / 8 endpoints
fine scope ["loan:read","member:read"] -> 2 / 8 endpoints
granting a coarse scope to a client that wants read access opens 3 extra endpoints.
renewal attempts (original scope: ["loan:read","loan:write","member:read"])
["loan:read"] accept
["loan:read","member:read"] accept
["loan:read","fine:write"] deny — requested scope outside the original token: fine:write
The Reason for Denial Must Be Distinguishable
The first part of the output lists denied requests together with their reasons. The two reasons are distinct, and the distinction tells a developer different things.
“Token lacks scope” is a recoverable situation: the client can ask the user for
additional consent and obtain a token with a broader scope. “Subject lacks permission”
is not recoverable — no consent flow can grant a clerk permission to read the overdue
report, because that permission is defined in user management. The reporting-tool row is
an example of this: the token carries the report:read scope, but the subject has no
permission, so the request is denied.
The response sent to the client must carry this distinction. For insufficient scope, the standard is a response stating which scope is required; for insufficient permission, no extra detail is given — spelling out what the user is not authorized for leaks the authority map to the outside.
Scope Is an Access Surface
The second part counts how many endpoints each token can reach. This number is a direct measure of how much damage can be done if the token leaks: the reading-list service’s token reaches one of eight endpoints, the desk app’s token reaches five.
The surface being measurable is a design tool. Before a client’s requested scope set is
approved, the question to ask is: is the number of endpoints this set opens
proportional to what the client actually does? If the reading-list service only reads
loan history, loan:read is enough; asking for loan:write triples the surface and has
no justification.
Grain Size Is a Decision
The third part compares a coarse and a fine scope. Defining a single scope per
resource — something like loan:* — shortens the scope list, but also grants write
authority to a client that only wants to read; in the model, that means three extra
endpoints.
At the opposite extreme, defining a separate scope for every endpoint does not work
either: as the scope list grows, the consent screen becomes unreadable and the user
approves it without reading it. The grain size that works is the resource-and-action
pair: loan:read, loan:write. At this grain, the scope count is twice the resource
count, and every scope’s meaning can be stated in one sentence.
The scope’s own name is also part of the contract. When the endpoint changes, the scope name stays fixed; when the scope name changes, every client must obtain consent again. This is the authorization side of the breaking-change rule from the Versioning Strategies lesson.
Renewal Narrows; It Does Not Widen
The last part shows renewal behavior. A client renewing its current token may request a narrower scope set; this makes it possible to run with less authority temporarily during a long-lived session. It may not request a broader set: widening requires a new consent flow.
The rule is written in one sentence: a renewed token’s scope must be a subset of the original token’s scope. Not enforcing this rule turns the renewal endpoint into a silent privilege-escalation path — because renewal is a flow that runs without user consent.
Summary
- Scope keeps a token’s authority narrower than its owner’s authority; the decision is the intersection of the two layers, and scope never widens authority.
- Insufficient scope and insufficient permission produce different responses: the first states which scope is required, the second gives no detail.
- The number of endpoints a token reaches is the measure of the damage a leak could do; in the model, token surfaces range from thirteen to sixty-three percent.
- The grain is kept at the resource-action level: a coarse scope opens three extra endpoints to a client that wants to read, an overly fine scope makes the consent screen unreadable.
- On renewal, scope can only be narrowed; widening requires new consent, otherwise renewal turns into a silent privilege-escalation path.
Next Step
Every decision made so far has been given inside a single library: one member, one clerk, one branch. Once the same server serves more than one library, a new boundary enters the picture, and this boundary is drawn not by role or scope but by data: one library’s clerk should be able to see their own institution’s records and should never be able to see a neighboring institution’s records under any condition. The next lesson takes up this isolation and shows how the boundary is carried into the query itself.
To keep your progress and take notes, Log in
My notes
Log in to take notes.