mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-01 15:06:05 +00:00
feat: cache expanded permission sets
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user