Improve auth explain diagnostics
This commit is contained in:
parent
6f2fb53132
commit
ee80ff780c
7 changed files with 516 additions and 46 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue