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/// Actions a client identity can be granted.
59pub const CLIENT_ACTIONS: &[&str] = &["start", "signal", "query", "read", "cancel", "schedule"];
60
61fn default_client_actions() -> Vec<String> {
62    ["start", "signal", "query", "read"]
63        .iter()
64        .map(|s| s.to_string())
65        .collect()
66}
67
68/// An application allowed to use the execution API for some workflows only
69/// (`[[client]]` in the workers file).
70#[derive(Debug, Clone, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ClientIdentity {
73    pub id: String,
74    #[serde(default)]
75    pub token_sha256: Option<String>,
76    #[serde(default)]
77    pub client_cert: Option<String>,
78    /// Workflow name patterns: exact names, `Prefix*`, or `*`.
79    pub workflows: Vec<String>,
80    /// Granted actions; defaults to start, signal, query and read.
81    #[serde(default = "default_client_actions")]
82    pub actions: Vec<String>,
83}
84
85impl ClientIdentity {
86    pub fn allows(&self, action: &str, workflow: &str) -> bool {
87        self.actions.iter().any(|a| a == action) && self.workflows.iter().any(|p| glob(p, workflow))
88    }
89
90    /// May this client see anything about the workflow at all?
91    pub fn can_read(&self, workflow: &str) -> bool {
92        self.allows("read", workflow)
93    }
94}
95
96/// `*` matches anything; `Prefix*` a prefix; otherwise an exact name.
97fn glob(pattern: &str, name: &str) -> bool {
98    match pattern.strip_suffix('*') {
99        Some(prefix) => name.starts_with(prefix),
100        None => pattern == name,
101    }
102}
103
104#[derive(Debug, Default, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct WorkersFile {
107    #[serde(default, rename = "worker")]
108    pub workers: Vec<WorkerIdentity>,
109    #[serde(default, rename = "client")]
110    pub clients: Vec<ClientIdentity>,
111}
112
113#[derive(Debug, Default)]
114pub struct Identities {
115    workers: Vec<WorkerIdentity>,
116    clients: Vec<ClientIdentity>,
117}
118
119pub fn sha256_hex(s: &str) -> String {
120    Sha256::digest(s.as_bytes())
121        .iter()
122        .map(|b| format!("{b:02x}"))
123        .collect()
124}
125
126fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
127    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
128}
129
130impl Identities {
131    pub fn load(path: &Path) -> Result<Identities, String> {
132        let text = std::fs::read_to_string(path)
133            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
134        Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
135    }
136
137    pub fn parse(text: &str) -> Result<Identities, String> {
138        let file: WorkersFile = toml::from_str(text).map_err(|e| e.to_string())?;
139        let mut seen = std::collections::HashSet::new();
140        for w in &file.workers {
141            if w.id.trim().is_empty() {
142                return Err("a worker has an empty `id`".into());
143            }
144            if !seen.insert(w.id.clone()) {
145                return Err(format!("worker `{}` is declared twice", w.id));
146            }
147            if w.token_sha256.is_none() && w.client_cert.is_none() {
148                return Err(format!(
149                    "worker `{}` needs `token_sha256` or `client_cert`",
150                    w.id
151                ));
152            }
153            if let Some(h) = &w.token_sha256 {
154                if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
155                    return Err(format!(
156                        "worker `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
157                        w.id
158                    ));
159                }
160            }
161            if w.tasks.is_empty() {
162                return Err(format!(
163                    "worker `{}` must list at least one task pattern",
164                    w.id
165                ));
166            }
167        }
168        for c in &file.clients {
169            if c.id.trim().is_empty() {
170                return Err("a client has an empty `id`".into());
171            }
172            if !seen.insert(c.id.clone()) {
173                return Err(format!("identity `{}` is declared twice", c.id));
174            }
175            if c.token_sha256.is_none() && c.client_cert.is_none() {
176                return Err(format!(
177                    "client `{}` needs `token_sha256` or `client_cert`",
178                    c.id
179                ));
180            }
181            if let Some(h) = &c.token_sha256 {
182                if h.len() != 64 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
183                    return Err(format!(
184                        "client `{}`: `token_sha256` must be 64 hex characters (use `orbit token`)",
185                        c.id
186                    ));
187                }
188            }
189            if c.workflows.is_empty() {
190                return Err(format!(
191                    "client `{}` must list at least one workflow pattern",
192                    c.id
193                ));
194            }
195            if let Some(bad) = c
196                .actions
197                .iter()
198                .find(|a| !CLIENT_ACTIONS.contains(&a.as_str()))
199            {
200                return Err(format!(
201                    "client `{}`: unknown action `{bad}` (allowed: {})",
202                    c.id,
203                    CLIENT_ACTIONS.join(", ")
204                ));
205            }
206        }
207        Ok(Identities {
208            workers: file.workers,
209            clients: file.clients,
210        })
211    }
212
213    pub fn is_empty(&self) -> bool {
214        self.workers.is_empty() && self.clients.is_empty()
215    }
216
217    /// The client whose token hashes to `token`'s hash.
218    pub fn client_by_token(&self, token: &str) -> Option<&ClientIdentity> {
219        let h = sha256_hex(token);
220        self.clients.iter().find(|c| {
221            c.token_sha256
222                .as_ref()
223                .is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
224        })
225    }
226
227    /// The client identified by a verified DER client certificate.
228    pub fn client_by_certificate(&self, der: &[u8]) -> Option<&ClientIdentity> {
229        let names = certificate_names(der);
230        self.clients.iter().find(|c| {
231            c.client_cert
232                .as_ref()
233                .is_some_and(|n| names.iter().any(|x| x.eq_ignore_ascii_case(n)))
234        })
235    }
236
237    pub fn len(&self) -> usize {
238        self.workers.len()
239    }
240
241    /// The worker whose token hashes to `token`'s hash.
242    pub fn by_token(&self, token: &str) -> Option<&WorkerIdentity> {
243        let h = sha256_hex(token);
244        self.workers.iter().find(|w| {
245            w.token_sha256
246                .as_ref()
247                .is_some_and(|t| constant_time_eq(t.to_ascii_lowercase().as_bytes(), h.as_bytes()))
248        })
249    }
250
251    /// The worker identified by a DER client certificate (already verified
252    /// against the client CA by TLS).
253    pub fn by_certificate(&self, der: &[u8]) -> Option<&WorkerIdentity> {
254        let names = certificate_names(der);
255        self.workers.iter().find(|w| {
256            w.client_cert
257                .as_ref()
258                .is_some_and(|c| names.iter().any(|n| n.eq_ignore_ascii_case(c)))
259        })
260    }
261}
262
263/// DNS SANs and subject common names of a certificate.
264pub fn certificate_names(der: &[u8]) -> Vec<String> {
265    let Ok((_, cert)) = x509_parser::parse_x509_certificate(der) else {
266        return vec![];
267    };
268    let mut names: Vec<String> = cert
269        .subject()
270        .iter_common_name()
271        .filter_map(|cn| cn.as_str().ok().map(str::to_string))
272        .collect();
273    if let Ok(Some(san)) = cert.subject_alternative_name() {
274        for n in &san.value.general_names {
275            if let x509_parser::extensions::GeneralName::DNSName(d) = n {
276                names.push(d.to_string());
277            }
278        }
279    }
280    names
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn parses_and_matches() {
289        let token_hash = sha256_hex("s3cret");
290        let ids = Identities::parse(&format!(
291            r#"
292            [[worker]]
293            id = "pay"
294            token_sha256 = "{token_hash}"
295            tasks = ["payments.*", "audit.log"]
296            [[worker]]
297            id = "inv"
298            client_cert = "inventory.internal"
299            tasks = ["*"]
300            "#
301        ))
302        .unwrap();
303        let pay = ids.by_token("s3cret").unwrap();
304        assert_eq!(pay.id, "pay");
305        assert!(pay.may_serve("payments.charge") && pay.may_serve("audit.log"));
306        assert!(!pay.may_serve("inventory.reserve") && !pay.may_serve("audit.logs"));
307        assert!(ids.by_token("wrong").is_none());
308        assert!(
309            Identities::parse("[[worker]]\nid = \"x\"\ntasks = [\"*\"]").is_err(),
310            "needs a credential"
311        );
312        assert!(Identities::parse(
313            "[[worker]]\nid = \"x\"\ntoken_sha256 = \"abc\"\ntasks = [\"*\"]"
314        )
315        .is_err());
316        assert!(Identities::parse(
317            "[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\nbogus = 1"
318        )
319        .is_err());
320    }
321
322    #[test]
323    fn client_identities_grant_workflows_and_actions() {
324        let h = sha256_hex("app-token");
325        let ids = Identities::parse(&format!(
326            r#"
327            [[client]]
328            id = "billing"
329            token_sha256 = "{h}"
330            workflows = ["Invoice*", "Refund"]
331            [[client]]
332            id = "ops"
333            client_cert = "ops.internal"
334            workflows = ["*"]
335            actions = ["read", "cancel"]
336            "#
337        ))
338        .unwrap();
339        assert!(!ids.is_empty());
340        let billing = ids.client_by_token("app-token").unwrap();
341        assert!(billing.allows("start", "InvoiceMonthly") && billing.allows("signal", "Refund"));
342        assert!(
343            !billing.allows("cancel", "Refund"),
344            "cancel is not a default action"
345        );
346        assert!(!billing.allows("start", "Payroll"));
347        assert!(
348            ids.by_token("app-token").is_none(),
349            "a client is not a worker"
350        );
351        let bad_action = "[[client]]\nid = \"x\"\nclient_cert = \"a\"\nworkflows = [\"*\"]\nactions = [\"deploy\"]";
352        assert!(Identities::parse(bad_action)
353            .unwrap_err()
354            .contains("unknown action"));
355        let dup =
356            "[[worker]]\nid = \"x\"\nclient_cert = \"a\"\ntasks = [\"*\"]\n[[client]]\nid = \"x\"\nclient_cert = \"b\"\nworkflows = [\"*\"]";
357        assert!(Identities::parse(dup)
358            .unwrap_err()
359            .contains("declared twice"));
360    }
361}