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