diff --git a/plan/oauth.md b/plan/oauth.md index f72ce6cf..8c4e7705 100644 --- a/plan/oauth.md +++ b/plan/oauth.md @@ -484,3 +484,25 @@ both gone. `a_login_whose_key_left_the_clients_key_set_does_not_refresh` and the refusals beside them, and `oauth::token`'s `a_confidential_login_refreshes_only_with_the_key_it_began_with`. +- [x] **Permission sets.** An `include:` scope grants what its set allows. + At PAR, before `login_hint` is resolved, `oauth::permission_set` + resolves the set the way the lexicon spec publishes one. The `_lexicon` + TXT record under the set's authority, asked of a DNS-over-HTTPS + resolver, names a DID. That DID's document names its server and key, + and `didbot_repo::read_record` checks the set's + `com.atproto.sync.getRecord` proof against the key. + `didbot_scope::Include::grants` keeps the set's `repo` and `rpc` + permissions under its own NSID group, with `inheritAud` taking the + `include:`'s `aud`, and ignores everything else. Those atoms replace + the `include:` before the ceiling sees the request, so the agent + approves the expanded, narrowed grant and the login stores it. Nothing + resolves the set again for that login. + + A set that does not resolve grants nothing, and the rest of the + request is judged as usual; the permission spec fails such a sign-in + instead. A resolution is cached for every account for an hour, and the + last copy stands in while the set's authority cannot be reached. A + stored grant is read under `MAX_GRANT_ATOMS`, four times a request's + bound: `app.bsky.authFullApp` alone grants 115 atoms. Tests: + `crates/didbot-serve/tests/permission_sets.rs`, against a stand-in + authority serving Leaflet's and Bluesky's published sets. diff --git a/plan/scope-policy.md b/plan/scope-policy.md index be32597a..0dddbf0b 100644 --- a/plan/scope-policy.md +++ b/plan/scope-policy.md @@ -159,10 +159,13 @@ an agent approves. 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 permission set cannot carry one. `didbot_scope::Include::grants` + reads only a set's `repo` and `rpc` permissions, as the permission spec + allows, so an `include:` never yields a hard-blocked atom. A set that + lists an `account` permission still grants the rest of what it lists. + Leaflet's scope is refused all the same: it names `transition:email` + directly, and `account:email?action=read`, whose `read` action this + grammar does not parse. A narrow ceiling cannot express this and must not be asked to. A blocked atom overlaps the atoms a ceiling *does* admit — @@ -173,6 +176,13 @@ an agent approves. that holds even with no list loaded, and the list is what makes the answer a refusal rather than a silence. +- [x] **An `include:` is judged as what its set grants.** Each `include:` + in a pushed request becomes the `repo:` and `rpc:` atoms its permission + set grants before the ceiling is applied. The ceiling narrows them like + any other atom, and the decision record names what it cut from them. A + set that does not resolve grants nothing. See [oauth](oauth.md)'s + "Permission sets". + - [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`,