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/// 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 prefix every Areev-minted bearer token carries.
378///
379/// Three jobs, all of which a bare random string cannot do: secret scanners
380/// (GitHub's, GitLab's, and every commercial one) can be taught a single
381/// regex for it; a human who finds one in a paste knows what it opens and
382/// who to tell; and the server can reject an obviously-malformed credential
383/// before it reaches the constant-time scan. Modelled on GitHub's `ghp_`.
384///
385/// It is deliberately NOT required — an operator's existing token keeps
386/// working — but [`token_is_minted`] is what the startup banner uses to warn
387/// that a credential's entropy is unknown.
388pub const TOKEN_PREFIX: &str = "areev_pat_";
389
390/// Whether `token` has the shape [`encode_token_body`] produces: the prefix plus at
391/// least 32 base32 characters (160 bits — the floor below which a digest in
392/// a shared file starts being worth grinding).
393///
394/// A `true` here means the entropy is known-good *because Areev generated
395/// it*. It is not a security check — an attacker can spell the prefix — it
396/// is an operator-hygiene signal, which is why nothing refuses on a `false`.
397pub fn token_is_minted(token: &str) -> bool {
398    match token.strip_prefix(TOKEN_PREFIX) {
399        Some(body) => {
400            body.len() >= 32 && body.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
401        }
402        None => false,
403    }
404}
405
406/// Render 32 random bytes as the token body. Split out from generation so
407/// the CSPRNG stays in the host that mints (the CLI) while the *format* —
408/// the thing every reader must agree on — lives here with its validator.
409///
410/// Base32-ish over `[a-z0-9]`: case-insensitive to transcribe, safe in a URL,
411/// a shell word, and a JSON string without escaping.
412pub fn encode_token_body(bytes: &[u8; 32]) -> String {
413    const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
414    // 32 bytes = 256 bits → 51 full 5-bit groups (255 bits); the last bit is
415    // dropped rather than padded, leaving 255 bits of entropy in 51 chars.
416    let mut out = String::with_capacity(51);
417    let mut acc: u16 = 0;
418    let mut bits = 0u8;
419    for &b in bytes {
420        acc = (acc << 8) | b as u16;
421        bits += 8;
422        while bits >= 5 {
423            bits -= 5;
424            let idx = ((acc >> bits) & 0x1f) as usize;
425            out.push(ALPHABET[idx] as char);
426        }
427    }
428    out
429}
430
431/// The host-side credential map (`areev-auth.json`): tokens → principal
432/// names, nothing else. No verbs, no namespaces, no raw secrets — a token is
433/// referenced by its SHA-256 or by the env var that holds it, so the file is
434/// inert if stolen or synced.
435#[derive(Debug, Serialize, Deserialize)]
436#[serde(deny_unknown_fields)]
437pub struct CredentialMap {
438    pub version: u32,
439    #[serde(default)]
440    pub tokens: Vec<CredentialEntry>,
441    /// IdP group → principal (A2). The same job the `tokens` list already
442    /// does — map an external identifier onto a principal the FILE grants
443    /// rights to — for the axis SSO actually scales on: without it every
444    /// person behind the proxy needs their own grant grain, which is the
445    /// administrative burden SSO exists to remove.
446    ///
447    /// A group-derived principal is a ROLE, not a person. That is why it can
448    /// never answer a HITL approval, even under `--sso-approvals allow`:
449    /// "someone in engineering approved this" is not an audit record.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub groups: Option<std::collections::BTreeMap<String, String>>,
452}
453
454#[derive(Debug, Serialize, Deserialize)]
455#[serde(deny_unknown_fields)]
456pub struct CredentialEntry {
457    /// Stable, operator-chosen name for THIS credential — not the principal.
458    ///
459    /// Two tokens for one principal (a laptop and a CI runner, or an old and
460    /// a new one mid-rotation) are otherwise indistinguishable: revoking one
461    /// means identifying a line by its digest, and no log line can ever name
462    /// which credential acted. The id appears on successful auth and in
463    /// `whoami`; it is **never** echoed on a failure, because a refused
464    /// secret must not confirm which credential it nearly matched.
465    ///
466    /// **Optional, with a derived fallback** ([`CredentialEntry::id`]) — an
467    /// `areev-auth.json` written before ids existed must keep working across
468    /// an upgrade. A console that refuses to start because a new field is
469    /// missing is a worse security outcome than an ugly default: it gets
470    /// fixed by rolling back.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub id: Option<String>,
473    /// Free-text note for humans (`"CI runner, rotates quarterly"`).
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub label: Option<String>,
476    /// Lowercase hex SHA-256 of the bearer token.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub sha256: Option<String>,
479    /// Name of the environment variable holding the bearer token.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub env: Option<String>,
482    /// The principal this credential authenticates as.
483    pub principal: String,
484    /// Per-memory scope (the enterprise plane's rule): when set, this
485    /// credential authenticates ONLY on services whose memory label is in
486    /// the list — one auth file shared across server instances, each token
487    /// reaching only its memories. `None` = every memory (the common
488    /// single-memory deployment).
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub memories: Option<Vec<String>>,
491    /// Optional expiry (ISO-8601; `"2026-12-31T23:59:59Z"`). Past it, the
492    /// credential authenticates nobody — indistinguishably from an unknown
493    /// token, so an expired credential cannot be used to probe which ids
494    /// exist.
495    ///
496    /// Deliberately OPTIONAL. A mandatory lifetime would break a homelab
497    /// console at 3am for a threat model it does not have; `areev auth mint
498    /// --expires 90d` is the documented path for the deployments that want
499    /// one, and the startup banner names credentials expiring within 14 days.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub expires_at: Option<String>,
502}
503
504impl CredentialEntry {
505    /// This credential's effective id: the operator's `id` when set, else a
506    /// stable one derived from what identifies the credential anyway.
507    ///
508    /// Derivation is deliberately *not* positional (`token-0`, `token-1`):
509    /// an index shifts when an unrelated line is added, so `areev auth
510    /// revoke --id token-1` would eventually revoke the wrong credential —
511    /// the exact failure an id exists to prevent. A digest prefix and an
512    /// env-var name are both properties of the credential itself, so they
513    /// survive reordering.
514    pub fn id(&self) -> String {
515        match (self.id.as_deref(), &self.sha256, &self.env) {
516            (Some(id), _, _) => id.to_string(),
517            // Not a secret: it is a prefix of a digest the file already
518            // publishes in full, one line up.
519            (None, Some(h), _) => h.chars().take(8).collect::<String>().to_ascii_lowercase(),
520            (None, None, Some(var)) => format!("env:{var}"),
521            (None, None, None) => "unnamed".to_string(),
522        }
523    }
524
525    /// Expiry as epoch ms, or `None` when the entry never expires.
526    ///
527    /// An `expires_at` that does not parse is treated as **already expired**
528    /// (`Some(i64::MIN)`), not as "no expiry". `from_json` refuses such a map
529    /// outright so this should be unreachable — but if it ever is reached,
530    /// failing closed is the only safe reading of a lifetime nobody can
531    /// interpret.
532    fn expires_at_ms(&self) -> Option<i64> {
533        self.expires_at
534            .as_deref()
535            .map(|s| crate::time::iso8601_to_ms(s).unwrap_or(i64::MIN))
536    }
537
538    /// Whether this credential is expired at `now_ms`.
539    pub fn is_expired_at(&self, now_ms: i64) -> bool {
540        self.expires_at_ms().is_some_and(|exp| now_ms >= exp)
541    }
542}
543
544impl CredentialMap {
545    /// Parse and validate. Fail closed: unknown keys, a bad version, an
546    /// entry with both or neither credential form, or a malformed digest all
547    /// refuse the whole map.
548    pub fn from_json(s: &str) -> Result<CredentialMap> {
549        let map: CredentialMap = serde_json::from_str(s)
550            .map_err(|e| AreevError::AuthzConfigInvalid(format!("credential map: {e}")))?;
551        if map.version != 1 {
552            return Err(AreevError::AuthzConfigInvalid(format!(
553                "credential map: unsupported version {} (expected 1)",
554                map.version
555            )));
556        }
557        let mut seen_ids: Vec<String> = Vec::with_capacity(map.tokens.len());
558        for (i, t) in map.tokens.iter().enumerate() {
559            if t.principal.trim().is_empty() {
560                return Err(AreevError::AuthzConfigInvalid(format!(
561                    "credential map: entry {i} has an empty principal"
562                )));
563            }
564            // The id is what makes one credential revocable and one log line
565            // attributable. It may be derived, but it must be legible and
566            // unique — a duplicate makes `areev auth revoke --id` ambiguous,
567            // which is the one thing an id exists to prevent.
568            if let Some(explicit) = t.id.as_deref() {
569                if explicit.trim().is_empty() {
570                    return Err(AreevError::AuthzConfigInvalid(format!(
571                        "credential map: entry {i} ({}) has an empty \"id\" — omit the field \
572                         to get a derived one, or give it a name",
573                        t.principal
574                    )));
575                }
576                if !explicit
577                    .bytes()
578                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
579                {
580                    return Err(AreevError::AuthzConfigInvalid(format!(
581                        "credential map: entry {i} id {explicit:?} must be alphanumeric with \
582                         -, _ or . (it is printed in logs and passed to `areev auth revoke`)"
583                    )));
584                }
585            }
586            let id = t.id();
587            if seen_ids.contains(&id) {
588                return Err(AreevError::AuthzConfigInvalid(format!(
589                    "credential map: duplicate id {id:?} — revoking it would be ambiguous, \
590                     which is the one thing an id exists to prevent"
591                )));
592            }
593            seen_ids.push(id.clone());
594            // An unparseable lifetime is refused at LOAD, not silently
595            // treated as "never expires" — the failure mode of a typo'd
596            // expiry must be a console that will not start, never a
597            // credential that outlives its intended window.
598            if let Some(raw) = &t.expires_at {
599                if crate::time::iso8601_to_ms(raw).is_none() {
600                    return Err(AreevError::AuthzConfigInvalid(format!(
601                        "credential map: entry {i} ({id}) has an unparseable \"expires_at\" \
602                         {raw:?} — expected ISO-8601, e.g. \"2026-12-31T23:59:59Z\""
603                    )));
604                }
605            }
606            match (&t.sha256, &t.env) {
607                (Some(_), Some(_)) | (None, None) => {
608                    return Err(AreevError::AuthzConfigInvalid(format!(
609                        "credential map: entry {i} ({}) must have exactly one of \
610                         \"sha256\" or \"env\"",
611                        t.principal
612                    )));
613                }
614                (Some(h), None) => {
615                    if h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
616                        return Err(AreevError::AuthzConfigInvalid(format!(
617                            "credential map: entry {i} ({}) sha256 must be 64 hex chars",
618                            t.principal
619                        )));
620                    }
621                }
622                (None, Some(v)) => {
623                    if v.trim().is_empty() {
624                        return Err(AreevError::AuthzConfigInvalid(format!(
625                            "credential map: entry {i} ({}) has an empty \"env\" variable name",
626                            t.principal
627                        )));
628                    }
629                }
630            }
631            if let Some(memories) = &t.memories {
632                if memories.is_empty() || memories.iter().any(|m| m.trim().is_empty()) {
633                    return Err(AreevError::AuthzConfigInvalid(format!(
634                        "credential map: entry {i} ({}) has an empty \"memories\" \
635                         scope — omit the field to grant every memory",
636                        t.principal
637                    )));
638                }
639            }
640        }
641        Ok(map)
642    }
643
644    /// Resolve a presented bearer token to its principal. The error carries
645    /// no part of the token — a refused secret must not leak into logs.
646    ///
647    /// Expiry is evaluated against the system clock; [`resolve_at`] takes the
648    /// instant explicitly for tests.
649    ///
650    /// [`resolve_at`]: Self::resolve_at
651    pub fn resolve(&self, presented: &str) -> Result<&str> {
652        self.resolve_at(presented, crate::time::now_ms())
653    }
654
655    /// [`resolve`](Self::resolve) at an explicit instant.
656    pub fn resolve_at(&self, presented: &str, now_ms: i64) -> Result<&str> {
657        self.entry_at(presented, now_ms).map(|t| t.principal.as_str())
658    }
659
660    /// The matching, unexpired entry for `presented` — the shared core of
661    /// every resolve path, so expiry and the empty-token rule cannot drift
662    /// between them.
663    fn entry_at(&self, presented: &str, now_ms: i64) -> Result<&CredentialEntry> {
664        // An empty presented token can never authenticate. Without this, an
665        // env-referenced credential whose variable is exported *empty*
666        // (`Environment=AREEV_BOT_TOKEN=` in a unit file, `export VAR=` in a
667        // wrapper) matches the empty string an `Authorization: Bearer `
668        // header parses to, and every caller becomes that principal.
669        if presented.is_empty() {
670            return Err(AreevError::AuthzTokenUnrecognized);
671        }
672        let digest = hex::encode(Sha256::digest(presented.as_bytes()));
673        // Constant-shape scan: every entry is examined whether or not an
674        // earlier one matched, so an EXPIRED credential is not even
675        // timing-distinguishable from an unknown one. (This is why expiry is
676        // filtered after the scan rather than short-circuiting inside it.)
677        let mut found: Option<&CredentialEntry> = None;
678        for t in &self.tokens {
679            let matched = match (&t.sha256, &t.env) {
680                (Some(h), None) => h.eq_ignore_ascii_case(&digest),
681                // The env var must actually hold a secret — an unset or
682                // empty variable authenticates nobody.
683                (None, Some(var)) => std::env::var(var)
684                    .is_ok_and(|v| !v.trim().is_empty() && ct_eq(v.as_bytes(), presented.as_bytes())),
685                _ => false,
686            };
687            if matched && found.is_none() {
688                found = Some(t);
689            }
690        }
691        match found {
692            Some(t) if !t.is_expired_at(now_ms) => Ok(t),
693            // Expired resolves exactly like unknown: same error, no mention
694            // of the id, so a stale credential cannot be used to enumerate
695            // which ids the map holds.
696            _ => Err(AreevError::AuthzTokenUnrecognized),
697        }
698    }
699
700    /// The credential id that authenticated `presented`, for the audit/log
701    /// line on a SUCCESSFUL auth. Never call this on a failure path.
702    pub fn resolve_id_at(&self, presented: &str, now_ms: i64) -> Option<String> {
703        self.entry_at(presented, now_ms).ok().map(|t| t.id())
704    }
705
706    /// Resolve a token FOR ONE MEMORY: like [`resolve`](Self::resolve), but
707    /// a credential carrying a `memories` scope only authenticates when
708    /// `memory` is listed. The refusal is indistinguishable from an unknown
709    /// token — a scoped credential must not confirm which memories exist.
710    pub fn resolve_for_memory(&self, presented: &str, memory: &str) -> Result<&str> {
711        self.resolve_for_memory_at(presented, memory, crate::time::now_ms())
712    }
713
714    /// [`resolve_for_memory`](Self::resolve_for_memory) at an explicit
715    /// instant.
716    pub fn resolve_for_memory_at(
717        &self,
718        presented: &str,
719        memory: &str,
720        now_ms: i64,
721    ) -> Result<&str> {
722        // Shares `entry_at` so the empty-token rule, the constant-shape scan
723        // and expiry cannot drift between the two resolve paths — the bug
724        // class where one caller honors an expiry the other ignores.
725        let t = self.entry_at(presented, now_ms)?;
726        match &t.memories {
727            Some(list) if !list.iter().any(|m| m == memory) => {
728                Err(AreevError::AuthzTokenUnrecognized)
729            }
730            _ => Ok(&t.principal),
731        }
732    }
733
734    /// The credential id that authenticated `presented` on `memory`, for the
735    /// success log line. `None` on any refusal.
736    pub fn resolve_id_for_memory_at(
737        &self,
738        presented: &str,
739        memory: &str,
740        now_ms: i64,
741    ) -> Option<String> {
742        let t = self.entry_at(presented, now_ms).ok()?;
743        match &t.memories {
744            Some(list) if !list.iter().any(|m| m == memory) => None,
745            _ => Some(t.id()),
746        }
747    }
748
749    /// Credentials expiring within `window_ms` of `now_ms` (and those already
750    /// expired), for the startup banner. Returns `(id, expires_at)` pairs.
751    ///
752    /// A console that only reports an expiry at the moment it starts refusing
753    /// is a console that reports it during an incident.
754    pub fn expiring_within(&self, now_ms: i64, window_ms: i64) -> Vec<(String, String)> {
755        self.tokens
756            .iter()
757            .filter_map(|t| {
758                let raw = t.expires_at.as_deref()?;
759                let exp = crate::time::iso8601_to_ms(raw)?;
760                (exp <= now_ms + window_ms).then_some((t.id(), raw.to_string()))
761            })
762            .collect()
763    }
764
765    /// The principal an IdP group maps to, if any (A2).
766    ///
767    /// Group names are compared case-insensitively: directories are
768    /// inconsistent about the case they emit (`Engineering` vs
769    /// `engineering`), and a mapping that silently misses because of it
770    /// fails *open* into whatever the identity alone was granted — which is
771    /// the wrong direction for an authorization lookup to be sloppy in.
772    pub fn principal_for_group(&self, group: &str) -> Option<&str> {
773        let g = group.trim();
774        self.groups.as_ref()?.iter().find_map(|(k, v)| {
775            k.eq_ignore_ascii_case(g).then_some(v.as_str())
776        })
777    }
778
779    /// Whether any credential authenticates as this principal — surfaces
780    /// that require a *known* principal name use this to refuse typos early.
781    pub fn knows_principal(&self, principal: &str) -> Result<()> {
782        if self.tokens.iter().any(|t| t.principal == principal) {
783            return Ok(());
784        }
785        Err(AreevError::AuthzUnknownPrincipal(principal.to_string()))
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn verbs_roundtrip_their_string_forms() {
795        for v in Verb::ALL {
796            assert_eq!(Verb::parse(v.as_str()).unwrap(), v);
797        }
798        assert!(Verb::parse("loop-run").is_err());
799        assert!(Verb::parse("").is_err());
800    }
801
802    #[test]
803    fn an_empty_env_credential_authenticates_nobody() {
804        // The variable name itself must be non-empty…
805        assert!(CredentialMap::from_json(
806            r#"{"version":1,"tokens":[{"env":"  ","principal":"agent:writer"}]}"#
807        )
808        .is_err());
809
810        // …and a variable exported EMPTY must not match the empty string an
811        // `Authorization: Bearer ` header parses to. Otherwise every caller
812        // becomes `agent:writer`.
813        std::env::set_var("AREEV_TEST_EMPTY_TOK", "");
814        let map = CredentialMap::from_json(
815            r#"{"version":1,"tokens":[{"env":"AREEV_TEST_EMPTY_TOK","principal":"agent:writer"}]}"#,
816        )
817        .unwrap();
818        assert!(map.resolve("").is_err(), "empty bearer must not authenticate");
819        assert!(map.resolve("anything").is_err());
820
821        // Unset behaves the same.
822        std::env::remove_var("AREEV_TEST_EMPTY_TOK");
823        assert!(map.resolve("").is_err());
824    }
825
826    #[test]
827    fn grant_object_string_roundtrips() {
828        let g = Grant {
829            verbs: vec![Verb::Read, Verb::Write],
830            namespaces: vec!["caller".into(), "shared".into()],
831        };
832        let s = g.to_object_string();
833        assert_eq!(s, "read,write ON caller,shared");
834        assert_eq!(Grant::from_object_string(&s).unwrap(), g);
835
836        let all = Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] };
837        assert_eq!(all.to_object_string(), "erase ON *");
838        assert_eq!(
839            Grant::from_object_string("erase ON *").unwrap().namespaces,
840            vec!["*".to_string()]
841        );
842
843        assert!(Grant::from_object_string("read caller").is_err());
844        assert!(Grant::from_object_string(" ON x").is_err());
845        assert!(Grant::from_object_string("read ON ").is_err());
846    }
847
848    #[test]
849    fn owner_allows_everything_restricted_fails_closed() {
850        let owner = AuthzSet::owner("user:local");
851        for v in Verb::ALL {
852            assert!(owner.check(v, "any-ns").is_ok());
853        }
854
855        let none = AuthzSet::restricted("agent:bot", Vec::new());
856        for v in Verb::ALL {
857            assert!(none.check(v, "caller").is_err(), "{v} must be refused");
858        }
859    }
860
861    #[test]
862    fn grants_cover_exactly_what_they_say() {
863        let set = AuthzSet::restricted(
864            "agent:bot",
865            vec![Grant {
866                verbs: vec![Verb::Read, Verb::Write],
867                namespaces: vec!["caller".into()],
868            }],
869        );
870        assert!(set.check(Verb::Read, "caller").is_ok());
871        assert!(set.check(Verb::Write, "caller").is_ok());
872        assert!(set.check(Verb::Write, "shared").is_err());
873        assert!(set.check(Verb::Delete, "caller").is_err());
874
875        let star = AuthzSet::restricted(
876            "job:sweep",
877            vec![Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] }],
878        );
879        assert!(star.check(Verb::Erase, "anything").is_ok());
880    }
881
882    #[test]
883    fn refusal_names_verb_namespace_and_principal_with_the_aut_code() {
884        let set = AuthzSet::restricted("agent:bot", Vec::new());
885        let err = set.check(Verb::Delete, "caller").unwrap_err();
886        assert_eq!(err.code(), "AUT-E001");
887        let msg = err.to_string();
888        for needle in ["delete", "caller", "agent:bot"] {
889            assert!(msg.contains(needle), "{msg:?} must name {needle}");
890        }
891    }
892
893    const MAP: &str = r#"{
894        "version": 1,
895        "tokens": [
896            { "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
897              "principal": "user:anna" },
898            { "env": "AREEV_TEST_BOT_TOKEN", "principal": "agent:bot" }
899        ]
900    }"#;
901
902    #[test]
903    fn credential_map_loads_and_resolves_by_sha256() {
904        let map = CredentialMap::from_json(MAP).unwrap();
905        // sha256("test") — the digest above.
906        assert_eq!(map.resolve("test").unwrap(), "user:anna");
907        assert!(map.knows_principal("user:anna").is_ok());
908        assert_eq!(
909            map.knows_principal("user:nobody").unwrap_err().code(),
910            "AUT-E002"
911        );
912    }
913
914    #[test]
915    fn credential_map_resolves_by_env_var() {
916        let map = CredentialMap::from_json(MAP).unwrap();
917        // Unique var name per test binary run; set before resolve.
918        std::env::set_var("AREEV_TEST_BOT_TOKEN", "s3cret");
919        assert_eq!(map.resolve("s3cret").unwrap(), "agent:bot");
920        std::env::remove_var("AREEV_TEST_BOT_TOKEN");
921    }
922
923    #[test]
924    fn unrecognized_token_error_never_echoes_the_secret() {
925        let map = CredentialMap::from_json(MAP).unwrap();
926        let err = map.resolve("super-secret-value").unwrap_err();
927        assert_eq!(err.code(), "AUT-E004");
928        assert!(!err.to_string().contains("super-secret-value"));
929    }
930
931    #[test]
932    fn credential_map_fails_closed() {
933        // Unknown key.
934        assert_eq!(
935            CredentialMap::from_json(r#"{"version":1,"tokens":[],"roles":{}}"#)
936                .unwrap_err()
937                .code(),
938            "AUT-E003"
939        );
940        // Wrong version.
941        assert!(CredentialMap::from_json(r#"{"version":2,"tokens":[]}"#).is_err());
942        // Both credential forms.
943        assert!(CredentialMap::from_json(
944            r#"{"version":1,"tokens":[{"sha256":"00","env":"X","principal":"p"}]}"#
945        )
946        .is_err());
947        // Neither form.
948        assert!(CredentialMap::from_json(
949            r#"{"version":1,"tokens":[{"principal":"p"}]}"#
950        )
951        .is_err());
952        // Malformed digest.
953        assert!(CredentialMap::from_json(
954            r#"{"version":1,"tokens":[{"sha256":"zz","principal":"p"}]}"#
955        )
956        .is_err());
957        // Empty principal.
958        assert!(CredentialMap::from_json(
959            r#"{"version":1,"tokens":[{"env":"X","principal":"  "}]}"#
960        )
961        .is_err());
962        // [A1] An id that cannot be printed or passed to `revoke`.
963        assert!(CredentialMap::from_json(
964            r#"{"version":1,"tokens":[{"env":"X","principal":"p","id":"has space"}]}"#
965        )
966        .is_err());
967        // [A1] Duplicate ids make revocation ambiguous.
968        assert!(CredentialMap::from_json(
969            r#"{"version":1,"tokens":[
970                {"env":"X","principal":"p","id":"dup"},
971                {"env":"Y","principal":"q","id":"dup"}
972            ]}"#
973        )
974        .is_err());
975        // [A1] A typo'd lifetime must refuse the map, never read as "never
976        // expires" — the failure mode of a bad expiry is a console that will
977        // not start, not a credential that outlives its window.
978        assert!(CredentialMap::from_json(
979            r#"{"version":1,"tokens":[{"env":"X","principal":"p","expires_at":"soon"}]}"#
980        )
981        .is_err());
982    }
983
984    /// [A1] A map written before ids existed still loads — and every entry
985    /// still gets a stable, non-positional id.
986    #[test]
987    fn pre_id_maps_load_and_derive_stable_ids() {
988        let map = CredentialMap::from_json(
989            r#"{"version":1,"tokens":[
990                {"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
991                 "principal":"user:a"},
992                {"env":"AREEV_LEGACY_TOK","principal":"user:b"}
993            ]}"#,
994        )
995        .unwrap();
996        assert_eq!(map.tokens[0].id(), "9f86d081");
997        assert_eq!(map.tokens[1].id(), "env:AREEV_LEGACY_TOK");
998
999        // Non-positional: prepending an entry does not renumber the others,
1000        // which is what makes `revoke --id` safe.
1001        let grown = CredentialMap::from_json(
1002            r#"{"version":1,"tokens":[
1003                {"env":"AREEV_NEW_TOK","principal":"user:c"},
1004                {"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
1005                 "principal":"user:a"}
1006            ]}"#,
1007        )
1008        .unwrap();
1009        assert_eq!(grown.tokens[1].id(), "9f86d081");
1010    }
1011
1012    /// [A1] Expiry refuses indistinguishably from an unknown token, and one
1013    /// credential expiring leaves its principal's others alone.
1014    #[test]
1015    fn expired_credentials_refuse_like_unknown_ones() {
1016        let map = CredentialMap::from_json(
1017            r#"{"version":1,"tokens":[
1018                {"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
1019                 "principal":"user:a","id":"old","expires_at":"2026-01-01T00:00:00Z"},
1020                {"sha256":"fd61a03af4f77d870fc21e05e7e80678095c92d808cfb3b5c279ee04c74aca13",
1021                 "principal":"user:a","id":"new"}
1022            ]}"#,
1023        )
1024        .unwrap();
1025        let before = crate::time::iso8601_to_ms("2025-06-01T00:00:00Z").unwrap();
1026        let after = crate::time::iso8601_to_ms("2026-06-01T00:00:00Z").unwrap();
1027
1028        // sha256("test") is the first digest; sha256("test3") the second.
1029        assert_eq!(map.resolve_at("test", before).unwrap(), "user:a");
1030        assert_eq!(map.resolve_id_at("test", before).as_deref(), Some("old"));
1031
1032        // Past the expiry it is refused — with the SAME error an unknown
1033        // token gets, and without naming the id.
1034        let err = map.resolve_at("test", after).unwrap_err();
1035        assert_eq!(err.code(), map.resolve_at("never-issued", after).unwrap_err().code());
1036        assert!(!err.to_string().contains("old"), "must not name the id: {err}");
1037        assert!(map.resolve_id_at("test", after).is_none());
1038
1039        // The principal's OTHER credential is untouched — expiring one token
1040        // must not lock the human out of their own account.
1041        assert_eq!(map.resolve_at("test3", after).unwrap(), "user:a");
1042
1043        // And the same rule holds on the memory-scoped path.
1044        assert!(map.resolve_for_memory_at("test", "m", after).is_err());
1045        assert_eq!(map.resolve_for_memory_at("test3", "m", after).unwrap(), "user:a");
1046    }
1047
1048    /// [A1] The banner input: what is about to expire, before it does.
1049    #[test]
1050    fn expiring_within_reports_the_window() {
1051        let map = CredentialMap::from_json(
1052            r#"{"version":1,"tokens":[
1053                {"env":"A","principal":"p","id":"soon","expires_at":"2026-01-10T00:00:00Z"},
1054                {"env":"B","principal":"p","id":"later","expires_at":"2027-01-01T00:00:00Z"},
1055                {"env":"C","principal":"p","id":"never"}
1056            ]}"#,
1057        )
1058        .unwrap();
1059        let now = crate::time::iso8601_to_ms("2026-01-01T00:00:00Z").unwrap();
1060        let two_weeks = 14 * 86_400_000;
1061        let due: Vec<String> = map
1062            .expiring_within(now, two_weeks)
1063            .into_iter()
1064            .map(|(id, _)| id)
1065            .collect();
1066        assert_eq!(due, vec!["soon".to_string()]);
1067    }
1068
1069    /// [A1] The token format: recognizable to a scanner, and honest about
1070    /// what it does and does not prove.
1071    #[test]
1072    fn minted_token_shape_is_recognizable() {
1073        let body = encode_token_body(&[0u8; 32]);
1074        let token = format!("{TOKEN_PREFIX}{body}");
1075        assert!(token_is_minted(&token));
1076        assert!(token.starts_with("areev_pat_"));
1077        assert_eq!(body.len(), 51, "51 base32 chars = 255 bits");
1078
1079        // Distinct entropy → distinct tokens.
1080        let other = encode_token_body(&[1u8; 32]);
1081        assert_ne!(body, other);
1082
1083        // An operator's own token is not "minted" — that is a hygiene
1084        // signal, not an auth check, so nothing here refuses it.
1085        assert!(!token_is_minted("hunter2"));
1086        assert!(!token_is_minted("areev_pat_short"));
1087    }
1088}