diff --git a/crates/didbot-scope/src/account.rs b/crates/didbot-scope/src/account.rs index a4b92933..1cd75caa 100644 --- a/crates/didbot-scope/src/account.rs +++ b/crates/didbot-scope/src/account.rs @@ -3,9 +3,7 @@ use std::fmt; -use percent_encoding::percent_decode_str; - -use crate::parse::split_query; +use crate::parse::attr_params; use crate::{Scope, ScopeParseError}; /// The part of an account's hosting an `account:` scope names. @@ -60,28 +58,16 @@ impl AccountAction { } } -/// Reads what follows `account`: the attribute, written positionally or as -/// `attr`, and at most one `action`, `read` when none is given. -pub(crate) fn parse(rest: &str, atom: &str) -> Result { - let (value, pairs) = split_query(rest)?; - let decode = |value: &str| percent_decode_str(value).decode_utf8_lossy().into_owned(); - let mut attr = (!value.is_empty()).then(|| decode(value)); - let mut action = None; - for (key, value) in pairs { - match key { - "attr" if attr.is_none() => attr = Some(decode(value)), - "action" if action.is_none() => action = Some(AccountAction::parse(&decode(value))?), - _ => { - return Err(ScopeParseError::UnexpectedParameter( - key.to_owned(), - atom.to_owned(), - )) - } - } - } - let attr = attr.ok_or_else(|| ScopeParseError::MissingValue(atom.to_owned()))?; +/// Reads an `account:` atom: the attribute and at most one `action`, `read` +/// when none is given. +pub(crate) fn parse(atom: &str) -> Result { + let (attr, [action]) = attr_params(atom, "account", ["action"])?; Ok(Scope::Account { attr: AccountAttr::parse(&attr)?, - action: action.unwrap_or(AccountAction::Read), + action: action + .as_deref() + .map(AccountAction::parse) + .transpose()? + .unwrap_or(AccountAction::Read), }) } diff --git a/crates/didbot-scope/src/action.rs b/crates/didbot-scope/src/action.rs index 096b11a4..71f72f42 100644 --- a/crates/didbot-scope/src/action.rs +++ b/crates/didbot-scope/src/action.rs @@ -5,17 +5,8 @@ use std::fmt; use crate::ScopeParseError; -/// One action an `action=` query may name. -/// -/// `Create`, `Update` and `Delete` are `repo:`'s vocabulary. `Manage` is -/// `identity:`'s — an identity is not written record by record, so the -/// grammar has one verb for "may change this" rather than three. Sharing one -/// enum across both keeps [`ActionSet`]'s containment and intersection logic -/// single, and a `repo:` scope requesting `manage` (or an `identity:` scope -/// requesting `create`) is nonsensical but harmless: containment against a -/// ceiling that never grants the mismatched verb simply always fails, same -/// as any other action mismatch. `account:` has its own, in -/// [`crate::AccountAction`]. +/// One action a `repo:` scope's `action=` query may name. `account:` has its +/// own, in [`crate::AccountAction`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Action { /// `repo:`'s "may create a new record". @@ -24,7 +15,9 @@ pub enum Action { Update, /// `repo:`'s "may delete a record". Delete, - /// `identity:`'s "may change this resource". + /// `manage`, which the permission spec does not give `repo:`. Every + /// write asks for one of the other three, so a scope naming only this + /// one grants no write. Manage, } @@ -49,7 +42,7 @@ impl Action { } } -/// The set of actions a `repo:` (or `identity:`) scope grants. +/// The set of actions a `repo:` scope grants. /// /// `All` is distinct from an explicit enumeration of every action: it is /// what an atom with no `action=` query means, and it is what containment diff --git a/crates/didbot-scope/src/error.rs b/crates/didbot-scope/src/error.rs index 43726563..4a7bb183 100644 --- a/crates/didbot-scope/src/error.rs +++ b/crates/didbot-scope/src/error.rs @@ -66,9 +66,8 @@ pub enum ScopeParseError { /// An `action=` value the atom's kind does not define. #[error("`{0}` is not a recognised action")] UnknownAction(String), - /// An `account:` atom named an attribute the permission spec does not - /// define. - #[error("`{0}` is not an account attribute")] + /// An `attr` value the atom's kind does not define. + #[error("`{0}` is not a recognised attribute")] UnknownAttribute(String), /// A parameter the atom's kind does not take, or one given twice. #[error("`{0}` is not a parameter `{1}` takes, or is given twice")] @@ -77,8 +76,9 @@ pub enum ScopeParseError { /// legacy scopes. #[error("`{0}` is not a recognised transition scope")] UnknownTransition(String), - /// An `identity:`, `account:` or `include:` atom had no value after the - /// colon. An `include:` with a value that names no permission set still + /// The atom gave no value for the parameter its kind requires: an + /// `identity:` or `account:` attribute, a `blob:` type, or an `include:` + /// set. An `include:` with a value that names no permission set still /// parses; see [`Include::parse`](crate::Include::parse). #[error("`{0}` is missing the value its kind requires")] MissingValue(String), diff --git a/crates/didbot-scope/src/identity.rs b/crates/didbot-scope/src/identity.rs new file mode 100644 index 00000000..876dbcf2 --- /dev/null +++ b/crates/didbot-scope/src/identity.rs @@ -0,0 +1,48 @@ +//! `identity:` scopes, as the permission spec defines them: control of the +//! account's DID document and handle. + +use std::fmt; + +use crate::parse::attr_params; +use crate::{Scope, ScopeParseError}; + +/// The part of an account's identity an `identity:` scope names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IdentityAttr { + /// `handle`: the account's handle, and its entry in the DID document. + Handle, + /// `*`: the whole DID document, and the handle. + Any, +} + +impl IdentityAttr { + fn parse(value: &str) -> Result { + match value { + "handle" => Ok(Self::Handle), + "*" => Ok(Self::Any), + other => Err(ScopeParseError::UnknownAttribute(other.to_owned())), + } + } + + /// Whether this attribute covers everything `other` does. + pub(crate) fn includes(self, other: Self) -> bool { + self == other || self == Self::Any + } +} + +impl fmt::Display for IdentityAttr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Handle => "handle", + Self::Any => "*", + }) + } +} + +/// Reads an `identity:` atom: the attribute, and no other parameter. +pub(crate) fn parse(atom: &str) -> Result { + let (attr, []) = attr_params(atom, "identity", [])?; + Ok(Scope::Identity { + attr: IdentityAttr::parse(&attr)?, + }) +} diff --git a/crates/didbot-scope/src/lib.rs b/crates/didbot-scope/src/lib.rs index aae9b35a..309cd0a4 100644 --- a/crates/didbot-scope/src/lib.rs +++ b/crates/didbot-scope/src/lib.rs @@ -38,17 +38,20 @@ //! one names as many as it repeats. How large a blob may be is not in this //! grammar; it is the operator's, in policy and in the server's blob //! limit. -//! - `identity:[?action=]` — `plan/scope-policy.md`'s -//! hard-blocked identity capability (handle changes, key rotation). +//! - `identity:` — `plan/scope-policy.md`'s hard-blocked identity +//! capability: [`IdentityAttr`] names the handle, or `*` for the whole DID +//! document and handle. It takes no other parameter. //! - `account:[?action=]` — the other hard-blocked capability: //! [`AccountAttr`] names what, and [`AccountAction`] how much, `read` when -//! the atom names none. The attribute may also be written as a parameter, -//! `account?attr=repo&action=manage`. +//! the atom names none. //! - `include:[?aud=%23]` — a permission set, published //! as a lexicon. As an atom it admits only itself. [`Include`] reads it, //! and [`Include::grants`] turns the set's published permissions into the //! `repo:` and `rpc:` atoms it grants. //! +//! `identity:` and `account:` may also write their attribute as a parameter: +//! `identity?attr=handle`, `account?attr=repo&action=manage`. +//! //! `collection` and `lxm` patterns may be an exact NSID, `*` (any), or a //! `some.prefix.*` wildcard, which this module treats as matching the prefix //! and everything recursively beneath it (`some.prefix.*` contains @@ -79,6 +82,7 @@ mod account; mod action; mod error; +mod identity; mod include; mod parse; mod pattern; @@ -95,6 +99,7 @@ pub use error::{ ScopeParseError, MAX_ATOM_BYTES, MAX_GRANT_ATOMS, MAX_GRANT_BYTES, MAX_SCOPE_ATOMS, MAX_SCOPE_BYTES, }; +pub use identity::IdentityAttr; pub use include::{Include, IncludeError}; pub use pattern::{MimePattern, NsidPattern}; pub use scope::Scope; diff --git a/crates/didbot-scope/src/parse.rs b/crates/didbot-scope/src/parse.rs index 4d5b5180..dafa0f9a 100644 --- a/crates/didbot-scope/src/parse.rs +++ b/crates/didbot-scope/src/parse.rs @@ -2,6 +2,8 @@ use std::collections::BTreeSet; +use percent_encoding::percent_decode_str; + use crate::{Action, ActionSet, ScopeParseError}; /// Splits an atom into its kind and everything after it. @@ -39,6 +41,47 @@ pub(crate) fn split_query(rest: &str) -> Result<(&str, QueryPairs<'_>), ScopePar Ok((value, pairs)) } +/// Reads an atom of `kind` whose positional parameter is `attr`, as the +/// permission spec writes one: the attribute after the `:`, or as `attr=` +/// when the atom has no `:`, and each parameter in `takes` at most once. +/// Returns the attribute and each of `takes`, percent-decoded. +/// +/// An atom with a `:` gives the attribute there even when it is empty, so +/// `identity:?attr=handle` names it twice. +pub(crate) fn attr_params( + atom: &str, + kind: &str, + takes: [&str; N], +) -> Result<(String, [Option; N]), ScopeParseError> { + let decode = |value: &str| percent_decode_str(value).decode_utf8_lossy().into_owned(); + let after = &atom[kind.len()..]; + let (mut attr, pairs) = match after.strip_prefix(':') { + Some(positional) => { + let (value, pairs) = split_query(positional)?; + (Some(decode(value)), pairs) + } + None => (None, split_query(after)?.1), + }; + let mut values: [Option; N] = std::array::from_fn(|_| None); + for (key, value) in pairs { + let unexpected = || ScopeParseError::UnexpectedParameter(key.to_owned(), atom.to_owned()); + let slot = if key == "attr" { + &mut attr + } else if let Some(i) = takes.iter().position(|taken| *taken == key) { + &mut values[i] + } else { + return Err(unexpected()); + }; + if slot.replace(decode(value)).is_some() { + return Err(unexpected()); + } + } + match attr { + Some(attr) if !attr.is_empty() => Ok((attr, values)), + _ => Err(ScopeParseError::MissingValue(atom.to_owned())), + } +} + /// Every `action=` pair contributes to one set, and each pair's value may /// itself be a comma list, so `action=create&action=update`, /// `action=create&action=update` and `action=create&action=update&action=update` all name diff --git a/crates/didbot-scope/src/scope.rs b/crates/didbot-scope/src/scope.rs index 97193376..aef19cb1 100644 --- a/crates/didbot-scope/src/scope.rs +++ b/crates/didbot-scope/src/scope.rs @@ -7,8 +7,8 @@ use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTRO use crate::parse::{parse_actions, split_kind, split_query}; use crate::{ - AccountAction, AccountAttr, Action, ActionSet, MimePattern, NsidPattern, ScopeParseError, - Transition, MAX_ATOM_BYTES, + AccountAction, AccountAttr, Action, ActionSet, IdentityAttr, MimePattern, NsidPattern, + ScopeParseError, Transition, MAX_ATOM_BYTES, }; /// One parsed scope atom. @@ -39,13 +39,11 @@ pub enum Scope { /// deduplicated, and never empty — see [`Scope::blob`]. accept: Vec, }, - /// `identity:[?action=...]` — hard-blocked per - /// `plan/scope-policy.md`; this module only parses it. + /// `identity:` — hard-blocked per `plan/scope-policy.md`; this + /// module only parses it. Identity { - /// The identity resource this scope names, e.g. `handle`. - resource: String, - /// Which actions are covered. - actions: ActionSet, + /// The part of the account's identity this scope names. + attr: IdentityAttr, }, /// `account:[?action=...]` — also hard-blocked. Account { @@ -120,17 +118,8 @@ impl Scope { } Ok(Scope::blob(accept)) } - "identity" => { - let (value, pairs) = split_query(rest)?; - if value.is_empty() { - return Err(ScopeParseError::MissingValue(atom.to_owned())); - } - Ok(Scope::Identity { - resource: value.to_owned(), - actions: parse_actions(&pairs)?, - }) - } - "account" => crate::account::parse(rest, atom), + "identity" => crate::identity::parse(atom), + "account" => crate::account::parse(atom), other => Err(ScopeParseError::UnknownKind(other.to_owned())), } } @@ -169,16 +158,7 @@ impl Scope { (Scope::Blob { accept: a1 }, Scope::Blob { accept: a2 }) => a2 .iter() .all(|wanted| a1.iter().any(|held| held.contains(wanted))), - ( - Scope::Identity { - resource: r1, - actions: a1, - }, - Scope::Identity { - resource: r2, - actions: a2, - }, - ) => resource_contains(r1, r2) && a1.contains(a2), + (Scope::Identity { attr: a1 }, Scope::Identity { attr: a2 }) => a1.includes(*a2), ( Scope::Account { attr: a1, @@ -247,19 +227,6 @@ impl Scope { } Some(Scope::blob(overlap)) } - ( - Scope::Identity { - resource: r1, - actions: a1, - }, - Scope::Identity { - resource: r2, - actions: a2, - }, - ) if r1 == r2 => Some(Scope::Identity { - resource: r1.clone(), - actions: a1.intersect(a2)?, - }), _ => None, } } @@ -328,10 +295,6 @@ impl Scope { } } -fn resource_contains(a: &str, b: &str) -> bool { - a == "*" || a == b -} - /// The `aud` that names every audience, as the permission spec writes it. pub(crate) const ANY_AUD: &str = "*"; @@ -391,7 +354,7 @@ impl fmt::Display for Scope { Ok(()) } }, - Scope::Identity { resource, actions } => write!(f, "identity:{resource}{actions}"), + Scope::Identity { attr } => write!(f, "identity:{attr}"), Scope::Account { attr, action } => { write!(f, "account:{attr}")?; if *action == AccountAction::Manage { diff --git a/crates/didbot-scope/src/tests.rs b/crates/didbot-scope/src/tests.rs index fb023969..8f2e12b3 100644 --- a/crates/didbot-scope/src/tests.rs +++ b/crates/didbot-scope/src/tests.rs @@ -86,7 +86,6 @@ fn repeated_actions_print_exactly_as_parsed() { for spec in [ "repo:app.bsky.feed.post?action=create&action=update", "repo:*?action=create&action=update&action=delete", - "identity:*?action=manage", ] { assert_eq!(scope(spec).to_string(), spec); } @@ -204,7 +203,7 @@ fn transition_generic_covers_granular_repo_rpc_blob() { assert!(generic.contains(&scope("rpc:app.bsky.feed.getTimeline"))); assert!(generic.contains(&scope("blob:image/png"))); // But not the hard-blocked kinds, and not another transition scope. - assert!(!generic.contains(&scope("identity:handle?action=manage"))); + assert!(!generic.contains(&scope("identity:*"))); assert!(!generic.contains(&scope("account:email?action=manage"))); } @@ -263,15 +262,68 @@ fn account_reads_the_spec_attributes_and_actions() { } } +/// The permission spec's `identity:`: one attribute, `handle` or `*`, +/// positional or named, and no other parameter. `*` includes `handle`. #[test] -fn identity_and_account_are_hard_kinds_this_module_only_parses() { - assert_eq!( - scope("identity:handle?action=manage"), - Scope::Identity { - resource: "handle".to_owned(), - actions: ActionSet::Only([Action::Manage].into_iter().collect()), - } - ); +fn identity_reads_the_spec_attribute_and_no_other_parameter() { + let handle = Scope::Identity { + attr: IdentityAttr::Handle, + }; + let any = Scope::Identity { + attr: IdentityAttr::Any, + }; + for (spelled, read) in [ + ("identity:handle", &handle), + ("identity:handle?", &handle), + ("identity?attr=handle", &handle), + ("identity:*", &any), + ("identity:*?", &any), + ("identity?attr=*", &any), + ("identity:%2A", &any), + ] { + assert_eq!(&scope(spelled), read, "`{spelled}`"); + } + assert_eq!(handle.to_string(), "identity:handle"); + assert_eq!(any.to_string(), "identity:*"); + assert!(any.contains(&handle)); + assert!(!handle.contains(&any)); + + for (atom, key) in [ + ("identity:handle?action=manage", "action"), + ("identity:*?action=manage", "action"), + ("identity?attr=*&action=manage", "action"), + ("identity:handle?aud=did:web:a.example", "aud"), + ("identity:handle?attr=handle", "attr"), + ("identity:?attr=handle", "attr"), + ("identity?attr=handle&attr=*", "attr"), + ] { + assert_eq!( + Scope::parse(atom), + Err(ScopeParseError::UnexpectedParameter( + key.to_owned(), + atom.to_owned() + )), + "`{atom}`" + ); + } + for (atom, attr) in [ + ("identity:email", "email"), + ("identity:Handle", "Handle"), + ("identity?attr=did", "did"), + ] { + assert_eq!( + Scope::parse(atom), + Err(ScopeParseError::UnknownAttribute(attr.to_owned())), + "`{atom}`" + ); + } + for atom in ["identity:", "identity?", "identity:?", "identity?attr="] { + assert_eq!( + Scope::parse(atom), + Err(ScopeParseError::MissingValue(atom.to_owned())), + "`{atom}`" + ); + } } #[test] @@ -606,7 +658,7 @@ fn transition_generic_ceiling_admits_granular_requests() { #[test] fn identity_and_account_are_never_reachable_through_a_repo_or_transition_ceiling_alone() { - let requested = set("identity:handle?action=manage"); + let requested = set("identity:* account:repo?action=manage"); let generic_ceiling = set("transition:generic"); assert!(requested.intersect(&generic_ceiling).is_err()); } diff --git a/crates/didbot-scope/tests/ceiling_boundary.rs b/crates/didbot-scope/tests/ceiling_boundary.rs index 2802461f..f960e477 100644 --- a/crates/didbot-scope/tests/ceiling_boundary.rs +++ b/crates/didbot-scope/tests/ceiling_boundary.rs @@ -57,9 +57,7 @@ const CORPUS: &[&str] = &[ "blob?accept=image/png&accept=text/html", "blob?accept=image/*&accept=video/*", "identity:*", - "identity:*?action=manage", "identity:handle", - "identity:handle?action=manage", "account:email", "account:email?action=manage", "account:repo", @@ -236,8 +234,8 @@ fn an_empty_ceiling_admits_nothing() { /// together is how a narrowing that is secretly a widening gets missed. /// /// Fails if: `under_prefix` drops its `.` check and becomes a bare -/// `starts_with`, or `resource_contains`/`aud_contains`/`MimePattern` -/// gain a prefix or substring test, or `Transition::covers` widens. +/// `starts_with`, or `aud_contains`/`MimePattern` gain a prefix or +/// substring test, or `Transition::covers` widens. #[test] fn a_ceiling_refuses_the_scope_next_door() { for (ceiling, refused) in [ @@ -259,14 +257,9 @@ fn a_ceiling_refuses_the_scope_next_door() { // MIME halves are independent, and neither is a prefix test. ("blob:image/png", "blob:image/jpeg"), ("blob:image/*", "blob:video/mp4"), - // A different named resource. `identity:` resources are opaque - // strings in this grammar, so a resource whose name merely - // *starts with* a granted one is exactly the input a prefix - // comparison would wave through — and nothing in the grammar stops - // a client asking for one. + // A different named resource. ("account:email", "account:repo"), ("identity:handle", "account:email"), - ("identity:handle", "identity:handleHistory"), // `transition:` covers what it covers and no more. ("transition:chat.bsky", "repo:app.bsky.feed.post"), ("transition:chat.bsky", "repo:chat.bskyfoo.convo"), diff --git a/crates/didbot-serve/src/oauth/decision.rs b/crates/didbot-serve/src/oauth/decision.rs index d3c5dbb8..70bfe927 100644 --- a/crates/didbot-serve/src/oauth/decision.rs +++ b/crates/didbot-serve/src/oauth/decision.rs @@ -1109,9 +1109,10 @@ mod tests { assert!(verdict.granted(&requested).0.is_empty()); } - /// Every atom on `plan/scope-policy.md`'s list, against a ceiling wide - /// enough that only the hard block can be refusing it. `blob:` is not on - /// it: an upload is checked against the grant and judged by policy. + /// Every atom on `plan/scope-policy.md`'s list, in the spellings the + /// permission spec gives it, against a ceiling wide enough that only the + /// hard block can be refusing it. `blob:` is not on it: an upload is + /// checked against the grant and judged by policy. #[test] fn every_hard_blocked_kind_is_denied_however_it_is_spelled() { let ceiling = ScopeSet::parse("atproto repo:* rpc:*").unwrap(); @@ -1120,8 +1121,12 @@ mod tests { "transition:chat.bsky", "transition:email", "identity:*", + "identity:*?", + "identity?attr=*", "identity:handle", + "identity?attr=handle", "account:email", + "account:email?action=read", "account:repo?action=manage", ] { let requested = ScopeSet::parse(&format!("atproto {atom}")).unwrap(); @@ -1129,7 +1134,8 @@ mod tests { let Verdict::Deny { rule, .. } = &verdict else { panic!("`{atom}` was not denied: {verdict:?}"); }; - assert_eq!(rule, &format!("hard-blocked: {atom}")); + let named = Scope::parse(atom).unwrap(); + assert_eq!(rule, &format!("hard-blocked: {named}")); } } diff --git a/crates/didbot-serve/tests/oauth_account_flow.rs b/crates/didbot-serve/tests/oauth_account_flow.rs index 3c054a98..ac3acc86 100644 --- a/crates/didbot-serve/tests/oauth_account_flow.rs +++ b/crates/didbot-serve/tests/oauth_account_flow.rs @@ -1826,6 +1826,60 @@ async fn a_request_naming_a_hard_blocked_atom_is_denied_and_grants_none_of_it() ); } +/// `identity:` over the wire. Each spelling the permission spec gives is +/// pushed and refused by the hard block: the record names the atom, and the +/// client is answered `access_denied`. A spelling the spec does not give +/// fails the push as a scope that does not parse. +#[tokio::test] +async fn every_identity_scope_the_spec_defines_is_refused_and_no_other_parses() { + let fixture = build_with( + Arc::new(FixedDpop), + Arc::new(didbot_serve::oauth::authorize::GrantAnyScope), + 8, + ); + let (_, challenge) = code_verifier_and_challenge(); + for (spelled, atom) in [ + ("identity:handle", "identity:handle"), + ("identity?attr=handle", "identity:handle"), + ("identity:*", "identity:*"), + ("identity:*?", "identity:*"), + ("identity?attr=*", "identity:*"), + ] { + let scope = format!("atproto {spelled}"); + let (status, json) = push_as(&fixture, &fixture.account_did, &scope, &challenge).await; + assert_eq!(status, StatusCode::CREATED, "`{spelled}`: {json}"); + let request_uri = json["request_uri"].as_str().unwrap(); + let (_, record) = get_xrpc( + &fixture.app, + &format!("bot.did.getAuthorization?requestUri={request_uri}"), + &fixture.account_token, + ) + .await; + assert_eq!( + record["verdict"]["rule"], + format!("hard-blocked: {atom}"), + "{record}" + ); + let (status, page) = get( + &fixture.app, + &format!("/oauth/authorize?client_id={CLIENT_ID}&request_uri={request_uri}"), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "`{spelled}`: {page}"); + assert!(page.contains("access_denied"), "`{spelled}`: {page}"); + } + for spelled in [ + "identity:handle?action=manage", + "identity:*?action=manage", + "identity:email", + ] { + let scope = format!("atproto {spelled}"); + let (status, json) = push_as(&fixture, &fixture.account_did, &scope, &challenge).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "`{spelled}`: {json}"); + assert_eq!(json["error"], "invalid_request", "`{spelled}`: {json}"); + } +} + /// A ceiling wide enough to admit `repo:*` still cannot answer a request for /// one collection with every collection. The projection direction /// `ScopeSet::narrow` enforces, seen from the token a client walks away diff --git a/plan/scope-policy.md b/plan/scope-policy.md index c7259ced..c869b7d6 100644 --- a/plan/scope-policy.md +++ b/plan/scope-policy.md @@ -165,10 +165,12 @@ an agent approves. lists an `account` permission still grants the rest of what it lists. Leaflet's scope is refused all the same, because it names `transition:email` directly: its push mints a record that denies it, - and the authorize step answers `access_denied`. An `account:` scope - reads as the permission spec defines it, `email` or `repo` and `read` - unless it says `manage`, so `account:email?action=read` is refused - here the same way and not as a scope that does not parse. + and the authorize step answers `access_denied`. `account:` and + `identity:` scopes read as the permission spec defines them. + `account:` names `email` or `repo`, `read` unless it says `manage`. + `identity:` names `handle` or `*`, and takes no other parameter. So + `account:email?action=read` and `identity?attr=handle` are refused + here the same way, and `identity:handle?action=manage` 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 —