orbit_runtime/
identity.rs1use 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 #[serde(default)]
35 pub token_sha256: Option<String>,
36 #[serde(default)]
38 pub client_cert: Option<String>,
39 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
58pub const CLIENT_ACTIONS: &[&str] = &["start", "signal", "query", "read", "cancel"];
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#[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 pub workflows: Vec<String>,
80 #[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 pub fn can_read(&self, workflow: &str) -> bool {
92 self.allows("read", workflow)
93 }
94}
95
96fn 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 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 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 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 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
263pub 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}