fix: show the user which scopes will not be granted based on delegation

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 fa50d7d54e
commit 01d93e44e7
7 changed files with 226 additions and 27 deletions
@@ -9,6 +9,7 @@ pub struct ScopeInfo {
pub description: String,
pub display_name: String,
pub granted: Option<bool>,
pub restricted: bool,
}
#[derive(Debug, Serialize)]
@@ -23,6 +24,7 @@ pub struct PermissionSetInfo {
pub include_scope: String,
pub expanded: Vec<ScopeInfo>,
pub granted: Option<bool>,
pub restricted: bool,
}
#[derive(Debug, Serialize)]
@@ -182,6 +184,13 @@ pub async fn consent_get(
.unwrap_or(true);
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
let grant_scope_str: Option<&str> = delegation_grant
.as_ref()
.map(|g| g.granted_scopes.as_str());
let is_restricted = |scope: &str| -> bool {
grant_scope_str.is_some_and(|g| !tranquil_pds::delegation::grant_covers(g, scope))
};
let make_scope_info = |scope: &str| -> ScopeInfo {
let (category, required, description, display_name) =
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(scope) {
@@ -225,6 +234,7 @@ pub async fn consent_get(
description,
display_name,
granted,
restricted: is_restricted(scope),
}
};
@@ -244,6 +254,9 @@ pub async fn consent_get(
Some(a) => format!("include:{}?aud={}", g.nsid, a),
None => format!("include:{}", g.nsid),
};
let expanded: Vec<ScopeInfo> =
g.expanded.iter().map(|s| make_scope_info(s)).collect();
let restricted = !expanded.is_empty() && expanded.iter().all(|s| s.restricted);
PermissionSetInfo {
nsid: g.nsid.clone(),
aud: g.aud.clone(),
@@ -251,7 +264,8 @@ pub async fn consent_get(
detail: g.detail.clone(),
granted: pref_map.get(include_scope.as_str()).copied(),
include_scope,
expanded: g.expanded.iter().map(|s| make_scope_info(s)).collect(),
expanded,
restricted,
}
})
.collect();
+1 -1
View File
@@ -6,7 +6,7 @@ pub use roles::{
};
pub use scopes::{
EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset,
ValidatedDelegationScope, intersect_scopes,
ValidatedDelegationScope, grant_covers, intersect_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
@@ -63,6 +63,15 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String {
scopes.join(" ")
}
pub fn grant_covers(granted: &str, scope: &str) -> bool {
if scope == "atproto" {
return true;
}
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
granted.split_whitespace().map(parse_scope).collect();
any_granted_covers(scope, &granted_parsed)
}
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))
@@ -251,4 +260,32 @@ mod tests {
});
});
}
#[test]
fn test_grant_covers_matches_intersection() {
let granted = "atproto repo:* blob:*/* account:*?action=manage";
let intersected = intersect_scopes(
"repo:app.bsky.feed.post?action=create identity:* account:*?action=manage",
granted,
);
assert!(grant_covers(granted, "repo:app.bsky.feed.post?action=create"));
assert!(grant_covers(granted, "account:*?action=manage"));
assert!(!grant_covers(granted, "identity:*"));
assert_eq!(
grant_covers(granted, "identity:*"),
intersected.contains("identity")
);
}
#[test]
fn test_grant_covers_atproto_always_true() {
assert!(grant_covers("", "atproto"));
assert!(grant_covers("repo:*", "atproto"));
}
#[test]
fn test_grant_covers_empty_grant_covers_nothing_else() {
assert!(!grant_covers("", "repo:app.bsky.feed.post?action=create"));
assert!(!grant_covers("", "identity:*"));
}
}
@@ -54,10 +54,15 @@ 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(&tranquil_types::Nsid::new(nsid).unwrap(), None);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let val = json!({
"scope": granular_scope,
"title": "Basic",
"detail": null
"detail": null,
"refreshed_at": now
})
.to_string();
state
@@ -245,6 +250,62 @@ async fn create_delegated_session_with_scope(
(session, consent_get_body, mock_client)
}
#[tokio::test]
async fn test_delegated_consent_marks_restricted_scopes() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
let scope = format!("atproto include:{}", PERMISSION_SET_NSID);
let (_session, consent_body, _mock) = create_delegated_session_with_scope(
"psr",
"https://example.com/permset-restricted-callback",
&scope,
)
.await;
let set_entry = consent_body["permission_sets"]
.as_array()
.and_then(|sets| {
sets.iter()
.find(|s| s["nsid"].as_str() == Some(PERMISSION_SET_NSID))
})
.unwrap_or_else(|| {
panic!(
"expected a permission_sets entry for '{}'. Got: {:?}",
PERMISSION_SET_NSID, consent_body
)
});
assert_eq!(
set_entry["restricted"].as_bool(),
Some(false),
"a partially-covered set must not be flagged fully restricted"
);
let expanded = set_entry["expanded"]
.as_array()
.expect("permission_sets entry should have an expanded array");
let repo = expanded
.iter()
.find(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create"))
.expect("expanded[] should list the repo scope");
assert_eq!(
repo["restricted"].as_bool(),
Some(false),
"repo scope is covered by the repo:* grant and must not be restricted"
);
let rpc = expanded
.iter()
.find(|s| s["scope"].as_str() == Some("rpc:io.atcr.getManifest?aud=*"))
.expect("expanded[] should list the rpc scope");
assert_eq!(
rpc["restricted"].as_bool(),
Some(true),
"rpc scope is not conferred by the OWNER grant and must be restricted"
);
}
#[tokio::test]
async fn test_delegated_include_scope_shows_granular_on_consent() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
+4 -1
View File
@@ -598,8 +598,11 @@
"required": "Required",
"rememberChoiceLabel": "Remember my choice for this application",
"permissionSets": "Permission bundles",
"unavailableSets": "Unavailable permission bundles",
"showIncludedScopes": "Show included permissions ({count})",
"unavailablePermissions": "Unavailable permissions",
"unavailableLimited": "Limited by delegation",
"unavailableFailed": "Failed to load",
"setPartiallyLimited": "Some permissions in this bundle are limited by your delegation; see \"Unavailable Permissions\" to find out which permissions weren't allowed.",
"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.",
+81 -19
View File
@@ -9,6 +9,7 @@
description: string
display_name: string
granted: boolean | null
restricted?: boolean
}
const SCOPE_LOCALE_MAP: Record<string, string> = {
@@ -40,6 +41,7 @@
include_scope: string
expanded: ScopeInfo[]
granted: boolean | null
restricted?: boolean
}
interface FailedSetInfo {
@@ -142,13 +144,16 @@
consentData = data
scopeSelections = Object.fromEntries(
data.scopes.map((scope) => [
scope.scope,
scope.required ? true : scope.granted ?? true,
])
data.scopes
.filter((scope) => !scope.restricted)
.map((scope) => [
scope.scope,
scope.required ? true : scope.granted ?? true,
])
)
for (const set of data.permission_sets ?? []) {
if (set.restricted) continue
scopeSelections[set.include_scope] = set.granted ?? true
}
@@ -274,7 +279,19 @@
fetchConsentData()
})
let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : [])
let grantedScopes = $derived(consentData ? consentData.scopes.filter(s => !s.restricted) : [])
let approvableSets = $derived(consentData ? (consentData.permission_sets ?? []).filter(s => !s.restricted) : [])
let scopeGroups = $derived(groupScopesByCategory(grantedScopes))
let restrictedScopes = $derived(consentData ? consentData.scopes.filter(s => s.restricted) : [])
let limitedBundles = $derived(
consentData ? (consentData.permission_sets ?? []).filter(s => s.expanded.some(e => e.restricted)) : []
)
let failedSets = $derived(consentData?.failed_sets ?? [])
let hasUnavailable = $derived(
restrictedScopes.length > 0 || limitedBundles.length > 0 || failedSets.length > 0
)
let hasGranularScopes = $derived(
(consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false) ||
((consentData?.permission_sets?.length ?? 0) > 0)
@@ -335,6 +352,20 @@
}
return { repo: [...repo.values()], rpc, other }
}
const grantedExpanded = (set: PermissionSetInfo) => set.expanded.filter(s => !s.restricted)
const withheldExpanded = (set: PermissionSetInfo) => set.expanded.filter(s => s.restricted)
function scopeLabel(scope: string): string {
const base = scope.split('?')[0]
if (base.startsWith('repo:')) {
const params = new URLSearchParams(scope.split('?')[1] ?? '')
const actions = params.getAll('action')
const coll = base.slice('repo:'.length) || '*'
return actions.length ? `${coll} (${actions.join(', ')})` : coll
}
if (base.startsWith('rpc:')) return base.slice('rpc:'.length)
return base
}
</script>
<div class="consent-container">
@@ -445,11 +476,13 @@
{/each}
{/if}
{#if consentData.permission_sets?.length}
{#if approvableSets.length}
<div class="scope-group">
<h3 class="category-title">{$_('oauth.consent.permissionSets')}</h3>
{#each consentData.permission_sets as set}
{@const desc = describeExpanded(set.expanded)}
{#each approvableSets as set}
{@const granted = grantedExpanded(set)}
{@const desc = describeExpanded(granted)}
{@const partiallyLimited = set.expanded.some(s => s.restricted)}
<label class="scope-item">
<input
type="checkbox"
@@ -460,8 +493,11 @@
<div class="scope-info">
<span class="scope-name">{set.title ?? set.nsid}</span>
{#if set.detail}<span class="scope-description">{set.detail}</span>{/if}
{#if partiallyLimited}
<span class="restricted-note">{$_('oauth.consent.setPartiallyLimited')}</span>
{/if}
<details class="scope-set-details">
<summary>{$_('oauth.consent.showIncludedScopes', { values: { count: set.expanded.length } })}</summary>
<summary>{$_('oauth.consent.showIncludedScopes', { values: { count: granted.length } })}</summary>
{#if desc.repo.length}
<table class="perm-table">
<thead>
@@ -504,17 +540,43 @@
</div>
{/if}
{#if consentData.failed_sets?.length}
<div class="scope-group failed-sets">
<h3 class="category-title">{$_('oauth.consent.unavailableSets')}</h3>
{#each consentData.failed_sets as f}
<div class="scope-item failed">
<div class="scope-info">
<span class="scope-name">{f.nsid}{#if f.aud} ({f.aud}){/if}</span>
<span class="scope-description">{$_(`oauth.consent.setFailureReason.${f.reason}`)}</span>
{#if hasUnavailable}
<div class="scope-group unavailable">
<h3 class="category-title">{$_('oauth.consent.unavailablePermissions')}</h3>
{#if restrictedScopes.length || limitedBundles.length}
<p class="unavailable-subhead">{$_('oauth.consent.unavailableLimited')}</p>
{#each restrictedScopes as scope}
<div class="scope-item restricted">
<div class="scope-info">
<span class="scope-name">{getLocalizedScopeName(scope)}</span>
<span class="scope-description scope-raw">{scope.scope}</span>
</div>
</div>
</div>
{/each}
{/each}
{#each limitedBundles as set}
<div class="scope-item restricted">
<div class="scope-info">
<span class="scope-name">{set.title ?? set.nsid}</span>
<ul class="scope-set-list">
{#each withheldExpanded(set) as s}<li><span class="scope-raw">{scopeLabel(s.scope)}</span></li>{/each}
</ul>
</div>
</div>
{/each}
{/if}
{#if failedSets.length}
<p class="unavailable-subhead">{$_('oauth.consent.unavailableFailed')}</p>
{#each failedSets as f}
<div class="scope-item failed">
<div class="scope-info">
<span class="scope-name">{f.nsid}{#if f.aud} ({f.aud}){/if}</span>
<span class="scope-description">{$_(`oauth.consent.setFailureReason.${f.reason}`)}</span>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
+26 -4
View File
@@ -642,7 +642,7 @@ button.forget-btn:hover {
.method-divider::before,
.method-divider::after {
content: '';
content: "";
flex: 1;
height: 1px;
background: var(--border-color);
@@ -711,7 +711,7 @@ button.forget-btn:hover {
.sso-divider::before,
.sso-divider::after {
content: '';
content: "";
flex: 1;
height: 1px;
background: var(--border-color);
@@ -1054,8 +1054,9 @@ button.forget-btn:hover {
margin-bottom: 0.1rem;
}
.scope-group.failed-sets .scope-item.failed {
.scope-item.failed {
opacity: 0.7;
cursor: default;
}
.required-badge {
@@ -1070,6 +1071,27 @@ button.forget-btn:hover {
width: fit-content;
}
.scope-item.restricted {
opacity: 0.65;
cursor: default;
}
.unavailable-subhead {
margin: var(--space-3) 0 var(--space-2);
font-size: var(--text-xs);
font-weight: var(--font-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.restricted-note {
display: block;
font-size: 0.75em;
color: var(--text-muted);
margin-top: var(--space-1);
}
.remember-choice {
display: flex;
align-items: center;
@@ -1543,7 +1565,7 @@ button.forget-btn:hover {
.method-divider::before,
.method-divider::after {
content: '';
content: "";
width: 1px;
height: var(--space-6);
background: var(--border-color);