Improve auth explain diagnostics

This commit is contained in:
Eric Wendland 2026-05-22 14:28:44 +02:00
commit ee80ff780c
7 changed files with 516 additions and 46 deletions

View file

@ -234,7 +234,7 @@ pub fn explain_auth_ops(
capability: Capability,
) -> AuthExplanation {
let view = reduce_auth_ops(ops);
explain_from_view(&view, ops.len(), subject, resource, capability)
explain_from_ops_and_view(ops, &view, subject, resource, capability)
}
pub fn explain_from_view(
@ -244,6 +244,99 @@ pub fn explain_from_view(
resource: ResourceId,
capability: Capability,
) -> AuthExplanation {
explain_from_ops_and_view(&[], view, subject, resource, capability)
.with_evaluated_ops(evaluated_ops)
}
fn explain_from_ops_and_view(
ops: &[AuthOp],
view: &AuthView,
subject: PrincipalId,
resource: ResourceId,
capability: Capability,
) -> AuthExplanation {
let evaluated_ops = ops.len();
if let Some(secret) = subject.as_str().strip_prefix("bearer:") {
let secret = SecretId::new(secret.to_owned());
if let Some(access) = view.bearer_access.get(&secret) {
if access.resource == resource {
if let Some(granted_capability) = access
.capabilities
.iter()
.find(|granted| capability_allows(granted, &capability))
{
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: true,
reason: format!(
"active bearer access {} allows resource-scoped capability via {} without granting node identity",
access.secret, granted_capability
),
evaluated_ops,
diagnostics: vec![
"subject:bearer-secret".to_owned(),
format!("bearer:active:{}", access.secret),
format!("capability:matched:{granted_capability}"),
],
};
}
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: false,
reason:
"active bearer access exists for this resource but lacks the requested capability"
.to_owned(),
evaluated_ops,
diagnostics: vec![
"subject:bearer-secret".to_owned(),
format!("bearer:active:{secret}"),
"capability:missing".to_owned(),
],
};
}
}
if ops.iter().any(|op| {
op.resource == resource
&& matches!(
&op.kind,
AuthOpKind::BearerAccessRevoke { secret: revoked } if revoked == &secret
)
}) {
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: false,
reason:
"bearer access for this resource was revoked; bearer secrets do not grant node identity"
.to_owned(),
evaluated_ops,
diagnostics: vec![
"subject:bearer-secret".to_owned(),
format!("bearer:revoked:{secret}"),
],
};
}
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: false,
reason: "no active bearer access exists for this resource and capability".to_owned(),
evaluated_ops,
diagnostics: vec![
"subject:bearer-secret".to_owned(),
"bearer:missing".to_owned(),
],
};
}
let group_principals = groups_for_subject(view, &resource, &subject);
for grant in view.grants.values() {
let Some(granted_capability) = grant
@ -267,6 +360,11 @@ pub fn explain_from_view(
grant.id, granted_capability
),
evaluated_ops,
diagnostics: vec![
"subject:direct-principal".to_owned(),
format!("grant:matched:{}", grant.id),
format!("capability:matched:{granted_capability}"),
],
};
}
if group_principals.contains(&grant.principal) {
@ -280,10 +378,29 @@ pub fn explain_from_view(
grant.id, granted_capability
),
evaluated_ops,
diagnostics: vec![
"subject:group-member".to_owned(),
format!("grant:matched:{}", grant.id),
format!("capability:matched:{granted_capability}"),
],
};
}
}
if let Some(grant_id) = revoked_matching_grant_id(ops, &subject, &resource, &capability) {
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: false,
reason: format!(
"matching grant {grant_id} was revoked; no active direct or group grant currently allows the requested capability"
),
evaluated_ops,
diagnostics: vec![format!("grant:revoked:{grant_id}")],
};
}
AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
@ -291,9 +408,65 @@ pub fn explain_from_view(
allowed: false,
reason: "no active direct or group grant contains the requested capability".to_owned(),
evaluated_ops,
diagnostics: vec!["grant:missing".to_owned()],
}
}
fn revoked_matching_grant_id(
ops: &[AuthOp],
subject: &PrincipalId,
resource: &ResourceId,
capability: &Capability,
) -> Option<String> {
let mut matching_grants = BTreeSet::new();
let mut group_members = BTreeMap::<GroupId, BTreeSet<PrincipalId>>::new();
for op in ops {
if &op.resource != resource {
continue;
}
match &op.kind {
AuthOpKind::GrantCreate {
grant_id,
principal,
capabilities,
} => {
let direct = principal == subject;
let group = group_members
.get(&GroupId::new(principal.as_str()))
.is_some_and(|members| members.contains(subject));
let capability_matches = capabilities
.iter()
.any(|granted| capability_allows(granted, capability));
if (direct || group) && capability_matches {
matching_grants.insert(grant_id.clone());
}
}
AuthOpKind::GrantRevoke { grant_id } => {
if matching_grants.remove(grant_id) {
return Some(grant_id.clone());
}
}
AuthOpKind::GroupAddMember { group, principal } => {
group_members
.entry(group.clone())
.or_default()
.insert(principal.clone());
}
AuthOpKind::GroupRemoveMember { group, principal } => {
if let Some(members) = group_members.get_mut(group) {
members.remove(principal);
}
}
AuthOpKind::ResourceCreate
| AuthOpKind::ResourceAuthoritySet { .. }
| AuthOpKind::BearerAccessCreate { .. }
| AuthOpKind::BearerAccessRevoke { .. }
| AuthOpKind::GroupCreate { .. } => {}
}
}
None
}
pub fn capability_allows(granted: &Capability, requested: &Capability) -> bool {
if granted == requested {
return true;
@ -345,9 +518,24 @@ pub struct AuthExplanation {
pub allowed: bool,
pub reason: String,
pub evaluated_ops: usize,
#[serde(default)]
pub diagnostics: Vec<String>,
}
impl AuthExplanation {
#[must_use]
pub fn with_evaluated_ops(mut self, evaluated_ops: usize) -> Self {
self.evaluated_ops = evaluated_ops;
self
}
pub fn add_diagnostic(&mut self, diagnostic: impl Into<String>) {
let diagnostic = diagnostic.into();
if !self.diagnostics.contains(&diagnostic) {
self.diagnostics.push(diagnostic);
}
}
#[must_use]
pub fn stub(subject: String, resource: String, capability: String) -> Self {
Self {
@ -357,6 +545,7 @@ impl AuthExplanation {
allowed: false,
reason: "authorization logs are scaffolded; no grant reducer is active yet".to_owned(),
evaluated_ops: 0,
diagnostics: vec!["auth-log:empty".to_owned()],
}
}
@ -369,6 +558,10 @@ impl AuthExplanation {
allowed: false,
reason: "subject is a discovered peer candidate only; discovery does not grant trust or authorization".to_owned(),
evaluated_ops: 0,
diagnostics: vec![
"subject:discovered-only".to_owned(),
"trust:missing".to_owned(),
],
}
}
}
@ -527,6 +720,12 @@ mod tests {
"kv.admin".into(),
);
assert!(!revoked.allowed);
assert!(revoked.reason.contains("grant:revoked"));
assert!(
revoked
.diagnostics
.contains(&"grant:revoked:grant:revoked".to_owned())
);
}
#[test]
@ -578,6 +777,47 @@ mod tests {
Some(Vec::new())
);
assert!(view.bearer_access.is_empty());
let revoked = explain_auth_ops(
&ops,
"bearer:secret:invite".into(),
"resource:notes".into(),
"pipe.connect".into(),
);
assert!(!revoked.allowed);
assert!(revoked.reason.contains("revoked"));
assert!(
revoked
.diagnostics
.contains(&"bearer:revoked:secret:invite".to_owned())
);
}
#[test]
fn auth_explain_reports_active_bearer_access() {
let ops = vec![op(
1,
AuthOpKind::BearerAccessCreate {
secret: "secret:invite".into(),
token_hash: Some("token-hash".to_owned()),
capabilities: vec!["pipe.connect".into()],
expires_at: None,
},
)];
let allowed = explain_auth_ops(
&ops,
"bearer:secret:invite".into(),
"resource:notes".into(),
"pipe.connect".into(),
);
assert!(allowed.allowed);
assert!(allowed.reason.contains("bearer access"));
assert!(
allowed
.diagnostics
.contains(&"bearer:active:secret:invite".to_owned())
);
}
#[test]

