Wire auth explain to local auth ops

This commit is contained in:
Eric Wendland 2026-05-16 16:32:03 +02:00
commit f54920bb65
9 changed files with 299 additions and 19 deletions

View file

@ -142,6 +142,17 @@ pub enum AuthCommand {
resource: String,
capability: String,
},
Grant {
subject: String,
resource: String,
capability: String,
#[arg(long)]
grant_id: Option<String>,
},
Revoke {
resource: String,
grant_id: String,
},
}
#[derive(Debug, Subcommand)]
@ -350,6 +361,23 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
resource,
capability,
},
Command::Auth {
command:
AuthCommand::Grant {
subject,
resource,
capability,
grant_id,
},
} => ControlRequest::AuthGrant {
subject,
resource,
capability,
grant_id,
},
Command::Auth {
command: AuthCommand::Revoke { resource, grant_id },
} => ControlRequest::AuthRevoke { resource, grant_id },
Command::Secret { command } => ControlRequest::ModuleStub {
module: "secret".to_owned(),
command: format!("{command:?}"),
@ -575,6 +603,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops);
}
ControlResponse::AuthOpRecorded { op } => {
println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource);
}
ControlResponse::SshCertRequested { request } => {
println!("ssh cert request: {}", request.id);
println!("status: {}", request.status);

View file

@ -1,4 +1,4 @@
use geth_auth::AuthExplanation;
use geth_auth::{AuthExplanation, AuthOp};
use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
@ -37,6 +37,16 @@ pub enum ControlRequest {
resource: String,
capability: String,
},
AuthGrant {
subject: String,
resource: String,
capability: String,
grant_id: Option<String>,
},
AuthRevoke {
resource: String,
grant_id: String,
},
SshCertRequest {
public_key_path: PathBuf,
cert_kind: String,
@ -105,6 +115,9 @@ pub enum ControlResponse {
},
KeychainStatus(KeychainStatusResponse),
AuthExplain(AuthExplanation),
AuthOpRecorded {
op: AuthOp,
},
SshCertRequested {
request: SshCertRequest,
},

View file

@ -1,6 +1,6 @@
pub mod service;
use geth_auth::AuthExplanation;
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
use geth_cas::{LocalCas, hash_path};
use geth_config::{GethConfig, GethPaths, RelayMode};
use geth_control::{
@ -16,10 +16,12 @@ use geth_ssh_identity::{
certificate_id, revocation_id, ssh_public_key_fingerprint,
};
use geth_store::{
Store, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate,
StoredSshRevocation,
};
use geth_types::{
NodeId, ResourceId, ResourceKind, ResourceName, SshCertId, SshCertRequestId, UnixMillis,
AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId,
SshCertRequestId, UnixMillis,
};
use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -249,16 +251,67 @@ pub fn handle_request(
resource,
capability,
} => {
if store.get_peer_card(&subject)?.is_some() {
Ok(ControlResponse::AuthExplain(
AuthExplanation::discovered_candidate(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 {
Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
subject, resource, capability,
)))
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 {
subject,
resource,
capability,
grant_id,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let grant_id =
grant_id.unwrap_or_else(|| generated_grant_id(&subject, &resource, &capability));
let op = AuthOp {
id: generated_auth_op_id("grant-create", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id,
principal: PrincipalId::new(subject),
capabilities: vec![Capability::new(capability)],
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::AuthOpRecorded { op })
}
ControlRequest::AuthRevoke { resource, grant_id } => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::AuthOpRecorded { op })
}
ControlRequest::SshCertRequest {
public_key_path,
cert_kind,
@ -447,6 +500,45 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
))
}
fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&StoredAuthOp {
op_id: op.id.to_string(),
resource_id: op.resource.to_string(),
op_json: serde_json::to_string(op)?,
created_at_ms: op.created_at.0,
})?;
Ok(())
}
fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthOp>, NodeError> {
store
.list_auth_ops_for_resource(resource)?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect()
}
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!(
"grant:{}",
geth_crypto::blake3_hex(format!("{subject}\0{resource}\0{capability}").as_bytes())
)
}
fn generated_auth_op_id(
kind: &str,
resource: &str,
stable_id: &str,
created_at: UnixMillis,
) -> AuthOpId {
AuthOpId::new(format!(
"auth-op:{}",
geth_crypto::blake3_hex(
format!("{}\0{kind}\0{resource}\0{stable_id}", created_at.0).as_bytes()
)
))
}
fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}")
}

View file

@ -289,6 +289,35 @@ impl Store {
.map_err(StoreError::from)
}
pub fn insert_auth_op(&self, op: &StoredAuthOp) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO auth_ops(op_id, resource_id, op_json, created_at_ms)
VALUES (?1, ?2, ?3, ?4)"#,
params![op.op_id, op.resource_id, op.op_json, op.created_at_ms],
)?;
Ok(())
}
pub fn list_auth_ops_for_resource(
&self,
resource_id: &str,
) -> Result<Vec<StoredAuthOp>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT op_id, resource_id, op_json, created_at_ms
FROM auth_ops WHERE resource_id = ?1 ORDER BY created_at_ms, op_id"#,
)?;
let rows = stmt.query_map(params![resource_id], |row| {
Ok(StoredAuthOp {
op_id: row.get(0)?,
resource_id: row.get(1)?,
op_json: row.get(2)?,
created_at_ms: row.get(3)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request(
&self,
request: &StoredSshCertRequest,
@ -476,6 +505,14 @@ pub struct StoredPeerCard {
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredAuthOp {
pub op_id: String,
pub resource_id: String,
pub op_json: String,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest {
pub request_id: String,
@ -589,4 +626,31 @@ mod tests {
);
assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]);
}
#[test]
fn auth_ops_roundtrip_by_resource() {
let store = Store::open_memory().expect("open");
let first = StoredAuthOp {
op_id: "op:auth:1".to_owned(),
resource_id: "resource:notes".to_owned(),
op_json: r#"{"id":"op:auth:1"}"#.to_owned(),
created_at_ms: 1,
};
let second = StoredAuthOp {
op_id: "op:auth:2".to_owned(),
resource_id: "resource:other".to_owned(),
op_json: r#"{"id":"op:auth:2"}"#.to_owned(),
created_at_ms: 2,
};
store.insert_auth_op(&second).expect("insert second");
store.insert_auth_op(&first).expect("insert first");
assert_eq!(
store
.list_auth_ops_for_resource("resource:notes")
.expect("list auth ops"),
vec![first]
);
}
}

View file

@ -154,6 +154,75 @@ fn auth_explain_distinguishes_discovered_peer_candidates() {
}
}
#[test]
fn auth_grant_revoke_and_explain_use_local_auth_log() {
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 response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthGrant {
subject: "node:laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:test-fetch".to_owned()),
},
)
.expect("grant capability");
match response {
geth_control::ControlResponse::AuthOpRecorded { op } => {
assert_eq!(op.resource.to_string(), "resource:cas:local");
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("explain grant");
match response {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(explanation.allowed);
assert_eq!(explanation.evaluated_ops, 1);
assert!(explanation.reason.contains("grant:test-fetch"));
}
other => panic!("unexpected response: {other:?}"),
}
geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthRevoke {
resource: "resource:cas:local".to_owned(),
grant_id: "grant:test-fetch".to_owned(),
},
)
.expect("revoke grant");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("explain revoke");
match response {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed);
assert_eq!(explanation.evaluated_ops, 2);
assert!(explanation.reason.contains("no active"));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir");