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

@ -314,6 +314,13 @@ use `ssh_cert.request`, `ssh_cert.read`, `ssh_cert.approve`, and
use `ssh_revocation.publish`, `ssh_revocation.read`, and use `ssh_revocation.publish`, `ssh_revocation.read`, and
`ssh_revocation.import` on `resource:ssh:revocations`. `ssh_revocation.import` on `resource:ssh:revocations`.
`geth auth explain <subject> <resource> <capability>` 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 ## Local State
If `GETH_HOME` is set, geth uses it. Otherwise it uses an OS-specific data If `GETH_HOME` is set, geth uses it. Otherwise it uses an OS-specific data

View file

@ -234,7 +234,7 @@ pub fn explain_auth_ops(
capability: Capability, capability: Capability,
) -> AuthExplanation { ) -> AuthExplanation {
let view = reduce_auth_ops(ops); 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( pub fn explain_from_view(
@ -244,6 +244,99 @@ pub fn explain_from_view(
resource: ResourceId, resource: ResourceId,
capability: Capability, capability: Capability,
) -> AuthExplanation { ) -> 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); let group_principals = groups_for_subject(view, &resource, &subject);
for grant in view.grants.values() { for grant in view.grants.values() {
let Some(granted_capability) = grant let Some(granted_capability) = grant
@ -267,6 +360,11 @@ pub fn explain_from_view(
grant.id, granted_capability grant.id, granted_capability
), ),
evaluated_ops, 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) { if group_principals.contains(&grant.principal) {
@ -280,10 +378,29 @@ pub fn explain_from_view(
grant.id, granted_capability grant.id, granted_capability
), ),
evaluated_ops, 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 { AuthExplanation {
subject: subject.to_string(), subject: subject.to_string(),
resource: resource.to_string(), resource: resource.to_string(),
@ -291,9 +408,65 @@ pub fn explain_from_view(
allowed: false, allowed: false,
reason: "no active direct or group grant contains the requested capability".to_owned(), reason: "no active direct or group grant contains the requested capability".to_owned(),
evaluated_ops, 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 { pub fn capability_allows(granted: &Capability, requested: &Capability) -> bool {
if granted == requested { if granted == requested {
return true; return true;
@ -345,9 +518,24 @@ pub struct AuthExplanation {
pub allowed: bool, pub allowed: bool,
pub reason: String, pub reason: String,
pub evaluated_ops: usize, pub evaluated_ops: usize,
#[serde(default)]
pub diagnostics: Vec<String>,
} }
impl AuthExplanation { 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] #[must_use]
pub fn stub(subject: String, resource: String, capability: String) -> Self { pub fn stub(subject: String, resource: String, capability: String) -> Self {
Self { Self {
@ -357,6 +545,7 @@ impl AuthExplanation {
allowed: false, allowed: false,
reason: "authorization logs are scaffolded; no grant reducer is active yet".to_owned(), reason: "authorization logs are scaffolded; no grant reducer is active yet".to_owned(),
evaluated_ops: 0, evaluated_ops: 0,
diagnostics: vec!["auth-log:empty".to_owned()],
} }
} }
@ -369,6 +558,10 @@ impl AuthExplanation {
allowed: false, allowed: false,
reason: "subject is a discovered peer candidate only; discovery does not grant trust or authorization".to_owned(), reason: "subject is a discovered peer candidate only; discovery does not grant trust or authorization".to_owned(),
evaluated_ops: 0, evaluated_ops: 0,
diagnostics: vec![
"subject:discovered-only".to_owned(),
"trust:missing".to_owned(),
],
} }
} }
} }
@ -527,6 +720,12 @@ mod tests {
"kv.admin".into(), "kv.admin".into(),
); );
assert!(!revoked.allowed); assert!(!revoked.allowed);
assert!(revoked.reason.contains("grant:revoked"));
assert!(
revoked
.diagnostics
.contains(&"grant:revoked:grant:revoked".to_owned())
);
} }
#[test] #[test]
@ -578,6 +777,47 @@ mod tests {
Some(Vec::new()) Some(Vec::new())
); );
assert!(view.bearer_access.is_empty()); 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] #[test]

View file

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

View file

@ -3306,6 +3306,103 @@ fn can_sync_resource(
.allowed) .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( fn bearer_proof(
bearer_secret: Option<String>, bearer_secret: Option<String>,
resource: &str, resource: &str,
@ -3360,6 +3457,7 @@ fn explain_peer_or_bearer(
allowed: false, allowed: false,
reason, reason,
evaluated_ops: peer_explanation.evaluated_ops, evaluated_ops: peer_explanation.evaluated_ops,
diagnostics: vec!["subject:bearer-secret".to_owned()],
}; };
if proof.resource != resource_id { 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" "bearer proof allows this resource-scoped capability without granting node identity"
.to_owned(), .to_owned(),
evaluated_ops: peer_explanation.evaluated_ops, 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, subject,
resource, resource,
capability, capability,
} => { } => Ok(ControlResponse::AuthExplain(explain_auth_for_operator(
let ops = load_auth_ops_for_resource(&store, &resource)?; &store,
let discovered = store.get_peer_card(&subject)?.is_some(); &subject,
if ops.is_empty() { &resource,
if discovered { &capability,
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))
}
}
ControlRequest::AuthGrant { ControlRequest::AuthGrant {
subject, subject,
resource, resource,

View file

@ -1853,6 +1853,133 @@ fn auth_explain_distinguishes_discovered_peer_candidates() {
geth_control::ControlResponse::AuthExplain(explanation) => { geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed); assert!(!explanation.allowed);
assert!(explanation.reason.contains("discovered peer candidate")); 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:?}"), other => panic!("unexpected response: {other:?}"),
} }
@ -1926,7 +2053,12 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
geth_control::ControlResponse::AuthExplain(explanation) => { geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed); assert!(!explanation.allowed);
assert_eq!(explanation.evaluated_ops, 2); 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:?}"), other => panic!("unexpected response: {other:?}"),
} }