View file

@ -2065,6 +2065,9 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("capability: {}", explain.capability);
println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops);
if !explain.diagnostics.is_empty() {
println!("diagnostics: {}", explain.diagnostics.join(","));
}
}
ControlResponse::AuthOpRecorded { op, signatures } => {
println!("recorded auth op: {}", op.id);

View file

@ -3306,6 +3306,103 @@ fn can_sync_resource(
.allowed)
}
fn explain_auth_for_operator(
store: &Store,
subject: &str,
resource: &str,
capability: &str,
) -> Result<AuthExplanation, NodeError> {
let ops = load_auth_ops_for_resource(store, resource)?;
let mut explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(subject.to_owned()),
ResourceId::new(resource.to_owned()),
Capability::new(capability.to_owned()),
);
if subject.starts_with("bearer:") {
return Ok(explanation);
}
let peer_card = store.get_peer_card(subject)?;
let keychain_view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?);
let subject_node = NodeId::new(subject.to_owned());
let trusted_node = keychain_view.nodes.get(&subject_node);
match (trusted_node, peer_card) {
(None, Some(_)) => {
explanation.add_diagnostic("subject:discovered-only");
explanation.add_diagnostic("trust:missing");
if !explanation
.reason
.contains("discovery does not grant trust or authorization")
{
explanation.reason = format!(
"subject is a discovered peer candidate only; discovery does not grant trust or authorization; {}",
explanation.reason
);
}
}
(None, None) => {
explanation.add_diagnostic("subject:unknown");
explanation.add_diagnostic("trust:missing");
if !explanation.allowed {
explanation.reason = format!(
"subject is not present in the keychain and has no discovered peer card; {}",
explanation.reason
);
}
}
(Some(node), None) => {
explanation.add_diagnostic("subject:trusted-node");
if node.endpoints.is_empty() {
explanation.add_diagnostic("endpoint-binding:missing");
} else {
explanation.add_diagnostic("endpoint-binding:present");
explanation.add_diagnostic("discovery:missing-peer-card");
}
}
(Some(node), Some(stored_card)) => {
explanation.add_diagnostic("subject:trusted-node");
let card = serde_json::from_str::<PeerCard>(&stored_card.card_json).ok();
let matching_endpoint = card.as_ref().and_then(|card| {
card.endpoints
.iter()
.map(|endpoint| endpoint.endpoint_id.as_str())
.find(|endpoint| {
keychain_view
.endpoints
.get(*endpoint)
.is_some_and(|bound_node| bound_node == &node.id)
})
.map(ToOwned::to_owned)
});
if let Some(endpoint) = matching_endpoint {
explanation.add_diagnostic(format!("endpoint-binding:matched:{endpoint}"));
} else if node.endpoints.is_empty() {
explanation.add_diagnostic("endpoint-binding:missing");
if !explanation.allowed {
explanation.reason = format!(
"trusted node has no active endpoint binding; {}",
explanation.reason
);
}
} else {
explanation.add_diagnostic("endpoint-binding:missing-for-peer-card");
if !explanation.allowed {
explanation.reason = format!(
"discovered peer card has no endpoint candidate bound to the trusted node; {}",
explanation.reason
);
}
}
}
}
Ok(explanation)
}
fn bearer_proof(
bearer_secret: Option<String>,
resource: &str,
@ -3360,6 +3457,7 @@ fn explain_peer_or_bearer(
allowed: false,
reason,
evaluated_ops: peer_explanation.evaluated_ops,
diagnostics: vec!["subject:bearer-secret".to_owned()],
};
if proof.resource != resource_id {
@ -3425,6 +3523,11 @@ fn explain_peer_or_bearer(
"bearer proof allows this resource-scoped capability without granting node identity"
.to_owned(),
evaluated_ops: peer_explanation.evaluated_ops,
diagnostics: vec![
"subject:bearer-secret".to_owned(),
format!("bearer:active:{}", access.secret),
"capability:matched".to_owned(),
],
})
}
@ -6756,35 +6859,12 @@ pub fn handle_request(
subject,
resource,
capability,
} => {
let ops = load_auth_ops_for_resource(&store, &resource)?;
let discovered = store.get_peer_card(&subject)?.is_some();
if ops.is_empty() {
if discovered {
Ok(ControlResponse::AuthExplain(
AuthExplanation::discovered_candidate(subject, resource, capability),
))
} else {
Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
subject, resource, capability,
)))
}
} else {
let mut explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(subject.clone()),
ResourceId::new(resource.clone()),
Capability::new(capability.clone()),
);
if discovered && !explanation.allowed {
explanation.reason = format!(
"subject is a discovered peer candidate only; discovery does not grant trust or authorization; {}",
explanation.reason
);
}
Ok(ControlResponse::AuthExplain(explanation))
}
}
} => Ok(ControlResponse::AuthExplain(explain_auth_for_operator(
&store,
&subject,
&resource,
&capability,
)?)),
ControlRequest::AuthGrant {
subject,
resource,

View file

@ -1853,6 +1853,133 @@ fn auth_explain_distinguishes_discovered_peer_candidates() {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed);
assert!(explanation.reason.contains("discovered peer candidate"));
assert!(
explanation
.diagnostics
.contains(&"subject:discovered-only".to_owned())
);
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn auth_explain_distinguishes_endpoint_binding_state() {
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let store = geth_store::Store::open(&paths.metadata_db()).expect("open store");
let created_at = geth_store::now_ms();
for op in [
geth_keychain::KeychainOp {
id: "op:keychain:init".into(),
created_at: geth_types::UnixMillis(created_at),
kind: geth_keychain::KeychainOpKind::KeychainInit,
},
geth_keychain::KeychainOp {
id: "op:user:add".into(),
created_at: geth_types::UnixMillis(created_at + 1),
kind: geth_keychain::KeychainOpKind::UserAdd {
user: "user:owner".into(),
name: "Owner".to_owned(),
},
},
geth_keychain::KeychainOp {
id: "op:device:add".into(),
created_at: geth_types::UnixMillis(created_at + 2),
kind: geth_keychain::KeychainOpKind::DeviceAdd {
device: "device:laptop".into(),
user: "user:owner".into(),
},
},
geth_keychain::KeychainOp {
id: "op:node:add".into(),
created_at: geth_types::UnixMillis(created_at + 3),
kind: geth_keychain::KeychainOpKind::NodeAdd {
node: "node:trusted".into(),
device: "device:laptop".into(),
name: "trusted".to_owned(),
},
},
] {
store
.insert_keychain_op(&geth_store::StoredKeychainOp {
op_id: op.id.to_string(),
op_json: serde_json::to_string(&op).expect("keychain op json"),
created_at_ms: op.created_at.0,
})
.expect("insert keychain op");
}
store
.upsert_peer_card(&geth_store::StoredPeerCard {
peer_id: "node:trusted".to_owned(),
card_json: r#"{
"node_id":"node:trusted",
"agent_id":"agent:test",
"endpoints":[{"endpoint_id":"endpoint:card-only","relay_url":null,"direct_addresses":[],"source":"manual"}],
"issued_at":1,
"signature":{"namespace":"","signer":"","public_key":"","signature":""}
}"#
.to_owned(),
updated_at_ms: created_at + 4,
})
.expect("insert peer card");
let missing = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:trusted".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("explain missing endpoint binding");
match missing {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed);
assert!(
explanation
.diagnostics
.contains(&"endpoint-binding:missing".to_owned())
);
}
other => panic!("unexpected response: {other:?}"),
}
let endpoint_op = geth_keychain::KeychainOp {
id: "op:node:endpoint:add".into(),
created_at: geth_types::UnixMillis(created_at + 5),
kind: geth_keychain::KeychainOpKind::NodeEndpointAdd {
node: "node:trusted".into(),
endpoint: "endpoint:card-only".to_owned(),
},
};
store
.insert_keychain_op(&geth_store::StoredKeychainOp {
op_id: endpoint_op.id.to_string(),
op_json: serde_json::to_string(&endpoint_op).expect("endpoint op json"),
created_at_ms: endpoint_op.created_at.0,
})
.expect("insert endpoint op");
let matched = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:trusted".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("explain matched endpoint binding");
match matched {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(
explanation
.diagnostics
.contains(&"endpoint-binding:matched:endpoint:card-only".to_owned())
);
}
other => panic!("unexpected response: {other:?}"),
}
@ -1926,7 +2053,12 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed);
assert_eq!(explanation.evaluated_ops, 2);
assert!(explanation.reason.contains("no active"));
assert!(explanation.reason.contains("revoked"));
assert!(
explanation
.diagnostics
.contains(&"grant:revoked:grant:test-fetch".to_owned())
);
}
other => panic!("unexpected response: {other:?}"),
}