From ee80ff780c168dd50693b2c077544b81c8361d0c Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Fri, 22 May 2026 14:28:44 +0200 Subject: [PATCH] Improve auth explain diagnostics --- README.md | 7 + crates/geth-auth/src/lib.rs | 242 ++++++++++++++++++++++++++++++++- crates/geth-cli/src/lib.rs | 3 + crates/geth-node/src/lib.rs | 138 +++++++++++++++---- crates/geth/tests/bootstrap.rs | 134 +++++++++++++++++- docs/architecture.md | 29 ++-- docs/roadmap.md | 9 +- 7 files changed, 516 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 6bc6b30..e1cfa19 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,13 @@ use `ssh_cert.request`, `ssh_cert.read`, `ssh_cert.approve`, and use `ssh_revocation.publish`, `ssh_revocation.read`, and `ssh_revocation.import` on `resource:ssh:revocations`. +`geth auth explain ` is the operator-facing +debug path for those decisions. Human output includes the allow/deny result, +the reason, evaluated auth-op count, and compact diagnostics. JSON output +includes the same diagnostics so scripts can distinguish discovered-only peers, +unknown subjects, missing or matched endpoint bindings, missing grants, revoked +grants, and bearer-secret access without scraping prose. + ## Local State If `GETH_HOME` is set, geth uses it. Otherwise it uses an OS-specific data diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index 3e92d12..f1a4e32 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -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 { + let mut matching_grants = BTreeSet::new(); + let mut group_members = BTreeMap::>::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, } 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) { + 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] diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 5bd1680..d54f4c5 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -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); diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 1ade483..ab475c9 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -3306,6 +3306,103 @@ fn can_sync_resource( .allowed) } +fn explain_auth_for_operator( + store: &Store, + subject: &str, + resource: &str, + capability: &str, +) -> Result { + 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::(&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, 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, diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index bedbded..217d2c7 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -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:?}"), } diff --git a/docs/architecture.md b/docs/architecture.md index de487c5..d8f49cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,11 +59,13 @@ endpoint candidates, timestamp, signing public key, and an Ed25519 signature over a canonical payload. Imported and ping-discovered peer cards are stored as untrusted metadata in `peer_cards`; trust reduction is future work. `auth explain` reports when a subject is only a discovered peer candidate and denies -access. The peer ping path authenticates the Iroh endpoint and peer-card -signature, but it does not authorize any resource module. Protected peer -control requests must also prove that the signed peer card binds the observed -Iroh EndpointID, then reduce resource auth ops; an EndpointID alone is not -accepted as a resource principal. +access. It also reports whether a trusted node has no endpoint binding, whether +the discovered peer card has no endpoint bound to that node, or whether a peer +card endpoint matches the reduced keychain view. The peer ping path +authenticates the Iroh endpoint and peer-card signature, but it does not +authorize any resource module. Protected peer control requests must also prove +that the signed peer card binds the observed Iroh EndpointID, then reduce +resource auth ops; an EndpointID alone is not accepted as a resource principal. The daemon starts this endpoint during `geth daemon run` and keeps it alive for the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is @@ -309,13 +311,16 @@ both signed logs so the new node can see its approved identity and permissions. The authorization plane is `geth-auth`: resource-local signed operation logs, grants, revocations, groups, and `auth explain`. Auth operations reduce into a current permission view for resources, grants, groups, and bearer access. The -library can explain direct and group grants. The daemon persists local auth -grant/revoke operations and `geth auth explain` evaluates that local operation -log. `geth node grant`, `geth node revoke-grant`, `geth auth grant`, and `geth -auth revoke` require `--signing-key` in the CLI and store OpenSSH-signed auth -operations. Enrollment approval uses the same signed auth operation path. Auth -sync imports only auth operations signed by currently trusted admin keys. -Broader delegated authority and module enforcement are still future work. +library can explain direct grants, group grants, missing grants, revoked grants, +and bearer-secret access. The daemon persists local auth grant/revoke operations +and `geth auth explain` enriches the reducer result with keychain and discovery +diagnostics, including discovered-only peers and endpoint-binding state. Human +output prints those diagnostics and JSON output exposes them as structured +strings for scripts. `geth node grant`, `geth node revoke-grant`, `geth auth +grant`, and `geth auth revoke` require `--signing-key` in the CLI and store +OpenSSH-signed auth operations. Enrollment approval uses the same signed auth +operation path. Auth sync imports only auth operations signed by currently +trusted admin keys. Broader delegated authority is still future work. Capability evaluation supports exact matches plus explicit scoped forms. For KV, `kv.write_prefix:` grants writes requested as `kv.write_key:` only diff --git a/docs/roadmap.md b/docs/roadmap.md index 5e2fb25..7a4a20e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ end-to-end test target for the intended personal mesh use cases. Implementation order: -1. `[~]` Close remote authorization and replicated-state safety gaps. +1. `[x]` Close remote authorization and replicated-state safety gaps. Acceptance criteria: - `[x]` Add initial two-daemon tests proving denied remote pubsub publish, remote pipe listen, and SSH admin shell requests do not mutate serving @@ -27,7 +27,7 @@ Implementation order: keychain/auth operations are rejected and not imported. - `[x]` Add tests proving conflicting replicated keychain/auth records do not mutate trust/resource state. - - `[ ]` Improve `auth explain` diagnostics enough for operators to + - `[x]` Improve `auth explain` diagnostics enough for operators to distinguish discovered-only peers, missing endpoint bindings, missing grants, matching grants, revocations, and bearer access. @@ -86,7 +86,7 @@ Implementation order: pubsub, pipe, SSH proxy/admin shell, SSH cert metadata, and revocations. - `[x]` Initial two-daemon denied-mutation coverage exists for remote pubsub publish, remote pipe listen, and SSH admin shell. - - `[ ]` `auth explain` output can explain discovered-only peers, missing + - `[x]` `auth explain` output can explain discovered-only peers, missing endpoint bindings, missing grants, matching grants, revocations, and bearer access. @@ -364,6 +364,9 @@ resource-scoped capability decisions. - `[x]` Output includes the grant ID or missing grant that caused the result. - `[x]` JSON output is stable enough for tests and scripts. - `[x]` Replicated auth sync requires trusted-admin signatures before import. + - `[x]` Human and JSON output include diagnostics for discovered-only peers, + missing and matched endpoint bindings, matching grants, revoked grants, and + bearer access. - `[~]` Resource secrets and bearer invites. Acceptance criteria: