diff --git a/crates/tranquil-pds/src/cache_keys.rs b/crates/tranquil-pds/src/cache_keys.rs index 9fd03d5..6625577 100644 --- a/crates/tranquil-pds/src/cache_keys.rs +++ b/crates/tranquil-pds/src/cache_keys.rs @@ -39,3 +39,10 @@ pub fn scope_ref_key(cid: &CidLink) -> String { pub fn auto_verify_sent_key(did: &Did) -> String { format!("auto_verify_sent:{}", did) } + +pub fn permission_set_key(nsid: &str, aud: Option<&str>) -> String { + match aud { + Some(a) => format!("permset:{}:{}", nsid, a), + None => format!("permset:{}", nsid), + } +} diff --git a/crates/tranquil-pds/src/oauth/mod.rs b/crates/tranquil-pds/src/oauth/mod.rs index 53707c3..d5ba187 100644 --- a/crates/tranquil-pds/src/oauth/mod.rs +++ b/crates/tranquil-pds/src/oauth/mod.rs @@ -1,5 +1,6 @@ pub mod client; pub mod db; +pub mod permission_set_resolver; pub mod scopes; pub mod verify; @@ -20,6 +21,7 @@ pub use tranquil_oauth::{ compute_pkce_challenge, verify_client_auth, }; +pub use permission_set_resolver::expand_scopes; pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions}; pub use verify::{ OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token, diff --git a/crates/tranquil-pds/src/oauth/permission_set_resolver.rs b/crates/tranquil-pds/src/oauth/permission_set_resolver.rs new file mode 100644 index 0000000..2f51d8a --- /dev/null +++ b/crates/tranquil-pds/src/oauth/permission_set_resolver.rs @@ -0,0 +1,182 @@ +use crate::cache::Cache; +use crate::cache_keys::permission_set_key; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tranquil_scopes::{ + fetch_and_expand, parse_include_scope, ExpansionOutcome, FailedSet, ResolveFailure, + ResolvedSetGroup, ScopeExpansionError, +}; +use tranquil_types::Nsid; + +#[derive(Serialize, Deserialize)] +struct CachedPermissionSet { + scope: String, + title: Option, + detail: Option, +} + +const PERMISSION_SET_CACHE_TTL_SECS: u64 = 24 * 60 * 60; + +pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOutcome { + let mut outcome = ExpansionOutcome::default(); + for tok in scope_string.split_whitespace() { + match tok.strip_prefix("include:") { + None => outcome.passthrough.push(tok.to_string()), + Some(rest) => { + let (nsid, aud) = parse_include_scope(rest); + match resolve_one(cache, nsid, aud).await { + Ok(group) => outcome.sets.push(group), + Err(reason) => outcome.failures.push(FailedSet { + nsid: nsid.to_string(), + aud: aud.map(str::to_string), + reason, + }), + } + } + } + } + outcome +} + +async fn resolve_one( + cache: &dyn Cache, + nsid: &str, + aud: Option<&str>, +) -> Result { + let key = permission_set_key(nsid, aud); + // Cache hit + if let Some(json) = cache.get(&key).await + && let Ok(v) = serde_json::from_str::(&json) + { + return Ok(group_from(nsid, aud, v.scope, v.title, v.detail)); + } + // Miss → network fetch → cache + let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Invalid)?; + match fetch_and_expand(&parsed, aud).await { + Ok(fetched) => { + let stored = CachedPermissionSet { + scope: fetched.expanded.clone(), + title: fetched.title.clone(), + detail: fetched.detail.clone(), + }; + if let Ok(json) = serde_json::to_string(&stored) { + let _ = cache + .set(&key, &json, Duration::from_secs(PERMISSION_SET_CACHE_TTL_SECS)) + .await; + } + Ok(group_from(nsid, aud, fetched.expanded, fetched.title, fetched.detail)) + } + Err(e) => Err(map_err(&e)), + } +} + +fn group_from( + nsid: &str, + aud: Option<&str>, + scope: String, + title: Option, + detail: Option, +) -> ResolvedSetGroup { + ResolvedSetGroup { + nsid: nsid.to_string(), + aud: aud.map(str::to_string), + title, + detail, + expanded: scope.split_whitespace().map(str::to_string).collect(), + } +} + +fn map_err(e: &ScopeExpansionError) -> ResolveFailure { + use ScopeExpansionError as E; + match e { + E::InvalidNsid(_) | E::UnexpectedType(_) | E::EmptyPermissions | E::MissingDefinition(_) => { + ResolveFailure::Invalid + } + E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::NetworkError, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::{Cache, CacheError}; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Default)] + struct MapCache(Mutex>); + + #[async_trait::async_trait] + impl Cache for MapCache { + async fn get(&self, key: &str) -> Option { + self.0.lock().unwrap().get(key).cloned() + } + async fn set(&self, key: &str, value: &str, _ttl: Duration) -> Result<(), CacheError> { + self.0 + .lock() + .unwrap() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + async fn delete(&self, key: &str) -> Result<(), CacheError> { + self.0.lock().unwrap().remove(key); + Ok(()) + } + async fn get_bytes(&self, _key: &str) -> Option> { + None + } + async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> { + Ok(()) + } + } + + fn seed(cache: &MapCache, nsid: &str, scope: &str) { + let key = crate::cache_keys::permission_set_key(nsid, None); + let val = serde_json::to_string(&CachedPermissionSet { + scope: scope.to_string(), + title: Some("Basic".into()), + detail: None, + }) + .unwrap(); + cache.0.lock().unwrap().insert(key, val); + } + + #[tokio::test] + async fn cache_hit_expands_without_network() { + let cache = MapCache::default(); + seed( + &cache, + "io.atcr.authFullApp", + "repo:io.atcr.manifest?action=create identity:*", + ); + let out = expand_scopes(&cache, "atproto include:io.atcr.authFullApp").await; + assert!(out.failures.is_empty()); + assert_eq!(out.passthrough, vec!["atproto".to_string()]); + assert_eq!(out.sets.len(), 1); + assert_eq!(out.sets[0].nsid, "io.atcr.authFullApp"); + assert!( + out.flat_scopes() + .iter() + .any(|s| s == "repo:io.atcr.manifest?action=create") + ); + } + + #[tokio::test] + async fn passthrough_scopes_untouched() { + let cache = MapCache::default(); + let out = expand_scopes(&cache, "atproto repo:app.bsky.feed.post?action=create").await; + assert!(out.failures.is_empty()); + assert!(out.sets.is_empty()); + assert_eq!(out.flat_scopes().len(), 2); + } + + #[tokio::test] + async fn cache_miss_unresolvable_is_a_failure() { + let cache = MapCache::default(); // empty → miss → network fetch of a fake NSID fails fast + let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await; + assert_eq!(out.sets.len(), 0); + assert_eq!(out.failures.len(), 1); + assert_eq!(out.failures[0].nsid, "nonexistent.fake.permissionSet"); + } +} diff --git a/crates/tranquil-scopes/src/lib.rs b/crates/tranquil-scopes/src/lib.rs index fd01786..ec35354 100644 --- a/crates/tranquil-scopes/src/lib.rs +++ b/crates/tranquil-scopes/src/lib.rs @@ -15,5 +15,8 @@ pub use parser::{ AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, }; -pub use permission_set::{ScopeExpansionError, expand_include_scopes}; +pub use permission_set::{ + ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, + ScopeExpansionError, expand_include_scopes, fetch_and_expand, parse_include_scope, +}; pub use permissions::ScopePermissions; diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index 7c60469..a19b342 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -26,6 +26,50 @@ pub enum ScopeExpansionError { EmptyPermissions, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveFailure { + NotFound, + NetworkError, + Invalid, +} + +#[derive(Debug, Clone)] +pub struct FailedSet { + pub nsid: String, + pub aud: Option, + pub reason: ResolveFailure, +} + +#[derive(Debug, Clone)] +pub struct ResolvedSetGroup { + pub nsid: String, + pub aud: Option, + pub title: Option, + pub detail: Option, + pub expanded: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ExpansionOutcome { + pub passthrough: Vec, + pub sets: Vec, + pub failures: Vec, +} + +impl ExpansionOutcome { + pub fn flat_scopes(&self) -> Vec { + let mut out = self.passthrough.clone(); + for s in &self.sets { + out.extend(s.expanded.iter().cloned()); + } + out + } + /// Space-joined `flat_scopes`. + pub fn to_scope_string(&self) -> String { + self.flat_scopes().join(" ") + } +} + static LEXICON_CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -63,6 +107,10 @@ struct LexiconDoc { struct LexiconDef { #[serde(rename = "type")] def_type: String, + #[serde(default)] + title: Option, + #[serde(default)] + detail: Option, permissions: Option>, } @@ -98,7 +146,7 @@ pub async fn expand_include_scopes(scope_string: &str) -> Result (&str, Option<&str>) { +pub fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { rest.split_once('?') .map(|(nsid, params)| { let aud = params.split('&').find_map(|p| p.strip_prefix("aud=")); @@ -126,33 +174,8 @@ async fn expand_permission_set( } } - let lexicon = fetch_lexicon_via_atproto(nsid).await?; - - let main_def = lexicon - .defs - .get("main") - .ok_or(ScopeExpansionError::MissingDefinition("main".to_string()))?; - - if main_def.def_type != "permission-set" { - return Err(ScopeExpansionError::UnexpectedType( - main_def.def_type.clone(), - )); - } - - let permissions = - main_def - .permissions - .as_ref() - .ok_or(ScopeExpansionError::MissingDefinition( - "permissions".to_string(), - ))?; - - let namespace_authority = extract_namespace_authority(nsid); - let expanded = build_expanded_scopes(permissions, aud, &namespace_authority); - - if expanded.is_empty() { - return Err(ScopeExpansionError::EmptyPermissions); - } + let fetched = fetch_and_expand(nsid, aud).await?; + let expanded = fetched.expanded; { let mut cache = LEXICON_CACHE.write().await; @@ -169,6 +192,44 @@ async fn expand_permission_set( Ok(expanded) } +pub struct FetchedSet { + pub expanded: String, + pub title: Option, + pub detail: Option, +} + +pub async fn fetch_and_expand( + nsid: &Nsid, + aud: Option<&str>, +) -> Result { + let lexicon = fetch_lexicon_via_atproto(nsid).await?; + let main_def = lexicon + .defs + .get("main") + .ok_or(ScopeExpansionError::MissingDefinition("main".to_string()))?; + if main_def.def_type != "permission-set" { + return Err(ScopeExpansionError::UnexpectedType( + main_def.def_type.clone(), + )); + } + let permissions = main_def + .permissions + .as_ref() + .ok_or(ScopeExpansionError::MissingDefinition( + "permissions".to_string(), + ))?; + let namespace_authority = extract_namespace_authority(nsid); + let expanded = build_expanded_scopes(permissions, aud, &namespace_authority); + if expanded.is_empty() { + return Err(ScopeExpansionError::EmptyPermissions); + } + Ok(FetchedSet { + expanded, + title: main_def.title.clone(), + detail: main_def.detail.clone(), + }) +} + async fn fetch_lexicon_via_atproto(nsid: &Nsid) -> Result { let parts: Vec<&str> = nsid.split('.').collect(); let authority = parts[..parts.len() - 1] @@ -678,4 +739,55 @@ mod tests { "bookmarks.lexicon.community" ); } + + #[test] + fn expansion_outcome_flat_scopes_and_string() { + let out = ExpansionOutcome { + passthrough: vec!["atproto".into()], + sets: vec![ResolvedSetGroup { + nsid: "io.atcr.authFullApp".into(), + aud: None, + title: Some("T".into()), + detail: None, + expanded: vec![ + "repo:io.atcr.manifest?action=create".into(), + "rpc:io.atcr.getManifest".into(), + ], + }], + failures: vec![FailedSet { + nsid: "nonexistent.fake.permissionSet".into(), + aud: None, + reason: ResolveFailure::NotFound, + }], + }; + let flat = out.flat_scopes(); + assert_eq!( + flat, + vec![ + "atproto", + "repo:io.atcr.manifest?action=create", + "rpc:io.atcr.getManifest" + ] + ); + assert_eq!( + out.to_scope_string(), + "atproto repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest" + ); + } + + #[test] + fn test_lexicon_def_captures_title_and_detail() { + let json = serde_json::json!({ + "defs": { "main": { + "type": "permission-set", + "title": "Basic App", + "detail": "Posts and interactions", + "permissions": [{ "resource": "repo", "collection": ["io.atcr.manifest"] }] + }} + }); + let doc: LexiconDoc = serde_json::from_value(json).unwrap(); + let main = doc.defs.get("main").unwrap(); + assert_eq!(main.title.as_deref(), Some("Basic App")); + assert_eq!(main.detail.as_deref(), Some("Posts and interactions")); + } }