1use crate::error::{AreevError, Result};
18use serde::Deserialize;
19use sha2::{Digest, Sha256};
20use std::fmt;
21
22pub const AUTHZ_NS: &str = "agent:authz";
26pub const HARNESS_NS: &str = "agent:harness";
28pub const REL_PERMITS: &str = "mg:permits";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Verb {
38 Read,
39 Write,
40 Supersede,
41 Delete,
42 Erase,
43 LoopRun,
44 LoopReview,
45 LoopApply,
46 Admin,
47 RunExecute,
51 RunRespond,
56 RunCancel,
60}
61
62impl Verb {
63 pub const ALL: [Verb; 12] = [
66 Verb::Read,
67 Verb::Write,
68 Verb::Supersede,
69 Verb::Delete,
70 Verb::Erase,
71 Verb::LoopRun,
72 Verb::LoopReview,
73 Verb::LoopApply,
74 Verb::Admin,
75 Verb::RunExecute,
76 Verb::RunRespond,
77 Verb::RunCancel,
78 ];
79
80 pub fn as_str(&self) -> &'static str {
81 match self {
82 Verb::Read => "read",
83 Verb::Write => "write",
84 Verb::Supersede => "supersede",
85 Verb::Delete => "delete",
86 Verb::Erase => "erase",
87 Verb::LoopRun => "loop.run",
88 Verb::LoopReview => "loop.review",
89 Verb::LoopApply => "loop.apply",
90 Verb::Admin => "admin",
91 Verb::RunExecute => "run.execute",
92 Verb::RunRespond => "run.respond",
93 Verb::RunCancel => "run.cancel",
94 }
95 }
96
97 pub fn parse(s: &str) -> Result<Verb> {
98 match s {
99 "read" => Ok(Verb::Read),
100 "write" => Ok(Verb::Write),
101 "supersede" => Ok(Verb::Supersede),
102 "delete" => Ok(Verb::Delete),
103 "erase" => Ok(Verb::Erase),
104 "loop.run" => Ok(Verb::LoopRun),
105 "loop.review" => Ok(Verb::LoopReview),
106 "loop.apply" => Ok(Verb::LoopApply),
107 "admin" => Ok(Verb::Admin),
108 "run.execute" => Ok(Verb::RunExecute),
109 "run.respond" => Ok(Verb::RunRespond),
110 "run.cancel" => Ok(Verb::RunCancel),
111 other => Err(AreevError::Validation(format!(
112 "unknown verb {other:?} — one of: read, write, supersede, delete, erase, \
113 loop.run, loop.review, loop.apply, admin, run.execute, run.respond, \
114 run.cancel"
115 ))),
116 }
117 }
118}
119
120impl fmt::Display for Verb {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.write_str(self.as_str())
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Grant {
130 pub verbs: Vec<Verb>,
131 pub namespaces: Vec<String>,
133}
134
135impl Grant {
136 pub fn covers(&self, verb: Verb, ns: &str) -> bool {
137 self.verbs.contains(&verb)
138 && (self.namespaces.is_empty()
139 || self.namespaces.iter().any(|n| n == "*" || n == ns))
140 }
141
142 pub fn to_object_string(&self) -> String {
148 let mut verbs: Vec<&str> = self.verbs.iter().map(Verb::as_str).collect();
149 verbs.sort_unstable();
150 verbs.dedup();
151 let ns = if self.namespaces.is_empty() {
152 "*".to_string()
153 } else {
154 let mut ns: Vec<&str> = self.namespaces.iter().map(String::as_str).collect();
155 ns.sort_unstable();
156 ns.dedup();
157 ns.join(",")
158 };
159 format!("{} ON {}", verbs.join(","), ns)
160 }
161
162 pub fn from_object_string(s: &str) -> Result<Grant> {
163 let (verbs_part, ns_part) = s.split_once(" ON ").ok_or_else(|| {
164 AreevError::Validation(format!(
165 "malformed grant object {s:?} — expected \"<verbs> ON <namespaces>\""
166 ))
167 })?;
168 let mut verbs = Vec::new();
169 for v in verbs_part.split(',') {
170 let v = Verb::parse(v.trim())?;
171 if !verbs.contains(&v) {
172 verbs.push(v);
173 }
174 }
175 if verbs.is_empty() {
176 return Err(AreevError::Validation(format!(
177 "grant object {s:?} names no verbs"
178 )));
179 }
180 let mut namespaces = Vec::new();
181 for n in ns_part.split(',') {
182 let n = n.trim();
183 if n.is_empty() {
184 return Err(AreevError::Validation(format!(
185 "grant object {s:?} has an empty namespace"
186 )));
187 }
188 if !namespaces.iter().any(|x| x == n) {
189 namespaces.push(n.to_string());
190 }
191 }
192 Ok(Grant { verbs, namespaces })
193 }
194}
195
196#[derive(Debug, Clone)]
200pub struct AuthzSet {
201 principal: String,
202 owner: bool,
203 grants: Vec<Grant>,
204}
205
206impl AuthzSet {
207 pub fn owner(principal: impl Into<String>) -> Self {
210 AuthzSet {
211 principal: principal.into(),
212 owner: true,
213 grants: Vec::new(),
214 }
215 }
216
217 pub fn restricted(principal: impl Into<String>, grants: Vec<Grant>) -> Self {
220 AuthzSet {
221 principal: principal.into(),
222 owner: false,
223 grants,
224 }
225 }
226
227 pub fn principal(&self) -> &str {
228 &self.principal
229 }
230
231 pub fn is_owner(&self) -> bool {
232 self.owner
233 }
234
235 pub fn allows(&self, verb: Verb, ns: &str) -> bool {
236 self.owner || self.grants.iter().any(|g| g.covers(verb, ns))
237 }
238
239 pub fn check(&self, verb: Verb, ns: &str) -> Result<()> {
244 if self.allows(verb, ns) {
245 return Ok(());
246 }
247 Err(AreevError::AuthzDenied(format!(
248 "principal {} lacks {verb} on namespace {ns:?}",
249 self.principal
250 )))
251 }
252}
253
254pub fn observer_kind(principal: &str) -> &'static str {
259 for prefix in ["agent:", "bot:", "job:", "svc:", "engine:"] {
260 if principal.starts_with(prefix) {
261 return "agent";
262 }
263 }
264 "human"
265}
266
267pub fn subject_fingerprint(identity: &str) -> String {
290 let digest = Sha256::digest(identity.as_bytes());
291 hex_lower(&digest[..8])
292}
293
294fn ct_eq(a: &[u8], b: &[u8]) -> bool {
297 if a.len() != b.len() {
298 return false;
299 }
300 let mut diff = 0u8;
301 for (x, y) in a.iter().zip(b.iter()) {
302 diff |= x ^ y;
303 }
304 diff == 0
305}
306
307fn hex_lower(bytes: &[u8]) -> String {
308 const HEX: &[u8; 16] = b"0123456789abcdef";
309 let mut out = String::with_capacity(bytes.len() * 2);
310 for b in bytes {
311 out.push(HEX[(b >> 4) as usize] as char);
312 out.push(HEX[(b & 0x0f) as usize] as char);
313 }
314 out
315}
316
317pub fn audit_observation(
335 principal: &str,
336 verb: &str,
337 target: &str,
338 because: Option<&str>,
339 count: usize,
340 now_ms: i64,
341) -> crate::types::Observation {
342 use std::sync::atomic::{AtomicU64, Ordering};
343 static AUDIT_SEQ: AtomicU64 = AtomicU64::new(0);
346 let mut obs = crate::types::Observation {
347 observer_id: principal.to_string(),
348 observer_type: observer_kind(principal).to_string(),
349 subject: Some(target.to_string()),
350 object: Some(verb.to_string()),
351 observer_model: None,
352 frame_id: Some(format!(
353 "tier2:{now_ms}:{}",
354 AUDIT_SEQ.fetch_add(1, Ordering::Relaxed)
355 )),
356 sync_group: None,
357 observation_mode: None,
358 observation_scope: None,
359 compression_ratio: None,
360 common: Default::default(),
361 };
362 obs.common.namespace = Some(AUTHZ_NS.to_string());
363 obs.common.created_at = Some(now_ms);
364 obs.common.context = Some(serde_json::json!({
365 "audit": "tier2",
366 "verb": verb,
367 "target": target,
368 "because": because.unwrap_or(""),
369 "grains_erased": count,
370 "subject_ref": "sha256-64/hex",
373 }));
374 obs
375}
376
377#[derive(Debug, Deserialize)]
382#[serde(deny_unknown_fields)]
383pub struct CredentialMap {
384 pub version: u32,
385 #[serde(default)]
386 pub tokens: Vec<CredentialEntry>,
387}
388
389#[derive(Debug, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct CredentialEntry {
392 #[serde(default)]
394 pub sha256: Option<String>,
395 #[serde(default)]
397 pub env: Option<String>,
398 pub principal: String,
400 #[serde(default)]
406 pub memories: Option<Vec<String>>,
407}
408
409impl CredentialMap {
410 pub fn from_json(s: &str) -> Result<CredentialMap> {
414 let map: CredentialMap = serde_json::from_str(s)
415 .map_err(|e| AreevError::AuthzConfigInvalid(format!("credential map: {e}")))?;
416 if map.version != 1 {
417 return Err(AreevError::AuthzConfigInvalid(format!(
418 "credential map: unsupported version {} (expected 1)",
419 map.version
420 )));
421 }
422 for (i, t) in map.tokens.iter().enumerate() {
423 if t.principal.trim().is_empty() {
424 return Err(AreevError::AuthzConfigInvalid(format!(
425 "credential map: entry {i} has an empty principal"
426 )));
427 }
428 match (&t.sha256, &t.env) {
429 (Some(_), Some(_)) | (None, None) => {
430 return Err(AreevError::AuthzConfigInvalid(format!(
431 "credential map: entry {i} ({}) must have exactly one of \
432 \"sha256\" or \"env\"",
433 t.principal
434 )));
435 }
436 (Some(h), None) => {
437 if h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
438 return Err(AreevError::AuthzConfigInvalid(format!(
439 "credential map: entry {i} ({}) sha256 must be 64 hex chars",
440 t.principal
441 )));
442 }
443 }
444 (None, Some(v)) => {
445 if v.trim().is_empty() {
446 return Err(AreevError::AuthzConfigInvalid(format!(
447 "credential map: entry {i} ({}) has an empty \"env\" variable name",
448 t.principal
449 )));
450 }
451 }
452 }
453 if let Some(memories) = &t.memories {
454 if memories.is_empty() || memories.iter().any(|m| m.trim().is_empty()) {
455 return Err(AreevError::AuthzConfigInvalid(format!(
456 "credential map: entry {i} ({}) has an empty \"memories\" \
457 scope — omit the field to grant every memory",
458 t.principal
459 )));
460 }
461 }
462 }
463 Ok(map)
464 }
465
466 pub fn resolve(&self, presented: &str) -> Result<&str> {
469 if presented.is_empty() {
475 return Err(AreevError::AuthzTokenUnrecognized);
476 }
477 let digest = hex::encode(Sha256::digest(presented.as_bytes()));
478 for t in &self.tokens {
479 let matched = match (&t.sha256, &t.env) {
480 (Some(h), None) => h.eq_ignore_ascii_case(&digest),
481 (None, Some(var)) => std::env::var(var)
484 .is_ok_and(|v| !v.trim().is_empty() && ct_eq(v.as_bytes(), presented.as_bytes())),
485 _ => false,
486 };
487 if matched {
488 return Ok(&t.principal);
489 }
490 }
491 Err(AreevError::AuthzTokenUnrecognized)
492 }
493
494 pub fn resolve_for_memory(&self, presented: &str, memory: &str) -> Result<&str> {
499 if presented.is_empty() {
500 return Err(AreevError::AuthzTokenUnrecognized);
501 }
502 let digest = hex::encode(Sha256::digest(presented.as_bytes()));
503 let mut found: Option<&CredentialEntry> = None;
507 for t in &self.tokens {
508 let matched = match (&t.sha256, &t.env) {
509 (Some(h), None) => h.eq_ignore_ascii_case(&digest),
510 (None, Some(var)) => std::env::var(var)
511 .is_ok_and(|v| !v.trim().is_empty() && ct_eq(v.as_bytes(), presented.as_bytes())),
512 _ => false,
513 };
514 if matched && found.is_none() {
515 found = Some(t);
516 }
517 }
518 match found {
519 Some(t) => match &t.memories {
520 Some(list) if !list.iter().any(|m| m == memory) => {
521 Err(AreevError::AuthzTokenUnrecognized)
522 }
523 _ => Ok(&t.principal),
524 },
525 None => Err(AreevError::AuthzTokenUnrecognized),
526 }
527 }
528
529 pub fn knows_principal(&self, principal: &str) -> Result<()> {
532 if self.tokens.iter().any(|t| t.principal == principal) {
533 return Ok(());
534 }
535 Err(AreevError::AuthzUnknownPrincipal(principal.to_string()))
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn verbs_roundtrip_their_string_forms() {
545 for v in Verb::ALL {
546 assert_eq!(Verb::parse(v.as_str()).unwrap(), v);
547 }
548 assert!(Verb::parse("loop-run").is_err());
549 assert!(Verb::parse("").is_err());
550 }
551
552 #[test]
553 fn an_empty_env_credential_authenticates_nobody() {
554 assert!(CredentialMap::from_json(
556 r#"{"version":1,"tokens":[{"env":" ","principal":"agent:writer"}]}"#
557 )
558 .is_err());
559
560 std::env::set_var("AREEV_TEST_EMPTY_TOK", "");
564 let map = CredentialMap::from_json(
565 r#"{"version":1,"tokens":[{"env":"AREEV_TEST_EMPTY_TOK","principal":"agent:writer"}]}"#,
566 )
567 .unwrap();
568 assert!(map.resolve("").is_err(), "empty bearer must not authenticate");
569 assert!(map.resolve("anything").is_err());
570
571 std::env::remove_var("AREEV_TEST_EMPTY_TOK");
573 assert!(map.resolve("").is_err());
574 }
575
576 #[test]
577 fn grant_object_string_roundtrips() {
578 let g = Grant {
579 verbs: vec![Verb::Read, Verb::Write],
580 namespaces: vec!["caller".into(), "shared".into()],
581 };
582 let s = g.to_object_string();
583 assert_eq!(s, "read,write ON caller,shared");
584 assert_eq!(Grant::from_object_string(&s).unwrap(), g);
585
586 let all = Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] };
587 assert_eq!(all.to_object_string(), "erase ON *");
588 assert_eq!(
589 Grant::from_object_string("erase ON *").unwrap().namespaces,
590 vec!["*".to_string()]
591 );
592
593 assert!(Grant::from_object_string("read caller").is_err());
594 assert!(Grant::from_object_string(" ON x").is_err());
595 assert!(Grant::from_object_string("read ON ").is_err());
596 }
597
598 #[test]
599 fn owner_allows_everything_restricted_fails_closed() {
600 let owner = AuthzSet::owner("user:local");
601 for v in Verb::ALL {
602 assert!(owner.check(v, "any-ns").is_ok());
603 }
604
605 let none = AuthzSet::restricted("agent:bot", Vec::new());
606 for v in Verb::ALL {
607 assert!(none.check(v, "caller").is_err(), "{v} must be refused");
608 }
609 }
610
611 #[test]
612 fn grants_cover_exactly_what_they_say() {
613 let set = AuthzSet::restricted(
614 "agent:bot",
615 vec![Grant {
616 verbs: vec![Verb::Read, Verb::Write],
617 namespaces: vec!["caller".into()],
618 }],
619 );
620 assert!(set.check(Verb::Read, "caller").is_ok());
621 assert!(set.check(Verb::Write, "caller").is_ok());
622 assert!(set.check(Verb::Write, "shared").is_err());
623 assert!(set.check(Verb::Delete, "caller").is_err());
624
625 let star = AuthzSet::restricted(
626 "job:sweep",
627 vec![Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] }],
628 );
629 assert!(star.check(Verb::Erase, "anything").is_ok());
630 }
631
632 #[test]
633 fn refusal_names_verb_namespace_and_principal_with_the_aut_code() {
634 let set = AuthzSet::restricted("agent:bot", Vec::new());
635 let err = set.check(Verb::Delete, "caller").unwrap_err();
636 assert_eq!(err.code(), "AUT-E001");
637 let msg = err.to_string();
638 for needle in ["delete", "caller", "agent:bot"] {
639 assert!(msg.contains(needle), "{msg:?} must name {needle}");
640 }
641 }
642
643 const MAP: &str = r#"{
644 "version": 1,
645 "tokens": [
646 { "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
647 "principal": "user:anna" },
648 { "env": "AREEV_TEST_BOT_TOKEN", "principal": "agent:bot" }
649 ]
650 }"#;
651
652 #[test]
653 fn credential_map_loads_and_resolves_by_sha256() {
654 let map = CredentialMap::from_json(MAP).unwrap();
655 assert_eq!(map.resolve("test").unwrap(), "user:anna");
657 assert!(map.knows_principal("user:anna").is_ok());
658 assert_eq!(
659 map.knows_principal("user:nobody").unwrap_err().code(),
660 "AUT-E002"
661 );
662 }
663
664 #[test]
665 fn credential_map_resolves_by_env_var() {
666 let map = CredentialMap::from_json(MAP).unwrap();
667 std::env::set_var("AREEV_TEST_BOT_TOKEN", "s3cret");
669 assert_eq!(map.resolve("s3cret").unwrap(), "agent:bot");
670 std::env::remove_var("AREEV_TEST_BOT_TOKEN");
671 }
672
673 #[test]
674 fn unrecognized_token_error_never_echoes_the_secret() {
675 let map = CredentialMap::from_json(MAP).unwrap();
676 let err = map.resolve("super-secret-value").unwrap_err();
677 assert_eq!(err.code(), "AUT-E004");
678 assert!(!err.to_string().contains("super-secret-value"));
679 }
680
681 #[test]
682 fn credential_map_fails_closed() {
683 assert_eq!(
685 CredentialMap::from_json(r#"{"version":1,"tokens":[],"roles":{}}"#)
686 .unwrap_err()
687 .code(),
688 "AUT-E003"
689 );
690 assert!(CredentialMap::from_json(r#"{"version":2,"tokens":[]}"#).is_err());
692 assert!(CredentialMap::from_json(
694 r#"{"version":1,"tokens":[{"sha256":"00","env":"X","principal":"p"}]}"#
695 )
696 .is_err());
697 assert!(CredentialMap::from_json(
699 r#"{"version":1,"tokens":[{"principal":"p"}]}"#
700 )
701 .is_err());
702 assert!(CredentialMap::from_json(
704 r#"{"version":1,"tokens":[{"sha256":"zz","principal":"p"}]}"#
705 )
706 .is_err());
707 assert!(CredentialMap::from_json(
709 r#"{"version":1,"tokens":[{"env":"X","principal":" "}]}"#
710 )
711 .is_err());
712 }
713}