View file

@ -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 over a canonical payload. Imported and ping-discovered peer cards are stored as
untrusted metadata in `peer_cards`; trust reduction is future work. `auth untrusted metadata in `peer_cards`; trust reduction is future work. `auth
explain` reports when a subject is only a discovered peer candidate and denies 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 access. It also reports whether a trusted node has no endpoint binding, whether
signature, but it does not authorize any resource module. Protected peer the discovered peer card has no endpoint bound to that node, or whether a peer
control requests must also prove that the signed peer card binds the observed card endpoint matches the reduced keychain view. The peer ping path
Iroh EndpointID, then reduce resource auth ops; an EndpointID alone is not authenticates the Iroh endpoint and peer-card signature, but it does not
accepted as a resource principal. 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 starts this endpoint during `geth daemon run` and keeps it alive for
the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is 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, The authorization plane is `geth-auth`: resource-local signed operation logs,
grants, revocations, groups, and `auth explain`. Auth operations reduce into a grants, revocations, groups, and `auth explain`. Auth operations reduce into a
current permission view for resources, grants, groups, and bearer access. The current permission view for resources, grants, groups, and bearer access. The
library can explain direct and group grants. The daemon persists local auth library can explain direct grants, group grants, missing grants, revoked grants,
grant/revoke operations and `geth auth explain` evaluates that local operation and bearer-secret access. The daemon persists local auth grant/revoke operations
log. `geth node grant`, `geth node revoke-grant`, `geth auth grant`, and `geth and `geth auth explain` enriches the reducer result with keychain and discovery
auth revoke` require `--signing-key` in the CLI and store OpenSSH-signed auth diagnostics, including discovered-only peers and endpoint-binding state. Human
operations. Enrollment approval uses the same signed auth operation path. Auth output prints those diagnostics and JSON output exposes them as structured
sync imports only auth operations signed by currently trusted admin keys. strings for scripts. `geth node grant`, `geth node revoke-grant`, `geth auth
Broader delegated authority and module enforcement are still future work. 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, Capability evaluation supports exact matches plus explicit scoped forms. For KV,
`kv.write_prefix:<prefix>` grants writes requested as `kv.write_key:<key>` only `kv.write_prefix:<prefix>` grants writes requested as `kv.write_key:<key>` only

View file

@ -16,7 +16,7 @@ end-to-end test target for the intended personal mesh use cases.
Implementation order: Implementation order:
1. `[~]` Close remote authorization and replicated-state safety gaps. 1. `[x]` Close remote authorization and replicated-state safety gaps.
Acceptance criteria: Acceptance criteria:
- `[x]` Add initial two-daemon tests proving denied remote pubsub publish, - `[x]` Add initial two-daemon tests proving denied remote pubsub publish,
remote pipe listen, and SSH admin shell requests do not mutate serving 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. keychain/auth operations are rejected and not imported.
- `[x]` Add tests proving conflicting replicated keychain/auth records do - `[x]` Add tests proving conflicting replicated keychain/auth records do
not mutate trust/resource state. 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 distinguish discovered-only peers, missing endpoint bindings, missing
grants, matching grants, revocations, and bearer access. grants, matching grants, revocations, and bearer access.
@ -86,7 +86,7 @@ Implementation order:
pubsub, pipe, SSH proxy/admin shell, SSH cert metadata, and revocations. pubsub, pipe, SSH proxy/admin shell, SSH cert metadata, and revocations.
- `[x]` Initial two-daemon denied-mutation coverage exists for remote pubsub - `[x]` Initial two-daemon denied-mutation coverage exists for remote pubsub
publish, remote pipe listen, and SSH admin shell. 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 endpoint bindings, missing grants, matching grants, revocations, and bearer
access. access.
@ -364,6 +364,9 @@ resource-scoped capability decisions.
- `[x]` Output includes the grant ID or missing grant that caused the result. - `[x]` Output includes the grant ID or missing grant that caused the result.
- `[x]` JSON output is stable enough for tests and scripts. - `[x]` JSON output is stable enough for tests and scripts.
- `[x]` Replicated auth sync requires trusted-admin signatures before import. - `[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. - `[~]` Resource secrets and bearer invites.
Acceptance criteria: Acceptance criteria: