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, Serialize};
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/// Reserved namespace for grain attestations: one Observation per signed
29/// grain saying "author key K signed content hash H", linked to the grain by
30/// [`REL_ATTESTS`]. The signature is detached on purpose — the attested
31/// grain's bytes and address never change (`docs/grain-attestation-plan.md`).
32pub const ATTEST_NS: &str = "agent:attest";
33/// Relation from an attestation to the grain it attests.
34pub const REL_ATTESTS: &str = "mg:attests";
35/// Relation carried by a grant grain (OMS `PERMISSION` category).
36pub const REL_PERMITS: &str = "mg:permits";
37
38/// One operation class — the unit of granting, `GRANT SELECT`-style.
39///
40/// The string forms (`read`, `loop.review`, …) are the wire/CAL vocabulary;
41/// they appear in grant-grain objects and eventually in `GRANT` statements,
42/// so they are frozen once the spec ships.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Verb {
45    Read,
46    Write,
47    Supersede,
48    Delete,
49    Erase,
50    LoopRun,
51    LoopReview,
52    LoopApply,
53    Admin,
54    /// D5 (governed-agents §6.8): start/resume/fork a workflow run —
55    /// Control-tier: it spends budgets and executes effects, so it is not
56    /// plain `write`.
57    RunExecute,
58    /// D5: answer a `requires_action` Client ask. This IS the approval
59    /// boundary — Control-tier, and the driver additionally refuses
60    /// responder == triggering principal on approval asks (separation of
61    /// duties, mirroring the loop's self-approval block).
62    RunRespond,
63    /// D5: cancel a run. Deliberately LOW-tier and broadly grantable — the
64    /// brake must never be blocked by missing privilege (the kill-switch
65    /// SLA depends on it).
66    RunCancel,
67}
68
69impl Verb {
70    /// Every verb, in canonical display order. Append-only, like error
71    /// codes: the string forms live in grant grains that replicate.
72    pub const ALL: [Verb; 12] = [
73        Verb::Read,
74        Verb::Write,
75        Verb::Supersede,
76        Verb::Delete,
77        Verb::Erase,
78        Verb::LoopRun,
79        Verb::LoopReview,
80        Verb::LoopApply,
81        Verb::Admin,
82        Verb::RunExecute,
83        Verb::RunRespond,
84        Verb::RunCancel,
85    ];
86
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            Verb::Read => "read",
90            Verb::Write => "write",
91            Verb::Supersede => "supersede",
92            Verb::Delete => "delete",
93            Verb::Erase => "erase",
94            Verb::LoopRun => "loop.run",
95            Verb::LoopReview => "loop.review",
96            Verb::LoopApply => "loop.apply",
97            Verb::Admin => "admin",
98            Verb::RunExecute => "run.execute",
99            Verb::RunRespond => "run.respond",
100            Verb::RunCancel => "run.cancel",
101        }
102    }
103
104    pub fn parse(s: &str) -> Result<Verb> {
105        match s {
106            "read" => Ok(Verb::Read),
107            "write" => Ok(Verb::Write),
108            "supersede" => Ok(Verb::Supersede),
109            "delete" => Ok(Verb::Delete),
110            "erase" => Ok(Verb::Erase),
111            "loop.run" => Ok(Verb::LoopRun),
112            "loop.review" => Ok(Verb::LoopReview),
113            "loop.apply" => Ok(Verb::LoopApply),
114            "admin" => Ok(Verb::Admin),
115            "run.execute" => Ok(Verb::RunExecute),
116            "run.respond" => Ok(Verb::RunRespond),
117            "run.cancel" => Ok(Verb::RunCancel),
118            other => Err(AreevError::Validation(format!(
119                "unknown verb {other:?} — one of: read, write, supersede, delete, erase, \
120                 loop.run, loop.review, loop.apply, admin, run.execute, run.respond, \
121                 run.cancel"
122            ))),
123        }
124    }
125}
126
127impl fmt::Display for Verb {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133/// A set of verbs allowed on a set of namespaces, inside one memory.
134/// The memory axis is implicit — a grant lives in the file it governs (D4).
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Grant {
137    pub verbs: Vec<Verb>,
138    /// Governed namespaces. `["*"]` (or empty) = every namespace.
139    pub namespaces: Vec<String>,
140}
141
142impl Grant {
143    pub fn covers(&self, verb: Verb, ns: &str) -> bool {
144        self.verbs.contains(&verb)
145            && (self.namespaces.is_empty()
146                || self.namespaces.iter().any(|n| n == "*" || n == ns))
147    }
148
149    /// The canonical object string a grant grain carries:
150    /// `read,write ON caller,shared` / `delete ON *`. Normative per OMS 1.6
151    /// §12.6: lowercase, comma-separated, **lexicographically sorted** verbs
152    /// and namespaces, duplicates dropped — so two implementations writing
153    /// the same grant produce the same content address.
154    pub fn to_object_string(&self) -> String {
155        let mut verbs: Vec<&str> = self.verbs.iter().map(Verb::as_str).collect();
156        verbs.sort_unstable();
157        verbs.dedup();
158        let ns = if self.namespaces.is_empty() {
159            "*".to_string()
160        } else {
161            let mut ns: Vec<&str> = self.namespaces.iter().map(String::as_str).collect();
162            ns.sort_unstable();
163            ns.dedup();
164            ns.join(",")
165        };
166        format!("{} ON {}", verbs.join(","), ns)
167    }
168
169    pub fn from_object_string(s: &str) -> Result<Grant> {
170        let (verbs_part, ns_part) = s.split_once(" ON ").ok_or_else(|| {
171            AreevError::Validation(format!(
172                "malformed grant object {s:?} — expected \"<verbs> ON <namespaces>\""
173            ))
174        })?;
175        let mut verbs = Vec::new();
176        for v in verbs_part.split(',') {
177            let v = Verb::parse(v.trim())?;
178            if !verbs.contains(&v) {
179                verbs.push(v);
180            }
181        }
182        if verbs.is_empty() {
183            return Err(AreevError::Validation(format!(
184                "grant object {s:?} names no verbs"
185            )));
186        }
187        let mut namespaces = Vec::new();
188        for n in ns_part.split(',') {
189            let n = n.trim();
190            if n.is_empty() {
191                return Err(AreevError::Validation(format!(
192                    "grant object {s:?} has an empty namespace"
193                )));
194            }
195            if !namespaces.iter().any(|x| x == n) {
196                namespaces.push(n.to_string());
197            }
198        }
199        Ok(Grant { verbs, namespaces })
200    }
201}
202
203/// The resolved rights of one session: a principal plus the grants that
204/// cover it. Every dispatch layer asks the same question:
205/// [`AuthzSet::check`].
206#[derive(Debug, Clone)]
207pub struct AuthzSet {
208    principal: String,
209    owner: bool,
210    grants: Vec<Grant>,
211}
212
213impl AuthzSet {
214    /// The implicit-superuser session: a local open with no principal
215    /// asserted (`root@localhost`). Every verb on every namespace.
216    pub fn owner(principal: impl Into<String>) -> Self {
217        AuthzSet {
218            principal: principal.into(),
219            owner: true,
220            grants: Vec::new(),
221        }
222    }
223
224    /// A restricted session: only what the grants cover. Zero grants =
225    /// nothing (fail closed).
226    pub fn restricted(principal: impl Into<String>, grants: Vec<Grant>) -> Self {
227        AuthzSet {
228            principal: principal.into(),
229            owner: false,
230            grants,
231        }
232    }
233
234    pub fn principal(&self) -> &str {
235        &self.principal
236    }
237
238    pub fn is_owner(&self) -> bool {
239        self.owner
240    }
241
242    pub fn allows(&self, verb: Verb, ns: &str) -> bool {
243        self.owner || self.grants.iter().any(|g| g.covers(verb, ns))
244    }
245
246    /// The one enforcement question. The refusal names the verb, the
247    /// resource, and the principal — the pieces a granting admin needs.
248    /// (Once `GRANT` parses, the message will also spell the statement that
249    /// fixes it — not before, to avoid pointing at unshipped syntax.)
250    pub fn check(&self, verb: Verb, ns: &str) -> Result<()> {
251        if self.allows(verb, ns) {
252            return Ok(());
253        }
254        Err(AreevError::AuthzDenied(format!(
255            "principal {} lacks {verb} on namespace {ns:?}",
256            self.principal
257        )))
258    }
259}
260
261/// The observer kind a principal label implies (`"agent"` / `"human"`),
262/// used for audit stamping wherever no credential record declares one. The
263/// answer always derives from the host-held label — never from statement or
264/// request text, which must not be able to claim humanity.
265pub fn observer_kind(principal: &str) -> &'static str {
266    for prefix in ["agent:", "bot:", "job:", "svc:", "engine:"] {
267        if principal.starts_with(prefix) {
268            return "agent";
269        }
270    }
271    "human"
272}
273
274/// A verifiable, non-disclosing reference to an erased identity, for audit
275/// targets: the first 16 hex of SHA-256 over the identity string.
276///
277/// **Why not the identity itself.** An audit grain is immutable, replicates,
278/// and lands in archives. Writing the raw identifier into it re-introduces
279/// exactly the reference the erasure just removed — the erased subject stays
280/// recallable from `agent:authz` forever, un-erasable by the subject
281/// selector (which never matches `subject:<id> ns:<ns>` as a partition key),
282/// and travels into every bundle and segment. That is a right-to-erasure
283/// failure hiding inside the accountability record.
284///
285/// A fingerprint keeps both properties: given a candidate identity anyone
286/// can recompute the digest and **verify** that a specific audit record is
287/// about that person (answering "prove you erased me"), but the log cannot
288/// be mined to enumerate who was erased. The human-readable reference — the
289/// ticket or request number — belongs in BECAUSE, which the operator
290/// controls and which names a *request*, not a data subject.
291///
292/// Truncated to 64 bits of digest: this is a correlation handle, not a
293/// security boundary (identity strings are low-entropy, so a determined
294/// attacker with a candidate list can always confirm guesses — which is the
295/// same property that makes verification work).
296pub fn subject_fingerprint(identity: &str) -> String {
297    let digest = Sha256::digest(identity.as_bytes());
298    hex_lower(&digest[..8])
299}
300
301/// Constant-time byte comparison — avoids leaking a bearer token through
302/// response timing. A length mismatch fails fast (token length is not secret).
303fn ct_eq(a: &[u8], b: &[u8]) -> bool {
304    if a.len() != b.len() {
305        return false;
306    }
307    let mut diff = 0u8;
308    for (x, y) in a.iter().zip(b.iter()) {
309        diff |= x ^ y;
310    }
311    diff == 0
312}
313
314fn hex_lower(bytes: &[u8]) -> String {
315    const HEX: &[u8; 16] = b"0123456789abcdef";
316    let mut out = String::with_capacity(bytes.len() * 2);
317    for b in bytes {
318        out.push(HEX[(b >> 4) as usize] as char);
319        out.push(HEX[(b & 0x0f) as usize] as char);
320    }
321    out
322}
323
324/// Build the Tier-2 audit Observation for one destructive execution — the
325/// accountability record (GDPR Art. 5(2)/30) that `areev audit export`
326/// emits.
327///
328/// **One builder, every surface.** CAL destruction, the CLI's
329/// `forget-subject`/`purge-older-than`, and anything else that destroys
330/// must produce byte-identical audit shapes, or the evidence export becomes
331/// a union of dialects. Note the deliberate asymmetry with REQ-ERASE-5: the
332/// *engine* (`Areev::forget_subject`) still writes no audit grain of its
333/// own — a library caller owns its own logging — but the surfaces a human
334/// or agent actually invokes are hosts, and hosts audit.
335///
336/// `target` describes what was destroyed in the surface's own vocabulary:
337/// `hash:<hex>` (a content address of already-deleted content — not identity
338/// material), `subject:<fp> ns:<ns>` where `<fp>` is a
339/// [`subject_fingerprint`] (**never** the raw identity — see that function
340/// for why), or `older_than:<n>d ns:<ns>` (an age, no identity at all).
341pub fn audit_observation(
342    principal: &str,
343    verb: &str,
344    target: &str,
345    because: Option<&str>,
346    count: usize,
347    now_ms: i64,
348) -> crate::types::Observation {
349    use std::sync::atomic::{AtomicU64, Ordering};
350    // Process-static: two identical erasures in the same millisecond must
351    // stay two records, not collapse into one content address.
352    static AUDIT_SEQ: AtomicU64 = AtomicU64::new(0);
353    let mut obs = crate::types::Observation {
354        observer_id: principal.to_string(),
355        observer_type: observer_kind(principal).to_string(),
356        subject: Some(target.to_string()),
357        object: Some(verb.to_string()),
358        observer_model: None,
359        frame_id: Some(format!(
360            "tier2:{now_ms}:{}",
361            AUDIT_SEQ.fetch_add(1, Ordering::Relaxed)
362        )),
363        sync_group: None,
364        observation_mode: None,
365        observation_scope: None,
366        compression_ratio: None,
367        common: Default::default(),
368    };
369    obs.common.namespace = Some(AUTHZ_NS.to_string());
370    obs.common.created_at = Some(now_ms);
371    obs.common.context = Some(serde_json::json!({
372        "audit": "tier2",
373        "verb": verb,
374        "target": target,
375        "because": because.unwrap_or(""),
376        "grains_erased": count,
377        // Names the scheme so a verifier knows how to recompute a subject
378        // fingerprint years later, without reading our source.
379        "subject_ref": "sha256-64/hex",
380    }));
381    obs
382}
383
384/// Link a Tier-2 audit Observation into the memory's destruction chain (#280).
385///
386/// `previous` is the content address of the record this one follows, and
387/// `seq` its 1-based position. The predecessor goes in `common.derived_from`
388/// — the same field the loop's own audit trail uses
389/// (`areev_loop::AuditRecord::to_grain_spec`) — so one verifier walks both;
390/// `context.seq` makes a *gap* detectable, which `derived_from` alone cannot
391/// do once an interior record has been forgotten and its successor's
392/// predecessor no longer resolves.
393///
394/// The first record of a chain carries `chain_root: true` and no predecessor.
395/// Records written before chaining existed carry neither, and an export must
396/// report those as `unchained`, never as a break — absence of a link in a
397/// record written by an older build is not evidence of tampering.
398pub fn chain_audit_observation(
399    obs: &mut crate::types::Observation,
400    previous: Option<&str>,
401    seq: u64,
402) {
403    if let Some(prev) = previous {
404        obs.common.derived_from = Some(prev.to_string());
405    }
406    if let Some(serde_json::Value::Object(map)) = obs.common.context.as_mut() {
407        map.insert("seq".into(), serde_json::json!(seq));
408        match previous {
409            None => {
410                map.insert("chain_root".into(), serde_json::json!(true));
411            }
412            Some(prev) => {
413                map.insert("previous_audit".into(), serde_json::json!(prev));
414            }
415        }
416    }
417}
418
419/// The prefix every Areev-minted bearer token carries.
420///
421/// Three jobs, all of which a bare random string cannot do: secret scanners
422/// (GitHub's, GitLab's, and every commercial one) can be taught a single
423/// regex for it; a human who finds one in a paste knows what it opens and
424/// who to tell; and the server can reject an obviously-malformed credential
425/// before it reaches the constant-time scan. Modelled on GitHub's `ghp_`.
426///
427/// It is deliberately NOT required — an operator's existing token keeps
428/// working — but [`token_is_minted`] is what the startup banner uses to warn
429/// that a credential's entropy is unknown.
430pub const TOKEN_PREFIX: &str = "areev_pat_";
431
432/// Whether `token` has the shape [`encode_token_body`] produces: the prefix plus at
433/// least 32 base32 characters (160 bits — the floor below which a digest in
434/// a shared file starts being worth grinding).
435///
436/// A `true` here means the entropy is known-good *because Areev generated
437/// it*. It is not a security check — an attacker can spell the prefix — it
438/// is an operator-hygiene signal, which is why nothing refuses on a `false`.
439pub fn token_is_minted(token: &str) -> bool {
440    match token.strip_prefix(TOKEN_PREFIX) {
441        Some(body) => {
442            body.len() >= 32 && body.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
443        }
444        None => false,
445    }
446}
447
448/// Render 32 random bytes as the token body. Split out from generation so
449/// the CSPRNG stays in the host that mints (the CLI) while the *format* —
450/// the thing every reader must agree on — lives here with its validator.
451///
452/// Base32-ish over `[a-z0-9]`: case-insensitive to transcribe, safe in a URL,
453/// a shell word, and a JSON string without escaping.
454pub fn encode_token_body(bytes: &[u8; 32]) -> String {
455    const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
456    // 32 bytes = 256 bits → 51 full 5-bit groups (255 bits); the last bit is
457    // dropped rather than padded, leaving 255 bits of entropy in 51 chars.
458    let mut out = String::with_capacity(51);
459    let mut acc: u16 = 0;
460    let mut bits = 0u8;
461    for &b in bytes {
462        acc = (acc << 8) | b as u16;
463        bits += 8;
464        while bits >= 5 {
465            bits -= 5;
466            let idx = ((acc >> bits) & 0x1f) as usize;
467            out.push(ALPHABET[idx] as char);
468        }
469    }
470    out
471}
472
473/// The host-side credential map (`areev-auth.json`): tokens → principal
474/// names, nothing else. No verbs, no namespaces, no raw secrets — a token is
475/// referenced by its SHA-256 or by the env var that holds it, so the file is
476/// inert if stolen or synced.
477#[derive(Debug, Serialize, Deserialize)]
478#[serde(deny_unknown_fields)]
479pub struct CredentialMap {
480    pub version: u32,
481    #[serde(default)]
482    pub tokens: Vec<CredentialEntry>,
483    /// IdP group → principal (A2). The same job the `tokens` list already
484    /// does — map an external identifier onto a principal the FILE grants
485    /// rights to — for the axis SSO actually scales on: without it every
486    /// person behind the proxy needs their own grant grain, which is the
487    /// administrative burden SSO exists to remove.
488    ///
489    /// A group-derived principal is a ROLE, not a person. That is why it can
490    /// never answer a HITL approval, even under `--sso-approvals allow`:
491    /// "someone in engineering approved this" is not an audit record.
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub groups: Option<std::collections::BTreeMap<String, String>>,
494}
495
496#[derive(Debug, Serialize, Deserialize)]
497#[serde(deny_unknown_fields)]
498pub struct CredentialEntry {
499    /// Stable, operator-chosen name for THIS credential — not the principal.
500    ///
501    /// Two tokens for one principal (a laptop and a CI runner, or an old and
502    /// a new one mid-rotation) are otherwise indistinguishable: revoking one
503    /// means identifying a line by its digest, and no log line can ever name
504    /// which credential acted. The id appears on successful auth and in
505    /// `whoami`; it is **never** echoed on a failure, because a refused
506    /// secret must not confirm which credential it nearly matched.
507    ///
508    /// **Optional, with a derived fallback** ([`CredentialEntry::id`]) — an
509    /// `areev-auth.json` written before ids existed must keep working across
510    /// an upgrade. A console that refuses to start because a new field is
511    /// missing is a worse security outcome than an ugly default: it gets
512    /// fixed by rolling back.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub id: Option<String>,
515    /// Free-text note for humans (`"CI runner, rotates quarterly"`).
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub label: Option<String>,
518    /// Lowercase hex SHA-256 of the bearer token.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub sha256: Option<String>,
521    /// Name of the environment variable holding the bearer token.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub env: Option<String>,
524    /// The principal this credential authenticates as.
525    pub principal: String,
526    /// Per-memory scope (the enterprise plane's rule): when set, this
527    /// credential authenticates ONLY on services whose memory label is in
528    /// the list — one auth file shared across server instances, each token
529    /// reaching only its memories. `None` = every memory (the common
530    /// single-memory deployment).
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub memories: Option<Vec<String>>,
533    /// Optional expiry (ISO-8601; `"2026-12-31T23:59:59Z"`). Past it, the
534    /// credential authenticates nobody — indistinguishably from an unknown
535    /// token, so an expired credential cannot be used to probe which ids
536    /// exist.
537    ///
538    /// Deliberately OPTIONAL. A mandatory lifetime would break a homelab
539    /// console at 3am for a threat model it does not have; `areev auth mint
540    /// --expires 90d` is the documented path for the deployments that want
541    /// one, and the startup banner names credentials expiring within 14 days.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub expires_at: Option<String>,
544}
545
546impl CredentialEntry {
547    /// This credential's effective id: the operator's `id` when set, else a
548    /// stable one derived from what identifies the credential anyway.
549    ///
550    /// Derivation is deliberately *not* positional (`token-0`, `token-1`):
551    /// an index shifts when an unrelated line is added, so `areev auth
552    /// revoke --id token-1` would eventually revoke the wrong credential —
553    /// the exact failure an id exists to prevent. A digest prefix and an
554    /// env-var name are both properties of the credential itself, so they
555    /// survive reordering.
556    pub fn id(&self) -> String {
557        match (self.id.as_deref(), &self.sha256, &self.env) {
558            (Some(id), _, _) => id.to_string(),
559            // Not a secret: it is a prefix of a digest the file already
560            // publishes in full, one line up.
561            (None, Some(h), _) => h.chars().take(8).collect::<String>().to_ascii_lowercase(),
562            (None, None, Some(var)) => format!("env:{var}"),
563            (None, None, None) => "unnamed".to_string(),
564        }
565    }
566
567    /// Expiry as epoch ms, or `None` when the entry never expires.
568    ///
569    /// An `expires_at` that does not parse is treated as **already expired**
570    /// (`Some(i64::MIN)`), not as "no expiry". `from_json` refuses such a map
571    /// outright so this should be unreachable — but if it ever is reached,
572    /// failing closed is the only safe reading of a lifetime nobody can
573    /// interpret.
574    fn expires_at_ms(&self) -> Option<i64> {
575        self.expires_at
576            .as_deref()
577            .map(|s| crate::time::iso8601_to_ms(s).unwrap_or(i64::MIN))
578    }
579
580    /// Whether this credential is expired at `now_ms`.
581    pub fn is_expired_at(&self, now_ms: i64) -> bool {
582        self.expires_at_ms().is_some_and(|exp| now_ms >= exp)
583    }
584}
585
586impl CredentialMap {
587    /// Parse and validate. Fail closed: unknown keys, a bad version, an
588    /// entry with both or neither credential form, or a malformed digest all
589    /// refuse the whole map.
590    pub fn from_json(s: &str) -> Result<CredentialMap> {
591        let map: CredentialMap = serde_json::from_str(s)
592            .map_err(|e| AreevError::AuthzConfigInvalid(format!("credential map: {e}")))?;
593        if map.version != 1 {
594            return Err(AreevError::AuthzConfigInvalid(format!(
595                "credential map: unsupported version {} (expected 1)",
596                map.version
597            )));
598        }
599        let mut seen_ids: Vec<String> = Vec::with_capacity(map.tokens.len());
600        for (i, t) in map.tokens.iter().enumerate() {
601            if t.principal.trim().is_empty() {
602                return Err(AreevError::AuthzConfigInvalid(format!(
603                    "credential map: entry {i} has an empty principal"
604                )));
605            }
606            // The id is what makes one credential revocable and one log line
607            // attributable. It may be derived, but it must be legible and
608            // unique — a duplicate makes `areev auth revoke --id` ambiguous,
609            // which is the one thing an id exists to prevent.
610            if let Some(explicit) = t.id.as_deref() {
611                if explicit.trim().is_empty() {
612                    return Err(AreevError::AuthzConfigInvalid(format!(
613                        "credential map: entry {i} ({}) has an empty \"id\" — omit the field \
614                         to get a derived one, or give it a name",
615                        t.principal
616                    )));
617                }
618                if !explicit
619                    .bytes()
620                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
621                {
622                    return Err(AreevError::AuthzConfigInvalid(format!(
623                        "credential map: entry {i} id {explicit:?} must be alphanumeric with \
624                         -, _ or . (it is printed in logs and passed to `areev auth revoke`)"
625                    )));
626                }
627            }
628            let id = t.id();
629            if seen_ids.contains(&id) {
630                return Err(AreevError::AuthzConfigInvalid(format!(
631                    "credential map: duplicate id {id:?} — revoking it would be ambiguous, \
632                     which is the one thing an id exists to prevent"
633                )));
634            }
635            seen_ids.push(id.clone());
636            // An unparseable lifetime is refused at LOAD, not silently
637            // treated as "never expires" — the failure mode of a typo'd
638            // expiry must be a console that will not start, never a
639            // credential that outlives its intended window.
640            if let Some(raw) = &t.expires_at {
641                if crate::time::iso8601_to_ms(raw).is_none() {
642                    return Err(AreevError::AuthzConfigInvalid(format!(
643                        "credential map: entry {i} ({id}) has an unparseable \"expires_at\" \
644                         {raw:?} — expected ISO-8601, e.g. \"2026-12-31T23:59:59Z\""
645                    )));
646                }
647            }
648            match (&t.sha256, &t.env) {
649                (Some(_), Some(_)) | (None, None) => {
650                    return Err(AreevError::AuthzConfigInvalid(format!(
651                        "credential map: entry {i} ({}) must have exactly one of \
652                         \"sha256\" or \"env\"",
653                        t.principal
654                    )));
655                }
656                (Some(h), None) => {
657                    if h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
658                        return Err(AreevError::AuthzConfigInvalid(format!(
659                            "credential map: entry {i} ({}) sha256 must be 64 hex chars",
660                            t.principal
661                        )));
662                    }
663                }
664                (None, Some(v)) => {
665                    if v.trim().is_empty() {
666                        return Err(AreevError::AuthzConfigInvalid(format!(
667                            "credential map: entry {i} ({}) has an empty \"env\" variable name",
668                            t.principal
669                        )));
670                    }
671                }
672            }
673            if let Some(memories) = &t.memories {
674                if memories.is_empty() || memories.iter().any(|m| m.trim().is_empty()) {
675                    return Err(AreevError::AuthzConfigInvalid(format!(
676                        "credential map: entry {i} ({}) has an empty \"memories\" \
677                         scope — omit the field to grant every memory",
678                        t.principal
679                    )));
680                }
681            }
682        }
683        Ok(map)
684    }
685
686    /// Resolve a presented bearer token to its principal. The error carries
687    /// no part of the token — a refused secret must not leak into logs.
688    ///
689    /// Expiry is evaluated against the system clock; [`resolve_at`] takes the
690    /// instant explicitly for tests.
691    ///
692    /// [`resolve_at`]: Self::resolve_at
693    pub fn resolve(&self, presented: &str) -> Result<&str> {
694        self.resolve_at(presented, crate::time::now_ms())
695    }
696
697    /// [`resolve`](Self::resolve) at an explicit instant.
698    pub fn resolve_at(&self, presented: &str, now_ms: i64) -> Result<&str> {
699        self.entry_at(presented, now_ms).map(|t| t.principal.as_str())
700    }
701
702    /// The matching, unexpired entry for `presented` — the shared core of
703    /// every resolve path, so expiry and the empty-token rule cannot drift
704    /// between them.
705    fn entry_at(&self, presented: &str, now_ms: i64) -> Result<&CredentialEntry> {
706        // An empty presented token can never authenticate. Without this, an
707        // env-referenced credential whose variable is exported *empty*
708        // (`Environment=AREEV_BOT_TOKEN=` in a unit file, `export VAR=` in a
709        // wrapper) matches the empty string an `Authorization: Bearer `
710        // header parses to, and every caller becomes that principal.
711        if presented.is_empty() {
712            return Err(AreevError::AuthzTokenUnrecognized);
713        }
714        let digest = hex::encode(Sha256::digest(presented.as_bytes()));
715        // Constant-shape scan: every entry is examined whether or not an
716        // earlier one matched, so an EXPIRED credential is not even
717        // timing-distinguishable from an unknown one. (This is why expiry is
718        // filtered after the scan rather than short-circuiting inside it.)
719        let mut found: Option<&CredentialEntry> = None;
720        for t in &self.tokens {
721            let matched = match (&t.sha256, &t.env) {
722                (Some(h), None) => h.eq_ignore_ascii_case(&digest),
723                // The env var must actually hold a secret — an unset or
724                // empty variable authenticates nobody.
725                (None, Some(var)) => std::env::var(var)
726                    .is_ok_and(|v| !v.trim().is_empty() && ct_eq(v.as_bytes(), presented.as_bytes())),
727                _ => false,
728            };
729            if matched && found.is_none() {
730                found = Some(t);
731            }
732        }
733        match found {
734            Some(t) if !t.is_expired_at(now_ms) => Ok(t),
735            // Expired resolves exactly like unknown: same error, no mention
736            // of the id, so a stale credential cannot be used to enumerate
737            // which ids the map holds.
738            _ => Err(AreevError::AuthzTokenUnrecognized),
739        }
740    }
741
742    /// The credential id that authenticated `presented`, for the audit/log
743    /// line on a SUCCESSFUL auth. Never call this on a failure path.
744    pub fn resolve_id_at(&self, presented: &str, now_ms: i64) -> Option<String> {
745        self.entry_at(presented, now_ms).ok().map(|t| t.id())
746    }
747
748    /// Resolve a token FOR ONE MEMORY: like [`resolve`](Self::resolve), but
749    /// a credential carrying a `memories` scope only authenticates when
750    /// `memory` is listed. The refusal is indistinguishable from an unknown
751    /// token — a scoped credential must not confirm which memories exist.
752    pub fn resolve_for_memory(&self, presented: &str, memory: &str) -> Result<&str> {
753        self.resolve_for_memory_at(presented, memory, crate::time::now_ms())
754    }
755
756    /// [`resolve_for_memory`](Self::resolve_for_memory) at an explicit
757    /// instant.
758    pub fn resolve_for_memory_at(
759        &self,
760        presented: &str,
761        memory: &str,
762        now_ms: i64,
763    ) -> Result<&str> {
764        // Shares `entry_at` so the empty-token rule, the constant-shape scan
765        // and expiry cannot drift between the two resolve paths — the bug
766        // class where one caller honors an expiry the other ignores.
767        let t = self.entry_at(presented, now_ms)?;
768        match &t.memories {
769            Some(list) if !list.iter().any(|m| m == memory) => {
770                Err(AreevError::AuthzTokenUnrecognized)
771            }
772            _ => Ok(&t.principal),
773        }
774    }
775
776    /// The credential id that authenticated `presented` on `memory`, for the
777    /// success log line. `None` on any refusal.
778    pub fn resolve_id_for_memory_at(
779        &self,
780        presented: &str,
781        memory: &str,
782        now_ms: i64,
783    ) -> Option<String> {
784        let t = self.entry_at(presented, now_ms).ok()?;
785        match &t.memories {
786            Some(list) if !list.iter().any(|m| m == memory) => None,
787            _ => Some(t.id()),
788        }
789    }
790
791    /// Credentials expiring within `window_ms` of `now_ms` (and those already
792    /// expired), for the startup banner. Returns `(id, expires_at)` pairs.
793    ///
794    /// A console that only reports an expiry at the moment it starts refusing
795    /// is a console that reports it during an incident.
796    pub fn expiring_within(&self, now_ms: i64, window_ms: i64) -> Vec<(String, String)> {
797        self.tokens
798            .iter()
799            .filter_map(|t| {
800                let raw = t.expires_at.as_deref()?;
801                let exp = crate::time::iso8601_to_ms(raw)?;
802                (exp <= now_ms + window_ms).then_some((t.id(), raw.to_string()))
803            })
804            .collect()
805    }
806
807    /// The principal an IdP group maps to, if any (A2).
808    ///
809    /// Group names are compared case-insensitively: directories are
810    /// inconsistent about the case they emit (`Engineering` vs
811    /// `engineering`), and a mapping that silently misses because of it
812    /// fails *open* into whatever the identity alone was granted — which is
813    /// the wrong direction for an authorization lookup to be sloppy in.
814    pub fn principal_for_group(&self, group: &str) -> Option<&str> {
815        let g = group.trim();
816        self.groups.as_ref()?.iter().find_map(|(k, v)| {
817            k.eq_ignore_ascii_case(g).then_some(v.as_str())
818        })
819    }
820
821    /// Whether any credential authenticates as this principal — surfaces
822    /// that require a *known* principal name use this to refuse typos early.
823    pub fn knows_principal(&self, principal: &str) -> Result<()> {
824        if self.tokens.iter().any(|t| t.principal == principal) {
825            return Ok(());
826        }
827        Err(AreevError::AuthzUnknownPrincipal(principal.to_string()))
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    #[test]
834    fn chaining_an_audit_observation_sets_the_link_and_the_sequence() {
835        // #280: the first record is a chain root with no predecessor; every
836        // later one names its predecessor twice — once in `derived_from` (so
837        // a provenance walk reaches it) and once in `context.previous_audit`
838        // (so an export line is self-describing).
839        let mut first = audit_observation("u", "erase", "subject:ab ns:n", Some("dsar"), 1, 1_000);
840        chain_audit_observation(&mut first, None, 1);
841        let ctx = first.common.context.clone().unwrap();
842        assert_eq!(ctx["seq"], serde_json::json!(1));
843        assert_eq!(ctx["chain_root"], serde_json::json!(true));
844        assert!(first.common.derived_from.is_none());
845
846        let mut second = audit_observation("u", "delete", "hash:ff", None, 1, 2_000);
847        chain_audit_observation(&mut second, Some("aabb"), 2);
848        let ctx = second.common.context.clone().unwrap();
849        assert_eq!(ctx["seq"], serde_json::json!(2));
850        assert_eq!(ctx["previous_audit"], serde_json::json!("aabb"));
851        assert!(ctx.get("chain_root").is_none());
852        assert_eq!(second.common.derived_from.as_deref(), Some("aabb"));
853    }
854
855    use super::*;
856
857    #[test]
858    fn verbs_roundtrip_their_string_forms() {
859        for v in Verb::ALL {
860            assert_eq!(Verb::parse(v.as_str()).unwrap(), v);
861        }
862        assert!(Verb::parse("loop-run").is_err());
863        assert!(Verb::parse("").is_err());
864    }
865
866    #[test]
867    fn an_empty_env_credential_authenticates_nobody() {
868        // The variable name itself must be non-empty…
869        assert!(CredentialMap::from_json(
870            r#"{"version":1,"tokens":[{"env":"  ","principal":"agent:writer"}]}"#
871        )
872        .is_err());
873
874        // …and a variable exported EMPTY must not match the empty string an
875        // `Authorization: Bearer ` header parses to. Otherwise every caller
876        // becomes `agent:writer`.
877        std::env::set_var("AREEV_TEST_EMPTY_TOK", "");
878        let map = CredentialMap::from_json(
879            r#"{"version":1,"tokens":[{"env":"AREEV_TEST_EMPTY_TOK","principal":"agent:writer"}]}"#,
880        )
881        .unwrap();
882        assert!(map.resolve("").is_err(), "empty bearer must not authenticate");
883        assert!(map.resolve("anything").is_err());
884
885        // Unset behaves the same.
886        std::env::remove_var("AREEV_TEST_EMPTY_TOK");
887        assert!(map.resolve("").is_err());
888    }
889
890    #[test]
891    fn grant_object_string_roundtrips() {
892        let g = Grant {
893            verbs: vec![Verb::Read, Verb::Write],
894            namespaces: vec!["caller".into(), "shared".into()],
895        };
896        let s = g.to_object_string();
897        assert_eq!(s, "read,write ON caller,shared");
898        assert_eq!(Grant::from_object_string(&s).unwrap(), g);
899
900        let all = Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] };
901        assert_eq!(all.to_object_string(), "erase ON *");
902        assert_eq!(
903            Grant::from_object_string("erase ON *").unwrap().namespaces,
904            vec!["*".to_string()]
905        );
906
907        assert!(Grant::from_object_string("read caller").is_err());
908        assert!(Grant::from_object_string(" ON x").is_err());
909        assert!(Grant::from_object_string("read ON ").is_err());
910    }
911
912    #[test]
913    fn owner_allows_everything_restricted_fails_closed() {
914        let owner = AuthzSet::owner("user:local");
915        for v in Verb::ALL {
916            assert!(owner.check(v, "any-ns").is_ok());
917        }
918
919        let none = AuthzSet::restricted("agent:bot", Vec::new());
920        for v in Verb::ALL {
921            assert!(none.check(v, "caller").is_err(), "{v} must be refused");
922        }
923    }
924
925    #[test]
926    fn grants_cover_exactly_what_they_say() {
927        let set = AuthzSet::restricted(
928            "agent:bot",
929            vec![Grant {
930                verbs: vec![Verb::Read, Verb::Write],
931                namespaces: vec!["caller".into()],
932            }],
933        );
934        assert!(set.check(Verb::Read, "caller").is_ok());
935        assert!(set.check(Verb::Write, "caller").is_ok());
936        assert!(set.check(Verb::Write, "shared").is_err());
937        assert!(set.check(Verb::Delete, "caller").is_err());
938
939        let star = AuthzSet::restricted(
940            "job:sweep",
941            vec![Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] }],
942        );
943        assert!(star.check(Verb::Erase, "anything").is_ok());
944    }
945
946    #[test]
947    fn refusal_names_verb_namespace_and_principal_with_the_aut_code() {
948        let set = AuthzSet::restricted("agent:bot", Vec::new());
949        let err = set.check(Verb::Delete, "caller").unwrap_err();
950        assert_eq!(err.code(), "AUT-E001");
951        let msg = err.to_string();
952        for needle in ["delete", "caller", "agent:bot"] {
953            assert!(msg.contains(needle), "{msg:?} must name {needle}");
954        }
955    }
956
957    const MAP: &str = r#"{
958        "version": 1,
959        "tokens": [
960            { "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
961              "principal": "user:anna" },
962            { "env": "AREEV_TEST_BOT_TOKEN", "principal": "agent:bot" }
963        ]
964    }"#;
965
966    #[test]
967    fn credential_map_loads_and_resolves_by_sha256() {
968        let map = CredentialMap::from_json(MAP).unwrap();
969        // sha256("test") — the digest above.
970        assert_eq!(map.resolve("test").unwrap(), "user:anna");
971        assert!(map.knows_principal("user:anna").is_ok());
972        assert_eq!(
973            map.knows_principal("user:nobody").unwrap_err().code(),
974            "AUT-E002"
975        );
976    }
977
978    #[test]
979    fn credential_map_resolves_by_env_var() {
980        let map = CredentialMap::from_json(MAP).unwrap();
981        // Unique var name per test binary run; set before resolve.
982        std::env::set_var("AREEV_TEST_BOT_TOKEN", "s3cret");
983        assert_eq!(map.resolve("s3cret").unwrap(), "agent:bot");
984        std::env::remove_var("AREEV_TEST_BOT_TOKEN");
985    }
986
987    #[test]
988    fn unrecognized_token_error_never_echoes_the_secret() {
989        let map = CredentialMap::from_json(MAP).unwrap();
990        let err = map.resolve("super-secret-value").unwrap_err();
991        assert_eq!(err.code(), "AUT-E004");
992        assert!(!err.to_string().contains("super-secret-value"));
993    }
994
995    #[test]
996    fn credential_map_fails_closed() {
997        // Unknown key.
998        assert_eq!(
999            CredentialMap::from_json(r#"{"version":1,"tokens":[],"roles":{}}"#)
1000                .unwrap_err()
1001                .code(),
1002            "AUT-E003"
1003        );
1004        // Wrong version.
1005        assert!(CredentialMap::from_json(r#"{"version":2,"tokens":[]}"#).is_err());
1006        // Both credential forms.
1007        assert!(CredentialMap::from_json(
1008            r#"{"version":1,"tokens":[{"sha256":"00","env":"X","principal":"p"}]}"#
1009        )
1010        .is_err());
1011        // Neither form.
1012        assert!(CredentialMap::from_json(
1013            r#"{"version":1,"tokens":[{"principal":"p"}]}"#
1014        )
1015        .is_err());
1016        // Malformed digest.
1017        assert!(CredentialMap::from_json(
1018            r#"{"version":1,"tokens":[{"sha256":"zz","principal":"p"}]}"#
1019        )
1020        .is_err());
1021        // Empty principal.
1022        assert!(CredentialMap::from_json(
1023            r#"{"version":1,"tokens":[{"env":"X","principal":"  "}]}"#
1024        )
1025        .is_err());
1026        // [A1] An id that cannot be printed or passed to `revoke`.
1027        assert!(CredentialMap::from_json(
1028            r#"{"version":1,"tokens":[{"env":"X","principal":"p","id":"has space"}]}"#
1029        )
1030        .is_err());
1031        // [A1] Duplicate ids make revocation ambiguous.
1032        assert!(CredentialMap::from_json(
1033            r#"{"version":1,"tokens":[
1034                {"env":"X","principal":"p","id":"dup"},
1035                {"env":"Y","principal":"q","id":"dup"}
1036            ]}"#
1037        )
1038        .is_err());
1039        // [A1] A typo'd lifetime must refuse the map, never read as "never
1040        // expires" — the failure mode of a bad expiry is a console that will
1041        // not start, not a credential that outlives its window.
1042        assert!(CredentialMap::from_json(
1043            r#"{"version":1,"tokens":[{"env":"X","principal":"p","expires_at":"soon"}]}"#
1044        )
1045        .is_err());
1046    }
1047
1048    /// [A1] A map written before ids existed still loads — and every entry
1049    /// still gets a stable, non-positional id.
1050    #[test]
1051    fn pre_id_maps_load_and_derive_stable_ids() {
1052        let map = CredentialMap::from_json(
1053            r#"{"version":1,"tokens":[
1054                {"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
1055                 "principal":"user:a"},
1056                {"env":"AREEV_LEGACY_TOK","principal":"user:b"}
1057            ]}"#,
1058        )
1059        .unwrap();
1060        assert_eq!(map.tokens[0].id(), "9f86d081");
1061        assert_eq!(map.tokens[1].id(), "env:AREEV_LEGACY_TOK");
1062
1063        // Non-positional: prepending an entry does not renumber the others,
1064        // which is what makes `revoke --id` safe.
1065        let grown = CredentialMap::from_json(
1066            r#"{"version":1,"tokens":[
1067                {"env":"AREEV_NEW_TOK","principal":"user:c"},
1068                {"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
1069                 "principal":"user:a"}
1070            ]}"#,
1071        )
1072        .unwrap();
1073        assert_eq!(grown.tokens[1].id(), "9f86d081");
1074    }
1075
1076    /// [A1] Expiry refuses indistinguishably from an unknown token, and one
1077    /// credential expiring leaves its principal's others alone.
1078    #[test]
1079    fn expired_credentials_refuse_like_unknown_ones() {
1080        let map = CredentialMap::from_json(
1081            r#"{"version":1,"tokens":[
1082                {"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
1083                 "principal":"user:a","id":"old","expires_at":"2026-01-01T00:00:00Z"},
1084                {"sha256":"fd61a03af4f77d870fc21e05e7e80678095c92d808cfb3b5c279ee04c74aca13",
1085                 "principal":"user:a","id":"new"}
1086            ]}"#,
1087        )
1088        .unwrap();
1089        let before = crate::time::iso8601_to_ms("2025-06-01T00:00:00Z").unwrap();
1090        let after = crate::time::iso8601_to_ms("2026-06-01T00:00:00Z").unwrap();
1091
1092        // sha256("test") is the first digest; sha256("test3") the second.
1093        assert_eq!(map.resolve_at("test", before).unwrap(), "user:a");
1094        assert_eq!(map.resolve_id_at("test", before).as_deref(), Some("old"));
1095
1096        // Past the expiry it is refused — with the SAME error an unknown
1097        // token gets, and without naming the id.
1098        let err = map.resolve_at("test", after).unwrap_err();
1099        assert_eq!(err.code(), map.resolve_at("never-issued", after).unwrap_err().code());
1100        assert!(!err.to_string().contains("old"), "must not name the id: {err}");
1101        assert!(map.resolve_id_at("test", after).is_none());
1102
1103        // The principal's OTHER credential is untouched — expiring one token
1104        // must not lock the human out of their own account.
1105        assert_eq!(map.resolve_at("test3", after).unwrap(), "user:a");
1106
1107        // And the same rule holds on the memory-scoped path.
1108        assert!(map.resolve_for_memory_at("test", "m", after).is_err());
1109        assert_eq!(map.resolve_for_memory_at("test3", "m", after).unwrap(), "user:a");
1110    }
1111
1112    /// [A1] The banner input: what is about to expire, before it does.
1113    #[test]
1114    fn expiring_within_reports_the_window() {
1115        let map = CredentialMap::from_json(
1116            r#"{"version":1,"tokens":[
1117                {"env":"A","principal":"p","id":"soon","expires_at":"2026-01-10T00:00:00Z"},
1118                {"env":"B","principal":"p","id":"later","expires_at":"2027-01-01T00:00:00Z"},
1119                {"env":"C","principal":"p","id":"never"}
1120            ]}"#,
1121        )
1122        .unwrap();
1123        let now = crate::time::iso8601_to_ms("2026-01-01T00:00:00Z").unwrap();
1124        let two_weeks = 14 * 86_400_000;
1125        let due: Vec<String> = map
1126            .expiring_within(now, two_weeks)
1127            .into_iter()
1128            .map(|(id, _)| id)
1129            .collect();
1130        assert_eq!(due, vec!["soon".to_string()]);
1131    }
1132
1133    /// [A1] The token format: recognizable to a scanner, and honest about
1134    /// what it does and does not prove.
1135    #[test]
1136    fn minted_token_shape_is_recognizable() {
1137        let body = encode_token_body(&[0u8; 32]);
1138        let token = format!("{TOKEN_PREFIX}{body}");
1139        assert!(token_is_minted(&token));
1140        assert!(token.starts_with("areev_pat_"));
1141        assert_eq!(body.len(), 51, "51 base32 chars = 255 bits");
1142
1143        // Distinct entropy → distinct tokens.
1144        let other = encode_token_body(&[1u8; 32]);
1145        assert_ne!(body, other);
1146
1147        // An operator's own token is not "minted" — that is a hygiene
1148        // signal, not an auth check, so nothing here refuses it.
1149        assert!(!token_is_minted("hunter2"));
1150        assert!(!token_is_minted("areev_pat_short"));
1151    }
1152}