use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerIdentity {
pub id: String,
#[serde(default)]
pub token_sha256: Option<String>,
#[serde(default)]
pub client_cert: Option<String>,
pub tasks: Vec<String>,
#[serde(default)]
pub runtime: Option<String>,
}
impl WorkerIdentity {
pub fn may_serve(&self, task: &str) -> bool {
self.tasks.iter().any(|p| pattern_matches(p, task))
}
}
pub fn pattern_matches(pattern: &str, task: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => task.starts_with(prefix),
None => pattern == task,
}
}
pub const CLIENT_ACTIONS: &[&str] = &["start", "signal", "query", "read", "cancel", "schedule"];
fn default_client_actions() -> Vec<String> {
["start", "signal", "query", "read"]
.iter()
.map(|s| s.to_string())
.collect()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientIdentity {
pub id: String,
#[serde(default)]
pub token_sha256: Option<String>,
#[serde(default)]
pub client_cert: Option<String>,
pub workflows: Vec<String>,
#[serde(default = "default_client_actions")]
pub actions: Vec<String>,
}
impl ClientIdentity {
pub fn allows(&self, action: &str, workflow: &str) -> bool {
self.actions.iter().any(|a| a == action) && self.workflows.iter().any(|p| glob(p, workflow))
}
pub fn can_read(&self, workflow: &str) -> bool {
self.allows("read", workflow)
}
}
fn glob(pattern: &str, name: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => pattern == name,
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkersFile {
#[serde(default, rename = "worker")]
pub workers: Vec<WorkerIdentity>,
#[serde(default, rename = "client")]
pub clients: Vec<ClientIdentity>,
}
#[derive(Debug, Default)]
pub struct Identities {
workers: Vec<WorkerIdentity>,
clients: Vec<ClientIdentity>,
}
pub fn sha256_hex(s: &str) -> String {
Sha256::digest(s.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
impl Identities {
pub fn load(path: &Path) -> Result<Identities, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn parse(text: &str) -> Result<Identities, String> {
let file: WorkersFile = toml::from_str(text).map_err(|e| e.to_string())?;
let mut seen = std::collections::HashSet::new();
for w in &file.workers {
if w.id.trim().is_empty() {
return Err("a worker has an empty `id`".into());
}
if !seen.insert(w.id.clone()) {
return Err(format!("worker `{}` is declared twice", w.id));
}
if w.token_sha256.is_none() && w.client_cert.is_none() {
return Err(format!(
"worker `{}` needs `token_sha256` or `client_cert`",
w.id
));
}
if let Some(h) = &w.token_sha256 {
if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"worker `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
w.id
));
}
}
if w.tasks.is_empty() {
return Err(format!(
"worker `{}` must list at least one task pattern",
w.id
));
}
}
for c in &file.clients {
if c.id.trim().is_empty() {
return Err("a client has an empty `id`".into());
}
if !seen.insert(c.id.clone()) {
return Err(format!("identity `{}` is declared twice", c.id));
}
if c.token_sha256.is_none() && c.client_cert.is_none() {
return Err(format!(
"client `{}` needs `token_sha256` or `client_cert`",
c.id
));
}
if let Some(h) = &c.token_sha256 {
if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"client `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
c.id
));
}
}
if c.workflows.is_empty() {
return Err(format!(
"client `{}` must list at least one workflow pattern",
c.id
));
}
if let Some(bad) = c
.actions
.iter()
.find(|a| !CLIENT_ACTIONS.contains(&a.as_str()))
{
return Err(format!(
"client `{}`: unknown action `{bad}` (allowed: {})",
c.id,
CLIENT_ACTIONS.join(", ")
));
}
}
Ok(Identities {
workers: file.workers,
clients: file.clients,
})
}
pub fn is_empty(&self) -> bool {
self.workers.is_empty() && self.clients.is_empty()
}
pub fn client_by_token(&self, token: &str) -> Option<&ClientIdentity> {
let h = sha256_hex(token);
self.clients.iter().find(|c| {
c.token_sha256
.as_ref()
.is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
})
}
pub fn client_by_certificate(&self, der: &[u8]) -> Option<&ClientIdentity> {
let names = certificate_names(der);
self.clients.iter().find(|c| {
c.client_cert
.as_ref()
.is_some_and(|n| names.iter().any(|x| x.eq_ignore_ascii_case(n)))
})
}
pub fn len(&self) -> usize {
self.workers.len()
}
pub fn by_token(&self, token: &str) -> Option<&WorkerIdentity> {
let h = sha256_hex(token);
self.workers.iter().find(|w| {
w.token_sha256
.as_ref()
.is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
})
}
pub fn by_certificate(&self, der: &[u8]) -> Option<&WorkerIdentity> {
let names = certificate_names(der);
self.workers.iter().find(|w| {
w.client_cert
.as_ref()
.is_some_and(|c| names.iter().any(|n| n.eq_ignore_ascii_case(c)))
})
}
}
pub fn certificate_names(der: &[u8]) -> Vec<String> {
let Ok((_, cert)) = x509_parser::parse_x509_certificate(der) else {
return vec![];
};
let mut names: Vec<String> = cert
.subject()
.iter_common_name()
.filter_map(|cn| cn.as_str().ok().map(str::to_string))
.collect();
if let Ok(Some(san)) = cert.subject_alternative_name() {
for n in &san.value.general_names {
if let x509_parser::extensions::GeneralName::DNSName(d) = n {
names.push(d.to_string());
}
}
}
names
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_matches() {
let token_hash = sha256_hex("s3cret");
let ids = Identities::parse(&format!(
r#"
[[worker]]
id = "pay"
token_sha256 = "{token_hash}"
tasks = ["payments.*", "audit.log"]
[[worker]]
id = "inv"
client_cert = "inventory.internal"
tasks = ["*"]
"#
))
.unwrap();
let pay = ids.by_token("s3cret").unwrap();
assert_eq!(pay.id, "pay");
assert!(pay.may_serve("payments.charge") && pay.may_serve("audit.log"));
assert!(!pay.may_serve("inventory.reserve") && !pay.may_serve("audit.logs"));
assert!(ids.by_token("wrong").is_none());
assert!(
Identities::parse("[[worker]]\nid = \"x\"\ntasks = [\"*\"]").is_err(),
"needs a credential"
);
assert!(Identities::parse(
"[[worker]]\nid = \"x\"\ntoken_sha256 = \"abc\"\ntasks = [\"*\"]"
)
.is_err());
assert!(Identities::parse(
"[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\nbogus = 1"
)
.is_err());
}
#[test]
fn client_identities_grant_workflows_and_actions() {
let h = sha256_hex("app-token");
let ids = Identities::parse(&format!(
r#"
[[client]]
id = "billing"
token_sha256 = "{h}"
workflows = ["Invoice*", "Refund"]
[[client]]
id = "ops"
client_cert = "ops.internal"
workflows = ["*"]
actions = ["read", "cancel"]
"#
))
.unwrap();
assert!(!ids.is_empty());
let billing = ids.client_by_token("app-token").unwrap();
assert!(billing.allows("start", "InvoiceMonthly") && billing.allows("signal", "Refund"));
assert!(
!billing.allows("cancel", "Refund"),
"cancel is not a default action"
);
assert!(!billing.allows("start", "Payroll"));
assert!(
ids.by_token("app-token").is_none(),
"a client is not a worker"
);
let bad_action = "[[client]]\nid = \"x\"\nclient_cert = \"a\"\nworkflows = [\"*\"]\nactions = [\"deploy\"]";
assert!(Identities::parse(bad_action)
.unwrap_err()
.contains("unknown action"));
let dup =
"[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\n[[client]]\nid = \"x\"\nclient_cert = \"b\"\nworkflows = [\"*\"]";
assert!(Identities::parse(dup)
.unwrap_err()
.contains("declared twice"));
}
}