Enforce local KV write capabilities
This commit is contained in:
parent
1ab24631cd
commit
5751748458
8 changed files with 118 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue