Add resource-scoped bearer access metadata
This commit is contained in:
parent
a05112e6a2
commit
d072843cac
10 changed files with 344 additions and 11 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue