Skip to main content

areev_core/
authz.rs

1//! Authorization primitives — principals, verbs, grants, and the host-side
2//! credential map.
3//!
4//! The model (design of record: `docs/cal-all-you-need-proposal.md`, D2–D4):
5//! *policy* (who may do what) lives **in the memory file** as grant grains,
6//! scoped per namespace; *credentials* (who is this caller) live host-side in
7//! a credential map that holds **no policy and no raw secrets** — tokens are
8//! referenced by SHA-256 or by env-var name. A memory with no grant grains
9//! grants nothing to anyone but the owner session (fail closed); the owner
10//! session — a local open with no principal asserted — is the implicit
11//! superuser, so the single-user path never meets any of this.
12//!
13//! This module is the shared vocabulary only. Building an [`AuthzSet`] from a
14//! file's grant grains is the store's job; enforcing it at dispatch is the
15//! facade's and the surfaces'.
16
17use crate::error::{AreevError, Result};
18use serde::Deserialize;
19use sha2::{Digest, Sha256};
20use std::fmt;
21
22/// The reserved namespace grant grains live in. The OMS 1.6 spec draft
23/// (§12.6) is the source of truth for this name — it follows the spec's
24/// `agent:identity` / `agent:recommendations` reserved-namespace precedent.
25pub const AUTHZ_NS: &str = "agent:authz";
26/// Reserved namespace for reproducible run and assembly manifests.
27pub const HARNESS_NS: &str = "agent:harness";
28/// Relation carried by a grant grain (OMS `PERMISSION` category).
29pub const REL_PERMITS: &str = "mg:permits";
30
31/// One operation class — the unit of granting, `GRANT SELECT`-style.
32///
33/// The string forms (`read`, `loop.review`, …) are the wire/CAL vocabulary;
34/// they appear in grant-grain objects and eventually in `GRANT` statements,
35/// so they are frozen once the spec ships.
36#[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    /// D5 (governed-agents §6.8): start/resume/fork a workflow run —
48    /// Control-tier: it spends budgets and executes effects, so it is not
49    /// plain `write`.
50    RunExecute,
51    /// D5: answer a `requires_action` Client ask. This IS the approval
52    /// boundary — Control-tier, and the driver additionally refuses
53    /// responder == triggering principal on approval asks (separation of
54    /// duties, mirroring the loop's self-approval block).
55    RunRespond,
56    /// D5: cancel a run. Deliberately LOW-tier and broadly grantable — the
57    /// brake must never be blocked by missing privilege (the kill-switch
58    /// SLA depends on it).
59    RunCancel,
60}
61
62impl Verb {
63    /// Every verb, in canonical display order. Append-only, like error
64    /// codes: the string forms live in grant grains that replicate.
65    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/// A set of verbs allowed on a set of namespaces, inside one memory.
127/// The memory axis is implicit — a grant lives in the file it governs (D4).
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Grant {
130    pub verbs: Vec<Verb>,
131    /// Governed namespaces. `["*"]` (or empty) = every namespace.
132    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    /// The canonical object string a grant grain carries:
143    /// `read,write ON caller,shared` / `delete ON *`. Normative per OMS 1.6
144    /// §12.6: lowercase, comma-separated, **lexicographically sorted** verbs
145    /// and namespaces, duplicates dropped — so two implementations writing
146    /// the same grant produce the same content address.
147    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/// The resolved rights of one session: a principal plus the grants that
197/// cover it. Every dispatch layer asks the same question:
198/// [`AuthzSet::check`].
199#[derive(Debug, Clone)]
200pub struct AuthzSet {
201    principal: String,
202    owner: bool,
203    grants: Vec<Grant>,
204}
205
206impl AuthzSet {
207    /// The implicit-superuser session: a local open with no principal
208    /// asserted (`root@localhost`). Every verb on every namespace.
209    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    /// A restricted session: only what the grants cover. Zero grants =
218    /// nothing (fail closed).
219    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    /// The one enforcement question. The refusal names the verb, the
240    /// resource, and the principal — the pieces a granting admin needs.
241    /// (Once `GRANT` parses, the message will also spell the statement that
242    /// fixes it — not before, to avoid pointing at unshipped syntax.)
243    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
254/// The observer kind a principal label implies (`"agent"` / `"human"`),
255/// used for audit stamping wherever no credential record declares one. The
256/// answer always derives from the host-held label — never from statement or
257/// request text, which must not be able to claim humanity.
258pub 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
267/// A verifiable, non-disclosing reference to an erased identity, for audit
268/// targets: the first 16 hex of SHA-256 over the identity string.
269///
270/// **Why not the identity itself.** An audit grain is immutable, replicates,
271/// and lands in archives. Writing the raw identifier into it re-introduces
272/// exactly the reference the erasure just removed — the erased subject stays
273/// recallable from `agent:authz` forever, un-erasable by the subject
274/// selector (which never matches `subject:<id> ns:<ns>` as a partition key),
275/// and travels into every bundle and segment. That is a right-to-erasure
276/// failure hiding inside the accountability record.
277///
278/// A fingerprint keeps both properties: given a candidate identity anyone
279/// can recompute the digest and **verify** that a specific audit record is
280/// about that person (answering "prove you erased me"), but the log cannot
281/// be mined to enumerate who was erased. The human-readable reference — the
282/// ticket or request number — belongs in BECAUSE, which the operator
283/// controls and which names a *request*, not a data subject.
284///
285/// Truncated to 64 bits of digest: this is a correlation handle, not a
286/// security boundary (identity strings are low-entropy, so a determined
287/// attacker with a candidate list can always confirm guesses — which is the
288/// same property that makes verification work).
289pub fn subject_fingerprint(identity: &str) -> String {
290    let digest = Sha256::digest(identity.as_bytes());
291    hex_lower(&digest[..8])
292}
293
294/// Constant-time byte comparison — avoids leaking a bearer token through
295/// response timing. A length mismatch fails fast (token length is not secret).
296fn 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
317/// Build the Tier-2 audit Observation for one destructive execution — the
318/// accountability record (GDPR Art. 5(2)/30) that `areev audit export`
319/// emits.
320///
321/// **One builder, every surface.** CAL destruction, the CLI's
322/// `forget-subject`/`purge-older-than`, and anything else that destroys
323/// must produce byte-identical audit shapes, or the evidence export becomes
324/// a union of dialects. Note the deliberate asymmetry with REQ-ERASE-5: the
325/// *engine* (`Areev::forget_subject`) still writes no audit grain of its
326/// own — a library caller owns its own logging — but the surfaces a human
327/// or agent actually invokes are hosts, and hosts audit.
328///
329/// `target` describes what was destroyed in the surface's own vocabulary:
330/// `hash:<hex>` (a content address of already-deleted content — not identity
331/// material), `subject:<fp> ns:<ns>` where `<fp>` is a
332/// [`subject_fingerprint`] (**never** the raw identity — see that function
333/// for why), or `older_than:<n>d ns:<ns>` (an age, no identity at all).
334pub 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    // Process-static: two identical erasures in the same millisecond must
344    // stay two records, not collapse into one content address.
345    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        // Names the scheme so a verifier knows how to recompute a subject
371        // fingerprint years later, without reading our source.
372        "subject_ref": "sha256-64/hex",
373    }));
374    obs
375}
376
377/// The host-side credential map (`areev-auth.json`): tokens → principal
378/// names, nothing else. No verbs, no namespaces, no raw secrets — a token is
379/// referenced by its SHA-256 or by the env var that holds it, so the file is
380/// inert if stolen or synced.
381#[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    /// Lowercase hex SHA-256 of the bearer token.
393    #[serde(default)]
394    pub sha256: Option<String>,
395    /// Name of the environment variable holding the bearer token.
396    #[serde(default)]
397    pub env: Option<String>,
398    /// The principal this credential authenticates as.
399    pub principal: String,
400    /// Per-memory scope (the enterprise plane's rule): when set, this
401    /// credential authenticates ONLY on services whose memory label is in
402    /// the list — one auth file shared across server instances, each token
403    /// reaching only its memories. `None` = every memory (the common
404    /// single-memory deployment).
405    #[serde(default)]
406    pub memories: Option<Vec<String>>,
407}
408
409impl CredentialMap {
410    /// Parse and validate. Fail closed: unknown keys, a bad version, an
411    /// entry with both or neither credential form, or a malformed digest all
412    /// refuse the whole map.
413    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    /// Resolve a presented bearer token to its principal. The error carries
467    /// no part of the token — a refused secret must not leak into logs.
468    pub fn resolve(&self, presented: &str) -> Result<&str> {
469        // An empty presented token can never authenticate. Without this, an
470        // env-referenced credential whose variable is exported *empty*
471        // (`Environment=AREEV_BOT_TOKEN=` in a unit file, `export VAR=` in a
472        // wrapper) matches the empty string an `Authorization: Bearer `
473        // header parses to, and every caller becomes that principal.
474        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                // The env var must actually hold a secret — an unset or
482                // empty variable authenticates nobody.
483                (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    /// Resolve a token FOR ONE MEMORY: like [`resolve`](Self::resolve), but
495    /// a credential carrying a `memories` scope only authenticates when
496    /// `memory` is listed. The refusal is indistinguishable from an unknown
497    /// token — a scoped credential must not confirm which memories exist.
498    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        // Constant-shape scan: every entry is examined whether or not an
504        // earlier one matched, so an out-of-scope token is not even
505        // timing-distinguishable from an unknown one.
506        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    /// Whether any credential authenticates as this principal — surfaces
530    /// that require a *known* principal name use this to refuse typos early.
531    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        // The variable name itself must be non-empty…
555        assert!(CredentialMap::from_json(
556            r#"{"version":1,"tokens":[{"env":"  ","principal":"agent:writer"}]}"#
557        )
558        .is_err());
559
560        // …and a variable exported EMPTY must not match the empty string an
561        // `Authorization: Bearer ` header parses to. Otherwise every caller
562        // becomes `agent:writer`.
563        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        // Unset behaves the same.
572        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        // sha256("test") — the digest above.
656        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        // Unique var name per test binary run; set before resolve.
668        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        // Unknown key.
684        assert_eq!(
685            CredentialMap::from_json(r#"{"version":1,"tokens":[],"roles":{}}"#)
686                .unwrap_err()
687                .code(),
688            "AUT-E003"
689        );
690        // Wrong version.
691        assert!(CredentialMap::from_json(r#"{"version":2,"tokens":[]}"#).is_err());
692        // Both credential forms.
693        assert!(CredentialMap::from_json(
694            r#"{"version":1,"tokens":[{"sha256":"00","env":"X","principal":"p"}]}"#
695        )
696        .is_err());
697        // Neither form.
698        assert!(CredentialMap::from_json(
699            r#"{"version":1,"tokens":[{"principal":"p"}]}"#
700        )
701        .is_err());
702        // Malformed digest.
703        assert!(CredentialMap::from_json(
704            r#"{"version":1,"tokens":[{"sha256":"zz","principal":"p"}]}"#
705        )
706        .is_err());
707        // Empty principal.
708        assert!(CredentialMap::from_json(
709            r#"{"version":1,"tokens":[{"env":"X","principal":"  "}]}"#
710        )
711        .is_err());
712    }
713}