From 02444a7cfd47bb9506c8e404644f368db9367cc1 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sun, 17 May 2026 18:26:30 +0200 Subject: [PATCH] Add KV prefix capability matching --- AGENTS.md | 4 +- README.md | 5 +- crates/geth-auth/src/lib.rs | 103 ++++++++++++++++++++++++++++++++++-- docs/architecture.md | 5 ++ docs/roadmap.md | 5 +- 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cfc9621..e7c5857 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,9 @@ Roadmap items should be actionable and checkable: read-only SQLite schema summary/hash. cr-sqlite loading, change extraction, and sync are still roadmap work. - KV stores support local SQLite-backed create/set/get. Iroh Documents - replication and prefix-capability enforcement are still roadmap work. + replication and command-level prefix-capability enforcement are still roadmap + work. The auth evaluator already understands `kv.write_prefix:` + grants for `kv.write_key:` requests. - Document resources can be registered locally with empty JSON state and local-only status. Automerge editing/state and sync are still roadmap work. - Pubsub supports local daemon-lifetime publish/subscribe snapshots through a diff --git a/README.md b/README.md index eb90ea9..e6a527c 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,10 @@ Everything meaningful is modeled as a resource. Planned resource kinds are: - `ssh-proxy`: authorized SSH proxy/admin access over Iroh Authorization is resource-scoped and capability-based. Bearer secrets may grant -specific resource capabilities but do not create trusted node identity. +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. ## Local State diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index 592b3e6..5d07e2b 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -232,7 +232,14 @@ pub fn explain_from_view( ) -> AuthExplanation { let group_principals = groups_for_subject(view, &resource, &subject); for grant in view.grants.values() { - if grant.resource != resource || !grant.capabilities.contains(&capability) { + let Some(granted_capability) = grant + .capabilities + .iter() + .find(|granted| capability_allows(granted, &capability)) + else { + continue; + }; + if grant.resource != resource { continue; } if grant.principal == subject { @@ -241,7 +248,10 @@ pub fn explain_from_view( resource: resource.to_string(), capability: capability.to_string(), allowed: true, - reason: format!("direct grant {} allows capability", grant.id), + reason: format!( + "direct grant {} allows capability via {}", + grant.id, granted_capability + ), evaluated_ops, }; } @@ -251,7 +261,10 @@ pub fn explain_from_view( resource: resource.to_string(), capability: capability.to_string(), allowed: true, - reason: format!("group grant {} allows capability", grant.id), + reason: format!( + "group grant {} allows capability via {}", + grant.id, granted_capability + ), evaluated_ops, }; } @@ -267,6 +280,37 @@ pub fn explain_from_view( } } +pub fn capability_allows(granted: &Capability, requested: &Capability) -> bool { + if granted == requested { + return true; + } + + match (granted.as_str(), requested.as_str()) { + ("kv.write", requested) + if requested == "kv.write" + || requested.starts_with("kv.write_key:") + || requested.starts_with("kv.write_prefix:") => + { + true + } + (granted, requested) => { + let Some(granted_prefix) = granted.strip_prefix("kv.write_prefix:") else { + return false; + }; + + if let Some(requested_key) = requested.strip_prefix("kv.write_key:") { + return requested_key.starts_with(granted_prefix); + } + + if let Some(requested_prefix) = requested.strip_prefix("kv.write_prefix:") { + return requested_prefix.starts_with(granted_prefix); + } + + false + } + } +} + fn groups_for_subject( view: &AuthView, resource: &ResourceId, @@ -520,4 +564,57 @@ mod tests { ); assert!(view.bearer_access.is_empty()); } + + #[test] + fn prefix_scoped_kv_capabilities_allow_only_matching_keys() { + assert!(capability_allows( + &Capability::from("kv.write_prefix:apps/foo/"), + &Capability::from("kv.write_key:apps/foo/config"), + )); + assert!(capability_allows( + &Capability::from("kv.write_prefix:apps/foo/"), + &Capability::from("kv.write_prefix:apps/foo/nested/"), + )); + assert!(!capability_allows( + &Capability::from("kv.write_prefix:apps/foo/"), + &Capability::from("kv.write_key:apps/bar/config"), + )); + assert!(!capability_allows( + &Capability::from("kv.write_prefix:apps/foo/"), + &Capability::from("kv.read"), + )); + assert!(capability_allows( + &Capability::from("kv.write"), + &Capability::from("kv.write_key:apps/bar/config"), + )); + } + + #[test] + fn auth_explain_uses_prefix_scoped_kv_capabilities() { + let ops = vec![op( + 1, + AuthOpKind::GrantCreate { + grant_id: "grant:kv-prefix".to_owned(), + principal: "node:laptop".into(), + capabilities: vec!["kv.write_prefix:apps/foo/".into()], + }, + )]; + + let allowed = explain_auth_ops( + &ops, + "node:laptop".into(), + "resource:notes".into(), + "kv.write_key:apps/foo/config".into(), + ); + assert!(allowed.allowed); + assert!(allowed.reason.contains("kv.write_prefix:apps/foo/")); + + let denied = explain_auth_ops( + &ops, + "node:laptop".into(), + "resource:notes".into(), + "kv.write_key:apps/bar/config".into(), + ); + assert!(!denied.allowed); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 67a0c00..8696c3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,6 +138,11 @@ grant/revoke operations and `geth auth explain` evaluates that local operation log. Signature validation, replication, and module enforcement are still future work. +Capability evaluation supports exact matches plus explicit scoped forms. For KV, +`kv.write_prefix:` grants writes requested as `kv.write_key:` only +when the key is under that prefix; `kv.write` remains the broad write +capability. Command-level KV enforcement is still future work. + Both keychain and auth operations use `geth-codec` canonical envelopes for signature payloads. The envelope includes a version, an explicit signature namespace, and the operation payload encoded with postcard. JSON remains useful diff --git a/docs/roadmap.md b/docs/roadmap.md index ed3d640..95888f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -225,7 +225,10 @@ authorization and durable-state boundaries clear. Acceptance criteria: - `[x]` `geth kv create/set/get` works against a named local KV resource. - `[x]` KV metadata and entries are durable in the local SQLite store. - - `[ ]` Prefix-scoped capabilities can allow or deny writes. + - `[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. - `[ ]` KV metadata is replicated through Iroh Documents. - `[~]` Iroh-gossip pubsub integration.