A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356//! Forward-time scope checking for service-proxied requests.//!//! # Why this is deliberately incomplete//!//! There is no general "which scope governs this XRPC method" function to//! write. The AT Protocol scope grammar answers *semantic* questions — may this//! collection be created, may this method be called against this audience — and//! the mapping from a method name to one of those questions lives inside the//! PDS. Proposal 0011 says outright that it "does not include full definitions//! for the initial permissions," and `@atproto/oauth-scopes` exposes no such//! mapping either.//!//! So this maps only the handful of operations HappyView can map//! *unambiguously*, and forwards everything else unchecked for the PDS to//! enforce. That is honest defence-in-depth rather than a second enforcement//! layer pretending to be complete — reproducing a guess at the PDS's internals//! would be permissive where the guess is wrong in one direction and would//! break working apps in the other.//!//! The value is real regardless: repo writes and blob uploads are exactly where//! a PDS with weak granular-scope enforcement would hurt most.
use happyview_scopes::{RepoAction, ScopePermissions};use serde_json::Value;
use crate::error::AppError;
/// A permission a forwarded request needs.#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) enum Required { /// Every listed action on `collection`. Repo { collection: String, actions: Vec<RepoAction>, }, Blob { mime: String, }, Rpc { lxm: String, aud: String, },}
/// What must be held for this request, or an empty list when the method is one/// we decline to map.pub(crate) fn required_for(method: &str, body: &Value, proxy_aud: Option<&str>) -> Vec<Required> { // A request being relayed onwards is an `rpc` call to the named audience, // whatever the method does at the far end. The header supplies exactly the // `aud` the grammar requires, so this case maps cleanly. if let Some(aud) = proxy_aud { return vec![Required::Rpc { lxm: method.to_string(), aud: aud.to_string(), }]; }
let collection = || { body.get("collection") .and_then(|v| v.as_str()) .map(str::to_string) };
match method { "com.atproto.repo.createRecord" => collection() .map(|collection| { vec![Required::Repo { collection, actions: vec![RepoAction::Create], }] }) .unwrap_or_default(),
"com.atproto.repo.deleteRecord" => collection() .map(|collection| { vec![Required::Repo { collection, actions: vec![RepoAction::Delete], }] }) .unwrap_or_default(),
// `putRecord` is an upsert, so it needs create *and* update — unless a // `swapCid` is supplied, which is a genuine no-create guarantee (the // PDS enforces the compare-and-swap) and narrows it to update alone. // Same rule `linked_repos::pds::require_put_scope` already applies. "com.atproto.repo.putRecord" => collection() .map(|collection| { let actions = if body.get("swapCid").is_some_and(|v| !v.is_null()) { vec![RepoAction::Update] } else { vec![RepoAction::Create, RepoAction::Update] }; vec![Required::Repo { collection, actions, }] }) .unwrap_or_default(),
// One check per write in the batch, each with its own collection and // action. A batch is not a single permission. "com.atproto.repo.applyWrites" => body .get("writes") .and_then(|v| v.as_array()) .map(|writes| writes.iter().filter_map(write_permission).collect()) .unwrap_or_default(),
_ => Vec::new(), }}
fn write_permission(write: &Value) -> Option<Required> { let collection = write.get("collection")?.as_str()?.to_string(); let ty = write.get("$type")?.as_str()?;
let actions = match ty.rsplit_once('#')?.1 { "create" => vec![RepoAction::Create], "update" => vec![RepoAction::Update], "delete" => vec![RepoAction::Delete], _ => return None, };
Some(Required::Repo { collection, actions, })}
/// Refuse the request unless every required permission is held.pub(crate) fn check( granted: &ScopePermissions, required: &[Required], method: &str,) -> Result<(), AppError> { for requirement in required { let (ok, needed) = match requirement { Required::Repo { collection, actions, } => ( actions .iter() .all(|action| granted.allows_repo(collection, *action)), actions .iter() .map(|a| format!("repo:{collection}?action={}", a.as_str())) .collect::<Vec<_>>() .join(" "), ), Required::Blob { mime } => (granted.allows_blob(mime), format!("blob:{mime}")), Required::Rpc { lxm, aud } => { (granted.allows_rpc(lxm, aud), format!("rpc:{lxm}?aud={aud}")) } };
if !ok { return Err(AppError::Forbidden(format!( "this session is not authorized for {method}: it needs {needed}. \ Scopes are fixed when a session is created, so this needs a new \ authorization with the scope included." ))); } } Ok(())}
#[cfg(test)]mod tests { use super::*; use serde_json::json;
fn body(collection: &str) -> Value { json!({ "repo": "did:plc:x", "collection": collection, "record": {} }) }
#[test] fn create_record_needs_create_on_its_collection() { let required = required_for( "com.atproto.repo.createRecord", &body("com.example.post"), None, ); assert_eq!( required, vec![Required::Repo { collection: "com.example.post".into(), actions: vec![RepoAction::Create], }] ); }
#[test] fn delete_record_needs_delete() { let required = required_for( "com.atproto.repo.deleteRecord", &body("com.example.post"), None, ); assert_eq!( required, vec![Required::Repo { collection: "com.example.post".into(), actions: vec![RepoAction::Delete], }] ); }
/// The upsert rule, matching `require_put_scope`: both actions normally, /// narrowed to update alone when `swapCid` makes creation impossible. #[test] fn put_record_needs_both_actions_unless_swap_cid_is_given() { let required = required_for( "com.atproto.repo.putRecord", &body("com.example.post"), None, ); assert_eq!( required[0], Required::Repo { collection: "com.example.post".into(), actions: vec![RepoAction::Create, RepoAction::Update], } );
let mut with_swap = body("com.example.post"); with_swap["swapCid"] = json!("bafyreiabc"); let required = required_for("com.atproto.repo.putRecord", &with_swap, None); assert_eq!( required[0], Required::Repo { collection: "com.example.post".into(), actions: vec![RepoAction::Update], } ); }
/// A null `swapCid` is not a swap — it must not narrow the requirement. #[test] fn a_null_swap_cid_does_not_narrow_put_record() { let mut with_null = body("com.example.post"); with_null["swapCid"] = Value::Null; let required = required_for("com.atproto.repo.putRecord", &with_null, None); assert_eq!( required[0], Required::Repo { collection: "com.example.post".into(), actions: vec![RepoAction::Create, RepoAction::Update], } ); }
#[test] fn apply_writes_checks_every_op_separately() { let batch = json!({ "repo": "did:plc:x", "writes": [ { "$type": "com.atproto.repo.applyWrites#create", "collection": "com.example.a" }, { "$type": "com.atproto.repo.applyWrites#delete", "collection": "com.example.b" }, ] }); let required = required_for("com.atproto.repo.applyWrites", &batch, None); assert_eq!( required, vec![ Required::Repo { collection: "com.example.a".into(), actions: vec![RepoAction::Create] }, Required::Repo { collection: "com.example.b".into(), actions: vec![RepoAction::Delete] }, ] ); }
#[test] fn a_proxied_request_is_an_rpc_call_to_the_named_audience() { let required = required_for( "com.atproto.repo.createRecord", &body("com.example.post"), Some("did:web:api.bsky.app#bsky_appview"), ); assert_eq!( required, vec![Required::Rpc { lxm: "com.atproto.repo.createRecord".into(), aud: "did:web:api.bsky.app#bsky_appview".into(), }] ); }
/// The deliberate gap: anything not mapped forwards unchecked, because /// guessing at the PDS's mapping is worse than deferring to it. #[test] fn unmapped_methods_require_nothing() { assert!(required_for("com.atproto.server.getServiceAuth", &json!({}), None).is_empty()); assert!(required_for("com.atproto.identity.updateHandle", &json!({}), None).is_empty()); assert!(required_for("com.example.whatever", &json!({}), None).is_empty()); }
/// A malformed body is not a licence to skip the check silently — but it /// also cannot be mapped, so it forwards and the PDS rejects it. #[test] fn a_body_with_no_collection_maps_to_nothing() { assert!(required_for("com.atproto.repo.createRecord", &json!({}), None).is_empty()); }
#[test] fn granted_scopes_satisfy_their_requirement() { let granted = ScopePermissions::parse("atproto repo:com.example.post?action=create"); let required = required_for( "com.atproto.repo.createRecord", &body("com.example.post"), None, ); assert!(check(&granted, &required, "com.atproto.repo.createRecord").is_ok()); }
#[test] fn a_missing_action_is_refused_and_names_what_is_needed() { let granted = ScopePermissions::parse("atproto repo:com.example.post?action=create"); let required = required_for( "com.atproto.repo.putRecord", &body("com.example.post"), None, ); let err = check(&granted, &required, "com.atproto.repo.putRecord").unwrap_err(); let message = err.to_string(); assert!(message.contains("action=update"), "{message}"); }
#[test] fn transition_generic_satisfies_repo_requirements() { let granted = ScopePermissions::parse("atproto transition:generic"); let required = required_for( "com.atproto.repo.createRecord", &body("com.example.post"), None, ); assert!(check(&granted, &required, "com.atproto.repo.createRecord").is_ok()); }
#[test] fn a_different_collection_is_refused() { let granted = ScopePermissions::parse("atproto repo:com.example.post?action=create"); let required = required_for( "com.atproto.repo.createRecord", &body("com.other.post"), None, ); assert!(check(&granted, &required, "com.atproto.repo.createRecord").is_err()); }}