Add KV prefix capability matching
This commit is contained in:
parent
6c85a341b8
commit
02444a7cfd
5 changed files with 116 additions and 6 deletions
|
|
@ -122,7 +122,9 @@ Roadmap items should be actionable and checkable:
|
||||||
read-only SQLite schema summary/hash. cr-sqlite loading, change extraction,
|
read-only SQLite schema summary/hash. cr-sqlite loading, change extraction,
|
||||||
and sync are still roadmap work.
|
and sync are still roadmap work.
|
||||||
- KV stores support local SQLite-backed create/set/get. Iroh Documents
|
- 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
|
- Document resources can be registered locally with empty JSON state and
|
||||||
local-only status. Automerge editing/state and sync are still roadmap work.
|
local-only status. Automerge editing/state and sync are still roadmap work.
|
||||||
- Pubsub supports local daemon-lifetime publish/subscribe snapshots through a
|
- Pubsub supports local daemon-lifetime publish/subscribe snapshots through a
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,10 @@ Everything meaningful is modeled as a resource. Planned resource kinds are:
|
||||||
- `ssh-proxy`: authorized SSH proxy/admin access over Iroh
|
- `ssh-proxy`: authorized SSH proxy/admin access over Iroh
|
||||||
|
|
||||||
Authorization is resource-scoped and capability-based. Bearer secrets may grant
|
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
|
## Local State
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,14 @@ pub fn explain_from_view(
|
||||||
) -> AuthExplanation {
|
) -> AuthExplanation {
|
||||||
let group_principals = groups_for_subject(view, &resource, &subject);
|
let group_principals = groups_for_subject(view, &resource, &subject);
|
||||||
for grant in view.grants.values() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if grant.principal == subject {
|
if grant.principal == subject {
|
||||||
|
|
@ -241,7 +248,10 @@ pub fn explain_from_view(
|
||||||
resource: resource.to_string(),
|
resource: resource.to_string(),
|
||||||
capability: capability.to_string(),
|
capability: capability.to_string(),
|
||||||
allowed: true,
|
allowed: true,
|
||||||
reason: format!("direct grant {} allows capability", grant.id),
|
reason: format!(
|
||||||
|
"direct grant {} allows capability via {}",
|
||||||
|
grant.id, granted_capability
|
||||||
|
),
|
||||||
evaluated_ops,
|
evaluated_ops,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +261,10 @@ pub fn explain_from_view(
|
||||||
resource: resource.to_string(),
|
resource: resource.to_string(),
|
||||||
capability: capability.to_string(),
|
capability: capability.to_string(),
|
||||||
allowed: true,
|
allowed: true,
|
||||||
reason: format!("group grant {} allows capability", grant.id),
|
reason: format!(
|
||||||
|
"group grant {} allows capability via {}",
|
||||||
|
grant.id, granted_capability
|
||||||
|
),
|
||||||
evaluated_ops,
|
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(
|
fn groups_for_subject(
|
||||||
view: &AuthView,
|
view: &AuthView,
|
||||||
resource: &ResourceId,
|
resource: &ResourceId,
|
||||||
|
|
@ -520,4 +564,57 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert!(view.bearer_access.is_empty());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
log. Signature validation, replication, and module enforcement are still future
|
||||||
work.
|
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
|
Both keychain and auth operations use `geth-codec` canonical envelopes for
|
||||||
signature payloads. The envelope includes a version, an explicit signature
|
signature payloads. The envelope includes a version, an explicit signature
|
||||||
namespace, and the operation payload encoded with postcard. JSON remains useful
|
namespace, and the operation payload encoded with postcard. JSON remains useful
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,10 @@ authorization and durable-state boundaries clear.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` `geth kv create/set/get` works against a named local KV resource.
|
- `[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.
|
- `[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.
|
- `[ ]` KV metadata is replicated through Iroh Documents.
|
||||||
|
|
||||||
- `[~]` Iroh-gossip pubsub integration.
|
- `[~]` Iroh-gossip pubsub integration.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue