refactor: use tranquil-scopes instead of bespoke scope handling in delegation auth

Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Trezy
2026-07-24 20:11:26 +03:00
committed by Tangled
parent 00ca223b5f
commit f17adc6f88
4 changed files with 201 additions and 112 deletions
Generated
+6
View File
@@ -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]]
+10 -112
View File
@@ -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());
+183
View File
@@ -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"));
}
}
+2
View File
@@ -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,