diff --git a/Cargo.lock b/Cargo.lock index 40a2ea2..85df977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7698,6 +7698,7 @@ dependencies = [ "totp-rs", "tranquil-config", "tranquil-crypto", + "tranquil-types", "urlencoding", "uuid", ] @@ -7825,6 +7826,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tranquil-types", "unicode-segmentation", "urlencoding", "wiremock", @@ -8034,6 +8036,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "tranquil-types", "urlencoding", ] @@ -8189,13 +8192,16 @@ dependencies = [ name = "tranquil-types" version = "0.6.5" dependencies = [ + "base64 0.22.1", "chrono", "cid", "jacquard-common", + "rand 0.8.5", "serde", "serde_json", "sqlx", "thiserror 2.0.18", + "uuid", ] [[package]] diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 07b0196..0f857e8 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use tranquil_scopes::{covers, parse_scope}; + pub use tranquil_db_traits::{ DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError, }; @@ -46,12 +48,13 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[ pub fn intersect_scopes(requested: &str, granted: &str) -> String { let requested_set: HashSet<&str> = requested.split_whitespace().collect(); - let granted_set: HashSet<&str> = granted.split_whitespace().collect(); + let granted_parsed: Vec = + granted.split_whitespace().map(parse_scope).collect(); let mut scopes: Vec<&str> = requested_set .iter() .filter(|requested_scope| { - **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set) + **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed) }) .copied() .chain(requested_set.contains("atproto").then_some("atproto")) @@ -60,81 +63,9 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String { scopes.join(" ") } -fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool { - granted - .iter() - .any(|granted_scope| scope_covers(granted_scope, requested)) -} - -fn scope_covers(granted: &str, requested: &str) -> bool { - if granted == requested { - return true; - } - - let (granted_base, granted_params) = split_scope(granted); - let (requested_base, requested_params) = split_scope(requested); - - let base_matches = if granted_base.ends_with(":*") - && requested_base.starts_with(&granted_base[..granted_base.len() - 1]) - { - true - } else if let Some(prefix) = granted_base.strip_suffix(".*") - && requested_base.starts_with(prefix) - && requested_base.len() > prefix.len() - { - true - } else { - granted_base == requested_base - }; - - if !base_matches { - return false; - } - - match (granted_params, requested_params) { - (None, _) => true, - (Some(_), None) => true, - (Some(gp), Some(rp)) => params_cover(gp, rp), - } -} - -fn params_cover(granted_params: &str, requested_params: &str) -> bool { - let granted_kv: HashSet<(&str, &str)> = granted_params - .split('&') - .filter_map(|pair| pair.split_once('=')) - .collect(); - let requested_kv: HashSet<(&str, &str)> = requested_params - .split('&') - .filter_map(|pair| pair.split_once('=')) - .collect(); - - let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect(); - let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect(); - - requested_keys.iter().all(|key| { - if !granted_keys.contains(key) { - return false; - } - let requested_values: HashSet<&str> = requested_kv - .iter() - .filter(|(k, _)| k == key) - .map(|(_, v)| *v) - .collect(); - let granted_values: HashSet<&str> = granted_kv - .iter() - .filter(|(k, _)| k == key) - .map(|(_, v)| *v) - .collect(); - requested_values.is_subset(&granted_values) - }) -} - -fn split_scope(scope: &str) -> (&str, Option<&str>) { - if let Some(idx) = scope.find('?') { - (&scope[..idx], Some(&scope[idx + 1..])) - } else { - (scope, None) - } +fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool { + let requested_parsed = parse_scope(requested); + granted.iter().any(|g| covers(g, &requested_parsed)) } #[cfg(test)] @@ -280,12 +211,12 @@ mod tests { } #[test] - fn test_intersect_granted_with_params_covers_requested_no_params() { + fn test_intersect_partial_action_grant_drops_actionless_request() { let result = intersect_scopes( "repo:app.bsky.feed.post", "repo:*?action=create&action=delete", ); - assert_eq!(result, "repo:app.bsky.feed.post"); + assert_eq!(result, ""); } #[test] @@ -297,39 +228,6 @@ mod tests { assert_eq!(result, "repo:*?action=create"); } - #[test] - fn test_scope_covers_base_only() { - assert!(scope_covers("repo:*", "repo:app.bsky.feed.post")); - assert!(scope_covers( - "repo:*", - "repo:app.bsky.feed.post?action=create" - )); - assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post")); - } - - #[test] - fn test_scope_covers_params() { - assert!(scope_covers("repo:*?action=create", "repo:*?action=create")); - assert!(!scope_covers( - "repo:*?action=create", - "repo:*?action=delete" - )); - assert!(scope_covers( - "repo:*?action=create&action=delete", - "repo:*?action=create" - )); - assert!(!scope_covers( - "repo:*?action=create", - "repo:*?action=create&action=delete" - )); - } - - #[test] - fn test_scope_covers_no_granted_params_means_all() { - assert!(scope_covers("repo:*", "repo:*?action=create")); - assert!(scope_covers("repo:*", "repo:*?action=delete")); - } - #[test] fn test_validate_scopes_valid() { assert!(ValidatedDelegationScope::new("atproto").is_ok()); diff --git a/crates/tranquil-scopes/src/coverage.rs b/crates/tranquil-scopes/src/coverage.rs new file mode 100644 index 0000000..9a4c1af --- /dev/null +++ b/crates/tranquil-scopes/src/coverage.rs @@ -0,0 +1,183 @@ +use crate::parser::{ + AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, ParsedScope, + RepoScope, RpcScope, +}; + +/// Returns true if the `granted` scope authorizes everything the `requested` scope asks for. +/// Typed replacement for the old string-prefix `scope_covers`. +pub fn covers(granted: &ParsedScope, requested: &ParsedScope) -> bool { + use ParsedScope::*; + match (granted, requested) { + (Atproto, Atproto) => true, + (TransitionGeneric, TransitionGeneric) => true, + (TransitionChat, TransitionChat) => true, + (TransitionEmail, TransitionEmail) => true, + (Repo(g), Repo(r)) => repo_covers(g, r), + (Blob(g), Blob(r)) => blob_covers(g, r), + (Rpc(g), Rpc(r)) => rpc_covers(g, r), + (Account(g), Account(r)) => account_covers(g, r), + (Identity(g), Identity(r)) => identity_covers(g, r), + (Include(g), Include(r)) => g.nsid == r.nsid && g.aud == r.aud, + (Unknown(g), Unknown(r)) => g == r, + _ => false, + } +} + +fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool { + let collection_ok = match &g.collection { + None => true, // repo:* — any collection + Some(gc) => match &r.collection { + None => false, // a specific grant cannot cover a wildcard request + Some(rc) => match gc.strip_suffix(".*") { + Some(prefix) => rc.starts_with(prefix) && rc.as_bytes().get(prefix.len()) == Some(&b'.'), + None => gc == rc, + }, + }, + }; + // parse_scope expands a missing ?action to all three actions, so subset is exact. + collection_ok && r.actions.is_subset(&g.actions) +} + +fn blob_covers(g: &BlobScope, r: &BlobScope) -> bool { + if g.accept.is_empty() || g.accept.contains("*/*") { + return true; + } + // every accept pattern the request wants must be matched by the grant + !r.accept.is_empty() && r.accept.iter().all(|pat| g.matches_mime(pat)) +} + +fn rpc_covers(g: &RpcScope, r: &RpcScope) -> bool { + let lxm_ok = match &g.lxm { + None => true, + Some(gl) if gl == "*" => true, + Some(gl) => r.lxm.as_deref() == Some(gl.as_str()), + }; + let aud_ok = match &g.aud { + None => true, + Some(ga) if ga == "*" => true, + Some(ga) => r.aud.as_deref() == Some(ga.as_str()), + }; + lxm_ok && aud_ok +} + +fn account_covers(g: &AccountScope, r: &AccountScope) -> bool { + let attr_ok = g.attr == AccountAttr::Wildcard || g.attr == r.attr; + let action_ok = match (g.action, r.action) { + (AccountAction::Manage, _) => true, // manage ⊇ read + (AccountAction::Read, AccountAction::Read) => true, + (AccountAction::Read, AccountAction::Manage) => false, + }; + attr_ok && action_ok +} + +fn identity_covers(g: &IdentityScope, r: &IdentityScope) -> bool { + g.attr == IdentityAttr::Wildcard || g.attr == r.attr +} + +#[cfg(test)] +mod tests { + use super::covers; + use crate::parser::parse_scope; + + fn c(granted: &str, requested: &str) -> bool { + covers(&parse_scope(granted), &parse_scope(requested)) + } + + #[test] + fn repo_wildcard_covers_specific() { + assert!(c("repo:*", "repo:app.bsky.feed.post")); + assert!(c("repo:*", "repo:app.bsky.feed.post?action=create")); + } + + #[test] + fn repo_specific_does_not_cover_wildcard() { + assert!(!c("repo:app.bsky.feed.post", "repo:*")); + } + + #[test] + fn repo_action_subset_required() { + // granted no ?action == all three actions + assert!(c("repo:*", "repo:*?action=create")); + assert!(!c("repo:*?action=create", "repo:*?action=delete")); + assert!(c("repo:*?action=create&action=delete", "repo:*?action=create")); + assert!(!c("repo:*?action=create", "repo:*?action=create&action=delete")); + } + + #[test] + fn repo_partial_action_grant_does_not_cover_actionless_request() { + // SECURITY: actionless requested repo == all three actions; a create+delete + // grant must NOT cover it (previously the string engine wrongly did). + assert!(!c("repo:*?action=create&action=delete", "repo:app.bsky.feed.post")); + } + + #[test] + fn repo_collection_prefix_wildcard() { + assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); + assert!(!c("repo:app.bsky.*", "repo:app.bsky")); + assert!(!c("repo:app.bsky.*", "repo:com.example.foo")); + } + + #[test] + fn blob_wildcard_covers_all() { + assert!(c("blob:*/*", "blob:image/png")); + assert!(c("blob:*/*", "blob:*/*")); + } + + #[test] + fn blob_type_prefix() { + assert!(c("blob:image/*", "blob:image/png")); + assert!(!c("blob:image/*", "blob:video/mp4")); + } + + #[test] + fn rpc_wildcards() { + assert!(c("rpc:*?aud=did:web:x", "rpc:app.bsky.getX?aud=did:web:x")); + assert!(c("rpc:app.bsky.getX?aud=*", "rpc:app.bsky.getX?aud=did:web:x")); + assert!(!c("rpc:app.bsky.getX?aud=did:web:x", "rpc:app.bsky.getY?aud=did:web:x")); + } + + #[test] + fn account_manage_covers_read_and_wildcard_attr() { + assert!(c("account:*?action=manage", "account:email?action=read")); + assert!(c("account:*?action=manage", "account:email?action=manage")); + assert!(!c("account:email?action=read", "account:email?action=manage")); + } + + #[test] + fn identity_wildcard_covers_handle() { + assert!(c("identity:*", "identity:handle")); + assert!(!c("identity:handle", "identity:*")); + } + + #[test] + fn cross_type_never_covers() { + assert!(!c("repo:*", "blob:*/*")); + assert!(!c("blob:*/*", "repo:app.bsky.feed.post")); + } + + #[test] + fn atproto_and_transition_self_cover() { + assert!(c("atproto", "atproto")); + assert!(c("transition:generic", "transition:generic")); + assert!(!c("transition:generic", "atproto")); + } + + #[test] + fn repo_prefix_wildcard_respects_dot_boundary() { + assert!(!c("repo:app.bsky.*", "repo:app.bskyEXTRA")); + assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); + assert!(!c("repo:app.bsky.*", "repo:app.bsky")); // no trailing segment + } + + #[test] + fn include_exact_match_only() { + assert!(c("include:io.x.set", "include:io.x.set")); + assert!(!c("include:io.x.set", "include:io.y.set")); + } + + #[test] + fn unknown_exact_match_only() { + assert!(c("weird:token", "weird:token")); + assert!(!c("weird:token", "other:token")); + } +} diff --git a/crates/tranquil-scopes/src/lib.rs b/crates/tranquil-scopes/src/lib.rs index 9b9a5c9..fd01786 100644 --- a/crates/tranquil-scopes/src/lib.rs +++ b/crates/tranquil-scopes/src/lib.rs @@ -1,9 +1,11 @@ +mod coverage; mod definitions; mod error; mod parser; mod permission_set; mod permissions; +pub use coverage::covers; pub use definitions::{ SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition, format_scope_for_display, get_required_scopes, get_scope_definition, is_valid_scope,