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]