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
local-only status. Automerge editing/state and sync are still roadmap work.
- Resource secret epoch metadata can be created, rotated, and listed locally.
Payload encryption, key envelopes, and bearer invite enforcement are still
roadmap work.
Bearer access metadata can be created/listed/revoked as resource-scoped auth
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,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
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 create <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 grant <subject> <resource> <capability> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id>`

View file

@ -161,8 +161,32 @@ pub enum AuthCommand {
#[derive(Debug, Subcommand)]
pub enum SecretCommand {
Status,
Create { resource: String },
Rotate { resource: String },
Create {
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)]
@ -396,6 +420,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
SecretCommand::Status => ControlRequest::SecretStatus,
SecretCommand::Create { resource } => ControlRequest::SecretCreate { 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 {
CasCommand::Add { path } => ControlRequest::CasAdd { path },
@ -653,6 +692,46 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("resource: {}", secret.resource);
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) => {
println!("allowed: {}", explain.allowed);
println!("subject: {}", explain.subject);

View file

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

View file

@ -14,7 +14,7 @@ use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}
use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_kv::{KvEntry, KvResource};
use geth_resource::ResourceDescriptor;
use geth_secrets::ResourceMasterSecret;
use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id,
@ -71,6 +71,8 @@ pub enum NodeError {
DocumentNotFound(String),
#[error("resource not found: {0}")]
ResourceNotFound(String),
#[error("secrets error: {0}")]
Secrets(#[from] geth_secrets::SecretsError),
#[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
@ -360,6 +362,73 @@ pub fn handle_request(
let secret = create_resource_secret(&store, &resource, next_epoch)?;
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 {
subject,
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> {
store.insert_auth_op(&StoredAuthOp {
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)
}
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> {
self.conn.execute(
r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms)
@ -995,6 +1012,7 @@ mod tests {
.expect("list auth ops"),
vec![first]
);
assert_eq!(store.list_auth_ops().expect("list all auth ops").len(), 2);
}
#[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]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
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,
key envelopes, bearer secrets, and rotation. Revocation for private data is
modeled initially as secret epoch rotation. The daemon persists resource secret
epoch metadata through `secret create/rotate/status`, but it does not yet store
payload key material, encrypt resource data, or distribute key envelopes.
epoch metadata through `secret create/rotate/status`. Bearer access is recorded
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

View file

@ -181,9 +181,11 @@ resource-scoped capability decisions.
- `[x]` `geth secret rotate <resource>` records the next resource secret
epoch.
- `[x]` Secret epoch rotation is represented in durable metadata.
- `[ ]` Bearer secrets grant only resource-scoped capabilities.
- `[ ]` Bearer principals cannot mutate trust graph state by default.
- `[ ]` Tests verify bearer access does not imply node identity.
- `[x]` Bearer secrets grant only resource-scoped capabilities.
- `[x]` Bearer principals cannot mutate trust graph state by default.
- `[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.
Acceptance criteria: