Wire auth explain to local auth ops

This commit is contained in:
Eric Wendland 2026-05-16 16:32:03 +02:00
commit f54920bb65
9 changed files with 299 additions and 19 deletions

View file

@ -111,7 +111,9 @@ Roadmap items should be actionable and checkable:
payloads. The keychain reducer builds an active identity view for admin keys, payloads. The keychain reducer builds an active identity view for admin keys,
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. Daemon-side enforcement is still roadmap work. groups, and bearer access. The daemon persists local auth grant/revoke ops
and uses them for `auth explain`. Signature validation and daemon-side module
enforcement are still roadmap work.
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future

View file

@ -76,6 +76,8 @@ The bootstrap implementation provides:
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain status` - `geth keychain status`
- `geth auth explain <subject> <resource> <capability>` - `geth auth explain <subject> <resource> <capability>`
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id>`
- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list` - local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list`
- SSH certificate flow metadata: - SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name>` - `geth ssh cert request --public-key <path> --principal <name>`

View file

@ -142,6 +142,17 @@ pub enum AuthCommand {
resource: String, resource: String,
capability: String, capability: String,
}, },
Grant {
subject: String,
resource: String,
capability: String,
#[arg(long)]
grant_id: Option<String>,
},
Revoke {
resource: String,
grant_id: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -350,6 +361,23 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
resource, resource,
capability, capability,
}, },
Command::Auth {
command:
AuthCommand::Grant {
subject,
resource,
capability,
grant_id,
},
} => ControlRequest::AuthGrant {
subject,
resource,
capability,
grant_id,
},
Command::Auth {
command: AuthCommand::Revoke { resource, grant_id },
} => ControlRequest::AuthRevoke { resource, grant_id },
Command::Secret { command } => ControlRequest::ModuleStub { Command::Secret { command } => ControlRequest::ModuleStub {
module: "secret".to_owned(), module: "secret".to_owned(),
command: format!("{command:?}"), command: format!("{command:?}"),
@ -575,6 +603,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("reason: {}", explain.reason); println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops); println!("evaluated_ops: {}", explain.evaluated_ops);
} }
ControlResponse::AuthOpRecorded { op } => {
println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource);
}
ControlResponse::SshCertRequested { request } => { ControlResponse::SshCertRequested { request } => {
println!("ssh cert request: {}", request.id); println!("ssh cert request: {}", request.id);
println!("status: {}", request.status); println!("status: {}", request.status);

View file

@ -1,4 +1,4 @@
use geth_auth::AuthExplanation; use geth_auth::{AuthExplanation, AuthOp};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
@ -37,6 +37,16 @@ pub enum ControlRequest {
resource: String, resource: String,
capability: String, capability: String,
}, },
AuthGrant {
subject: String,
resource: String,
capability: String,
grant_id: Option<String>,
},
AuthRevoke {
resource: String,
grant_id: String,
},
SshCertRequest { SshCertRequest {
public_key_path: PathBuf, public_key_path: PathBuf,
cert_kind: String, cert_kind: String,
@ -105,6 +115,9 @@ pub enum ControlResponse {
}, },
KeychainStatus(KeychainStatusResponse), KeychainStatus(KeychainStatusResponse),
AuthExplain(AuthExplanation), AuthExplain(AuthExplanation),
AuthOpRecorded {
op: AuthOp,
},
SshCertRequested { SshCertRequested {
request: SshCertRequest, request: SshCertRequest,
}, },

View file

@ -1,6 +1,6 @@
pub mod service; pub mod service;
use geth_auth::AuthExplanation; use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
use geth_cas::{LocalCas, hash_path}; use geth_cas::{LocalCas, hash_path};
use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_config::{GethConfig, GethPaths, RelayMode};
use geth_control::{ use geth_control::{
@ -16,10 +16,12 @@ use geth_ssh_identity::{
certificate_id, revocation_id, ssh_public_key_fingerprint, certificate_id, revocation_id, ssh_public_key_fingerprint,
}; };
use geth_store::{ use geth_store::{
Store, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate,
StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
NodeId, ResourceId, ResourceKind, ResourceName, SshCertId, SshCertRequestId, UnixMillis, AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId,
SshCertRequestId, UnixMillis,
}; };
use std::path::Path; use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -249,7 +251,10 @@ pub fn handle_request(
resource, resource,
capability, capability,
} => { } => {
if store.get_peer_card(&subject)?.is_some() { let ops = load_auth_ops_for_resource(&store, &resource)?;
let discovered = store.get_peer_card(&subject)?.is_some();
if ops.is_empty() {
if discovered {
Ok(ControlResponse::AuthExplain( Ok(ControlResponse::AuthExplain(
AuthExplanation::discovered_candidate(subject, resource, capability), AuthExplanation::discovered_candidate(subject, resource, capability),
)) ))
@ -258,6 +263,54 @@ pub fn handle_request(
subject, resource, capability, subject, resource, capability,
))) )))
} }
} else {
let mut explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(subject.clone()),
ResourceId::new(resource.clone()),
Capability::new(capability.clone()),
);
if discovered && !explanation.allowed {
explanation.reason = format!(
"subject is a discovered peer candidate only; discovery does not grant trust or authorization; {}",
explanation.reason
);
}
Ok(ControlResponse::AuthExplain(explanation))
}
}
ControlRequest::AuthGrant {
subject,
resource,
capability,
grant_id,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let grant_id =
grant_id.unwrap_or_else(|| generated_grant_id(&subject, &resource, &capability));
let op = AuthOp {
id: generated_auth_op_id("grant-create", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id,
principal: PrincipalId::new(subject),
capabilities: vec![Capability::new(capability)],
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::AuthOpRecorded { op })
}
ControlRequest::AuthRevoke { resource, grant_id } => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::AuthOpRecorded { op })
} }
ControlRequest::SshCertRequest { ControlRequest::SshCertRequest {
public_key_path, public_key_path,
@ -447,6 +500,45 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
)) ))
} }
fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&StoredAuthOp {
op_id: op.id.to_string(),
resource_id: op.resource.to_string(),
op_json: serde_json::to_string(op)?,
created_at_ms: op.created_at.0,
})?;
Ok(())
}
fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthOp>, NodeError> {
store
.list_auth_ops_for_resource(resource)?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect()
}
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!(
"grant:{}",
geth_crypto::blake3_hex(format!("{subject}\0{resource}\0{capability}").as_bytes())
)
}
fn generated_auth_op_id(
kind: &str,
resource: &str,
stable_id: &str,
created_at: UnixMillis,
) -> AuthOpId {
AuthOpId::new(format!(
"auth-op:{}",
geth_crypto::blake3_hex(
format!("{}\0{kind}\0{resource}\0{stable_id}", created_at.0).as_bytes()
)
))
}
fn stable_node_id(agent_id: &str) -> String { fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}") format!("node:{agent_id}")
} }

View file

@ -289,6 +289,35 @@ impl Store {
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn insert_auth_op(&self, op: &StoredAuthOp) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO auth_ops(op_id, resource_id, op_json, created_at_ms)
VALUES (?1, ?2, ?3, ?4)"#,
params![op.op_id, op.resource_id, op.op_json, op.created_at_ms],
)?;
Ok(())
}
pub fn list_auth_ops_for_resource(
&self,
resource_id: &str,
) -> Result<Vec<StoredAuthOp>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT op_id, resource_id, op_json, created_at_ms
FROM auth_ops WHERE resource_id = ?1 ORDER BY created_at_ms, op_id"#,
)?;
let rows = stmt.query_map(params![resource_id], |row| {
Ok(StoredAuthOp {
op_id: row.get(0)?,
resource_id: row.get(1)?,
op_json: row.get(2)?,
created_at_ms: row.get(3)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request( pub fn insert_ssh_cert_request(
&self, &self,
request: &StoredSshCertRequest, request: &StoredSshCertRequest,
@ -476,6 +505,14 @@ pub struct StoredPeerCard {
pub updated_at_ms: i64, pub updated_at_ms: i64,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredAuthOp {
pub op_id: String,
pub resource_id: String,
pub op_json: String,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest { pub struct StoredSshCertRequest {
pub request_id: String, pub request_id: String,
@ -589,4 +626,31 @@ mod tests {
); );
assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]); assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]);
} }
#[test]
fn auth_ops_roundtrip_by_resource() {
let store = Store::open_memory().expect("open");
let first = StoredAuthOp {
op_id: "op:auth:1".to_owned(),
resource_id: "resource:notes".to_owned(),
op_json: r#"{"id":"op:auth:1"}"#.to_owned(),
created_at_ms: 1,
};
let second = StoredAuthOp {
op_id: "op:auth:2".to_owned(),
resource_id: "resource:other".to_owned(),
op_json: r#"{"id":"op:auth:2"}"#.to_owned(),
created_at_ms: 2,
};
store.insert_auth_op(&second).expect("insert second");
store.insert_auth_op(&first).expect("insert first");
assert_eq!(
store
.list_auth_ops_for_resource("resource:notes")
.expect("list auth ops"),
vec![first]
);
}
} }

View file

@ -154,6 +154,75 @@ fn auth_explain_distinguishes_discovered_peer_candidates() {
} }
} }
#[test]
fn auth_grant_revoke_and_explain_use_local_auth_log() {
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthGrant {
subject: "node:laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:test-fetch".to_owned()),
},
)
.expect("grant capability");
match response {
geth_control::ControlResponse::AuthOpRecorded { op } => {
assert_eq!(op.resource.to_string(), "resource:cas:local");
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("explain grant");
match response {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(explanation.allowed);
assert_eq!(explanation.evaluated_ops, 1);
assert!(explanation.reason.contains("grant:test-fetch"));
}
other => panic!("unexpected response: {other:?}"),
}
geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthRevoke {
resource: "resource:cas:local".to_owned(),
grant_id: "grant:test-fetch".to_owned(),
},
)
.expect("revoke grant");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthExplain {
subject: "node:laptop".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.expect("explain revoke");
match response {
geth_control::ControlResponse::AuthExplain(explanation) => {
assert!(!explanation.allowed);
assert_eq!(explanation.evaluated_ops, 2);
assert!(explanation.reason.contains("no active"));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test] #[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() { fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -110,8 +110,10 @@ bindings. Revoked identity subtrees are excluded from that active view.
The authorization plane is `geth-auth`: resource-local signed operation logs, The authorization plane is `geth-auth`: resource-local signed operation logs,
grants, revocations, groups, and `auth explain`. Auth operations reduce into a grants, revocations, groups, and `auth explain`. Auth operations reduce into a
current permission view for resources, grants, groups, and bearer access. The current permission view for resources, grants, groups, and bearer access. The
library can explain direct and group grants, while daemon-side enforcement and library can explain direct and group grants. The daemon persists local auth
durable auth-log storage are still future work. grant/revoke operations and `geth auth explain` evaluates that local operation
log. Signature validation, replication, and module enforcement are 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

View file

@ -159,11 +159,15 @@ resource-scoped capability decisions.
`kv.write_prefix:apps/foo/`. `kv.write_prefix:apps/foo/`.
- Tests cover grant, revoke, group membership, and denied access. - Tests cover grant, revoke, group membership, and denied access.
- `[ ]` `auth explain` real decision path. - `[~]` `auth explain` real decision path.
Acceptance criteria: Acceptance criteria:
- `geth auth explain <subject> <resource> <capability>` reports allowed/denied. - `[x]` `geth auth grant` and `geth auth revoke` persist local auth ops.
- Output includes the operation chain or missing grant that caused the result. - `[x]` `geth auth explain <subject> <resource> <capability>` reports
- JSON output is stable enough for tests and scripts. allowed/denied from the local auth-op reducer when local ops exist.
- `[x]` Output includes the grant ID or missing grant that caused the result.
- `[x]` JSON output is stable enough for tests and scripts.
- `[ ]` Future completion requires signed-op validation before accepting
replicated auth ops.
- `[ ]` Resource secrets and bearer invites. - `[ ]` Resource secrets and bearer invites.
Acceptance criteria: Acceptance criteria: