From 5751748458120e7c589c3736e4a415514a6999cd Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Mon, 18 May 2026 04:06:41 +0200 Subject: [PATCH] Enforce local KV write capabilities --- AGENTS.md | 5 ++-- README.md | 9 ++++--- crates/geth-cli/src/lib.rs | 14 +++++++++- crates/geth-control/src/lib.rs | 1 + crates/geth-node/src/lib.rs | 42 +++++++++++++++++++++++++++++- crates/geth/tests/bootstrap.rs | 47 ++++++++++++++++++++++++++++++++++ docs/architecture.md | 7 +++-- docs/roadmap.md | 3 ++- 8 files changed, 118 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 14d5005..99b893a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,8 +113,9 @@ Roadmap items should be actionable and checkable: users, devices, nodes, agents, and endpoint bindings. - The auth reducer builds a current permission view for resources, grants, 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. + and uses them for `auth explain`. `kv set --subject ` enforces + local KV write grants for non-local test callers. Signature validation and + broader daemon-side module enforcement are still roadmap work. - The daemon persists local keychain init/admin-key ops and reduces them for `keychain status`. SSH signature capture/verification is still roadmap work. - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and diff --git a/README.md b/README.md index 264f2af..7df4db7 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ The bootstrap implementation provides: `geth db status ` with schema and `crsql_changes` metadata; the DB crate and daemon can extract typed local `crsql_changes` batches through `geth db changes ` for future sync -- local SQLite-backed KV commands: `geth kv create/set/get` +- local SQLite-backed KV commands: `geth kv create/set/get`; `kv set` accepts + `--subject ` to exercise local capability checks for non-local + callers - local JSON document commands: `geth document create/status/set/get` - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub` - SSH certificate flow metadata: @@ -135,8 +137,9 @@ Everything meaningful is modeled as a resource. Planned resource kinds are: Authorization is resource-scoped and capability-based. Bearer secrets may grant specific resource capabilities but do not create trusted node identity. The auth evaluator supports scoped KV write grants such as `kv.write_prefix:apps/foo/` -for `kv.write_key:apps/foo/config` explain checks; command-level KV enforcement -is still future work. +for `kv.write_key:apps/foo/config` explain checks. `geth kv set --subject +` enforces those local grants for test callers; the local node/agent +still has owner access for local administration. ## Local State diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 35d3016..123079c 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -285,6 +285,8 @@ pub enum KvCommand { name: String, key: String, value: String, + #[arg(long)] + subject: Option, }, Get { name: String, @@ -564,7 +566,17 @@ fn request_for_command(command: Command) -> Result { }, Command::Kv { command } => match command { KvCommand::Create { name } => ControlRequest::KvCreate { name }, - KvCommand::Set { name, key, value } => ControlRequest::KvSet { name, key, value }, + KvCommand::Set { + name, + key, + value, + subject, + } => ControlRequest::KvSet { + name, + key, + value, + subject, + }, KvCommand::Get { name, key } => ControlRequest::KvGet { name, key }, }, Command::Pubsub { command } => match command { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 52c0ac8..dfc55ae 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -167,6 +167,7 @@ pub enum ControlRequest { name: String, key: String, value: String, + subject: Option, }, KvGet { name: String, diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 576718a..c5661d5 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -83,6 +83,8 @@ pub enum NodeError { Document(#[from] geth_document::DocumentError), #[error("resource not found: {0}")] ResourceNotFound(String), + #[error("unauthorized: {0}")] + Unauthorized(String), #[error("secrets error: {0}")] Secrets(#[from] geth_secrets::SecretsError), #[error("pubsub error: {0}")] @@ -1009,12 +1011,18 @@ pub fn handle_request( kv: kv_resource_from_stored(&stored), }) } - ControlRequest::KvSet { name, key, value } => { + ControlRequest::KvSet { + name, + key, + value, + subject, + } => { geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?; geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?; let kv = store .get_kv_store_by_name(&name)? .ok_or_else(|| NodeError::KvNotFound(name.clone()))?; + ensure_kv_write_authorized(&store, node, subject, &kv.resource_id, &key)?; let stored = StoredKvEntry { kv_id: kv.kv_id, key, @@ -1321,6 +1329,38 @@ fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeEr } } +fn ensure_kv_write_authorized( + store: &Store, + node: &LocalNode, + subject: Option, + resource_id: &str, + key: &str, +) -> Result<(), NodeError> { + let Some(subject) = subject else { + return Ok(()); + }; + if subject == node.node_id || subject == node.agent_id { + return Ok(()); + } + + let capability = format!("kv.write_key:{key}"); + let ops = load_auth_ops_for_resource(store, resource_id)?; + let explanation = geth_auth::explain_auth_ops( + &ops, + PrincipalId::new(subject.clone()), + ResourceId::new(resource_id.to_owned()), + Capability::new(capability.clone()), + ); + if explanation.allowed { + Ok(()) + } else { + Err(NodeError::Unauthorized(format!( + "{subject} lacks {capability} on {resource_id}: {}", + explanation.reason + ))) + } +} + fn create_resource_secret( store: &Store, resource_id: &str, diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 0a55a38..173a188 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -795,6 +795,7 @@ fn kv_create_set_get_use_local_store() { name: "prefs".to_owned(), key: "apps/foo/theme".to_owned(), value: "dark".to_owned(), + subject: None, }, ) .expect("set kv"); @@ -837,6 +838,51 @@ fn kv_create_set_get_use_local_store() { other => panic!("unexpected response: {other:?}"), } + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::KvSet { + name: "prefs".to_owned(), + key: "apps/foo/accent".to_owned(), + value: "blue".to_owned(), + subject: Some("node:tablet".to_owned()), + }, + ) + .is_err() + ); + geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthGrant { + subject: "node:tablet".to_owned(), + resource: "resource:kv:prefs".to_owned(), + capability: "kv.write_prefix:apps/foo/".to_owned(), + grant_id: None, + }, + ) + .expect("grant prefix write"); + geth_node::handle_request( + &node, + geth_control::ControlRequest::KvSet { + name: "prefs".to_owned(), + key: "apps/foo/accent".to_owned(), + value: "blue".to_owned(), + subject: Some("node:tablet".to_owned()), + }, + ) + .expect("authorized prefix write"); + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::KvSet { + name: "prefs".to_owned(), + key: "apps/bar/accent".to_owned(), + value: "red".to_owned(), + subject: Some("node:tablet".to_owned()), + }, + ) + .is_err() + ); + assert!( geth_node::handle_request( &node, @@ -853,6 +899,7 @@ fn kv_create_set_get_use_local_store() { name: "missing".to_owned(), key: "apps/foo/theme".to_owned(), value: "dark".to_owned(), + subject: None, }, ) .is_err() diff --git a/docs/architecture.md b/docs/architecture.md index 5d2a642..71d7f14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,8 +111,11 @@ through `db changes`. Loading cr-sqlite, applying remote changes, and DB sync are future work. `geth-kv` currently provides a SQLite-backed local fallback for named KV stores -through `kv create/set/get`. Iroh Documents namespaces, prefix authorization -enforcement, and replication are future work. +through `kv create/set/get`. `kv set --subject ` evaluates local auth +ops for `kv.write_key:` so prefix grants can be tested before networked +callers exist. The local node/agent retains owner access for administration. +Iroh Documents namespaces, remote caller identity, and replication are future +work. `geth-document` currently registers local document resources and stores validated JSON state in the local SQLite metadata store through diff --git a/docs/roadmap.md b/docs/roadmap.md index 70d3e7e..6a9a639 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -237,7 +237,8 @@ authorization and durable-state boundaries clear. - `[x]` The auth evaluator allows `kv.write_prefix:` grants to satisfy matching `kv.write_key:` requests. - `[x]` Tests cover allowed and denied prefix-scoped KV write explanations. - - `[ ]` `geth kv set` enforces local capability decisions for the caller. + - `[x]` `geth kv set --subject ` enforces local capability + decisions for non-local test callers. - `[ ]` KV metadata is replicated through Iroh Documents. - `[~]` Iroh-gossip pubsub integration.