use crate::error::{AreevError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
pub const AUTHZ_NS: &str = "agent:authz";
pub const HARNESS_NS: &str = "agent:harness";
pub const ATTEST_NS: &str = "agent:attest";
pub const REL_ATTESTS: &str = "mg:attests";
pub const REL_PERMITS: &str = "mg:permits";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Verb {
Read,
Write,
Supersede,
Delete,
Erase,
LoopRun,
LoopReview,
LoopApply,
Admin,
RunExecute,
RunRespond,
RunCancel,
}
impl Verb {
pub const ALL: [Verb; 12] = [
Verb::Read,
Verb::Write,
Verb::Supersede,
Verb::Delete,
Verb::Erase,
Verb::LoopRun,
Verb::LoopReview,
Verb::LoopApply,
Verb::Admin,
Verb::RunExecute,
Verb::RunRespond,
Verb::RunCancel,
];
pub fn as_str(&self) -> &'static str {
match self {
Verb::Read => "read",
Verb::Write => "write",
Verb::Supersede => "supersede",
Verb::Delete => "delete",
Verb::Erase => "erase",
Verb::LoopRun => "loop.run",
Verb::LoopReview => "loop.review",
Verb::LoopApply => "loop.apply",
Verb::Admin => "admin",
Verb::RunExecute => "run.execute",
Verb::RunRespond => "run.respond",
Verb::RunCancel => "run.cancel",
}
}
pub fn parse(s: &str) -> Result<Verb> {
match s {
"read" => Ok(Verb::Read),
"write" => Ok(Verb::Write),
"supersede" => Ok(Verb::Supersede),
"delete" => Ok(Verb::Delete),
"erase" => Ok(Verb::Erase),
"loop.run" => Ok(Verb::LoopRun),
"loop.review" => Ok(Verb::LoopReview),
"loop.apply" => Ok(Verb::LoopApply),
"admin" => Ok(Verb::Admin),
"run.execute" => Ok(Verb::RunExecute),
"run.respond" => Ok(Verb::RunRespond),
"run.cancel" => Ok(Verb::RunCancel),
other => Err(AreevError::Validation(format!(
"unknown verb {other:?} — one of: read, write, supersede, delete, erase, \
loop.run, loop.review, loop.apply, admin, run.execute, run.respond, \
run.cancel"
))),
}
}
}
impl fmt::Display for Verb {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
pub verbs: Vec<Verb>,
pub namespaces: Vec<String>,
}
impl Grant {
pub fn covers(&self, verb: Verb, ns: &str) -> bool {
self.verbs.contains(&verb)
&& (self.namespaces.is_empty()
|| self.namespaces.iter().any(|n| n == "*" || n == ns))
}
pub fn to_object_string(&self) -> String {
let mut verbs: Vec<&str> = self.verbs.iter().map(Verb::as_str).collect();
verbs.sort_unstable();
verbs.dedup();
let ns = if self.namespaces.is_empty() {
"*".to_string()
} else {
let mut ns: Vec<&str> = self.namespaces.iter().map(String::as_str).collect();
ns.sort_unstable();
ns.dedup();
ns.join(",")
};
format!("{} ON {}", verbs.join(","), ns)
}
pub fn from_object_string(s: &str) -> Result<Grant> {
let (verbs_part, ns_part) = s.split_once(" ON ").ok_or_else(|| {
AreevError::Validation(format!(
"malformed grant object {s:?} — expected \"<verbs> ON <namespaces>\""
))
})?;
let mut verbs = Vec::new();
for v in verbs_part.split(',') {
let v = Verb::parse(v.trim())?;
if !verbs.contains(&v) {
verbs.push(v);
}
}
if verbs.is_empty() {
return Err(AreevError::Validation(format!(
"grant object {s:?} names no verbs"
)));
}
let mut namespaces = Vec::new();
for n in ns_part.split(',') {
let n = n.trim();
if n.is_empty() {
return Err(AreevError::Validation(format!(
"grant object {s:?} has an empty namespace"
)));
}
if !namespaces.iter().any(|x| x == n) {
namespaces.push(n.to_string());
}
}
Ok(Grant { verbs, namespaces })
}
}
#[derive(Debug, Clone)]
pub struct AuthzSet {
principal: String,
owner: bool,
grants: Vec<Grant>,
}
impl AuthzSet {
pub fn owner(principal: impl Into<String>) -> Self {
AuthzSet {
principal: principal.into(),
owner: true,
grants: Vec::new(),
}
}
pub fn restricted(principal: impl Into<String>, grants: Vec<Grant>) -> Self {
AuthzSet {
principal: principal.into(),
owner: false,
grants,
}
}
pub fn principal(&self) -> &str {
&self.principal
}
pub fn is_owner(&self) -> bool {
self.owner
}
pub fn allows(&self, verb: Verb, ns: &str) -> bool {
self.owner || self.grants.iter().any(|g| g.covers(verb, ns))
}
pub fn check(&self, verb: Verb, ns: &str) -> Result<()> {
if self.allows(verb, ns) {
return Ok(());
}
Err(AreevError::AuthzDenied(format!(
"principal {} lacks {verb} on namespace {ns:?}",
self.principal
)))
}
pub fn namespaces(&self, verb: Verb) -> GrantedNamespaces {
if self.owner {
return GrantedNamespaces::All;
}
let mut exact = std::collections::BTreeSet::new();
for g in self.grants.iter().filter(|g| g.verbs.contains(&verb)) {
if g.namespaces.is_empty() || g.namespaces.iter().any(|n| n == "*") {
return GrantedNamespaces::All;
}
exact.extend(g.namespaces.iter().cloned());
}
GrantedNamespaces::Exact(exact)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantedNamespaces {
All,
Exact(std::collections::BTreeSet<String>),
}
impl GrantedNamespaces {
pub fn is_empty(&self) -> bool {
matches!(self, GrantedNamespaces::Exact(s) if s.is_empty())
}
pub fn exact(&self) -> Option<&std::collections::BTreeSet<String>> {
match self {
GrantedNamespaces::All => None,
GrantedNamespaces::Exact(s) => Some(s),
}
}
}
pub fn observer_kind(principal: &str) -> &'static str {
for prefix in ["agent:", "bot:", "job:", "svc:", "engine:"] {
if principal.starts_with(prefix) {
return "agent";
}
}
"human"
}
pub fn subject_fingerprint(identity: &str) -> String {
let digest = Sha256::digest(identity.as_bytes());
hex_lower(&digest[..8])
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
pub fn audit_observation(
principal: &str,
verb: &str,
target: &str,
because: Option<&str>,
count: usize,
now_ms: i64,
) -> crate::types::Observation {
use std::sync::atomic::{AtomicU64, Ordering};
static AUDIT_SEQ: AtomicU64 = AtomicU64::new(0);
let mut obs = crate::types::Observation {
observer_id: principal.to_string(),
observer_type: observer_kind(principal).to_string(),
subject: Some(target.to_string()),
object: Some(verb.to_string()),
observer_model: None,
frame_id: Some(format!(
"tier2:{now_ms}:{}",
AUDIT_SEQ.fetch_add(1, Ordering::Relaxed)
)),
sync_group: None,
observation_mode: None,
observation_scope: None,
compression_ratio: None,
common: Default::default(),
};
obs.common.namespace = Some(AUTHZ_NS.to_string());
obs.common.created_at = Some(now_ms);
obs.common.context = Some(serde_json::json!({
"audit": "tier2",
"verb": verb,
"target": target,
"because": because.unwrap_or(""),
"grains_erased": count,
"subject_ref": "sha256-64/hex",
}));
obs
}
pub fn chain_audit_observation(
obs: &mut crate::types::Observation,
previous: Option<&str>,
seq: u64,
) {
if let Some(prev) = previous {
obs.common.derived_from = Some(prev.to_string());
}
if let Some(serde_json::Value::Object(map)) = obs.common.context.as_mut() {
map.insert("seq".into(), serde_json::json!(seq));
match previous {
None => {
map.insert("chain_root".into(), serde_json::json!(true));
}
Some(prev) => {
map.insert("previous_audit".into(), serde_json::json!(prev));
}
}
}
}
pub const TOKEN_PREFIX: &str = "areev_pat_";
pub fn token_is_minted(token: &str) -> bool {
match token.strip_prefix(TOKEN_PREFIX) {
Some(body) => {
body.len() >= 32 && body.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
None => false,
}
}
pub fn encode_token_body(bytes: &[u8; 32]) -> String {
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut out = String::with_capacity(51);
let mut acc: u16 = 0;
let mut bits = 0u8;
for &b in bytes {
acc = (acc << 8) | b as u16;
bits += 8;
while bits >= 5 {
bits -= 5;
let idx = ((acc >> bits) & 0x1f) as usize;
out.push(ALPHABET[idx] as char);
}
}
out
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialMap {
pub version: u32,
#[serde(default)]
pub tokens: Vec<CredentialEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub groups: Option<std::collections::BTreeMap<String, String>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<String>,
pub principal: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memories: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
impl CredentialEntry {
pub fn id(&self) -> String {
match (self.id.as_deref(), &self.sha256, &self.env) {
(Some(id), _, _) => id.to_string(),
(None, Some(h), _) => h.chars().take(8).collect::<String>().to_ascii_lowercase(),
(None, None, Some(var)) => format!("env:{var}"),
(None, None, None) => "unnamed".to_string(),
}
}
fn expires_at_ms(&self) -> Option<i64> {
self.expires_at
.as_deref()
.map(|s| crate::time::iso8601_to_ms(s).unwrap_or(i64::MIN))
}
pub fn is_expired_at(&self, now_ms: i64) -> bool {
self.expires_at_ms().is_some_and(|exp| now_ms >= exp)
}
}
impl CredentialMap {
pub fn from_json(s: &str) -> Result<CredentialMap> {
let map: CredentialMap = serde_json::from_str(s)
.map_err(|e| AreevError::AuthzConfigInvalid(format!("credential map: {e}")))?;
if map.version != 1 {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: unsupported version {} (expected 1)",
map.version
)));
}
let mut seen_ids: Vec<String> = Vec::with_capacity(map.tokens.len());
for (i, t) in map.tokens.iter().enumerate() {
if t.principal.trim().is_empty() {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} has an empty principal"
)));
}
if let Some(explicit) = t.id.as_deref() {
if explicit.trim().is_empty() {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} ({}) has an empty \"id\" — omit the field \
to get a derived one, or give it a name",
t.principal
)));
}
if !explicit
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} id {explicit:?} must be alphanumeric with \
-, _ or . (it is printed in logs and passed to `areev auth revoke`)"
)));
}
}
let id = t.id();
if seen_ids.contains(&id) {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: duplicate id {id:?} — revoking it would be ambiguous, \
which is the one thing an id exists to prevent"
)));
}
seen_ids.push(id.clone());
if let Some(raw) = &t.expires_at {
if crate::time::iso8601_to_ms(raw).is_none() {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} ({id}) has an unparseable \"expires_at\" \
{raw:?} — expected ISO-8601, e.g. \"2026-12-31T23:59:59Z\""
)));
}
}
match (&t.sha256, &t.env) {
(Some(_), Some(_)) | (None, None) => {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} ({}) must have exactly one of \
\"sha256\" or \"env\"",
t.principal
)));
}
(Some(h), None) => {
if h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} ({}) sha256 must be 64 hex chars",
t.principal
)));
}
}
(None, Some(v)) => {
if v.trim().is_empty() {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} ({}) has an empty \"env\" variable name",
t.principal
)));
}
}
}
if let Some(memories) = &t.memories {
if memories.is_empty() || memories.iter().any(|m| m.trim().is_empty()) {
return Err(AreevError::AuthzConfigInvalid(format!(
"credential map: entry {i} ({}) has an empty \"memories\" \
scope — omit the field to grant every memory",
t.principal
)));
}
}
}
Ok(map)
}
pub fn resolve(&self, presented: &str) -> Result<&str> {
self.resolve_at(presented, crate::time::now_ms())
}
pub fn resolve_at(&self, presented: &str, now_ms: i64) -> Result<&str> {
self.entry_at(presented, now_ms).map(|t| t.principal.as_str())
}
fn entry_at(&self, presented: &str, now_ms: i64) -> Result<&CredentialEntry> {
if presented.is_empty() {
return Err(AreevError::AuthzTokenUnrecognized);
}
let digest = hex::encode(Sha256::digest(presented.as_bytes()));
let mut found: Option<&CredentialEntry> = None;
for t in &self.tokens {
let matched = match (&t.sha256, &t.env) {
(Some(h), None) => h.eq_ignore_ascii_case(&digest),
(None, Some(var)) => std::env::var(var)
.is_ok_and(|v| !v.trim().is_empty() && ct_eq(v.as_bytes(), presented.as_bytes())),
_ => false,
};
if matched && found.is_none() {
found = Some(t);
}
}
match found {
Some(t) if !t.is_expired_at(now_ms) => Ok(t),
_ => Err(AreevError::AuthzTokenUnrecognized),
}
}
pub fn resolve_id_at(&self, presented: &str, now_ms: i64) -> Option<String> {
self.entry_at(presented, now_ms).ok().map(|t| t.id())
}
pub fn resolve_for_memory(&self, presented: &str, memory: &str) -> Result<&str> {
self.resolve_for_memory_at(presented, memory, crate::time::now_ms())
}
pub fn resolve_for_memory_at(
&self,
presented: &str,
memory: &str,
now_ms: i64,
) -> Result<&str> {
let t = self.entry_at(presented, now_ms)?;
match &t.memories {
Some(list) if !list.iter().any(|m| m == memory) => {
Err(AreevError::AuthzTokenUnrecognized)
}
_ => Ok(&t.principal),
}
}
pub fn resolve_id_for_memory_at(
&self,
presented: &str,
memory: &str,
now_ms: i64,
) -> Option<String> {
let t = self.entry_at(presented, now_ms).ok()?;
match &t.memories {
Some(list) if !list.iter().any(|m| m == memory) => None,
_ => Some(t.id()),
}
}
pub fn expiring_within(&self, now_ms: i64, window_ms: i64) -> Vec<(String, String)> {
self.tokens
.iter()
.filter_map(|t| {
let raw = t.expires_at.as_deref()?;
let exp = crate::time::iso8601_to_ms(raw)?;
(exp <= now_ms + window_ms).then_some((t.id(), raw.to_string()))
})
.collect()
}
pub fn principal_for_group(&self, group: &str) -> Option<&str> {
let g = group.trim();
self.groups.as_ref()?.iter().find_map(|(k, v)| {
k.eq_ignore_ascii_case(g).then_some(v.as_str())
})
}
pub fn knows_principal(&self, principal: &str) -> Result<()> {
if self.tokens.iter().any(|t| t.principal == principal) {
return Ok(());
}
Err(AreevError::AuthzUnknownPrincipal(principal.to_string()))
}
}
#[cfg(test)]
mod tests {
#[test]
fn chaining_an_audit_observation_sets_the_link_and_the_sequence() {
let mut first = audit_observation("u", "erase", "subject:ab ns:n", Some("dsar"), 1, 1_000);
chain_audit_observation(&mut first, None, 1);
let ctx = first.common.context.clone().unwrap();
assert_eq!(ctx["seq"], serde_json::json!(1));
assert_eq!(ctx["chain_root"], serde_json::json!(true));
assert!(first.common.derived_from.is_none());
let mut second = audit_observation("u", "delete", "hash:ff", None, 1, 2_000);
chain_audit_observation(&mut second, Some("aabb"), 2);
let ctx = second.common.context.clone().unwrap();
assert_eq!(ctx["seq"], serde_json::json!(2));
assert_eq!(ctx["previous_audit"], serde_json::json!("aabb"));
assert!(ctx.get("chain_root").is_none());
assert_eq!(second.common.derived_from.as_deref(), Some("aabb"));
}
use super::*;
#[test]
fn verbs_roundtrip_their_string_forms() {
for v in Verb::ALL {
assert_eq!(Verb::parse(v.as_str()).unwrap(), v);
}
assert!(Verb::parse("loop-run").is_err());
assert!(Verb::parse("").is_err());
}
#[test]
fn an_empty_env_credential_authenticates_nobody() {
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":" ","principal":"agent:writer"}]}"#
)
.is_err());
std::env::set_var("AREEV_TEST_EMPTY_TOK", "");
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":"AREEV_TEST_EMPTY_TOK","principal":"agent:writer"}]}"#,
)
.unwrap();
assert!(map.resolve("").is_err(), "empty bearer must not authenticate");
assert!(map.resolve("anything").is_err());
std::env::remove_var("AREEV_TEST_EMPTY_TOK");
assert!(map.resolve("").is_err());
}
#[test]
fn grant_object_string_roundtrips() {
let g = Grant {
verbs: vec![Verb::Read, Verb::Write],
namespaces: vec!["caller".into(), "shared".into()],
};
let s = g.to_object_string();
assert_eq!(s, "read,write ON caller,shared");
assert_eq!(Grant::from_object_string(&s).unwrap(), g);
let all = Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] };
assert_eq!(all.to_object_string(), "erase ON *");
assert_eq!(
Grant::from_object_string("erase ON *").unwrap().namespaces,
vec!["*".to_string()]
);
assert!(Grant::from_object_string("read caller").is_err());
assert!(Grant::from_object_string(" ON x").is_err());
assert!(Grant::from_object_string("read ON ").is_err());
}
#[test]
fn owner_allows_everything_restricted_fails_closed() {
let owner = AuthzSet::owner("user:local");
for v in Verb::ALL {
assert!(owner.check(v, "any-ns").is_ok());
}
let none = AuthzSet::restricted("agent:bot", Vec::new());
for v in Verb::ALL {
assert!(none.check(v, "caller").is_err(), "{v} must be refused");
}
}
#[test]
fn grants_cover_exactly_what_they_say() {
let set = AuthzSet::restricted(
"agent:bot",
vec![Grant {
verbs: vec![Verb::Read, Verb::Write],
namespaces: vec!["caller".into()],
}],
);
assert!(set.check(Verb::Read, "caller").is_ok());
assert!(set.check(Verb::Write, "caller").is_ok());
assert!(set.check(Verb::Write, "shared").is_err());
assert!(set.check(Verb::Delete, "caller").is_err());
let star = AuthzSet::restricted(
"job:sweep",
vec![Grant { verbs: vec![Verb::Erase], namespaces: vec!["*".into()] }],
);
assert!(star.check(Verb::Erase, "anything").is_ok());
}
#[test]
fn refusal_names_verb_namespace_and_principal_with_the_aut_code() {
let set = AuthzSet::restricted("agent:bot", Vec::new());
let err = set.check(Verb::Delete, "caller").unwrap_err();
assert_eq!(err.code(), "AUT-E001");
let msg = err.to_string();
for needle in ["delete", "caller", "agent:bot"] {
assert!(msg.contains(needle), "{msg:?} must name {needle}");
}
}
const MAP: &str = r#"{
"version": 1,
"tokens": [
{ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"principal": "user:anna" },
{ "env": "AREEV_TEST_BOT_TOKEN", "principal": "agent:bot" }
]
}"#;
#[test]
fn credential_map_loads_and_resolves_by_sha256() {
let map = CredentialMap::from_json(MAP).unwrap();
assert_eq!(map.resolve("test").unwrap(), "user:anna");
assert!(map.knows_principal("user:anna").is_ok());
assert_eq!(
map.knows_principal("user:nobody").unwrap_err().code(),
"AUT-E002"
);
}
#[test]
fn credential_map_resolves_by_env_var() {
let map = CredentialMap::from_json(MAP).unwrap();
std::env::set_var("AREEV_TEST_BOT_TOKEN", "s3cret");
assert_eq!(map.resolve("s3cret").unwrap(), "agent:bot");
std::env::remove_var("AREEV_TEST_BOT_TOKEN");
}
#[test]
fn unrecognized_token_error_never_echoes_the_secret() {
let map = CredentialMap::from_json(MAP).unwrap();
let err = map.resolve("super-secret-value").unwrap_err();
assert_eq!(err.code(), "AUT-E004");
assert!(!err.to_string().contains("super-secret-value"));
}
#[test]
fn credential_map_fails_closed() {
assert_eq!(
CredentialMap::from_json(r#"{"version":1,"tokens":[],"roles":{}}"#)
.unwrap_err()
.code(),
"AUT-E003"
);
assert!(CredentialMap::from_json(r#"{"version":2,"tokens":[]}"#).is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"sha256":"00","env":"X","principal":"p"}]}"#
)
.is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"principal":"p"}]}"#
)
.is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"sha256":"zz","principal":"p"}]}"#
)
.is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":"X","principal":" "}]}"#
)
.is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":"X","principal":"p","id":"has space"}]}"#
)
.is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"env":"X","principal":"p","id":"dup"},
{"env":"Y","principal":"q","id":"dup"}
]}"#
)
.is_err());
assert!(CredentialMap::from_json(
r#"{"version":1,"tokens":[{"env":"X","principal":"p","expires_at":"soon"}]}"#
)
.is_err());
}
#[test]
fn pre_id_maps_load_and_derive_stable_ids() {
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"principal":"user:a"},
{"env":"AREEV_LEGACY_TOK","principal":"user:b"}
]}"#,
)
.unwrap();
assert_eq!(map.tokens[0].id(), "9f86d081");
assert_eq!(map.tokens[1].id(), "env:AREEV_LEGACY_TOK");
let grown = CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"env":"AREEV_NEW_TOK","principal":"user:c"},
{"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"principal":"user:a"}
]}"#,
)
.unwrap();
assert_eq!(grown.tokens[1].id(), "9f86d081");
}
#[test]
fn expired_credentials_refuse_like_unknown_ones() {
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"sha256":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"principal":"user:a","id":"old","expires_at":"2026-01-01T00:00:00Z"},
{"sha256":"fd61a03af4f77d870fc21e05e7e80678095c92d808cfb3b5c279ee04c74aca13",
"principal":"user:a","id":"new"}
]}"#,
)
.unwrap();
let before = crate::time::iso8601_to_ms("2025-06-01T00:00:00Z").unwrap();
let after = crate::time::iso8601_to_ms("2026-06-01T00:00:00Z").unwrap();
assert_eq!(map.resolve_at("test", before).unwrap(), "user:a");
assert_eq!(map.resolve_id_at("test", before).as_deref(), Some("old"));
let err = map.resolve_at("test", after).unwrap_err();
assert_eq!(err.code(), map.resolve_at("never-issued", after).unwrap_err().code());
assert!(!err.to_string().contains("old"), "must not name the id: {err}");
assert!(map.resolve_id_at("test", after).is_none());
assert_eq!(map.resolve_at("test3", after).unwrap(), "user:a");
assert!(map.resolve_for_memory_at("test", "m", after).is_err());
assert_eq!(map.resolve_for_memory_at("test3", "m", after).unwrap(), "user:a");
}
#[test]
fn expiring_within_reports_the_window() {
let map = CredentialMap::from_json(
r#"{"version":1,"tokens":[
{"env":"A","principal":"p","id":"soon","expires_at":"2026-01-10T00:00:00Z"},
{"env":"B","principal":"p","id":"later","expires_at":"2027-01-01T00:00:00Z"},
{"env":"C","principal":"p","id":"never"}
]}"#,
)
.unwrap();
let now = crate::time::iso8601_to_ms("2026-01-01T00:00:00Z").unwrap();
let two_weeks = 14 * 86_400_000;
let due: Vec<String> = map
.expiring_within(now, two_weeks)
.into_iter()
.map(|(id, _)| id)
.collect();
assert_eq!(due, vec!["soon".to_string()]);
}
#[test]
fn minted_token_shape_is_recognizable() {
let body = encode_token_body(&[0u8; 32]);
let token = format!("{TOKEN_PREFIX}{body}");
assert!(token_is_minted(&token));
assert!(token.starts_with("areev_pat_"));
assert_eq!(body.len(), 51, "51 base32 chars = 255 bits");
let other = encode_token_body(&[1u8; 32]);
assert_ne!(body, other);
assert!(!token_is_minted("hunter2"));
assert!(!token_is_minted("areev_pat_short"));
}
}