--- id: scope-policy title: An agent cannot be granted what its owner has not allowed status: open crates: [didbot-serve, didbot-pds] dependsOn: [oauth, policy-store] exitCriterion: > An app requesting a scope outside an agent's profile is refused at the authorize step, and a hard-blocked scope is refused whatever policy says. --- # scope-policy **Superseded.** [policy](policy.md) settles the model this epic assumed — denials only, evaluated at the write, from three sources — and where the two disagree, this file is wrong. Read that one first. **This was `blocked` and is not.** Both things it waited on are in the tree: [oauth](oauth.md)'s granular grammar landed (`crates/didbot-serve/src/oauth/scope.rs`, with `Scope::contains`, `Scope::intersect` and `ScopeSet::intersect`), and the hook this epic exists to fill is called on the live path — `ScopePolicy::ceiling`, at PAR, whose only implementations today are `GrantAnyScope` and `ConfiguredCeiling`. A hook waiting for an implementer is not a dependency that has not landed. The [policy-store](policy-store.md) half is not blocking either, by this file's own "from server configuration first" item. ## The ceiling is not policy What an app asks for is intersected with what this agent may ever hold, and the intersection, taken at every use, is what the token may do. Calling that a policy overstates it, and the honest description is worth keeping in front of whoever implements the rest of this file: - **It is the deployment's statement**, not the owner's and not a rule in [policy](policy.md)'s tree. It says the most any agent on this server may ever hold, and it is keyed on neither the agent nor the client — both arguments to `ScopePolicy::ceiling` are ignored by every implementation there is. - **It applies by intersection, and can only subtract.** `ConfiguredCeiling::new` keeps an atom only if the compiled-in ceiling (`GrantAnyScope`) already admits it whole, so the widest a configured ceiling can be is the compiled-in one. That is what lets a scope live in a configuration file at all — see `plan/policy.md`'s "configuration may narrow, never grant". - **It runs where the tree runs**, at the pushed authorization request, and its narrowing is written into the same decision record and the same evaluation log as the tree's denials. It runs again at every token issue and every write, against the login's whole request, so an approval covers that request and not only what the record granted; see [oauth](oauth.md). **Retiring it has a precondition.** A ceiling belongs in the tree the day the tree can express it, and today it cannot: `didbot_policy::Subject::Grant` carries a `client_id` and the requested scopes, and no account, so a per-account rule has nothing to match on. Adding that field to the grant subject is the work that closes the gap; until it lands, one blunt deployment-wide ceiling is the only shape available. - [ ] **Per-agent scope profiles, attached at provisioning and keyed on the agent's type.** A read-only profile is not expressible: the grammar has no read action, so what can be built is a no-write profile. - [ ] **Narrow, never silently: say which scope was cut, and by what.** Downscoping is a first-class verdict rather than a refusal, so an app asking for more than its agent may hold gets the part it may have and the agent is told what it did not get. A refusal stays the answer when the ceiling grants nothing at all. What it did not get is withheld for now, not refused, and the agent is told that too: a narrowed `didbot-oauth pending` line carries `approves=asked ceiling-checked=each-use`. **Built at the decision, not yet on the wire format this item also names.** `ScopeSet::narrow` returns the grant and the atoms the ceiling did not give in full, and `oauth::decision::Verdict` carries `narrow` with `granted`, `cut` and the rule that cut it — see [oauth](oauth.md)'s decision record, which is what an agent and the consent page both read. `ScopeSet::intersect` still refuses, which is what a later ceiling check against an issued grant wants. What is *not* built is the refusal's wire format: it is an `invalid_scope` JSON body, not a `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. The write routes send that challenge, in the `DPoP` scheme, for a write outside its token's scope; see Done. - [ ] **A ceiling is an enumeration, not a pattern.** Only whole wildcards are legal, so a new lexicon is outside every profile until somebody names it. That fails closed and needs a view of collections no rule mentions. - [ ] **From server configuration first, from the owner's records second.** Configuration is what makes the boundary testable before [policy-dashboard](policy-dashboard.md) exists. **The configuration half is built.** `[oauth] scope_ceiling` in `didbot-config` is an enumeration of scope atoms, and `oauth::authorize::ConfiguredCeiling` is the `ScopePolicy` built from it. It may only narrow: every atom is checked against the compiled-in ceiling and dropped unless that ceiling admits it whole, so an operator naming a hard-blocked capability gets nothing for it. **The owner's-records half is built beside it, not in its place.** `crates/didbot-serve/src/policy_poll.rs` builds the operator's `bot.did.policy` and `bot.did.policyBinding` records into the gate, and PAR asks that gate about a `Subject::Grant` alongside the ceiling. ## Done - [x] **A login keeps what the agent approved, and the ceiling is asked at every use.** A login stores the scope the decision record granted — the set the agent was shown — and what a token may do is that set as the ceiling in force grants it (`oauth::token::in_force`, the verdict PAR reaches), asked at exchange, at every refresh and at every write. A ceiling tightened after sign-in narrows a live login at once; one loosened gives a cut scope back only to a fresh sign-in, which puts the whole request in front of the agent again. A refresh the ceiling now grants nothing is refused and ends nothing. Tests: `a_loosened_ceiling_never_gives_a_live_login_what_the_agent_was_told_was_cut` and `a_tightened_ceiling_narrows_a_live_login_at_once` in `crates/didbot-serve/tests/token_scope.rs`. - [x] **A token writes only what it was granted.** Each `com.atproto.repo.*` write route checks its writes against the grant (`auth::Writer::authorize`) once the repository is known: `repo:` with the write's action, and both `create` and `update` for `putRecord`, whose action is not known until the store is read. A write outside it is `403 InsufficientScope` with `WWW-Authenticate: DPoP error="insufficient_scope", scope="…"`, and a `token-scope` row in the evaluation log naming the client. The account's own agent token is not scoped. Tests: `crates/didbot-serve/tests/token_scope.rs`. - [x] **Hard-blocked capabilities no profile can be granted.** `blob`, `account` and `identity` are a ready-made boundary around deletion, key rotation and migration out; `transition:*` is on the list for the opposite reason, carrying records, blobs and RPC in one string and making every granular rule beside it decorative. A request naming any of them is refused whole — `oauth::decision::hard_blocked`, checked before the ceiling is consulted, giving a `deny` verdict whose rule is `hard-blocked: ` and no token to approve it with. **Requested directly is not yet a thing that can be asked for**, so today this is a flat refusal rather than the "cannot hide inside a permission set" this item describes. The distinction needs a request shape that says "this atom alone, deliberately", which nothing has. A narrow ceiling cannot express this and must not be asked to. A blocked atom overlaps the atoms a ceiling *does* admit — `transition:generic` covers `repo:*` and `rpc:*` — so a ceiling check on its own pays it out in those pieces, which is a wider grant than the rest of the request would have got. `ScopeSet::narrow` projects from the requested atom onto the ceiling and only within one kind, so that holds even with no list loaded, and the list is what makes the answer a refusal rather than a silence. - [x] **Log every scopedown.** Twice over, for two readers. The agent's copy is the decision record's own `cut` and `rule` ([oauth](oauth.md)); the operator's is a payload-free row in `didbot_pds::evaluation_log`, written for every narrowing and every denial, naming what was cut and what cut it and never what the client sent.