Enforce local KV write capabilities

This commit is contained in:
Eric Wendland 2026-05-18 04:06:41 +02:00
commit 5751748458
8 changed files with 118 additions and 10 deletions

View file

@ -113,8 +113,9 @@ Roadmap items should be actionable and checkable:
users, devices, nodes, agents, and endpoint bindings. users, devices, nodes, agents, and endpoint bindings.
- The auth reducer builds a current permission view for resources, grants, - The auth reducer builds a current permission view for resources, grants,
groups, and bearer access. The daemon persists local auth grant/revoke ops groups, and bearer access. The daemon persists local auth grant/revoke ops
and uses them for `auth explain`. Signature validation and daemon-side module and uses them for `auth explain`. `kv set --subject <principal>` enforces
enforcement are still roadmap work. 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 - The daemon persists local keychain init/admin-key ops and reduces them for
`keychain status`. SSH signature capture/verification is still roadmap work. `keychain status`. SSH signature capture/verification is still roadmap work.
- Local CAS supports pin/unpin metadata, surfaced through `cas list`, and - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and

View file

@ -98,7 +98,9 @@ The bootstrap implementation provides:
`geth db status <name>` with schema and `crsql_changes` metadata; the DB `geth db status <name>` with schema and `crsql_changes` metadata; the DB
crate and daemon can extract typed local `crsql_changes` batches through crate and daemon can extract typed local `crsql_changes` batches through
`geth db changes <name>` for future sync `geth db changes <name>` 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 <principal>` to exercise local capability checks for non-local
callers
- local JSON document commands: `geth document create/status/set/get` - local JSON document commands: `geth document create/status/set/get`
- local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub` - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`
- SSH certificate flow metadata: - 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 Authorization is resource-scoped and capability-based. Bearer secrets may grant
specific resource capabilities but do not create trusted node identity. The auth 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/` 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 for `kv.write_key:apps/foo/config` explain checks. `geth kv set --subject
is still future work. <principal>` enforces those local grants for test callers; the local node/agent
still has owner access for local administration.
## Local State ## Local State

View file

@ -285,6 +285,8 @@ pub enum KvCommand {
name: String, name: String,
key: String, key: String,
value: String, value: String,
#[arg(long)]
subject: Option<String>,
}, },
Get { Get {
name: String, name: String,
@ -564,7 +566,17 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
}, },
Command::Kv { command } => match command { Command::Kv { command } => match command {
KvCommand::Create { name } => ControlRequest::KvCreate { name }, 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 }, KvCommand::Get { name, key } => ControlRequest::KvGet { name, key },
}, },
Command::Pubsub { command } => match command { Command::Pubsub { command } => match command {

View file

@ -167,6 +167,7 @@ pub enum ControlRequest {
name: String, name: String,
key: String, key: String,
value: String, value: String,
subject: Option<String>,
}, },
KvGet { KvGet {
name: String, name: String,

View file

@ -83,6 +83,8 @@ pub enum NodeError {
Document(#[from] geth_document::DocumentError), Document(#[from] geth_document::DocumentError),
#[error("resource not found: {0}")] #[error("resource not found: {0}")]
ResourceNotFound(String), ResourceNotFound(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("secrets error: {0}")] #[error("secrets error: {0}")]
Secrets(#[from] geth_secrets::SecretsError), Secrets(#[from] geth_secrets::SecretsError),
#[error("pubsub error: {0}")] #[error("pubsub error: {0}")]
@ -1009,12 +1011,18 @@ pub fn handle_request(
kv: kv_resource_from_stored(&stored), 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_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?; geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?;
let kv = store let kv = store
.get_kv_store_by_name(&name)? .get_kv_store_by_name(&name)?
.ok_or_else(|| NodeError::KvNotFound(name.clone()))?; .ok_or_else(|| NodeError::KvNotFound(name.clone()))?;
ensure_kv_write_authorized(&store, node, subject, &kv.resource_id, &key)?;
let stored = StoredKvEntry { let stored = StoredKvEntry {
kv_id: kv.kv_id, kv_id: kv.kv_id,
key, 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<String>,
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( fn create_resource_secret(
store: &Store, store: &Store,
resource_id: &str, resource_id: &str,

View file

@ -795,6 +795,7 @@ fn kv_create_set_get_use_local_store() {
name: "prefs".to_owned(), name: "prefs".to_owned(),
key: "apps/foo/theme".to_owned(), key: "apps/foo/theme".to_owned(),
value: "dark".to_owned(), value: "dark".to_owned(),
subject: None,
}, },
) )
.expect("set kv"); .expect("set kv");
@ -837,6 +838,51 @@ fn kv_create_set_get_use_local_store() {
other => panic!("unexpected response: {other:?}"), 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!( assert!(
geth_node::handle_request( geth_node::handle_request(
&node, &node,
@ -853,6 +899,7 @@ fn kv_create_set_get_use_local_store() {
name: "missing".to_owned(), name: "missing".to_owned(),
key: "apps/foo/theme".to_owned(), key: "apps/foo/theme".to_owned(),
value: "dark".to_owned(), value: "dark".to_owned(),
subject: None,
}, },
) )
.is_err() .is_err()

View file

@ -111,8 +111,11 @@ through `db changes`. Loading cr-sqlite, applying remote changes, and DB sync
are future work. are future work.
`geth-kv` currently provides a SQLite-backed local fallback for named KV stores `geth-kv` currently provides a SQLite-backed local fallback for named KV stores
through `kv create/set/get`. Iroh Documents namespaces, prefix authorization through `kv create/set/get`. `kv set --subject <principal>` evaluates local auth
enforcement, and replication are future work. ops for `kv.write_key:<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 `geth-document` currently registers local document resources and stores
validated JSON state in the local SQLite metadata store through validated JSON state in the local SQLite metadata store through

View file

@ -237,7 +237,8 @@ authorization and durable-state boundaries clear.
- `[x]` The auth evaluator allows `kv.write_prefix:<prefix>` grants to - `[x]` The auth evaluator allows `kv.write_prefix:<prefix>` grants to
satisfy matching `kv.write_key:<key>` requests. satisfy matching `kv.write_key:<key>` requests.
- `[x]` Tests cover allowed and denied prefix-scoped KV write explanations. - `[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 <principal>` enforces local capability
decisions for non-local test callers.
- `[ ]` KV metadata is replicated through Iroh Documents. - `[ ]` KV metadata is replicated through Iroh Documents.
- `[~]` Iroh-gossip pubsub integration. - `[~]` Iroh-gossip pubsub integration.