diff --git a/AGENTS.md b/AGENTS.md index 3753cdf..8c017fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,9 @@ Roadmap items should be actionable and checkable: payloads. The keychain reducer builds an active identity view for admin keys, users, devices, nodes, agents, and endpoint bindings. - The auth reducer builds a current permission view for resources, grants, - groups, and bearer access. Daemon-side enforcement is still roadmap work. + groups, and bearer access. The daemon persists local auth grant/revoke ops + and uses them for `auth explain`. Signature validation and daemon-side module + enforcement are still roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/README.md b/README.md index 5df1c42..35960ed 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ The bootstrap implementation provides: - `geth resource create ` - `geth keychain status` - `geth auth explain ` +- `geth auth grant [--grant-id ]` +- `geth auth revoke ` - local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list` - SSH certificate flow metadata: - `geth ssh cert request --public-key --principal ` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index c0d60e0..b5f6d18 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -142,6 +142,17 @@ pub enum AuthCommand { resource: String, capability: String, }, + Grant { + subject: String, + resource: String, + capability: String, + #[arg(long)] + grant_id: Option, + }, + Revoke { + resource: String, + grant_id: String, + }, } #[derive(Debug, Subcommand)] @@ -350,6 +361,23 @@ fn request_for_command(command: Command) -> Result { 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); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index e5c8d16..f77c744 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -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, + }, + 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, }, diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 48082cf..fb5e708 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -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 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, 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}") } diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 3f10047..ae1dbb9 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -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, 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::, _>>() + .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] + ); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index dad7a47..cd85880 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -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"); diff --git a/docs/architecture.md b/docs/architecture.md index 468af4e..5b8ec04 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,8 +110,10 @@ bindings. Revoked identity subtrees are excluded from that active view. 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, while daemon-side enforcement and -durable auth-log storage are still future work. +library can explain direct and group grants. The daemon persists local auth +grant/revoke operations and `geth auth explain` evaluates that local operation +log. Signature validation, replication, and module enforcement are still future +work. Both keychain and auth operations use `geth-codec` canonical envelopes for signature payloads. The envelope includes a version, an explicit signature diff --git a/docs/roadmap.md b/docs/roadmap.md index fedf2dd..81c49b8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -159,11 +159,15 @@ resource-scoped capability decisions. `kv.write_prefix:apps/foo/`. - Tests cover grant, revoke, group membership, and denied access. -- `[ ]` `auth explain` real decision path. +- `[~]` `auth explain` real decision path. Acceptance criteria: - - `geth auth explain ` reports allowed/denied. - - Output includes the operation chain or missing grant that caused the result. - - JSON output is stable enough for tests and scripts. + - `[x]` `geth auth grant` and `geth auth revoke` persist local auth ops. + - `[x]` `geth auth explain ` reports + allowed/denied from the local auth-op reducer when local ops exist. + - `[x]` Output includes the grant ID or missing grant that caused the result. + - `[x]` JSON output is stable enough for tests and scripts. + - `[ ]` Future completion requires signed-op validation before accepting + replicated auth ops. - `[ ]` Resource secrets and bearer invites. Acceptance criteria: