Add resource-scoped bearer access metadata

This commit is contained in:
Eric Wendland 2026-05-17 02:58:58 +02:00
commit d072843cac
10 changed files with 344 additions and 11 deletions

View file

@ -126,8 +126,9 @@ Roadmap items should be actionable and checkable:
- 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.
- Resource secret epoch metadata can be created, rotated, and listed locally. - Resource secret epoch metadata can be created, rotated, and listed locally.
Payload encryption, key envelopes, and bearer invite enforcement are still Bearer access metadata can be created/listed/revoked as resource-scoped auth
roadmap work. ops and must not allow trust graph mutation capabilities. Payload encryption,
key envelopes, and bearer challenge-response 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

@ -79,6 +79,9 @@ The bootstrap implementation provides:
- `geth secret status` - `geth secret status`
- `geth secret create <resource>` - `geth secret create <resource>`
- `geth secret rotate <resource>` - `geth secret rotate <resource>`
- `geth secret bearer create <resource> --capability <capability>`
- `geth secret bearer list`
- `geth secret bearer revoke <resource> <secret>`
- `geth auth explain <subject> <resource> <capability>` - `geth auth explain <subject> <resource> <capability>`
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]` - `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id>` - `geth auth revoke <resource> <grant-id>`

View file

@ -161,8 +161,32 @@ pub enum AuthCommand {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum SecretCommand { pub enum SecretCommand {
Status, Status,
Create { resource: String }, Create {
Rotate { resource: String }, resource: String,
},
Rotate {
resource: String,
},
Bearer {
#[command(subcommand)]
command: SecretBearerCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum SecretBearerCommand {
Create {
resource: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
#[arg(long)]
expires_at_ms: Option<i64>,
},
List,
Revoke {
resource: String,
secret: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -396,6 +420,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
SecretCommand::Status => ControlRequest::SecretStatus, SecretCommand::Status => ControlRequest::SecretStatus,
SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource }, SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource },
SecretCommand::Rotate { resource } => ControlRequest::SecretRotate { resource }, SecretCommand::Rotate { resource } => ControlRequest::SecretRotate { resource },
SecretCommand::Bearer { command } => match command {
SecretBearerCommand::Create {
resource,
capabilities,
expires_at_ms,
} => ControlRequest::SecretBearerCreate {
resource,
capabilities,
expires_at_ms,
},
SecretBearerCommand::List => ControlRequest::SecretBearerList,
SecretBearerCommand::Revoke { resource, secret } => {
ControlRequest::SecretBearerRevoke { resource, secret }
}
},
}, },
Command::Cas { command } => match command { Command::Cas { command } => match command {
CasCommand::Add { path } => ControlRequest::CasAdd { path }, CasCommand::Add { path } => ControlRequest::CasAdd { path },
@ -653,6 +692,46 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("resource: {}", secret.resource); println!("resource: {}", secret.resource);
println!("epoch: {}", secret.epoch); println!("epoch: {}", secret.epoch);
} }
ControlResponse::SecretBearerCreated { access } => {
println!("bearer secret: {}", access.secret);
println!("resource: {}", access.resource);
println!(
"capabilities: {}",
access
.capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",")
);
if let Some(expires_at) = access.expires_at {
println!("expires_at_ms: {}", expires_at.0);
}
println!("may_delegate: {}", access.may_delegate);
}
ControlResponse::SecretBearerList { access } => {
if access.is_empty() {
println!("no bearer access");
} else {
for item in access {
println!(
"{}\t{}\t{}\tmay_delegate={}",
item.secret,
item.resource,
item.capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
item.may_delegate
);
}
}
}
ControlResponse::SecretBearerRevoked { resource, secret } => {
println!("revoked bearer secret: {secret}");
println!("resource: {resource}");
}
ControlResponse::AuthExplain(explain) => { ControlResponse::AuthExplain(explain) => {
println!("allowed: {}", explain.allowed); println!("allowed: {}", explain.allowed);
println!("subject: {}", explain.subject); println!("subject: {}", explain.subject);

View file

@ -4,7 +4,7 @@ use geth_document::DocumentResource;
use geth_keychain::KeychainOp; use geth_keychain::KeychainOp;
use geth_kv::{KvEntry, KvResource}; use geth_kv::{KvEntry, KvResource};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::ResourceMasterSecret; use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
}; };
@ -56,6 +56,16 @@ pub enum ControlRequest {
SecretRotate { SecretRotate {
resource: String, resource: String,
}, },
SecretBearerCreate {
resource: String,
capabilities: Vec<String>,
expires_at_ms: Option<i64>,
},
SecretBearerList,
SecretBearerRevoke {
resource: String,
secret: String,
},
AuthExplain { AuthExplain {
subject: String, subject: String,
resource: String, resource: String,
@ -181,6 +191,16 @@ pub enum ControlResponse {
SecretCreated { SecretCreated {
secret: ResourceMasterSecret, secret: ResourceMasterSecret,
}, },
SecretBearerCreated {
access: BearerAccess,
},
SecretBearerList {
access: Vec<BearerAccess>,
},
SecretBearerRevoked {
resource: String,
secret: String,
},
AuthExplain(AuthExplanation), AuthExplain(AuthExplanation),
AuthOpRecorded { AuthOpRecorded {
op: AuthOp, op: AuthOp,

View file

@ -14,7 +14,7 @@ use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}
use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_kv::{KvEntry, KvResource}; use geth_kv::{KvEntry, KvResource};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::ResourceMasterSecret; use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id,
@ -71,6 +71,8 @@ pub enum NodeError {
DocumentNotFound(String), DocumentNotFound(String),
#[error("resource not found: {0}")] #[error("resource not found: {0}")]
ResourceNotFound(String), ResourceNotFound(String),
#[error("secrets error: {0}")]
Secrets(#[from] geth_secrets::SecretsError),
#[error("invalid ssh certificate kind: {0}")] #[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String), InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")] #[error("invalid ssh certificate request status: {0}")]
@ -360,6 +362,73 @@ pub fn handle_request(
let secret = create_resource_secret(&store, &resource, next_epoch)?; let secret = create_resource_secret(&store, &resource, next_epoch)?;
Ok(ControlResponse::SecretCreated { secret }) Ok(ControlResponse::SecretCreated { secret })
} }
ControlRequest::SecretBearerCreate {
resource,
capabilities,
expires_at_ms,
} => {
ensure_resource_exists(&store, &resource)?;
let capabilities = capabilities
.into_iter()
.map(Capability::new)
.collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&capabilities)?;
let created_at = UnixMillis(geth_store::now_ms());
let secret = geth_types::SecretId::new(format!(
"bearer:{}",
geth_crypto::blake3_hex(
format!(
"{resource}\0{}\0{}",
capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
created_at.0
)
.as_bytes()
)
));
let access = BearerAccess::resource_scoped(
secret.clone(),
ResourceId::new(resource.clone()),
capabilities.clone(),
);
let op = AuthOp {
id: generated_auth_op_id("bearer-create", &resource, secret.as_str(), created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::BearerAccessCreate {
secret,
capabilities,
expires_at: expires_at_ms.map(UnixMillis),
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::SecretBearerCreated {
access: BearerAccess {
expires_at: expires_at_ms.map(UnixMillis),
..access
},
})
}
ControlRequest::SecretBearerList => Ok(ControlResponse::SecretBearerList {
access: load_bearer_access(&store)?,
}),
ControlRequest::SecretBearerRevoke { resource, secret } => {
ensure_resource_exists(&store, &resource)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("bearer-revoke", &resource, &secret, created_at),
resource: ResourceId::new(resource.clone()),
created_at,
kind: AuthOpKind::BearerAccessRevoke {
secret: secret.clone().into(),
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::SecretBearerRevoked { resource, secret })
}
ControlRequest::AuthExplain { ControlRequest::AuthExplain {
subject, subject,
resource, resource,
@ -831,6 +900,26 @@ fn resource_secret_from_stored(stored: StoredResourceSecret) -> ResourceMasterSe
} }
} }
fn load_bearer_access(store: &Store) -> Result<Vec<BearerAccess>, NodeError> {
let ops = store
.list_auth_ops()?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect::<Result<Vec<AuthOp>, NodeError>>()?;
let view = geth_auth::reduce_auth_ops(&ops);
Ok(view
.bearer_access
.into_values()
.map(|record| BearerAccess {
secret: record.secret,
resource: record.resource,
capabilities: record.capabilities,
expires_at: record.expires_at,
may_delegate: false,
})
.collect())
}
fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&StoredAuthOp { store.insert_auth_op(&StoredAuthOp {
op_id: op.id.to_string(), op_id: op.id.to_string(),

View file

@ -45,3 +45,55 @@ impl BearerAccess {
} }
} }
} }
#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
#[error("bearer access must grant at least one capability")]
EmptyBearerCapabilities,
#[error("capability is not allowed for bearer access: {0}")]
ForbiddenBearerCapability(String),
}
pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), SecretsError> {
if capabilities.is_empty() {
return Err(SecretsError::EmptyBearerCapabilities);
}
for capability in capabilities {
let capability = capability.as_str();
if matches!(
capability,
"auth.delegate"
| "auth.revoke"
| "trust.modify"
| "ssh_proxy.admin_shell"
| "node.enroll"
) {
return Err(SecretsError::ForbiddenBearerCapability(
capability.to_owned(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bearer_capabilities_reject_trust_mutation() {
assert!(validate_bearer_capabilities(&["kv.read".into(), "kv.write".into()]).is_ok());
assert!(matches!(
validate_bearer_capabilities(&[]),
Err(SecretsError::EmptyBearerCapabilities)
));
assert!(matches!(
validate_bearer_capabilities(&["auth.delegate".into()]),
Err(SecretsError::ForbiddenBearerCapability(_))
));
assert!(matches!(
validate_bearer_capabilities(&["node.enroll".into()]),
Err(SecretsError::ForbiddenBearerCapability(_))
));
}
}

View file

@ -582,6 +582,23 @@ impl Store {
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn list_auth_ops(&self) -> Result<Vec<StoredAuthOp>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT op_id, resource_id, op_json, created_at_ms
FROM auth_ops ORDER BY resource_id, created_at_ms, op_id"#,
)?;
let rows = stmt.query_map([], |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_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> { pub fn insert_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms) r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms)
@ -995,6 +1012,7 @@ mod tests {
.expect("list auth ops"), .expect("list auth ops"),
vec![first] vec![first]
); );
assert_eq!(store.list_auth_ops().expect("list all auth ops").len(), 2);
} }
#[test] #[test]

View file

@ -667,6 +667,73 @@ fn secret_create_rotate_and_status_track_resource_epochs() {
); );
} }
#[test]
fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
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::SecretBearerCreate {
resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.fetch".to_owned(), "cas.pin".to_owned()],
expires_at_ms: Some(1234),
},
)
.expect("create bearer");
let secret = match response {
geth_control::ControlResponse::SecretBearerCreated { access } => {
assert_eq!(access.resource.to_string(), "resource:cas:local");
assert_eq!(access.capabilities.len(), 2);
assert_eq!(access.expires_at.map(|expires_at| expires_at.0), Some(1234));
assert!(!access.may_delegate);
access.secret.to_string()
}
other => panic!("unexpected response: {other:?}"),
};
let response = geth_node::handle_request(&node, geth_control::ControlRequest::SecretBearerList)
.expect("list bearer");
match response {
geth_control::ControlResponse::SecretBearerList { access } => {
assert_eq!(access.len(), 1);
assert_eq!(access[0].secret.to_string(), secret);
assert!(!access[0].may_delegate);
}
other => panic!("unexpected response: {other:?}"),
}
geth_node::handle_request(
&node,
geth_control::ControlRequest::SecretBearerRevoke {
resource: "resource:cas:local".to_owned(),
secret: secret.clone(),
},
)
.expect("revoke bearer");
let response = geth_node::handle_request(&node, geth_control::ControlRequest::SecretBearerList)
.expect("list revoked bearer");
match response {
geth_control::ControlResponse::SecretBearerList { access } => {
assert!(access.is_empty());
}
other => panic!("unexpected response: {other:?}"),
}
assert!(
geth_node::handle_request(
&node,
geth_control::ControlRequest::SecretBearerCreate {
resource: "resource:cas:local".to_owned(),
capabilities: vec!["auth.delegate".to_owned()],
expires_at_ms: None,
},
)
.is_err()
);
}
#[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

@ -140,8 +140,10 @@ for CLI/control output, but it is not the signed representation.
The payload access plane is `geth-secrets`: resource master secrets, epochs, The payload access plane is `geth-secrets`: resource master secrets, epochs,
key envelopes, bearer secrets, and rotation. Revocation for private data is key envelopes, bearer secrets, and rotation. Revocation for private data is
modeled initially as secret epoch rotation. The daemon persists resource secret modeled initially as secret epoch rotation. The daemon persists resource secret
epoch metadata through `secret create/rotate/status`, but it does not yet store epoch metadata through `secret create/rotate/status`. Bearer access is recorded
payload key material, encrypt resource data, or distribute key envelopes. as resource-scoped auth operations and rejects trust-mutation capabilities such
as `auth.delegate`, `auth.revoke`, and `node.enroll`. The daemon does not yet
store payload key material, encrypt resource data, or distribute key envelopes.
## Multi-User Direction ## Multi-User Direction

View file

@ -181,9 +181,11 @@ resource-scoped capability decisions.
- `[x]` `geth secret rotate <resource>` records the next resource secret - `[x]` `geth secret rotate <resource>` records the next resource secret
epoch. epoch.
- `[x]` Secret epoch rotation is represented in durable metadata. - `[x]` Secret epoch rotation is represented in durable metadata.
- `[ ]` Bearer secrets grant only resource-scoped capabilities. - `[x]` Bearer secrets grant only resource-scoped capabilities.
- `[ ]` Bearer principals cannot mutate trust graph state by default. - `[x]` Bearer principals cannot mutate trust graph state by default.
- `[ ]` Tests verify bearer access does not imply node identity. - `[x]` Tests verify bearer access does not imply node identity.
- `[ ]` Future completion requires bearer challenge-response proof instead
of metadata-only local records.
- `[~]` SSH certificate and revocation lifecycle. - `[~]` SSH certificate and revocation lifecycle.
Acceptance criteria: Acceptance criteria: