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