Skip to main content

orbit_runtime/
identity.rs

1//! Per-worker identities.
2//!
3//! A workers file (`orbit server --workers-file workers.toml`) declares every
4//! worker that may connect, how it authenticates, and what it may do:
5//!
6//! ```toml
7//! [[worker]]
8//! id = "payments-1"
9//! # SHA-256 of the bearer token (hex). Generate with `orbit token`.
10//! token_sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
11//! tasks = ["payments.*"]        # task patterns this worker may serve
12//! runtime = "python"            # optional: must match the announced runtime
13//!
14//! [[worker]]
15//! id = "inventory"
16//! # Authenticate with a client certificate (mutual TLS) instead of a token:
17//! # matched against the certificate's DNS SANs and subject common name.
18//! client_cert = "inventory.workers.internal"
19//! tasks = ["inventory.reserve", "inventory.release"]
20//! ```
21//!
22//! A worker can register only under its own `id`, announce only tasks its
23//! patterns allow, and use only its own sessions and task tokens.
24
25use serde::Deserialize;
26use sha2::{Digest, Sha256};
27use std::path::Path;
28
29#[derive(Debug, Clone, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct WorkerIdentity {
32    pub id: String,
33    /// Hex SHA-256 of the worker's bearer token.
34    #[serde(default)]
35    pub token_sha256: Option<String>,
36    /// Certificate name (DNS SAN or subject CN) for mutual-TLS authentication.
37    #[serde(default)]
38    pub client_cert: Option<String>,
39    /// Task name patterns: exact names, `prefix.*`, or `*`.
40    pub tasks: Vec<String>,
41    #[serde(default)]
42    pub runtime: Option<String>,
43}
44
45impl WorkerIdentity {
46    pub fn may_serve(&self, task: &str) -> bool {
47        self.tasks.iter().any(|p| pattern_matches(p, task))
48    }
49}
50
51pub fn pattern_matches(pattern: &str, task: &str) -> bool {
52    match pattern.strip_suffix('*') {
53        Some(prefix) => task.starts_with(prefix),
54        None => pattern == task,
55    }
56}
57
58#[derive(Debug, Default, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct WorkersFile {
61    #[serde(default, rename = "worker")]
62    pub workers: Vec<WorkerIdentity>,
63}
64
65#[derive(Debug, Default)]
66pub struct Identities {
67    workers: Vec<WorkerIdentity>,
68}
69
70pub fn sha256_hex(s: &str) -> String {
71    Sha256::digest(s.as_bytes())
72        .iter()
73        .map(|b| format!("{b:02x}"))
74        .collect()
75}
76
77fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
78    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
79}
80
81impl Identities {
82    pub fn load(path: &Path) -> Result<Identities, String> {
83        let text = std::fs::read_to_string(path)
84            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
85        Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
86    }
87
88    pub fn parse(text: &str) -> Result<Identities, String> {
89        let file: WorkersFile = toml::from_str(text).map_err(|e| e.to_string())?;
90        let mut seen = std::collections::HashSet::new();
91        for w in &file.workers {
92            if w.id.trim().is_empty() {
93                return Err("a worker has an empty `id`".into());
94            }
95            if !seen.insert(w.id.clone()) {
96                return Err(format!("worker `{}` is declared twice", w.id));
97            }
98            if w.token_sha256.is_none() && w.client_cert.is_none() {
99                return Err(format!(
100                    "worker `{}` needs `token_sha256` or `client_cert`",
101                    w.id
102                ));
103            }
104            if let Some(h) = &w.token_sha256 {
105                if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
106                    return Err(format!(
107                        "worker `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
108                        w.id
109                    ));
110                }
111            }
112            if w.tasks.is_empty() {
113                return Err(format!(
114                    "worker `{}` must list at least one task pattern",
115                    w.id
116                ));
117            }
118        }
119        Ok(Identities {
120            workers: file.workers,
121        })
122    }
123
124    pub fn is_empty(&self) -> bool {
125        self.workers.is_empty()
126    }
127
128    pub fn len(&self) -> usize {
129        self.workers.len()
130    }
131
132    /// The worker whose token hashes to `token`'s hash.
133    pub fn by_token(&self, token: &str) -> Option<&WorkerIdentity> {
134        let h = sha256_hex(token);
135        self.workers.iter().find(|w| {
136            w.token_sha256
137                .as_ref()
138                .is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
139        })
140    }
141
142    /// The worker identified by a DER client certificate (already verified
143    /// against the client CA by TLS).
144    pub fn by_certificate(&self, der: &[u8]) -> Option<&WorkerIdentity> {
145        let names = certificate_names(der);
146        self.workers.iter().find(|w| {
147            w.client_cert
148                .as_ref()
149                .is_some_and(|c| names.iter().any(|n| n.eq_ignore_ascii_case(c)))
150        })
151    }
152}
153
154/// DNS SANs and subject common names of a certificate.
155pub fn certificate_names(der: &[u8]) -> Vec<String> {
156    let Ok((_, cert)) = x509_parser::parse_x509_certificate(der) else {
157        return vec![];
158    };
159    let mut names: Vec<String> = cert
160        .subject()
161        .iter_common_name()
162        .filter_map(|cn| cn.as_str().ok().map(str::to_string))
163        .collect();
164    if let Ok(Some(san)) = cert.subject_alternative_name() {
165        for n in &san.value.general_names {
166            if let x509_parser::extensions::GeneralName::DNSName(d) = n {
167                names.push(d.to_string());
168            }
169        }
170    }
171    names
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn parses_and_matches() {
180        let token_hash = sha256_hex("s3cret");
181        let ids = Identities::parse(&format!(
182            r#"
183            [[worker]]
184            id = "pay"
185            token_sha256 = "{token_hash}"
186            tasks = ["payments.*", "audit.log"]
187            [[worker]]
188            id = "inv"
189            client_cert = "inventory.internal"
190            tasks = ["*"]
191            "#
192        ))
193        .unwrap();
194        let pay = ids.by_token("s3cret").unwrap();
195        assert_eq!(pay.id, "pay");
196        assert!(pay.may_serve("payments.charge") && pay.may_serve("audit.log"));
197        assert!(!pay.may_serve("inventory.reserve") && !pay.may_serve("audit.logs"));
198        assert!(ids.by_token("wrong").is_none());
199        assert!(
200            Identities::parse("[[worker]]\nid = \"x\"\ntasks = [\"*\"]").is_err(),
201            "needs a credential"
202        );
203        assert!(Identities::parse(
204            "[[worker]]\nid = \"x\"\ntoken_sha256 = \"abc\"\ntasks = [\"*\"]"
205        )
206        .is_err());
207        assert!(Identities::parse(
208            "[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\nbogus = 1"
209        )
210        .is_err());
211    }
212}