mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-01 15:06:05 +00:00
refactor: use tranquil-scopes instead of bespoke scope handling in delegation auth
Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Generated
+6
@@ -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]]
|
||||
|
||||
@@ -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<tranquil_scopes::ParsedScope> =
|
||||
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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user