Add KV prefix capability matching

This commit is contained in:
Eric Wendland 2026-05-17 18:26:30 +02:00
commit 02444a7cfd
5 changed files with 116 additions and 6 deletions

View file

@ -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:<prefix>`
grants for `kv.write_key:<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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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:<prefix>` grants writes requested as `kv.write_key:<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

View file

@ -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:<prefix>` grants to
satisfy matching `kv.write_key:<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.