Enforce SSH workflow capabilities locally

This commit is contained in:
Eric Wendland 2026-05-19 15:44:13 +02:00
commit f7e85960f7
8 changed files with 328 additions and 62 deletions

View file

@ -403,8 +403,13 @@ pub enum SshCertCommand {
renewal_of: Option<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
subject: Option<String>,
},
Requests {
#[arg(long)]
subject: Option<String>,
},
Requests,
Approve {
request_id: String,
#[arg(long)]
@ -415,13 +420,20 @@ pub enum SshCertCommand {
serial: Option<u64>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
subject: Option<String>,
},
Import {
request_id: String,
#[arg(long)]
cert: PathBuf,
#[arg(long)]
subject: Option<String>,
},
List {
#[arg(long)]
subject: Option<String>,
},
List,
Sync {
node: String,
},
@ -434,8 +446,13 @@ pub enum SshRevocationCommand {
target: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
subject: Option<String>,
},
List {
#[arg(long)]
subject: Option<String>,
},
List,
Export {
#[arg(long)]
out: PathBuf,
@ -443,11 +460,15 @@ pub enum SshRevocationCommand {
format: String,
#[arg(long)]
ca_public: Option<PathBuf>,
#[arg(long)]
subject: Option<String>,
},
Import {
path: PathBuf,
#[arg(long, default_value = "jsonl")]
format: String,
#[arg(long)]
subject: Option<String>,
},
Sync {
node: String,
@ -701,6 +722,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
valid_for,
renewal_of,
reason,
subject,
} => ControlRequest::SshCertRequest {
public_key_path: public_key,
cert_kind: kind,
@ -708,26 +730,34 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
requested_validity: valid_for,
renewal_of,
reason,
subject,
},
SshCertCommand::Requests => ControlRequest::SshCertRequests,
SshCertCommand::Requests { subject } => ControlRequest::SshCertRequests { subject },
SshCertCommand::Approve {
request_id,
ca_key,
valid_for,
serial,
out,
subject,
} => ControlRequest::SshCertApprove {
request_id,
ca_key_path: ca_key,
valid_for,
serial,
out,
subject,
},
SshCertCommand::Import { request_id, cert } => ControlRequest::SshCertImport {
SshCertCommand::Import {
request_id,
cert,
subject,
} => ControlRequest::SshCertImport {
request_id,
cert_path: cert,
subject,
},
SshCertCommand::List => ControlRequest::SshCertList,
SshCertCommand::List { subject } => ControlRequest::SshCertList { subject },
SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node },
},
SshCommand::Revocation { command } => match command {
@ -735,24 +765,36 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
kind,
target,
reason,
subject,
} => ControlRequest::SshRevocationAdd {
kind,
target,
reason,
subject,
},
SshRevocationCommand::List => ControlRequest::SshRevocationList,
SshRevocationCommand::List { subject } => {
ControlRequest::SshRevocationList { subject }
}
SshRevocationCommand::Export {
out,
format,
ca_public,
subject,
} => ControlRequest::SshRevocationExport {
out,
format,
ca_public,
subject,
},
SshRevocationCommand::Import {
path,
format,
subject,
} => ControlRequest::SshRevocationImport {
path,
format,
subject,
},
SshRevocationCommand::Import { path, format } => {
ControlRequest::SshRevocationImport { path, format }
}
SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node },
},
},

View file

