A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
13 kB · 378 lines
Rust
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379//! `include:<nsid>[?aud=<did>]` — expanding a lexicon-defined permission set.//!//! Everything here is pure: `expand` takes an **already-resolved** permission//! set document. Fetching the lexicon for an NSID is the caller's job, because//! only the caller has a lexicon registry. That split is deliberate — the parts//! that need I/O are trivial, and the parts that are security-critical are not.
use crate::resources::{RepoPermission, RpcPermission, is_absolute_did_ref};use crate::syntax::ScopeSyntax;
/// One value in a lexicon permission: the arity matters, because a scalar/// supplied where a list is expected (or vice versa) invalidates the/// permission rather than being coerced.#[derive(Debug, Clone, PartialEq, Eq)]pub enum LexValue { Scalar(String), List(Vec<String>), Bool(bool),}
/// A single `permission` entry inside a permission-set lexicon.#[derive(Debug, Clone, PartialEq, Eq)]pub struct LexPermission { pub resource: String, /// Every key other than `type` and `resource`, which are reserved. pub params: Vec<(String, LexValue)>,}
impl LexPermission { fn get(&self, key: &str) -> Option<&LexValue> { self.params.iter().find(|(k, _)| k == key).map(|(_, v)| v) }
fn keys(&self) -> Vec<&str> { self.params.iter().map(|(k, _)| k.as_str()).collect() }
fn list(&self, key: &str) -> Option<Vec<String>> { match self.get(key) { Some(LexValue::List(v)) => Some(v.clone()), _ => None, } }
fn scalar(&self, key: &str) -> Option<String> { match self.get(key) { Some(LexValue::Scalar(v)) => Some(v.clone()), _ => None, } }
fn bool(&self, key: &str) -> Option<bool> { match self.get(key) { Some(LexValue::Bool(v)) => Some(*v), _ => None, } }}
/// A `permission-set` lexicon's `permissions` array.#[derive(Debug, Clone, Default, PartialEq, Eq)]pub struct LexPermissionSet { pub permissions: Vec<LexPermission>,}
/// A permission yielded by expanding an `include:` scope.#[derive(Debug, Clone, PartialEq, Eq)]pub enum IncludedPermission { Repo(RepoPermission), Rpc(RpcPermission),}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct IncludeScope { pub nsid: String, /// The audience `inheritAud` permissions adopt, if the scope supplied one. pub aud: Option<String>,}
impl IncludeScope { pub fn parse(scope: &str) -> Option<Self> { let syntax = ScopeSyntax::parse(scope); if syntax.prefix != "include" { return None; } if !syntax.keys().iter().all(|k| ["nsid", "aud"].contains(k)) { return None; } if syntax.positional.is_some() && syntax.get_multi("nsid").is_some() { return None; }
let nsid = match syntax.get_single("nsid").ok()? { Some(v) => v.to_string(), None => syntax.positional.clone()?, }; if happyview_nsid::validate_nsid(&nsid).is_err() { return None; }
let aud = syntax.get_single("aud").ok()?.map(str::to_string); if let Some(ref a) = aud && !is_absolute_did_ref(a) { return None; }
Some(Self { nsid, aud }) }
/// Expand against a resolved permission-set document. /// /// Permissions that fail validation or containment are **dropped**, not /// errors — matching the reference, which skips them and continues. pub fn expand(&self, set: &LexPermissionSet) -> Vec<IncludedPermission> { set.permissions .iter() .filter_map(|p| self.expand_one(p)) .filter(|p| self.is_allowed(p)) .collect() }
fn expand_one(&self, permission: &LexPermission) -> Option<IncludedPermission> { match permission.resource.as_str() { "repo" => { if !permission .keys() .iter() .all(|k| ["collection", "action"].contains(k)) { return None; } let collection = permission.list("collection")?; let action = permission.list("action"); RepoPermission::from_parts(collection, action).map(IncludedPermission::Repo) } "rpc" => { let declared_aud = permission.scalar("aud");
// A permission set may not pin a concrete audience; only `*` or // nothing. Otherwise a set could direct a caller's credentials // at a service of its own choosing. if let Some(ref a) = declared_aud && a != "*" { return None; }
let inherit = permission.bool("inheritAud").unwrap_or(false); let (aud, allowed_keys): (Option<String>, &[&str]) = if inherit && declared_aud.is_none() && self.aud.is_some() { // `inheritAud` is consumed here, so it is not an unknown key. (self.aud.clone(), &["lxm", "aud", "inheritAud"]) } else { // Otherwise `inheritAud` stays on the permission and is // an unrecognised parameter, which invalidates it. That // is why `inheritAud` with no `?aud=` on the include // yields nothing at all. (declared_aud, &["lxm", "aud"]) };
if !permission.keys().iter().all(|k| allowed_keys.contains(k)) { return None; }
let lxm = permission.list("lxm")?; RpcPermission::from_parts(lxm, aud).map(IncludedPermission::Rpc) } _ => None, } }
/// A permission set may only grant NSIDs under its own authority group. /// /// Without this, publishing a permission-set lexicon would be enough to /// vouch for another authority's collections — `com.attacker.authBasic` /// could hand out `app.bsky.feed.post`. The reference calls this out as a /// security feature and is deliberately strict about it. fn is_allowed(&self, permission: &IncludedPermission) -> bool { match permission { IncludedPermission::Repo(p) => { p.collection.iter().all(|c| self.is_parent_authority_of(c)) } IncludedPermission::Rpc(p) => p.lxm.iter().all(|l| self.is_parent_authority_of(l)), } }
/// True when `other` sits under this scope's NSID authority group — the /// lexicon NSID up to and including its final dot. A wildcard is never /// under any authority. pub fn is_parent_authority_of(&self, other: &str) -> bool { if other == "*" { return false; } let Some(group_prefix_end) = self.nsid.rfind('.') else { // `parse` requires a valid NSID, which always has a dot. return false; }; if other.len() <= group_prefix_end + 1 { return false; } self.nsid.as_bytes()[..=group_prefix_end] == other.as_bytes()[..=group_prefix_end] }}
#[cfg(test)]mod tests { use super::*;
fn repo_perm(collections: &[&str]) -> LexPermission { LexPermission { resource: "repo".into(), params: vec![( "collection".into(), LexValue::List(collections.iter().map(|s| s.to_string()).collect()), )], } }
fn rpc_perm(lxms: &[&str], extra: Vec<(String, LexValue)>) -> LexPermission { let mut params = vec![( "lxm".into(), LexValue::List(lxms.iter().map(|s| s.to_string()).collect()), )]; params.extend(extra); LexPermission { resource: "rpc".into(), params, } }
#[test] fn parses_bare_and_aud_forms() { let s = IncludeScope::parse("include:com.example.authBasic").unwrap(); assert_eq!(s.nsid, "com.example.authBasic"); assert_eq!(s.aud, None);
let s = IncludeScope::parse("include:com.example.authBasic?aud=did:web:x.com%23svc").unwrap(); assert_eq!(s.aud.as_deref(), Some("did:web:x.com#svc")); }
#[test] fn rejects_bad_nsid_and_bad_aud() { assert!(IncludeScope::parse("include:").is_none()); assert!(IncludeScope::parse("include:notannsid").is_none()); assert!(IncludeScope::parse("include:com.example.a?aud=notadid").is_none()); }
/// The containment check, stated as the scenario it prevents. #[test] fn drops_permissions_outside_the_sets_authority() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![ repo_perm(&["com.example.profile"]), repo_perm(&["app.bsky.feed.post"]), ], }; let out = inc.expand(&set); assert_eq!(out.len(), 1); match &out[0] { IncludedPermission::Repo(p) => assert_eq!(p.collection, vec!["com.example.profile"]), other => panic!("unexpected {other:?}"), } }
#[test] fn a_wildcard_is_never_under_an_authority() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); assert!(!inc.is_parent_authority_of("*")); let set = LexPermissionSet { permissions: vec![repo_perm(&["*"])], }; assert!(inc.expand(&set).is_empty()); }
#[test] fn sibling_authority_is_not_a_parent() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); assert!(inc.is_parent_authority_of("com.example.anything")); assert!(!inc.is_parent_authority_of("com.examples.anything")); assert!(!inc.is_parent_authority_of("com.exampl.anything")); }
#[test] fn rpc_without_aud_expands_to_nothing() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![rpc_perm(&["com.example.getFeed"], vec![])], }; assert!(inc.expand(&set).is_empty()); }
#[test] fn rpc_with_wildcard_aud_expands() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![rpc_perm( &["com.example.getFeed"], vec![("aud".into(), LexValue::Scalar("*".into()))], )], }; let out = inc.expand(&set); assert_eq!(out.len(), 1); }
#[test] fn inherit_aud_takes_the_includes_audience() { let inc = IncludeScope::parse("include:com.example.authBasic?aud=did:web:x.com%23svc").unwrap(); let set = LexPermissionSet { permissions: vec![rpc_perm( &["com.example.getFeed"], vec![("inheritAud".into(), LexValue::Bool(true))], )], }; let out = inc.expand(&set); match &out[0] { IncludedPermission::Rpc(p) => assert_eq!(p.aud, "did:web:x.com#svc"), other => panic!("unexpected {other:?}"), } }
#[test] fn inherit_aud_without_an_include_audience_expands_to_nothing() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![rpc_perm( &["com.example.getFeed"], vec![("inheritAud".into(), LexValue::Bool(true))], )], }; assert!(inc.expand(&set).is_empty()); }
#[test] fn a_concrete_aud_in_a_permission_set_is_refused() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![rpc_perm( &["com.example.getFeed"], vec![( "aud".into(), LexValue::Scalar("did:web:evil.example.com#svc".into()), )], )], }; assert!(inc.expand(&set).is_empty()); }
#[test] fn non_repo_rpc_resources_are_dropped() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![LexPermission { resource: "account".into(), params: vec![("attr".into(), LexValue::Scalar("email".into()))], }], }; assert!(inc.expand(&set).is_empty()); }
#[test] fn a_scalar_where_a_list_is_required_is_dropped() { let inc = IncludeScope::parse("include:com.example.authBasic").unwrap(); let set = LexPermissionSet { permissions: vec![LexPermission { resource: "repo".into(), params: vec![( "collection".into(), LexValue::Scalar("com.example.profile".into()), )], }], }; assert!(inc.expand(&set).is_empty()); }}