feat: cache expanded permission sets

This commit is contained in:
Trezy
2026-07-24 20:11:26 +03:00
committed by Tangled
parent f17adc6f88
commit ecdda4c555
5 changed files with 335 additions and 29 deletions
+7
View File
@@ -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),
}
}
+2
View File
@@ -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,
@@ -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<String>,
detail: Option<String>,
}
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<ResolvedSetGroup, ResolveFailure> {
let key = permission_set_key(nsid, aud);
// Cache hit
if let Some(json) = cache.get(&key).await
&& let Ok(v) = serde_json::from_str::<CachedPermissionSet>(&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<String>,
detail: Option<String>,
) -> 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<HashMap<String, String>>);
#[async_trait::async_trait]
impl Cache for MapCache {
async fn get(&self, key: &str) -> Option<String> {
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<Vec<u8>> {
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");
}
}
+4 -1
View File
@@ -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;
+140 -28
View File
@@ -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<String>,
pub reason: ResolveFailure,
}
#[derive(Debug, Clone)]
pub struct ResolvedSetGroup {
pub nsid: String,
pub aud: Option<String>,
pub title: Option<String>,
pub detail: Option<String>,
pub expanded: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ExpansionOutcome {
pub passthrough: Vec<String>,
pub sets: Vec<ResolvedSetGroup>,
pub failures: Vec<FailedSet>,
}
impl ExpansionOutcome {
pub fn flat_scopes(&self) -> Vec<String> {
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<RwLock<HashMap<String, CachedLexicon>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
@@ -63,6 +107,10 @@ struct LexiconDoc {
struct LexiconDef {
#[serde(rename = "type")]
def_type: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
detail: Option<String>,
permissions: Option<Vec<PermissionEntry>>,
}
@@ -98,7 +146,7 @@ pub async fn expand_include_scopes(scope_string: &str) -> Result<String, ScopeEx
.map(|v| v.join(" "))
}
fn parse_include_scope(rest: &str) -> (&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<String>,
pub detail: Option<String>,
}
pub async fn fetch_and_expand(
nsid: &Nsid,
aud: Option<&str>,
) -> Result<FetchedSet, ScopeExpansionError> {
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<LexiconDoc, ScopeExpansionError> {
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"));
}
}