@ -139,20 +139,27 @@ pub enum ControlRequest {
requested_validity: Option<String>,
renewal_of: Option<String>,
reason: Option<String>,
subject: Option<String>,
},
SshCertRequests {
subject: Option<String>,
},
SshCertRequests,
SshCertApprove {
request_id: String,
ca_key_path: PathBuf,
valid_for: Option<String>,
serial: Option<u64>,
out: Option<PathBuf>,
subject: Option<String>,
},
SshCertImport {
request_id: String,
cert_path: PathBuf,
subject: Option<String>,
},
SshCertList {
subject: Option<String>,
},
SshCertList,
SshCertSync {
node: String,
},
@ -160,16 +167,21 @@ pub enum ControlRequest {
kind: String,
target: String,
reason: Option<String>,
subject: Option<String>,
},
SshRevocationList {
subject: Option<String>,
},
SshRevocationList,
SshRevocationExport {
out: PathBuf,
format: String,
ca_public: Option<PathBuf>,
subject: Option<String>,
},
SshRevocationImport {
path: PathBuf,
format: String,
subject: Option<String>,
},
SshRevocationSync {
node: String,
@ -920,6 +932,7 @@ mod tests {
out: PathBuf::from("revocations.krl-spec"),
format: "openssh-krl-spec".to_owned(),
ca_public: None,
subject: None,
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -929,6 +942,7 @@ mod tests {
let request = ControlRequest::SshRevocationImport {
path: PathBuf::from("revocations.jsonl"),
format: "jsonl".to_owned(),
subject: None,
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),

View file

@ -2965,7 +2965,15 @@ pub fn handle_request(
requested_validity,
renewal_of,
reason,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.request",
)?;
if principals.is_empty() {
return Err(NodeError::MissingSshCertPrincipal);
}
@ -2995,20 +3003,37 @@ pub fn handle_request(
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
Ok(ControlResponse::SshCertRequested { request })
}
ControlRequest::SshCertRequests => Ok(ControlResponse::SshCertRequests {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshCertRequests { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.read",
)?;
Ok(ControlResponse::SshCertRequests {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
})
}
ControlRequest::SshCertApprove {
request_id,
ca_key_path,
valid_for,
serial,
out,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.approve",
)?;
let stored = store
.get_ssh_cert_request(&request_id)?
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
@ -3051,7 +3076,15 @@ pub fn handle_request(
ControlRequest::SshCertImport {
request_id,
cert_path,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.import",
)?;
let certificate = std::fs::read_to_string(&cert_path)?;
let record = SshCertificateRecord {
id: certificate_id(&certificate),
@ -3069,23 +3102,40 @@ pub fn handle_request(
certificate: record,
})
}
ControlRequest::SshCertList => Ok(ControlResponse::SshCertList {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
certificates: store
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
}),
ControlRequest::SshCertList { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.read",
)?;
Ok(ControlResponse::SshCertList {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
certificates: store
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
})
}
ControlRequest::SshRevocationAdd {
kind,
target,
reason,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.publish",
)?;
let kind = kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
@ -3101,18 +3151,35 @@ pub fn handle_request(
store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?;
Ok(ControlResponse::SshRevocationAdded { revocation })
}
ControlRequest::SshRevocationList => Ok(ControlResponse::SshRevocationList {
revocations: store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshRevocationList { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.read",
)?;
Ok(ControlResponse::SshRevocationList {
revocations: store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?,
})
}
ControlRequest::SshRevocationExport {
out,
format,
ca_public,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.read",
)?;
let revocations = store
.list_ssh_revocations()?
.into_iter()
@ -3158,7 +3225,18 @@ pub fn handle_request(
note,
})
}
ControlRequest::SshRevocationImport { path, format } => {
ControlRequest::SshRevocationImport {
path,
format,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.import",
)?;
let body = std::fs::read_to_string(&path)?;
let created_at = UnixMillis(geth_store::now_ms());
let revocations = match format.as_str() {
@ -3672,6 +3750,20 @@ fn ensure_kv_write_authorized(
subject: Option<String>,
resource_id: &str,
key: &str,
) -> Result<(), NodeError> {
let Some(subject) = subject else {
return Ok(());
};
let capability = format!("kv.write_key:{key}");
ensure_subject_authorized(store, node, Some(&subject), resource_id, &capability)
}
fn ensure_subject_authorized(
store: &Store,
node: &LocalNode,
subject: Option<&str>,
resource_id: &str,
capability: &str,
) -> Result<(), NodeError> {
let Some(subject) = subject else {
return Ok(());
@ -3680,13 +3772,12 @@ fn ensure_kv_write_authorized(
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()),
PrincipalId::new(subject.to_owned()),
ResourceId::new(resource_id.to_owned()),
Capability::new(capability.clone()),
Capability::new(capability.to_owned()),
);
if explanation.allowed {
Ok(())
@ -4241,6 +4332,7 @@ mod tests {
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("test sync".to_owned()),
subject: None,
},
)
.expect("right ssh cert request");
@ -4254,6 +4346,7 @@ mod tests {
kind: "key-id".to_owned(),
target: "old-node-key".to_owned(),
reason: Some("test sync".to_owned()),
subject: None,
},
)
.expect("right ssh revocation");
@ -5004,6 +5097,7 @@ mod tests {
requested_validity: Some("+4w".to_owned()),
renewal_of: None,
reason: Some("background sync test".to_owned()),
subject: None,
},
)
.expect("right second ssh cert request");
@ -5017,6 +5111,7 @@ mod tests {
kind: "key-id".to_owned(),
target: "newly-revoked-key".to_owned(),
reason: Some("background sync test".to_owned()),
subject: None,
},
)
.expect("right second ssh revocation");

View file

@ -1375,6 +1375,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("renewal".to_owned()),
subject: None,
},
)
.expect("request cert");
@ -1391,6 +1392,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
valid_for: Some("+4w".to_owned()),
serial: Some(42),
out: None,
subject: None,
},
)
.expect("approve cert");
@ -1410,6 +1412,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
kind: "public-key".to_owned(),
target: "ssh:blake3:test".to_owned(),
reason: Some("lost key".to_owned()),
subject: None,
},
)
.expect("add revocation");
@ -1419,6 +1422,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
out: export_path.clone(),
format: "jsonl".to_owned(),
ca_public: None,
subject: None,
},
)
.expect("export revocations");
@ -1435,6 +1439,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
out: krl_spec_path.clone(),
format: "openssh-krl-spec".to_owned(),
ca_public: None,
subject: None,
},
)
.expect("export revocation krl spec");
@ -1465,6 +1470,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationImport {
path: krl_spec_path,
format: "openssh-krl-spec".to_owned(),
subject: None,
},
)
.expect("import krl spec");
@ -1490,6 +1496,7 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
geth_control::ControlRequest::SshRevocationImport {
path: home.path().join("revocations.jsonl"),
format: "jsonl".to_owned(),
subject: None,
},
)
.expect("import jsonl revocations");
@ -1501,6 +1508,89 @@ fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
}
}
#[test]
fn ssh_cert_and_revocation_commands_check_subject_capabilities() {
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 public_key_path = home.path().join("id_ed25519.pub");
std::fs::write(&public_key_path, "ssh-ed25519 AAAATEST eric@geth\n").expect("write pubkey");
assert!(
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshCertRequest {
public_key_path: public_key_path.clone(),
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("unauthorized".to_owned()),
subject: Some("node:ssh-operator".to_owned()),
},
)
.is_err()
);
geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthGrant {
subject: "node:ssh-operator".to_owned(),
resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.request".to_owned(),
grant_id: Some("grant:ssh-cert-request".to_owned()),
},
)
.expect("grant cert request");
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshCertRequest {
public_key_path,
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("authorized".to_owned()),
subject: Some("node:ssh-operator".to_owned()),
},
)
.expect("authorized cert request");
assert!(
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshRevocationAdd {
kind: "key-id".to_owned(),
target: "old-key".to_owned(),
reason: Some("unauthorized".to_owned()),
subject: Some("node:ssh-operator".to_owned()),
},
)
.is_err()
);
geth_node::handle_request(
&node,
geth_control::ControlRequest::AuthGrant {
subject: "node:ssh-operator".to_owned(),
resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.publish".to_owned(),
grant_id: Some("grant:ssh-revocation-publish".to_owned()),
},
)
.expect("grant revocation publish");
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshRevocationAdd {
kind: "key-id".to_owned(),
target: "old-key".to_owned(),
reason: Some("authorized".to_owned()),
subject: Some("node:ssh-operator".to_owned()),
},
)
.expect("authorized revocation add");
}
#[test]
fn ssh_revocation_export_can_write_binary_openssh_krl() {
if Command::new("ssh-keygen").arg("-?").output().is_err() {
@ -1531,6 +1621,7 @@ fn ssh_revocation_export_can_write_binary_openssh_krl() {
kind: "public-key".to_owned(),
target: public_key,
reason: Some("test binary krl".to_owned()),
subject: None,
},
)
.expect("add revocation");
@ -1541,6 +1632,7 @@ fn ssh_revocation_export_can_write_binary_openssh_krl() {
out: krl_path.clone(),
format: "openssh-krl".to_owned(),
ca_public: None,
subject: None,
},
)
.expect("export binary krl");