feat: add handling for more scope failures

Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
Trezy
2026-07-24 20:11:27 +03:00
committed by Tangled
parent 515c058006
commit 311fbfcb86
8 changed files with 70 additions and 42 deletions
@@ -30,7 +30,7 @@ pub struct FailedSetInfo {
pub nsid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
pub reason: String,
pub reason: tranquil_scopes::ResolveFailure,
}
#[derive(Debug, Serialize)]
@@ -142,7 +142,7 @@ pub async fn consent_get(
};
let authority = match delegation_grant.as_ref() {
Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()),
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
None => scope_resolution::Authority::FullSelf,
};
let effective = scope_resolution::resolve_effective_scopes(
@@ -263,12 +263,7 @@ pub async fn consent_get(
.map(|f| FailedSetInfo {
nsid: f.nsid.clone(),
aud: f.aud.clone(),
reason: match f.reason {
tranquil_scopes::ResolveFailure::NotFound => "not_found",
tranquil_scopes::ResolveFailure::NetworkError => "unreachable",
tranquil_scopes::ResolveFailure::Invalid => "invalid",
}
.to_string(),
reason: f.reason.clone(),
})
.collect();
@@ -410,7 +405,7 @@ pub async fn consent_post(
};
let authority = match delegation_grant.as_ref() {
Some(grant) => scope_resolution::Authority::Delegated(grant.granted_scopes.as_str()),
Some(grant) => scope_resolution::Authority::Delegated(&grant.granted_scopes),
None => scope_resolution::Authority::FullSelf,
};
let effective = scope_resolution::resolve_effective_scopes(
@@ -1,3 +1,4 @@
use tranquil_db_traits::DbScope;
use tranquil_pds::cache::Cache;
use tranquil_pds::delegation::intersect_scopes;
use tranquil_pds::oauth::permission_set_resolver::expand_scopes;
@@ -5,7 +6,7 @@ use tranquil_scopes::ExpansionOutcome;
pub enum Authority<'a> {
FullSelf,
Delegated(&'a str),
Delegated(&'a DbScope),
}
pub struct EffectiveScopes {
@@ -22,7 +23,7 @@ pub async fn resolve_effective_scopes(
let expanded = outcome.to_scope_string();
let resolved = match authority {
Authority::FullSelf => expanded,
Authority::Delegated(granted) => intersect_scopes(&expanded, granted),
Authority::Delegated(granted) => intersect_scopes(&expanded, granted.as_str()),
};
EffectiveScopes { resolved, outcome }
}
@@ -50,7 +51,7 @@ mod tests {
fn cache_with(nsid: &str, scopes: &str) -> MapCache {
let c = MapCache::default();
let key = tranquil_pds::cache_keys::permission_set_key(nsid, None);
let key = tranquil_pds::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None);
let json = serde_json::json!({ "scope": scopes, "title": null, "detail": null }).to_string();
c.0.lock().unwrap().insert(key, json);
c
@@ -69,9 +70,10 @@ mod tests {
#[tokio::test]
async fn delegated_intersects_expanded_against_grant() {
let c = cache_with("io.atcr.authFullApp", "repo:io.atcr.manifest?action=create identity:*");
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
let eff = resolve_effective_scopes(
&c, "atproto include:io.atcr.authFullApp",
Authority::Delegated("atproto repo:* blob:*/* account:*?action=manage"),
Authority::Delegated(&granted),
).await;
assert!(eff.resolved.contains("atproto"));
assert!(eff.resolved.contains("repo:io.atcr.manifest?action=create"));
@@ -133,7 +133,7 @@ pub async fn handle_authorization_code_grant(
let controller_did = authorized.controller_did.clone();
let requested_scope = authorized.parameters.scope.clone();
let granted_scopes: Option<String> = if let Some(ref controller) = controller_did {
let granted_scopes: Option<tranquil_db_traits::DbScope> = if let Some(ref controller) = controller_did {
let grant = state
.repos
.delegation
@@ -144,11 +144,11 @@ pub async fn handle_authorization_code_grant(
.ok_or_else(|| {
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
})?;
Some(grant.granted_scopes.as_str().to_string())
Some(grant.granted_scopes.clone())
} else {
None
};
let authority = match granted_scopes.as_deref() {
let authority = match granted_scopes.as_ref() {
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
};
@@ -254,7 +254,7 @@ async fn recompute_resolved_scope(
token_data: &TokenData,
) -> Result<String, OAuthError> {
let requested = token_data.scope.as_deref().unwrap_or("atproto");
let granted_scopes: Option<String> = if let Some(ref controller) = token_data.controller_did {
let granted_scopes: Option<tranquil_db_traits::DbScope> = if let Some(ref controller) = token_data.controller_did {
let grant = state
.repos
.delegation
@@ -265,11 +265,11 @@ async fn recompute_resolved_scope(
.ok_or_else(|| {
OAuthError::InvalidGrant("Delegation grant not found or revoked".to_string())
})?;
Some(grant.granted_scopes.as_str().to_string())
Some(grant.granted_scopes.clone())
} else {
None
};
let authority = match granted_scopes.as_deref() {
let authority = match granted_scopes.as_ref() {
Some(g) => crate::endpoints::authorize::scope_resolution::Authority::Delegated(g),
None => crate::endpoints::authorize::scope_resolution::Authority::FullSelf,
};
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn auto_verify_sent_key(did: &Did) -> String {
format!("auto_verify_sent:{}", did)
}
pub fn permission_set_key(nsid: &str, aud: Option<&str>) -> String {
pub fn permission_set_key(nsid: &tranquil_types::Nsid, aud: Option<&str>) -> String {
match aud {
Some(a) => format!("permset:{}:{}", nsid, a),
None => format!("permset:{}", nsid),
@@ -43,8 +43,8 @@ async fn resolve_one(
nsid: &str,
aud: Option<&str>,
) -> Result<ResolvedSetGroup, ResolveFailure> {
let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Invalid)?;
let key = permission_set_key(nsid, aud);
let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Malformed)?;
let key = permission_set_key(&parsed, aud);
if let Some(json) = cache.get(&key).await
&& let Ok(v) = serde_json::from_str::<CachedPermissionSet>(&json)
@@ -89,10 +89,12 @@ fn group_from(
fn map_err(e: &ScopeExpansionError) -> ResolveFailure {
use ScopeExpansionError as E;
match e {
E::InvalidNsid(_) | E::UnexpectedType(_) | E::EmptyPermissions | E::MissingDefinition(_) => {
ResolveFailure::Invalid
}
E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::NetworkError,
E::InvalidNsid(_) => ResolveFailure::Malformed,
E::RecordNotFound => ResolveFailure::NotFound,
E::UnexpectedType(_) => ResolveFailure::NotAPermissionSet,
E::MissingDefinition(_) => ResolveFailure::MalformedLexicon,
E::EmptyPermissions => ResolveFailure::EmptyPermissions,
E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::Unreachable,
}
}
@@ -132,7 +134,7 @@ mod tests {
}
fn seed(cache: &MapCache, nsid: &str, scope: &str) {
let key = crate::cache_keys::permission_set_key(nsid, None);
let key = crate::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None);
let val = serde_json::to_string(&CachedPermissionSet {
scope: scope.to_string(),
title: Some("Basic".into()),
@@ -53,7 +53,7 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
async fn seed_permission_set(nsid: &str, granular_scope: &str) {
let state = common::get_test_app_state().await;
let key = tranquil_pds::cache_keys::permission_set_key(nsid, None);
let key = tranquil_pds::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None);
let val = json!({
"scope": granular_scope,
"title": "Basic",
@@ -536,7 +536,7 @@ async fn test_consent_post_errors_when_set_unresolvable() {
);
let state = common::get_test_app_state().await;
let key = tranquil_pds::cache_keys::permission_set_key(UNRESOLVABLE_NSID, None);
let key = tranquil_pds::cache_keys::permission_set_key(&tranquil_types::Nsid::new(UNRESOLVABLE_NSID).unwrap(), None);
state.cache.delete(&key).await.unwrap();
let approved_scopes: Vec<&str> = scope.split_whitespace().collect();
+38 -12
View File
@@ -1,7 +1,7 @@
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use reqwest::Client;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::debug;
use tranquil_types::{Did, Nsid};
@@ -20,15 +20,27 @@ pub enum ScopeExpansionError {
HttpFailed(String),
#[error("DID resolution failed: {0}")]
DidResolution(String),
#[error("Lexicon record not found")]
RecordNotFound,
#[error("No valid permissions found in permission-set")]
EmptyPermissions,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolveFailure {
// Couldn't connect to PDS
Unreachable,
// Connected to PDS, but the lexicon record doesn't exist
NotFound,
NetworkError,
Invalid,
// The NSID is malformed
Malformed,
// A lexicon exists, but it isn't a `permission-set` (e.g. a query or record type).
NotAPermissionSet,
// Lexicon doc was malformed (no main, no permissions)
MalformedLexicon,
// The doc is valid, but grants nothing usable (i.e. empty permissions, or permissions are from a different namespace)
EmptyPermissions,
}
#[derive(Debug, Clone)]
@@ -87,6 +99,11 @@ struct PlcService {
service_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct XrpcError {
error: String,
}
#[derive(Debug, Deserialize)]
struct GetRecordResponse {
value: LexiconDoc,
@@ -200,16 +217,25 @@ async fn fetch_lexicon_via_atproto(nsid: &Nsid) -> Result<LexiconDoc, ScopeExpan
.await
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
if !response.status().is_success() {
return Err(ScopeExpansionError::HttpFailed(format!(
"HTTP {}",
response.status()
)));
let status = response.status();
let body = response
.text()
.await
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
if !status.is_success() {
let not_found = status == reqwest::StatusCode::NOT_FOUND
|| serde_json::from_str::<XrpcError>(&body)
.map(|e| e.error.eq_ignore_ascii_case("RecordNotFound"))
.unwrap_or(false);
return Err(if not_found {
ScopeExpansionError::RecordNotFound
} else {
ScopeExpansionError::HttpFailed(format!("HTTP {}", status))
});
}
let record: GetRecordResponse = response
.json()
.await
let record: GetRecordResponse = serde_json::from_str(&body)
.map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?;
Ok(record.value)
+4 -1
View File
@@ -603,7 +603,10 @@
"setFailureReason": {
"not_found": "This bundle could not be found and cannot be granted.",
"unreachable": "The bundle's publisher could not be reached; it cannot be granted right now.",
"invalid": "This bundle is invalid and cannot be granted."
"malformed": "This bundle's name is not a valid identifier, so it cannot be granted.",
"not_a_permission_set": "This identifier does not refer to a permission bundle, so it cannot be granted.",
"malformed_lexicon": "This bundle is published incorrectly and cannot be granted.",
"empty_permissions": "This bundle does not currently grant any permissions."
},
"permTable": {
"data": "Data",