From 71cd282d1e285f09d5a4877bab193dbc9f36d9b6 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 15 Sep 2026 14:38:11 -0500 Subject: [PATCH] feat: show repo actions narrowed out by client metadata on consent Signed-off-by: Trezy --- .../endpoints/authorize/scope_resolution.rs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs index 7984946..b966b2a 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs @@ -2,7 +2,9 @@ use tranquil_db_traits::DbScope; use tranquil_pds::cache::Cache; use tranquil_pds::delegation::{GrantCoverage, grant_coverage, intersect_scopes}; use tranquil_pds::oauth::permission_set_resolver::expand_scopes; -use tranquil_scopes::{ExpansionOutcome, RejectedScope, ScopeRejection}; +use tranquil_scopes::{ + ExpansionOutcome, ParsedScope, RejectedScope, RepoScope, ScopeRejection, parse_scope, +}; pub enum Authority<'a> { FullSelf, @@ -38,7 +40,13 @@ fn reject_unregistered(outcome: &mut ExpansionOutcome, registered: &str) { let mut rejected = Vec::new(); let mut keep = |scope: String| match grant_coverage(registered, &scope) { GrantCoverage::Full => Some(scope), - GrantCoverage::Narrowed(narrowed) => Some(narrowed), + GrantCoverage::Narrowed(narrowed) => { + rejected.extend(narrowed_out(&scope, &narrowed).map(|scope| RejectedScope { + scope, + reason: ScopeRejection::NotRegistered, + })); + Some(narrowed) + } GrantCoverage::Withheld => { rejected.push(RejectedScope { scope, @@ -60,6 +68,28 @@ fn reject_unregistered(outcome: &mut ExpansionOutcome, registered: &str) { outcome.rejected.extend(rejected); } +/// The repo actions dropped when `requested` was narrowed to `narrowed`, as a scope of their own. +/// Only repo scopes are ever narrowed; anything else yields `None`. +fn narrowed_out(requested: &str, narrowed: &str) -> Option { + let (ParsedScope::Repo(requested), ParsedScope::Repo(narrowed)) = + (parse_scope(requested), parse_scope(narrowed)) + else { + return None; + }; + let actions: std::collections::HashSet<_> = requested + .actions + .difference(&narrowed.actions) + .copied() + .collect(); + (!actions.is_empty()).then(|| { + RepoScope { + collection: requested.collection, + actions, + } + .to_scope_string() + }) +} + #[cfg(test)] mod tests { use super::*; @@ -205,4 +235,30 @@ mod tests { ); assert!(eff.permitted.contains("identity:*")); } + + #[tokio::test] + async fn actions_narrowed_out_by_client_metadata_are_reported_as_rejected() { + let c = MemoryCache::new(); + let eff = resolve_effective_scopes( + &c, + "atproto repo:app.bsky.feed.post?action=create&action=delete", + Authority::FullSelf, + Some("atproto repo:*?action=create"), + ) + .await; + assert!( + eff.permitted + .split_whitespace() + .any(|s| s == "repo:app.bsky.feed.post?action=create"), + "permitted was {:?}", + eff.permitted + ); + assert_eq!( + eff.outcome.rejected, + vec![RejectedScope { + scope: "repo:app.bsky.feed.post?action=delete".to_string(), + reason: ScopeRejection::NotRegistered, + }] + ); + } }