diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 2c44d59..e5007d6 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -125,13 +125,18 @@ fn owner_access_is_granted(granted: &[ParsedScope]) -> bool { .all(|scope| matches!(coverage(granted, &parse_scope(scope)), Coverage::Full)) } -// A delegate trusted with `rpc:*` may call any service on the account's behalf, which -// includes reading from the AppView the client talks to (timelines, feeds, profiles). +// A delegate trusted with `rpc:*`, or with writing to the whole repo, acts as the account in +// apps, which means reading from the AppView the client talks to (timelines, feeds, profiles). // Without this, Editor and Admin delegates sign in to apps whose every read is refused. +// Grants saved before the presets gained `rpc:*` (see the 20260626 migration) only carry +// repo access, so repo writes count too. fn appview_access_is_granted(granted: &[ParsedScope]) -> bool { granted.iter().any(|scope| { matches!(scope, ParsedScope::Rpc(rpc) if rpc.aud.is_none() && rpc.lxms.contains("*")) - }) + }) || matches!( + coverage(granted, &parse_scope("repo:*?action=create")), + Coverage::Full + ) } fn grant_access(granted: &[ParsedScope]) -> GrantAccess { @@ -237,9 +242,13 @@ mod tests { } #[test] - fn test_intersect_non_owner_does_not_gain_app_bsky_or_other_rpc_scopes() { + fn test_intersect_non_owner_gains_app_bsky_but_not_other_rpc_scopes() { let granted = "repo:* blob:*/* account:*?action=manage"; - assert_eq!(intersect_scopes("rpc:app.bsky.*?aud=*", granted), ""); + assert_eq!( + intersect_scopes("rpc:app.bsky.*?aud=*", granted), + "rpc:app.bsky.*?aud=*" + ); + assert_eq!(intersect_scopes("rpc:com.example.*?aud=*", granted), ""); assert_eq!( intersect_scopes("rpc:com.example.*?aud=*", OWNER_FULL_SCOPES), "" @@ -247,11 +256,20 @@ mod tests { } #[test] - fn test_intersect_partial_grant_does_not_gain_broad_transition_scopes() { + fn test_intersect_repo_writer_gains_transition_generic_but_not_chat() { let result = intersect_scopes( "transition:generic transition:chat.bsky", "repo:* blob:*/* account:*?action=manage", ); + assert_eq!(result, "transition:generic"); + } + + #[test] + fn test_intersect_partial_grant_does_not_gain_broad_transition_scopes() { + let result = intersect_scopes( + "transition:generic transition:chat.bsky", + "repo:app.bsky.feed.post blob:*/*", + ); assert!(result.is_empty()); } @@ -589,7 +607,8 @@ mod tests { #[test] fn test_rpc_wildcard_delegates_keep_appview_reads() { - let requested = "atproto transition:generic rpc:app.bsky.*?aud=did:web:api.bsky.app%23bsky_appview"; + let requested = + "atproto transition:generic rpc:app.bsky.*?aud=did:web:api.bsky.app%23bsky_appview"; [EDITOR_FULL_SCOPES, ADMIN_FULL_SCOPES, "atproto rpc:*"] .iter() .for_each(|granted| { @@ -607,8 +626,36 @@ mod tests { } #[test] - fn test_delegates_without_rpc_wildcard_stay_locked_out_of_appview() { - let granted = "atproto repo:* blob:*/*"; + fn test_legacy_admin_and_editor_grants_keep_appview_reads() { + let requested = + "atproto transition:generic rpc:app.bsky.*?aud=did:web:api.bsky.app%23bsky_appview"; + [ + "atproto repo:* blob:*/* identity:* account:*?action=manage", + "atproto repo:* blob:*/* account:*?action=manage", + "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", + ] + .iter() + .for_each(|granted| { + assert_eq!( + intersect_scopes(requested, granted), + "atproto rpc:app.bsky.*?aud=did:web:api.bsky.app%23bsky_appview transition:generic", + "grant `{}`", + granted + ); + assert_eq!( + grant_coverage(granted, "transition:chat.bsky"), + GrantCoverage::Withheld + ); + assert_eq!( + grant_coverage(granted, "rpc:com.example.thing?aud=*"), + GrantCoverage::Withheld + ); + }); + } + + #[test] + fn test_delegates_without_repo_writes_or_rpc_wildcard_stay_locked_out_of_appview() { + let granted = "atproto repo:app.bsky.feed.post?action=create blob:*/*"; assert_eq!( intersect_scopes("atproto transition:generic rpc:app.bsky.*?aud=*", granted), "atproto"