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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue