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}