mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-01 15:06:05 +00:00
feat: display bundled permission-sets on consent screen
Signed-off-by: Trezy <tre@trezy.com>
This commit is contained in:
@@ -10,6 +10,28 @@ pub struct ScopeInfo {
|
||||
pub granted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PermissionSetInfo {
|
||||
pub nsid: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
pub include_scope: String,
|
||||
pub expanded: Vec<ScopeInfo>,
|
||||
pub granted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FailedSetInfo {
|
||||
pub nsid: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConsentResponse {
|
||||
pub request_uri: String,
|
||||
@@ -18,6 +40,8 @@ pub struct ConsentResponse {
|
||||
pub client_uri: Option<String>,
|
||||
pub logo_uri: Option<String>,
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub permission_sets: Vec<PermissionSetInfo>,
|
||||
pub failed_sets: Vec<FailedSetInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -126,19 +150,6 @@ pub async fn consent_get(
|
||||
authority,
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
let names: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| f.nsid.clone())
|
||||
.collect();
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!("Could not resolve permission set(s): {}", names.join(", ")),
|
||||
);
|
||||
}
|
||||
let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect();
|
||||
let preferences = state
|
||||
.repos
|
||||
@@ -150,30 +161,36 @@ pub async fn consent_get(
|
||||
.iter()
|
||||
.map(|p| (p.scope.as_str(), p.granted))
|
||||
.collect();
|
||||
let requested_scope_strings: Vec<String> =
|
||||
requested_scopes.iter().map(|s| s.to_string()).collect();
|
||||
let presented_item_strings: Vec<String> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(effective.outcome.sets.iter().map(|g| match &g.aud {
|
||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
||||
None => format!("include:{}", g.nsid),
|
||||
}))
|
||||
.collect();
|
||||
let show_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&request_data.parameters.client_id,
|
||||
&requested_scope_strings,
|
||||
&presented_item_strings,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let scopes: Vec<ScopeInfo> = requested_scopes
|
||||
.iter()
|
||||
.map(|scope| {
|
||||
let (category, required, description, display_name) = if let Some(def) =
|
||||
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
|
||||
{
|
||||
let desc = if *scope == "atproto" && has_granular_scopes {
|
||||
|
||||
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) {
|
||||
let desc = if scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol baseline scope (permissions determined by selected options below)"
|
||||
.to_string()
|
||||
} else {
|
||||
def.description.to_string()
|
||||
};
|
||||
let name = if *scope == "atproto" && has_granular_scopes {
|
||||
let name = if scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol Access".to_string()
|
||||
} else {
|
||||
def.display_name.to_string()
|
||||
@@ -199,18 +216,61 @@ pub async fn consent_get(
|
||||
scope.to_string(),
|
||||
)
|
||||
};
|
||||
let granted = pref_map.get(*scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
let granted = pref_map.get(scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
}
|
||||
};
|
||||
|
||||
let scopes: Vec<ScopeInfo> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.map(|s| make_scope_info(s))
|
||||
.collect();
|
||||
|
||||
let permission_sets: Vec<PermissionSetInfo> = effective
|
||||
.outcome
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let include_scope = match &g.aud {
|
||||
Some(a) => format!("include:{}?aud={}", g.nsid, a),
|
||||
None => format!("include:{}", g.nsid),
|
||||
};
|
||||
PermissionSetInfo {
|
||||
nsid: g.nsid.clone(),
|
||||
aud: g.aud.clone(),
|
||||
title: g.title.clone(),
|
||||
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(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let failed_sets: Vec<FailedSetInfo> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.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(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let account_handle = state
|
||||
.repos
|
||||
.user
|
||||
@@ -259,6 +319,8 @@ pub async fn consent_get(
|
||||
client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
|
||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||
scopes,
|
||||
permission_sets,
|
||||
failed_sets,
|
||||
show_consent,
|
||||
did: did.clone(),
|
||||
handle: account_handle,
|
||||
@@ -356,21 +418,43 @@ pub async fn consent_post(
|
||||
authority,
|
||||
)
|
||||
.await;
|
||||
if !effective.outcome.failures.is_empty() {
|
||||
let names: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.map(|f| f.nsid.clone())
|
||||
.collect();
|
||||
let include_token = |nsid: &str, aud: &Option<String>| -> String {
|
||||
match aud {
|
||||
Some(a) => format!("include:{}?aud={}", nsid, a),
|
||||
None => format!("include:{}", nsid),
|
||||
}
|
||||
};
|
||||
let approved_failed_sets: Vec<String> = effective
|
||||
.outcome
|
||||
.failures
|
||||
.iter()
|
||||
.filter(|f| form.approved_scopes.contains(&include_token(&f.nsid, &f.aud)))
|
||||
.map(|f| f.nsid.clone())
|
||||
.collect();
|
||||
if !approved_failed_sets.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!("Could not resolve permission set(s): {}", names.join(", ")),
|
||||
&format!(
|
||||
"Could not resolve approved permission set(s): {}",
|
||||
approved_failed_sets.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
let requested_scopes: Vec<&str> = effective.resolved.split_whitespace().collect();
|
||||
let atproto_was_requested = requested_scopes.contains(&"atproto");
|
||||
let presented_items: Vec<String> = effective
|
||||
.outcome
|
||||
.passthrough
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
effective
|
||||
.outcome
|
||||
.sets
|
||||
.iter()
|
||||
.map(|g| include_token(&g.nsid, &g.aud)),
|
||||
)
|
||||
.collect();
|
||||
let atproto_was_requested = presented_items.iter().any(|s| s == "atproto");
|
||||
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -378,7 +462,8 @@ pub async fn consent_post(
|
||||
"The atproto scope was requested and must be approved",
|
||||
);
|
||||
}
|
||||
let final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
let mut final_approved: Vec<String> = form.approved_scopes.clone();
|
||||
final_approved.retain(|s| presented_items.iter().any(|p| p == s) || s == "atproto");
|
||||
if final_approved.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -396,11 +481,11 @@ pub async fn consent_post(
|
||||
);
|
||||
}
|
||||
if form.remember {
|
||||
let preferences: Vec<ScopePreference> = requested_scopes
|
||||
let preferences: Vec<ScopePreference> = presented_items
|
||||
.iter()
|
||||
.map(|s| ScopePreference {
|
||||
scope: s.to_string(),
|
||||
granted: form.approved_scopes.contains(&s.to_string()),
|
||||
scope: s.clone(),
|
||||
granted: form.approved_scopes.contains(s),
|
||||
})
|
||||
.collect();
|
||||
let _ = state
|
||||
|
||||
@@ -30,6 +30,10 @@ pub fn init_rate_limit_override() {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rate_limiting_disabled(disabled: bool) {
|
||||
RATE_LIMITING_DISABLED.store(disabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub repos: Arc<PostgresRepositories>,
|
||||
|
||||
@@ -16,6 +16,11 @@ const PERMISSION_SET_NSID: &str = "io.atcr.authFullApp";
|
||||
const PERMISSION_SET_GRANULAR_SCOPE: &str =
|
||||
"repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest?aud=*";
|
||||
|
||||
fn disable_rate_limiting_once() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| tranquil_pds::state::set_rate_limiting_disabled(true));
|
||||
}
|
||||
|
||||
fn generate_pkce() -> (String, String) {
|
||||
let verifier_bytes: [u8; 32] = rand::random();
|
||||
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
||||
@@ -94,6 +99,7 @@ async fn create_delegated_session_with_scope(
|
||||
scope: &str,
|
||||
) -> (DelegatedSession, Value, MockServer) {
|
||||
let url = base_url().await;
|
||||
disable_rate_limiting_once();
|
||||
let http_client = client();
|
||||
|
||||
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
|
||||
@@ -251,28 +257,47 @@ async fn test_delegated_include_scope_shows_granular_on_consent() {
|
||||
)
|
||||
.await;
|
||||
|
||||
let scopes = consent_body["scopes"]
|
||||
let permission_sets = consent_body["permission_sets"]
|
||||
.as_array()
|
||||
.expect("consent response should have a scopes array");
|
||||
assert!(!scopes.is_empty(), "consent scopes should not be empty");
|
||||
.expect("consent response should have a permission_sets array");
|
||||
assert!(
|
||||
!permission_sets.is_empty(),
|
||||
"consent permission_sets should not be empty. Got: {:?}",
|
||||
consent_body
|
||||
);
|
||||
|
||||
let has_granular = scopes
|
||||
let set_entry = permission_sets
|
||||
.iter()
|
||||
.find(|s| s["nsid"].as_str() == Some(PERMISSION_SET_NSID))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"permission_sets should contain an entry for nsid '{}'. Got: {:?}",
|
||||
PERMISSION_SET_NSID, permission_sets
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
set_entry["include_scope"].as_str(),
|
||||
Some(format!("include:{}", PERMISSION_SET_NSID).as_str()),
|
||||
"permission_sets entry should carry the include: token the frontend submits"
|
||||
);
|
||||
|
||||
let expanded = set_entry["expanded"]
|
||||
.as_array()
|
||||
.expect("permission_sets entry should have an expanded array");
|
||||
let has_granular = expanded
|
||||
.iter()
|
||||
.any(|s| s["scope"].as_str() == Some("repo:io.atcr.manifest?action=create"));
|
||||
assert!(
|
||||
has_granular,
|
||||
"consent scopes[] should list the expanded granular scope \
|
||||
'repo:io.atcr.manifest?action=create', not just 'atproto'. Got: {:?}",
|
||||
scopes
|
||||
"permission_sets entry's expanded[] should list the granular scope \
|
||||
'repo:io.atcr.manifest?action=create'. Got: {:?}",
|
||||
expanded
|
||||
);
|
||||
|
||||
let only_atproto = scopes
|
||||
.iter()
|
||||
.all(|s| s["scope"].as_str() == Some("atproto"));
|
||||
assert!(
|
||||
!only_atproto,
|
||||
"consent scopes[] should not collapse to just 'atproto'"
|
||||
);
|
||||
let scopes = consent_body["scopes"]
|
||||
.as_array()
|
||||
.expect("consent response should have a scopes array");
|
||||
|
||||
let has_raw_include = scopes.iter().any(|s| {
|
||||
s["scope"]
|
||||
@@ -282,7 +307,7 @@ async fn test_delegated_include_scope_shows_granular_on_consent() {
|
||||
});
|
||||
assert!(
|
||||
!has_raw_include,
|
||||
"consent scopes[] should list the expanded set, not the raw include: token"
|
||||
"consent scopes[] should not carry the raw include: token"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,6 +447,7 @@ async fn test_consent_post_errors_when_set_unresolvable() {
|
||||
seed_permission_set(UNRESOLVABLE_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
|
||||
|
||||
let url = base_url().await;
|
||||
disable_rate_limiting_once();
|
||||
let http_client = client();
|
||||
|
||||
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
|
||||
@@ -540,9 +566,367 @@ async fn test_consent_post_errors_when_set_unresolvable() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_consent_post_succeeds_when_unapproved_set_fails() {
|
||||
const GOOD_SET_NSID: &str = "io.atcr.goodSet";
|
||||
const BAD_SET_NSID: &str = "io.atcr.badSet";
|
||||
seed_permission_set(GOOD_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
|
||||
|
||||
let url = base_url().await;
|
||||
disable_rate_limiting_once();
|
||||
let http_client = client();
|
||||
|
||||
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
|
||||
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4];
|
||||
let delegated_handle = format!("psg{}", suffix);
|
||||
let delegated_res = http_client
|
||||
.post(format!("{}/xrpc/_delegation.createDelegatedAccount", url))
|
||||
.bearer_auth(&controller_jwt)
|
||||
.json(&json!({
|
||||
"handle": delegated_handle,
|
||||
"controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createDelegatedAccount request failed");
|
||||
if delegated_res.status() != StatusCode::OK {
|
||||
let error_body = delegated_res.text().await.unwrap();
|
||||
panic!("Failed to create delegated account: {}", error_body);
|
||||
}
|
||||
let delegated_account: Value = delegated_res.json().await.unwrap();
|
||||
let delegated_did = delegated_account["did"].as_str().unwrap().to_string();
|
||||
|
||||
let redirect_uri = "https://example.com/permset-partial-fail-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (_code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
let scope = format!(
|
||||
"atproto include:{} include:{}",
|
||||
GOOD_SET_NSID, BAD_SET_NSID
|
||||
);
|
||||
let par_res = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", scope.as_str()),
|
||||
("login_hint", delegated_did.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("PAR failed");
|
||||
assert!(
|
||||
par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
|
||||
"PAR should succeed, got {}",
|
||||
par_res.status()
|
||||
);
|
||||
let par_body: Value = par_res.json().await.unwrap();
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap().to_string();
|
||||
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/delegation/auth", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"delegated_did": delegated_did,
|
||||
"controller_did": controller_did,
|
||||
"password": "Testpass123!",
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Delegation auth request failed");
|
||||
if auth_res.status() != StatusCode::OK {
|
||||
let error_body = auth_res.text().await.unwrap();
|
||||
panic!("Delegation auth failed: {}", error_body);
|
||||
}
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
assert!(
|
||||
auth_body["success"].as_bool().unwrap_or(false),
|
||||
"Delegation auth should succeed: {:?}",
|
||||
auth_body
|
||||
);
|
||||
|
||||
let consent_get_res = http_client
|
||||
.get(format!("{}/oauth/authorize/consent", url))
|
||||
.query(&[("request_uri", request_uri.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent GET failed");
|
||||
assert_eq!(
|
||||
consent_get_res.status(),
|
||||
StatusCode::OK,
|
||||
"Consent GET should succeed"
|
||||
);
|
||||
let consent_get_body: Value = consent_get_res.json().await.unwrap();
|
||||
|
||||
let permission_sets = consent_get_body["permission_sets"]
|
||||
.as_array()
|
||||
.expect("consent response should have a permission_sets array");
|
||||
assert!(
|
||||
permission_sets
|
||||
.iter()
|
||||
.any(|s| s["nsid"].as_str() == Some(GOOD_SET_NSID)),
|
||||
"the resolvable set should be presented as a permission set. Got: {:?}",
|
||||
consent_get_body
|
||||
);
|
||||
let failed_sets = consent_get_body["failed_sets"]
|
||||
.as_array()
|
||||
.expect("consent response should have a failed_sets array");
|
||||
assert!(
|
||||
failed_sets
|
||||
.iter()
|
||||
.any(|s| s["nsid"].as_str() == Some(BAD_SET_NSID)),
|
||||
"the unresolvable set should be presented as a failed set. Got: {:?}",
|
||||
consent_get_body
|
||||
);
|
||||
|
||||
let approved_scopes = vec!["atproto".to_string(), format!("include:{}", GOOD_SET_NSID)];
|
||||
let consent_post_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": approved_scopes,
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent POST failed");
|
||||
let status = consent_post_res.status();
|
||||
let consent_post_body: Value = consent_post_res.json().await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"Consent POST must succeed when the user approves only the resolvable set and \
|
||||
leaves the unresolvable set unapproved. Got: {:?}",
|
||||
consent_post_body
|
||||
);
|
||||
assert!(
|
||||
consent_post_body["redirect_uri"].as_str().is_some(),
|
||||
"Consent POST should return a redirect_uri. Got: {:?}",
|
||||
consent_post_body
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_consent_remember_persists_set_preference() {
|
||||
const REMEMBER_SET_NSID: &str = "io.atcr.rememberSet";
|
||||
seed_permission_set(REMEMBER_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
|
||||
|
||||
let url = base_url().await;
|
||||
disable_rate_limiting_once();
|
||||
let http_client = client();
|
||||
|
||||
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
|
||||
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4];
|
||||
let delegated_handle = format!("psr{}", suffix);
|
||||
let delegated_res = http_client
|
||||
.post(format!("{}/xrpc/_delegation.createDelegatedAccount", url))
|
||||
.bearer_auth(&controller_jwt)
|
||||
.json(&json!({
|
||||
"handle": delegated_handle,
|
||||
"controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createDelegatedAccount request failed");
|
||||
if delegated_res.status() != StatusCode::OK {
|
||||
let error_body = delegated_res.text().await.unwrap();
|
||||
panic!("Failed to create delegated account: {}", error_body);
|
||||
}
|
||||
let delegated_account: Value = delegated_res.json().await.unwrap();
|
||||
let delegated_did = delegated_account["did"].as_str().unwrap().to_string();
|
||||
|
||||
let redirect_uri = "https://example.com/permset-remember-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (_code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
let scope = format!("atproto include:{}", REMEMBER_SET_NSID);
|
||||
let par_res = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", scope.as_str()),
|
||||
("login_hint", delegated_did.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("PAR failed");
|
||||
assert!(
|
||||
par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
|
||||
"PAR should succeed, got {}",
|
||||
par_res.status()
|
||||
);
|
||||
let par_body: Value = par_res.json().await.unwrap();
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap().to_string();
|
||||
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/delegation/auth", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"delegated_did": delegated_did,
|
||||
"controller_did": controller_did,
|
||||
"password": "Testpass123!",
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Delegation auth request failed");
|
||||
if auth_res.status() != StatusCode::OK {
|
||||
let error_body = auth_res.text().await.unwrap();
|
||||
panic!("Delegation auth failed: {}", error_body);
|
||||
}
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
assert!(
|
||||
auth_body["success"].as_bool().unwrap_or(false),
|
||||
"Delegation auth should succeed: {:?}",
|
||||
auth_body
|
||||
);
|
||||
|
||||
let consent_get_res = http_client
|
||||
.get(format!("{}/oauth/authorize/consent", url))
|
||||
.query(&[("request_uri", request_uri.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent GET failed");
|
||||
assert_eq!(
|
||||
consent_get_res.status(),
|
||||
StatusCode::OK,
|
||||
"Consent GET should succeed"
|
||||
);
|
||||
|
||||
let approved_scopes = vec![
|
||||
"atproto".to_string(),
|
||||
format!("include:{}", REMEMBER_SET_NSID),
|
||||
];
|
||||
let consent_post_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": approved_scopes,
|
||||
"remember": true
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent POST failed");
|
||||
if consent_post_res.status() != StatusCode::OK {
|
||||
let error_body = consent_post_res.text().await.unwrap();
|
||||
panic!("Consent POST with remember:true failed: {}", error_body);
|
||||
}
|
||||
|
||||
let did: tranquil_types::Did = delegated_did.parse().expect("valid did");
|
||||
let client_id_typed = tranquil_types::ClientId::new(client_id.clone());
|
||||
let stored_prefs = common::get_test_repos()
|
||||
.await
|
||||
.oauth
|
||||
.get_scope_preferences(&did, &client_id_typed)
|
||||
.await
|
||||
.expect("get_scope_preferences query failed");
|
||||
let include_token = format!("include:{}", REMEMBER_SET_NSID);
|
||||
let set_pref = stored_prefs
|
||||
.iter()
|
||||
.find(|p| p.scope == include_token)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected a stored scope preference for '{}', got: {:?}",
|
||||
include_token, stored_prefs
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
set_pref.granted,
|
||||
"the remembered set preference should be granted: true, got: {:?}",
|
||||
set_pref
|
||||
);
|
||||
assert!(
|
||||
!stored_prefs
|
||||
.iter()
|
||||
.any(|p| p.scope.starts_with("repo:") || p.scope.starts_with("rpc:")),
|
||||
"remember must not store the expanded granular scopes as preferences, got: {:?}",
|
||||
stored_prefs
|
||||
);
|
||||
|
||||
let (code_verifier2, code_challenge2) = generate_pkce();
|
||||
let par_res2 = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge2),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", scope.as_str()),
|
||||
("login_hint", delegated_did.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("second PAR failed");
|
||||
let _ = code_verifier2;
|
||||
assert!(par_res2.status() == StatusCode::OK || par_res2.status() == StatusCode::CREATED);
|
||||
let par_body2: Value = par_res2.json().await.unwrap();
|
||||
let request_uri2 = par_body2["request_uri"].as_str().unwrap().to_string();
|
||||
|
||||
let auth_res2 = http_client
|
||||
.post(format!("{}/oauth/delegation/auth", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri2,
|
||||
"delegated_did": delegated_did,
|
||||
"controller_did": controller_did,
|
||||
"password": "Testpass123!",
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("second delegation auth failed");
|
||||
assert_eq!(auth_res2.status(), StatusCode::OK);
|
||||
|
||||
let consent_get_res2 = http_client
|
||||
.get(format!("{}/oauth/authorize/consent", url))
|
||||
.query(&[("request_uri", request_uri2.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.expect("second consent GET failed");
|
||||
assert_eq!(consent_get_res2.status(), StatusCode::OK);
|
||||
let consent_get_body2: Value = consent_get_res2.json().await.unwrap();
|
||||
let permission_sets2 = consent_get_body2["permission_sets"]
|
||||
.as_array()
|
||||
.expect("consent response should have a permission_sets array");
|
||||
let set_entry2 = permission_sets2
|
||||
.iter()
|
||||
.find(|s| s["nsid"].as_str() == Some(REMEMBER_SET_NSID))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expected permission_sets to contain '{}', got: {:?}",
|
||||
REMEMBER_SET_NSID, consent_get_body2
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
set_entry2["granted"].as_bool(),
|
||||
Some(true),
|
||||
"the remembered set's include: token preference should round-trip as granted: true \
|
||||
on a subsequent consent_get. Got: {:?}",
|
||||
set_entry2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_legacy_granular_token_survives_refresh() {
|
||||
let url = base_url().await;
|
||||
disable_rate_limiting_once();
|
||||
let http_client = client();
|
||||
let redirect_uri = "https://example.com/permset-legacy-callback";
|
||||
let scope = "atproto repo:*?action=create";
|
||||
@@ -693,3 +1077,163 @@ async fn test_legacy_granular_token_survives_refresh() {
|
||||
new_jwt_scope
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_consent_post_drops_unpresented_scope() {
|
||||
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
|
||||
|
||||
let url = base_url().await;
|
||||
disable_rate_limiting_once();
|
||||
let http_client = client();
|
||||
|
||||
let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
|
||||
|
||||
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4];
|
||||
let delegated_handle = format!("psd{}", suffix);
|
||||
let delegated_res = http_client
|
||||
.post(format!("{}/xrpc/_delegation.createDelegatedAccount", url))
|
||||
.bearer_auth(&controller_jwt)
|
||||
.json(&json!({
|
||||
"handle": delegated_handle,
|
||||
"controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("createDelegatedAccount request failed");
|
||||
if delegated_res.status() != StatusCode::OK {
|
||||
let error_body = delegated_res.text().await.unwrap();
|
||||
panic!("Failed to create delegated account: {}", error_body);
|
||||
}
|
||||
let delegated_account: Value = delegated_res.json().await.unwrap();
|
||||
let delegated_did = delegated_account["did"].as_str().unwrap().to_string();
|
||||
|
||||
let redirect_uri = "https://example.com/permset-unpresented-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
let scope = format!("atproto include:{}", PERMISSION_SET_NSID);
|
||||
let par_res = http_client
|
||||
.post(format!("{}/oauth/par", url))
|
||||
.form(&[
|
||||
("response_type", "code"),
|
||||
("client_id", &client_id),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_challenge", &code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("scope", scope.as_str()),
|
||||
("login_hint", delegated_did.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("PAR failed");
|
||||
assert!(
|
||||
par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
|
||||
"PAR should succeed, got {}",
|
||||
par_res.status()
|
||||
);
|
||||
let par_body: Value = par_res.json().await.unwrap();
|
||||
let request_uri = par_body["request_uri"].as_str().unwrap().to_string();
|
||||
|
||||
let auth_res = http_client
|
||||
.post(format!("{}/oauth/delegation/auth", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"delegated_did": delegated_did,
|
||||
"controller_did": controller_did,
|
||||
"password": "Testpass123!",
|
||||
"remember_device": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Delegation auth request failed");
|
||||
if auth_res.status() != StatusCode::OK {
|
||||
let error_body = auth_res.text().await.unwrap();
|
||||
panic!("Delegation auth failed: {}", error_body);
|
||||
}
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
assert!(
|
||||
auth_body["success"].as_bool().unwrap_or(false),
|
||||
"Delegation auth should succeed: {:?}",
|
||||
auth_body
|
||||
);
|
||||
|
||||
let approved_scopes = vec![
|
||||
"atproto".to_string(),
|
||||
format!("include:{}", PERMISSION_SET_NSID),
|
||||
"repo:com.evil.collection?action=create".to_string(),
|
||||
];
|
||||
let consent_post_res = http_client
|
||||
.post(format!("{}/oauth/authorize/consent", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"request_uri": request_uri,
|
||||
"approved_scopes": approved_scopes,
|
||||
"remember": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Consent POST failed");
|
||||
if consent_post_res.status() != StatusCode::OK {
|
||||
let error_body = consent_post_res.text().await.unwrap();
|
||||
panic!("Consent POST failed: {}", error_body);
|
||||
}
|
||||
let consent_post_body: Value = consent_post_res.json().await.unwrap();
|
||||
let location = consent_post_body["redirect_uri"]
|
||||
.as_str()
|
||||
.expect("Expected redirect_uri from consent")
|
||||
.to_string();
|
||||
|
||||
let code = location
|
||||
.split("code=")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('&')
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let token_res = http_client
|
||||
.post(format!("{}/oauth/token", url))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", &code_verifier),
|
||||
("client_id", &client_id),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Token request failed");
|
||||
assert_eq!(
|
||||
token_res.status(),
|
||||
StatusCode::OK,
|
||||
"Token exchange should succeed"
|
||||
);
|
||||
let token_body: Value = token_res.json().await.unwrap();
|
||||
let access_token = token_body["access_token"].as_str().unwrap().to_string();
|
||||
|
||||
let token_id = token_id_from_jwt(&access_token);
|
||||
let token_data = common::get_test_repos()
|
||||
.await
|
||||
.oauth
|
||||
.get_token_by_id(&token_id)
|
||||
.await
|
||||
.expect("get_token_by_id query failed")
|
||||
.expect("token row should exist");
|
||||
let row_scope = token_data
|
||||
.scope
|
||||
.expect("stored token row should have a scope");
|
||||
assert!(
|
||||
!row_scope.contains("com.evil.collection"),
|
||||
"Stored oauth_token.scope row must not contain a scope that was never presented \
|
||||
to the resource owner, got: {}",
|
||||
row_scope
|
||||
);
|
||||
assert!(
|
||||
row_scope.contains(&format!("include:{}", PERMISSION_SET_NSID)),
|
||||
"Stored oauth_token.scope row should still preserve the legitimately approved \
|
||||
include: token, got: {}",
|
||||
row_scope
|
||||
);
|
||||
}
|
||||
|
||||
@@ -597,6 +597,22 @@
|
||||
"permissionsRequested": "Permissions Requested",
|
||||
"required": "Required",
|
||||
"rememberChoiceLabel": "Remember my choice for this application",
|
||||
"permissionSets": "Permission bundles",
|
||||
"unavailableSets": "Unavailable permission bundles",
|
||||
"showIncludedScopes": "Show included permissions ({count})",
|
||||
"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."
|
||||
},
|
||||
"permTable": {
|
||||
"data": "Data",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"allData": "All collections",
|
||||
"apiAccess": "API access"
|
||||
},
|
||||
"scopes": {
|
||||
"atproto": {
|
||||
"name": "AT Protocol Access",
|
||||
|
||||
@@ -32,6 +32,22 @@
|
||||
scope.startsWith('identity:')
|
||||
}
|
||||
|
||||
interface PermissionSetInfo {
|
||||
nsid: string
|
||||
aud?: string
|
||||
title?: string
|
||||
detail?: string
|
||||
include_scope: string
|
||||
expanded: ScopeInfo[]
|
||||
granted: boolean | null
|
||||
}
|
||||
|
||||
interface FailedSetInfo {
|
||||
nsid: string
|
||||
aud?: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface ConsentData {
|
||||
request_uri: string
|
||||
client_id: string
|
||||
@@ -39,6 +55,8 @@
|
||||
client_uri: string | null
|
||||
logo_uri: string | null
|
||||
scopes: ScopeInfo[]
|
||||
permission_sets: PermissionSetInfo[]
|
||||
failed_sets: FailedSetInfo[]
|
||||
show_consent: boolean
|
||||
did: string
|
||||
handle?: string
|
||||
@@ -130,6 +148,10 @@
|
||||
])
|
||||
)
|
||||
|
||||
for (const set of data.permission_sets ?? []) {
|
||||
scopeSelections[set.include_scope] = set.granted ?? true
|
||||
}
|
||||
|
||||
if (!data.show_consent) {
|
||||
await submitConsent()
|
||||
}
|
||||
@@ -152,7 +174,11 @@
|
||||
.filter(([_, approved]) => approved)
|
||||
.map(([scope]) => scope)
|
||||
|
||||
if (approvedScopes.length === 0 && consentData.scopes.length === 0) {
|
||||
if (
|
||||
approvedScopes.length === 0 &&
|
||||
consentData.scopes.length === 0 &&
|
||||
(consentData.permission_sets?.length ?? 0) === 0
|
||||
) {
|
||||
approvedScopes = ['atproto']
|
||||
}
|
||||
|
||||
@@ -249,7 +275,10 @@
|
||||
})
|
||||
|
||||
let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : [])
|
||||
let hasGranularScopes = $derived(consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false)
|
||||
let hasGranularScopes = $derived(
|
||||
(consentData?.scopes.some(s => isGranularScope(s.scope)) ?? false) ||
|
||||
((consentData?.permission_sets?.length ?? 0) > 0)
|
||||
)
|
||||
|
||||
function getLocalizedScopeName(scope: ScopeInfo): string {
|
||||
const localeKey = SCOPE_LOCALE_MAP[scope.scope]
|
||||
@@ -276,6 +305,36 @@
|
||||
const localized = $_(`oauth.consent.scopes.${localeKey}.description`)
|
||||
return localized !== `oauth.consent.scopes.${localeKey}.description` ? localized : scope.description
|
||||
}
|
||||
|
||||
type RepoRow = { collection: string; create: boolean; update: boolean; delete: boolean }
|
||||
function describeExpanded(expanded: ScopeInfo[]): {
|
||||
repo: RepoRow[]
|
||||
rpc: string[]
|
||||
other: ScopeInfo[]
|
||||
} {
|
||||
const repo = new Map<string, RepoRow>()
|
||||
const rpc: string[] = []
|
||||
const other: ScopeInfo[] = []
|
||||
for (const s of expanded) {
|
||||
const [base, query = ''] = s.scope.split('?')
|
||||
const params = new URLSearchParams(query)
|
||||
if (base.startsWith('repo:')) {
|
||||
const collection = base.slice('repo:'.length) || '*'
|
||||
const requested = params.getAll('action')
|
||||
const actions = requested.length > 0 ? requested : ['create', 'update', 'delete']
|
||||
const row = repo.get(collection) ?? { collection, create: false, update: false, delete: false }
|
||||
for (const a of actions) {
|
||||
if (a === 'create' || a === 'update' || a === 'delete') row[a] = true
|
||||
}
|
||||
repo.set(collection, row)
|
||||
} else if (base.startsWith('rpc:')) {
|
||||
rpc.push(base.slice('rpc:'.length))
|
||||
} else {
|
||||
other.push(s)
|
||||
}
|
||||
}
|
||||
return { repo: [...repo.values()], rpc, other }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="consent-container">
|
||||
@@ -385,6 +444,79 @@
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if consentData.permission_sets?.length}
|
||||
<div class="scope-group">
|
||||
<h3 class="category-title">{$_('oauth.consent.permissionSets')}</h3>
|
||||
{#each consentData.permission_sets as set}
|
||||
{@const desc = describeExpanded(set.expanded)}
|
||||
<label class="scope-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[set.include_scope]}
|
||||
disabled={submitting}
|
||||
onchange={() => handleScopeToggle(set.include_scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
<span class="scope-name">{set.title ?? set.nsid}</span>
|
||||
{#if set.detail}<span class="scope-description">{set.detail}</span>{/if}
|
||||
<details class="scope-set-details">
|
||||
<summary>{$_('oauth.consent.showIncludedScopes', { values: { count: set.expanded.length } })}</summary>
|
||||
{#if desc.repo.length}
|
||||
<table class="perm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{$_('oauth.consent.permTable.data')}</th>
|
||||
<th scope="col" class="perm-col">{$_('oauth.consent.permTable.create')}</th>
|
||||
<th scope="col" class="perm-col">{$_('oauth.consent.permTable.update')}</th>
|
||||
<th scope="col" class="perm-col">{$_('oauth.consent.permTable.delete')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each desc.repo as r}
|
||||
<tr>
|
||||
<td class="perm-nsid">{r.collection === '*' ? $_('oauth.consent.permTable.allData') : r.collection}</td>
|
||||
<td class="perm-col" class:on={r.create} aria-label={r.create ? $_('oauth.consent.permTable.create') : undefined}>{r.create ? '✓' : ''}</td>
|
||||
<td class="perm-col" class:on={r.update} aria-label={r.update ? $_('oauth.consent.permTable.update') : undefined}>{r.update ? '✓' : ''}</td>
|
||||
<td class="perm-col" class:on={r.delete} aria-label={r.delete ? $_('oauth.consent.permTable.delete') : undefined}>{r.delete ? '✓' : ''}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{#if desc.rpc.length}
|
||||
<div class="perm-rpc">
|
||||
<span class="perm-subhead">{$_('oauth.consent.permTable.apiAccess')}</span>
|
||||
<ul class="scope-set-list">
|
||||
{#each desc.rpc as lxm}<li><span class="scope-raw">{lxm}</span></li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if desc.other.length}
|
||||
<ul class="scope-set-list">
|
||||
{#each desc.other as s}<li>{getLocalizedScopeName(s)} <span class="scope-raw">{s.scope}</span></li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</details>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="remember-choice">
|
||||
|
||||
@@ -984,6 +984,80 @@ button.forget-btn:hover {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.scope-set-details {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.scope-set-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.scope-set-list {
|
||||
margin: 0.375rem 0 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.scope-set-list .scope-raw {
|
||||
opacity: 0.6;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.perm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.perm-table th,
|
||||
.perm-table td {
|
||||
padding: 0.28rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.perm-table thead th {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.perm-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.perm-table .perm-col {
|
||||
width: 4.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.perm-table .perm-nsid {
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.perm-table td.perm-col {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.perm-table td.perm-col.on {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.perm-rpc {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.perm-subhead {
|
||||
display: block;
|
||||
font-size: 0.72em;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.scope-group.failed-sets .scope-item.failed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.625rem;
|
||||
|
||||
Reference in New Issue
Block a user