Skip to main content

ijima_server/
auth.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Authentication + authorization via Schubert proof-carrying
5//! **multi-capability grant tokens**.
6//!
7//! Per `docs/DESIGN.md` D4 and the GrantToken-migration ADR
8//! (`docs/adr/grant-token-migration.md`), Ijima consumes Schubert 0.4's
9//! [`GrantToken`] end-to-end: one signed token carries several
10//! capabilities, each with its Schubert partition, and authorization is a
11//! **geometric containment** check (`cap_partition ≤ granted_partition`,
12//! component-wise) that is self-contained in the signed token — no
13//! capability registry is consulted for the authz *decision*, only for the
14//! static required-capability → partition lookup.
15//!
16//! Consequences of the geometry:
17//! - **Write implies read** — `[2] ≥ [1]`, so a `memory:write` grant also
18//!   satisfies `memory:read`.
19//! - **Admin implies all** — `[4,4,4,4]` (the point class on Gr(4,8)) is
20//!   ≥ every partition. The legacy `== "admin"` string short-circuit is
21//!   gone; admin falls out of the geometry.
22//!
23//! The wire format is Schubert's native
24//! [`GrantToken::to_bytes`](schubert::crypto::GrantToken::to_bytes) /
25//! [`from_bytes`](schubert::crypto::GrantToken::from_bytes), base64-encoded
26//! for bearer transport. Ijima no longer ships its own token serializer.
27
28use std::sync::Arc;
29
30use base64::{Engine, engine::general_purpose::STANDARD as B64};
31use ijima_core::{IjimaError, Result, TokenRevocation};
32use schubert::{
33    AccessController, CapabilityId, PrincipalId,
34    crypto::{CapabilityIssuer, GrantPolicy, GrantToken, GrantVerifier, KeyStore},
35};
36use sha2::{Digest, Sha256};
37
38/// Ijima's Schubert policy, embedded at compile time.
39const POLICY_TOML: &str = include_str!("../policy/policy.toml");
40
41/// Computes the revocation key for a bearer: SHA-256 hex of the trimmed
42/// bearer string, with an optional `Bearer ` scheme prefix stripped — so
43/// operators (and CLIs) can paste either the raw token or the full
44/// `Authorization` header value and hit the same revocation key. Storing
45/// hashes instead of raw bearers keeps live credentials out of store
46/// dumps, logs, and backups.
47pub fn bearer_hash(bearer: &str) -> String {
48    let trimmed = bearer.trim();
49    let raw = trimmed.strip_prefix("Bearer ").unwrap_or(trimmed);
50    let mut hasher = Sha256::new();
51    hasher.update(raw.as_bytes());
52    // sha2 0.11's `finalize()` output is not `{:x}`-formattable; hex it
53    // byte-by-byte (same lowercase encoding as before).
54    hasher
55        .finalize()
56        .iter()
57        .map(|b| format!("{b:02x}"))
58        .collect()
59}
60
61/// The authenticated principal + the verified grant carried by a bearer
62/// token.
63///
64/// Produced by [`IjimaAuth::verify_bearer`]. Handlers consult
65/// [`may`](Self::may) to enforce a specific capability. The grant's
66/// partitions are cryptographically signed, so the geometric containment
67/// check needs only the shared controller (to resolve a *required*
68/// capability's partition) and the shared verifier — both cheap `Arc`
69/// clones.
70#[derive(Debug, Clone)]
71pub struct AuthenticatedPrincipal {
72    /// The principal this grant was issued to.
73    pub principal: PrincipalId,
74    /// The verified multi-capability grant.
75    pub grant: GrantToken,
76    controller: Arc<AccessController>,
77    grant_verifier: Arc<GrantVerifier>,
78}
79
80impl AuthenticatedPrincipal {
81    /// Returns true if the grant geometrically implies `required`
82    /// (directly or because some granted partition `λ` satisfies
83    /// `required.partition ≤ λ` component-wise).
84    ///
85    /// An unknown `required` capability (not in the policy) is denied.
86    pub fn may(&self, required: &str) -> bool {
87        match self.controller.capability(required) {
88            Some(cap) => self.grant_verifier.may(&self.grant, &cap.partition),
89            None => false,
90        }
91    }
92
93    /// The list of capabilities explicitly carried by this grant (for
94    /// debugging / operator visibility). Note this is the *signed* set,
95    /// not the geometric closure — a grant of `[memory:write]` also implies
96    /// `memory:read` via [`may`](Self::may) even though `read` is not
97    /// listed here.
98    pub fn granted_capabilities(&self) -> Vec<String> {
99        self.grant
100            .capabilities
101            .iter()
102            .map(|c| c.id.as_str().to_string())
103            .collect()
104    }
105
106    /// This principal's default personal namespace id
107    /// (`ns_<principal>_private`). Every request is scoped to this
108    /// namespace unless explicit namespace parameters land later.
109    pub fn personal_namespace(&self) -> ijima_core::NamespaceId {
110        ijima_core::NamespaceId::new(format!("ns_{}_private", self.principal.as_str()))
111    }
112}
113
114/// Ijima's auth core: an [`AccessController`] (capability → partition
115/// resolver) plus a capability issuer and a grant verifier sharing one
116/// Ed25519 key, **and an in-memory revocation set** (the grant
117/// kill-switch — see `docs/adr/token-revocation.md`).
118///
119/// A daemon constructs one of these at startup, hydrates the revocation
120/// set from the store
121/// ([`hydrate_revocations`](Self::hydrate_revocations)), and serves; an
122/// admin CLI uses the issuer to mint grant tokens via
123/// [`IjimaAuth::issue_grant_bearer`].
124#[derive(Debug)]
125pub struct IjimaAuth {
126    controller: Arc<AccessController>,
127    issuer: CapabilityIssuer,
128    grant_verifier: Arc<GrantVerifier>,
129    /// SHA-256 hashes of revoked bearers. Mutex (not RwLock): the critical
130    /// section is a hash-set lookup, too short to be worth reader
131    /// parallelism.
132    revocations: std::sync::Mutex<std::collections::HashSet<String>>,
133}
134
135impl IjimaAuth {
136    /// Loads the embedded `policy/policy.toml` and generates a fresh
137    /// Ed25519 issuer key pair.
138    ///
139    /// Use only for tests/ephemeral runs — every call produces a new key,
140    /// so issued tokens will not verify against a different instance. For
141    /// a persistent daemon/CLI, use
142    /// [`from_embedded_policy_with_seed`](Self::from_embedded_policy_with_seed)
143    /// with a seed from [`key_store`](crate::key_store).
144    ///
145    /// # Errors
146    ///
147    /// Returns [`IjimaError::InvalidInput`] if the policy TOML is invalid.
148    pub fn from_embedded_policy() -> Result<Self> {
149        Self::from_embedded_policy_with_seed(Self::generate_seed())
150    }
151
152    /// Loads the embedded policy and constructs the issuer from a known
153    /// 32-byte Ed25519 seed. The same seed must be shared by every process
154    /// that issues or verifies tokens for this Ijima instance.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`IjimaError::InvalidInput`] if the policy TOML is invalid.
159    pub fn from_embedded_policy_with_seed(seed: [u8; 32]) -> Result<Self> {
160        let controller = AccessController::from_policy_toml(POLICY_TOML)
161            .map_err(|e| IjimaError::invalid_input(format!("policy load: {e}")))?;
162        let issuer = CapabilityIssuer::from_seed(seed);
163        let grant_verifier = GrantVerifier::new(issuer.public_key());
164        Ok(Self {
165            controller: Arc::new(controller),
166            issuer,
167            grant_verifier: Arc::new(grant_verifier),
168            revocations: std::sync::Mutex::new(std::collections::HashSet::new()),
169        })
170    }
171
172    /// Generates a fresh random 32-byte issuer seed (for first-time setup).
173    ///
174    /// Delegates to [`KeyStore::generate_seed`](schubert::crypto::KeyStore::generate_seed).
175    pub fn generate_seed() -> [u8; 32] {
176        KeyStore::generate_seed()
177    }
178
179    /// The issuer's Ed25519 public key as lowercase hex, for distribution
180    /// to verifiers and operator visibility.
181    pub fn issuer_public_key_hex(&self) -> String {
182        self.issuer.public_key_hex()
183    }
184
185    /// Returns the Grassmannian the controller operates on.
186    pub fn grassmannian(&self) -> (usize, usize) {
187        self.controller.grassmannian()
188    }
189
190    /// Issues a multi-capability grant token (base64 wire format) granting
191    /// every capability in `capabilities` to `principal`.
192    ///
193    /// Each capability's partition is resolved from the embedded policy;
194    /// an unknown capability is rejected. Singleton grants are issued with
195    /// [`issue_bearer`](Self::issue_bearer).
196    ///
197    /// # Errors
198    ///
199    /// Returns [`IjimaError::InvalidInput`] if a capability is unknown to
200    /// the policy, the grant is empty, or Schubert's issuer rejects the
201    /// inputs.
202    pub fn issue_grant_bearer(
203        &self,
204        principal: impl Into<PrincipalId>,
205        capabilities: &[&str],
206    ) -> Result<String> {
207        if capabilities.is_empty() {
208            return Err(IjimaError::invalid_input(
209                "grant must carry at least one capability",
210            ));
211        }
212        let entries = self.capability_entries(capabilities)?;
213        let grant = self
214            .issuer
215            .issue_grant(principal, &entries)
216            .map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
217        Ok(B64.encode(GrantToken::to_bytes(&grant)))
218    }
219
220    /// Issues a grant that dies at `expires_at_unix` (Unix seconds,
221    /// Schubert 0.5 ADR-0001: the boundary is inclusive — the grant is
222    /// dead the instant `now >= expires_at`). Expiry is covered by the
223    /// signature and enforced by [`GrantVerifier::verify`] standalone;
224    /// expired bearers fail `verify_bearer` with an `expired` detail.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`IjimaError::InvalidInput`] on unknown capabilities or
229    /// issuer rejection — same contract as
230    /// [`issue_grant_bearer`](Self::issue_grant_bearer).
231    pub fn issue_grant_bearer_with_expiry(
232        &self,
233        principal: impl Into<PrincipalId>,
234        capabilities: &[&str],
235        expires_at_unix: u64,
236    ) -> Result<String> {
237        if capabilities.is_empty() {
238            return Err(IjimaError::invalid_input(
239                "grant must carry at least one capability",
240            ));
241        }
242        let entries = self.capability_entries(capabilities)?;
243        let grant = self
244            .issuer
245            .issue_grant_with_expiry(principal, &entries, expires_at_unix)
246            .map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
247        Ok(B64.encode(GrantToken::to_bytes(&grant)))
248    }
249
250    /// Policy-constrained issuance (Schubert 0.5 #20.3): signs only what
251    /// `policy` entitles this principal to carry. Fails closed — an
252    /// unknown principal or a capability outside the entitlement denies
253    /// with [`schubert::SchubertError::GrantDeniedByPolicy`] detail (no
254    /// smuggling a stronger geometry under an allowed id). `expires_at`
255    /// passes through to the issuer (`None` = never, pre-0.5 behavior).
256    ///
257    /// This is the seam `ijima token issue` builds on; the unconstrained
258    /// [`issue_grant_bearer`](Self::issue_grant_bearer) remains for test
259    /// tooling and trusted offline flows.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`IjimaError::InvalidInput`] when the policy denies the
264    /// request, or for unknown capabilities.
265    pub fn issue_grant_bearer_under_policy(
266        &self,
267        principal: impl Into<PrincipalId>,
268        capabilities: &[&str],
269        policy: &GrantPolicy,
270        expires_at: Option<u64>,
271    ) -> Result<String> {
272        if capabilities.is_empty() {
273            return Err(IjimaError::invalid_input(
274                "grant must carry at least one capability",
275            ));
276        }
277        let entries = self.capability_entries(capabilities)?;
278        let principal = principal.into();
279        policy
280            .may_issue(&principal, &entries)
281            .map_err(|e| IjimaError::invalid_input(format!("grant denied by policy: {e}")))?;
282        let grant = match expires_at {
283            Some(at) => self.issuer.issue_grant_with_expiry(principal, &entries, at),
284            None => self.issuer.issue_grant(principal, &entries),
285        }
286        .map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
287        Ok(B64.encode(GrantToken::to_bytes(&grant)))
288    }
289
290    /// Resolves capability names to signed `(CapabilityId, partition)`
291    /// pairs via the loaded policy's partition map.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`IjimaError::InvalidInput`] for any name not in the
296    /// policy vocabulary.
297    fn capability_entries(&self, capabilities: &[&str]) -> Result<Vec<(CapabilityId, Vec<usize>)>> {
298        let mut entries = Vec::with_capacity(capabilities.len());
299        for cap in capabilities {
300            let partition = self
301                .controller
302                .capability(cap)
303                .map(|c| c.partition.clone())
304                .ok_or_else(|| IjimaError::invalid_input(format!("unknown capability: {cap}")))?;
305            entries.push((CapabilityId::new(*cap), partition));
306        }
307        Ok(entries)
308    }
309
310    /// The grant verifier (exposes `verify_at` for clock-injected checks).
311    pub fn grant_verifier(&self) -> &GrantVerifier {
312        &self.grant_verifier
313    }
314
315    /// Resolves the issuance policy for `ijima token issue` (Schubert 0.5
316    /// #20.3): an explicit `--policy` path wins (unreadable = hard error
317    /// — an explicit pointer must be honored); then `$IJIMA_POLICY`
318    /// (same hard-error rule); then `$IJIMA_DIR/policy.toml` if present;
319    /// otherwise the embedded default (which seeds no principals — a
320    /// fresh install mints nothing until the operator provisions a
321    /// policy file).
322    ///
323    /// # Errors
324    ///
325    /// Returns [`IjimaError::InvalidInput`] when an explicit/env policy
326    /// path cannot be read or the fallback resolution fails.
327    pub fn resolve_issuance_policy(explicit: Option<&std::path::Path>) -> Result<String> {
328        let env_path = std::env::var_os("IJIMA_POLICY").map(std::path::PathBuf::from);
329        let dir = std::env::var_os("IJIMA_DIR").map(std::path::PathBuf::from);
330        Ok(
331            resolve_policy_source(explicit, env_path.as_deref(), dir.as_deref())?
332                .unwrap_or_else(|| POLICY_TOML.to_string()),
333        )
334    }
335
336    /// Builds the [`schubert::policy::PolicyConfig`] that constrains issuance
337    /// from a resolved policy source. Two shapes are accepted:
338    ///
339    /// - **Full policy** (contains `[capabilities]`): parsed and validated
340    ///   as a complete policy. Must match the embedded partition map the
341    ///   daemon verifies with — a diverging geometry is an operator error,
342    ///   surfaced as a hard parse error.
343    /// - **Principals-only overlay** (the operator-friendly default):
344    ///   `[principals.<name>] grants = [...]` sections merged onto the
345    ///   embedded policy. Partitions always derive from the embedded
346    ///   policy, so an overlay can only *assign* existing capabilities —
347    ///   never redefine the geometry (the #20.3 anti-smuggling invariant).
348    ///   The overlay's principal map is authoritative: removing a principal
349    ///   removes their issuance entitlement (already-issued grants keep
350    ///   verifying — they are proof-carrying — until expiry or revocation).
351    ///
352    /// # Errors
353    ///
354    /// Returns [`IjimaError::InvalidInput`] when neither shape parses, an
355    /// overlay carries non-principal sections, or the merged config fails
356    /// validation.
357    pub fn issuance_policy_from_source(toml_str: &str) -> Result<schubert::policy::PolicyConfig> {
358        #[derive(serde::Deserialize)]
359        struct PrincipalsOverlay {
360            #[serde(default)]
361            principals: std::collections::BTreeMap<String, schubert::policy::PrincipalConfig>,
362        }
363
364        if toml_str.contains("[capabilities") {
365            let cfg = schubert::policy::PolicyConfig::from_toml(toml_str)
366                .map_err(|e| IjimaError::invalid_input(format!("policy parse: {e}")))?;
367            cfg.validate()
368                .map_err(|e| IjimaError::invalid_input(format!("policy validate: {e}")))?;
369            return Ok(cfg);
370        }
371
372        // Overlay: principals only, on top of the embedded partitions.
373        let raw: toml::Value = toml::from_str(toml_str)
374            .map_err(|e| IjimaError::invalid_input(format!("policy overlay parse: {e}")))?;
375        // Reject documents that try to do more than assign principals: any
376        // top-level section other than `principals` is out of contract.
377        if let Some(table) = raw.as_table() {
378            for key in table.keys() {
379                if key != "principals" {
380                    return Err(IjimaError::invalid_input(format!(
381                        "policy overlay may only contain [principals.*] (found `{key}`); \
382                         a full policy must carry [capabilities] and validate as a whole"
383                    )));
384                }
385            }
386        }
387        let overlay: PrincipalsOverlay = raw
388            .try_into()
389            .map_err(|e| IjimaError::invalid_input(format!("policy overlay parse: {e}")))?;
390        if overlay.principals.is_empty() {
391            return Err(IjimaError::invalid_input(
392                "policy overlay declares no principals",
393            ));
394        }
395        let mut merged = schubert::policy::PolicyConfig::from_toml(POLICY_TOML)
396            .map_err(|e| IjimaError::invalid_input(format!("embedded policy: {e}")))?;
397        merged.principals = overlay.principals;
398        merged
399            .validate()
400            .map_err(|e| IjimaError::invalid_input(format!("policy validate: {e}")))?;
401        Ok(merged)
402    }
403
404    /// Convenience: issues a single-capability grant. Equivalent to
405    /// [`issue_grant_bearer`](Self::issue_grant_bearer) with one entry.
406    ///
407    /// # Errors
408    ///
409    /// Returns [`IjimaError::InvalidInput`] if the capability is unknown or
410    /// Schubert's issuer rejects the inputs.
411    pub fn issue_bearer(
412        &self,
413        principal: impl Into<PrincipalId>,
414        capability: impl AsRef<str>,
415    ) -> Result<String> {
416        self.issue_grant_bearer(principal, &[capability.as_ref()])
417    }
418
419    /// Hydrates the in-memory revocation set from store-backed records
420    /// (daemon boot). Replaces any prior set.
421    pub fn hydrate_revocations(&self, revocations: &[TokenRevocation]) {
422        let mut set = self.revocations.lock().expect("revocations poisoned");
423        *set = revocations.iter().map(|r| r.token_hash.clone()).collect();
424    }
425
426    /// Adds a revocation to the in-memory set (after the store write — the
427    /// admin route persists first, then calls this). Idempotent.
428    pub fn revoke(&self, hash: &str) {
429        self.revocations
430            .lock()
431            .expect("revocations poisoned")
432            .insert(hash.to_string());
433    }
434
435    /// True if the bearer's hash is revoked.
436    pub fn is_revoked(&self, bearer: &str) -> bool {
437        self.revocations
438            .lock()
439            .expect("revocations poisoned")
440            .contains(&bearer_hash(bearer))
441    }
442
443    /// Decodes + cryptographically verifies a bearer grant token, returning
444    /// the authenticated principal and the verified grant. A revoked bearer
445    /// is rejected here — exactly as dead as a bad signature.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`IjimaError::InvalidInput`] on a malformed,
450    /// bad-signature, or **revoked** token.
451    pub fn verify_bearer(&self, bearer: &str) -> Result<AuthenticatedPrincipal> {
452        if self.is_revoked(bearer) {
453            return Err(IjimaError::invalid_input("token revoked"));
454        }
455        let buf = B64
456            .decode(bearer.trim())
457            .map_err(|e| IjimaError::invalid_input(format!("base64 decode: {e}")))?;
458        let grant = GrantToken::from_bytes(&buf)
459            .map_err(|e| IjimaError::invalid_input(format!("grant decode: {e}")))?;
460        self.grant_verifier
461            .verify(&grant)
462            .map_err(|e| IjimaError::invalid_input(format!("grant verify: {e}")))?;
463        Ok(AuthenticatedPrincipal {
464            principal: grant.principal.clone(),
465            grant,
466            controller: Arc::clone(&self.controller),
467            grant_verifier: Arc::clone(&self.grant_verifier),
468        })
469    }
470
471    /// Convenience guard for handlers: verifies the token (authn) and
472    /// authorizes via geometric containment — succeeds when the grant
473    /// implies `required` (see [`AuthenticatedPrincipal::may`]).
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if the token is invalid or does not imply `required`.
478    pub fn require(&self, bearer: &str, required: &str) -> Result<AuthenticatedPrincipal> {
479        let principal = self.verify_bearer(bearer)?;
480        if principal.may(required) {
481            Ok(principal)
482        } else {
483            Err(IjimaError::invalid_input(format!(
484                "access denied: grant does not imply '{required}'"
485            )))
486        }
487    }
488}
489
490/// Pure policy-source resolution core for
491/// [`IjimaAuth::resolve_issuance_policy`]: explicit path (unreadable =
492/// hard error) → env path (same rule) → `<dir>/policy.toml` if present →
493/// `None` (caller falls back to the embedded default). Env-free so the
494/// precedence chain is testable without env mutation.
495///
496/// # Errors
497///
498/// Returns [`IjimaError::InvalidInput`] when an explicit or env policy
499/// path exists but cannot be read.
500fn resolve_policy_source(
501    explicit: Option<&std::path::Path>,
502    env_path: Option<&std::path::Path>,
503    dir: Option<&std::path::Path>,
504) -> Result<Option<String>> {
505    let read_or_err = |p: &std::path::Path, origin: &str| {
506        std::fs::read_to_string(p).map(Some).map_err(|e| {
507            IjimaError::invalid_input(format!("policy file {origin} {}: {e}", p.display()))
508        })
509    };
510    if let Some(p) = explicit {
511        return read_or_err(p, "(--policy)");
512    }
513    if let Some(p) = env_path {
514        return read_or_err(p, "($IJIMA_POLICY)");
515    }
516    if let Some(dir) = dir {
517        let candidate = dir.join("policy.toml");
518        if candidate.exists() {
519            return read_or_err(&candidate, "($IJIMA_DIR/policy.toml)");
520        }
521    }
522    Ok(None)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use ijima_core::capabilities::{ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE};
529
530    fn fresh() -> IjimaAuth {
531        IjimaAuth::from_embedded_policy().expect("embedded policy must load")
532    }
533
534    #[test]
535    fn embedded_policy_loads_on_gr_4_8() {
536        let auth = fresh();
537        assert_eq!(auth.grassmannian(), (4, 8));
538    }
539
540    #[test]
541    fn issue_then_verify_round_trips() {
542        let auth = fresh();
543        let bearer = auth
544            .issue_bearer("elliott", MEMORY_READ)
545            .expect("must issue");
546        let principal = auth.verify_bearer(&bearer).expect("must verify");
547        assert_eq!(principal.principal.as_str(), "elliott");
548        assert_eq!(
549            principal.granted_capabilities(),
550            vec![MEMORY_READ.to_string()]
551        );
552    }
553
554    // ---- Schubert 0.5: expiry ----
555
556    #[test]
557    fn expired_grant_is_rejected_with_expired_detail() {
558        let auth = fresh();
559        let now = std::time::SystemTime::now()
560            .duration_since(std::time::UNIX_EPOCH)
561            .unwrap()
562            .as_secs();
563        // Issued with an expiry in the past — dead on arrival.
564        let bearer = auth
565            .issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ], now - 1)
566            .expect("issue");
567        let err = auth.verify_bearer(&bearer).expect_err("must be dead");
568        let msg = err.to_string();
569        assert!(msg.contains("expired"), "detail should name expiry: {msg}");
570    }
571
572    #[test]
573    fn expiry_boundary_is_inclusive_at_verify_at() {
574        let auth = fresh();
575        let bearer = auth
576            .issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ], 1_000_000)
577            .expect("issue");
578        let buf = B64.decode(bearer.trim()).expect("b64");
579        let grant = GrantToken::from_bytes(&buf).expect("grant");
580        // ADR-0001: dead the instant now >= expires_at.
581        assert!(auth.grant_verifier().verify_at(&grant, 999_999).is_ok());
582        let err = auth
583            .grant_verifier()
584            .verify_at(&grant, 1_000_000)
585            .expect_err("boundary is inclusive");
586        assert!(matches!(err, schubert::SchubertError::GrantExpired { .. }));
587    }
588
589    #[test]
590    fn unexpired_grant_with_expiry_still_verifies() {
591        let now = std::time::SystemTime::now()
592            .duration_since(std::time::UNIX_EPOCH)
593            .unwrap()
594            .as_secs();
595        let auth = fresh();
596        let bearer = auth
597            .issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ, MEMORY_WRITE], now + 3600)
598            .expect("issue");
599        let principal = auth.verify_bearer(&bearer).expect("valid for an hour");
600        assert!(principal.may(MEMORY_WRITE));
601    }
602
603    // ---- Schubert 0.5: policy-constrained issuance ----
604
605    fn policy_with_alice() -> schubert::policy::PolicyConfig {
606        let toml = format!(
607            "{POLICY_TOML}\n[principals.alice]\ngrants = [\"memory:read\", \"memory:write\"]\n"
608        );
609        schubert::policy::PolicyConfig::from_toml(&toml).expect("policy parses")
610    }
611
612    #[test]
613    fn under_policy_allows_entitled_request() {
614        let auth = fresh();
615        let policy =
616            schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
617        let bearer = auth
618            .issue_grant_bearer_under_policy("alice", &[MEMORY_READ], &policy, None)
619            .expect("alice is entitled to memory:read");
620        let principal = auth.verify_bearer(&bearer).expect("verify");
621        assert_eq!(principal.principal.as_str(), "alice");
622    }
623
624    #[test]
625    fn under_policy_denies_unknown_principal() {
626        let auth = fresh();
627        let policy =
628            schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
629        let err = auth
630            .issue_grant_bearer_under_policy("mallory", &[MEMORY_READ], &policy, None)
631            .expect_err("fails closed on unknown principals");
632        assert!(err.to_string().contains("mallory"));
633    }
634
635    #[test]
636    fn under_policy_denies_over_entitled_request() {
637        let auth = fresh();
638        let policy =
639            schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
640        let err = auth
641            .issue_grant_bearer_under_policy("alice", &[ADMIN], &policy, None)
642            .expect_err("alice cannot smuggle admin");
643        assert!(err.to_string().contains("admin"));
644    }
645
646    #[test]
647    fn principals_only_overlay_merges_onto_embedded_partitions() {
648        let overlay = "[principals.elliott]\ngrants = [\"memory:read\", \"memory:write\"]\n";
649        let cfg = IjimaAuth::issuance_policy_from_source(overlay).expect("overlay");
650        // Partitions come from the embedded policy (anti-smuggling).
651        let read = cfg.capabilities.get(MEMORY_READ).expect("embedded cap");
652        assert!(!read.partition.is_empty());
653        // The overlay principal is entitled.
654        let grants = cfg.grants_for("elliott");
655        assert_eq!(grants.len(), 2);
656        assert!(cfg.grants_for("mallory").is_empty());
657    }
658
659    #[test]
660    fn overlay_rejects_non_principal_sections() {
661        let bad = "[principals.elliott]\ngrants = [\"memory:read\"]\n\n[grassmannian]\nk = 9\n";
662        let err = IjimaAuth::issuance_policy_from_source(bad)
663            .expect_err("overlay may not touch geometry");
664        assert!(err.to_string().contains("grassmannian"));
665    }
666
667    #[test]
668    fn overlay_with_no_principals_is_rejected() {
669        let err = IjimaAuth::issuance_policy_from_source("# nothing\n").expect_err("empty overlay");
670        assert!(err.to_string().contains("no principals"));
671    }
672
673    #[test]
674    fn policy_source_precedence_explicit_env_dir_fallback() {
675        let tmp = std::env::temp_dir().join(format!("ijima-pol-{}", std::process::id()));
676        std::fs::create_dir_all(&tmp).expect("mkdir");
677        let explicit = tmp.join("explicit.toml");
678        let env = tmp.join("env.toml");
679        let dir_policy = tmp.join("policy.toml");
680        std::fs::write(&explicit, "# explicit").expect("w");
681        std::fs::write(&env, "# env").expect("w");
682        std::fs::write(&dir_policy, "# dir").expect("w");
683
684        // explicit wins over env and dir
685        assert_eq!(
686            resolve_policy_source(Some(&explicit), Some(&env), Some(&tmp))
687                .expect("res")
688                .as_deref(),
689            Some("# explicit")
690        );
691        // env wins over dir
692        assert_eq!(
693            resolve_policy_source(None, Some(&env), Some(&tmp))
694                .expect("res")
695                .as_deref(),
696            Some("# env")
697        );
698        // dir/policy.toml picked up when present
699        assert_eq!(
700            resolve_policy_source(None, None, Some(&tmp))
701                .expect("res")
702                .as_deref(),
703            Some("# dir")
704        );
705        // no dir policy → None (caller falls back to embedded)
706        let empty = tmp.join("empty");
707        std::fs::create_dir_all(&empty).expect("mkdir");
708        assert_eq!(
709            resolve_policy_source(None, None, Some(&empty)).expect("res"),
710            None
711        );
712        // explicit pointer at a missing file = hard error
713        let missing = tmp.join("missing.toml");
714        assert!(resolve_policy_source(Some(&missing), None, None).is_err());
715    }
716
717    #[test]
718    fn tampered_signature_is_rejected() {
719        let auth = fresh();
720        let mut buf = B64
721            .decode(
722                auth.issue_bearer("elliott", MEMORY_READ)
723                    .expect("must issue"),
724            )
725            .unwrap();
726        // Flip the last byte (part of the 64-byte signature).
727        let last = buf.len() - 1;
728        buf[last] ^= 0xff;
729        let tampered = B64.encode(&buf);
730        assert!(auth.verify_bearer(&tampered).is_err());
731    }
732
733    #[test]
734    fn admin_grant_implies_any_capability_via_geometry() {
735        // No string short-circuit: admin [4,4,4,4] ≥ every partition.
736        let auth = fresh();
737        let bearer = auth.issue_bearer("root", ADMIN).expect("must issue");
738        let principal = auth.require(&bearer, MEMORY_READ).expect("admin may read");
739        assert_eq!(principal.principal.as_str(), "root");
740        // And write, knowledge:read — all implied by the point class.
741        assert!(auth.require(&bearer, MEMORY_WRITE).is_ok());
742        assert!(auth.require(&bearer, KNOWLEDGE_READ).is_ok());
743    }
744
745    #[test]
746    fn read_does_not_imply_write() {
747        // [1] ≱ [2]: a read grant must not satisfy a write requirement.
748        let auth = fresh();
749        let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
750        assert!(auth.require(&bearer, MEMORY_WRITE).is_err());
751    }
752
753    #[test]
754    fn write_implies_read() {
755        // The geometric upgrade: [2] ≥ [1], so memory:write grants
756        // memory:read. This is the safe least-privilege direction.
757        let auth = fresh();
758        let bearer = auth.issue_bearer("bob", MEMORY_WRITE).expect("must issue");
759        assert!(auth.require(&bearer, MEMORY_READ).is_ok());
760    }
761
762    #[test]
763    fn unknown_required_capability_is_denied() {
764        let auth = fresh();
765        let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
766        let principal = auth.verify_bearer(&bearer).expect("must verify");
767        assert!(!principal.may("memory:nonexistent"));
768    }
769
770    #[test]
771    fn multi_capability_grant() {
772        // One token, several capabilities — the pi-shim 1-token model.
773        let auth = fresh();
774        let bearer = auth
775            .issue_grant_bearer("pi", &[MEMORY_READ, MEMORY_WRITE, KNOWLEDGE_READ])
776            .expect("must issue");
777        let principal = auth.verify_bearer(&bearer).expect("must verify");
778        assert_eq!(principal.principal.as_str(), "pi");
779        // All three implied (write also implies read, redundantly).
780        assert!(principal.may(MEMORY_READ));
781        assert!(principal.may(MEMORY_WRITE));
782        assert!(principal.may(KNOWLEDGE_READ));
783    }
784
785    #[test]
786    fn empty_grant_rejected() {
787        let auth = fresh();
788        assert!(auth.issue_grant_bearer("x", &[]).is_err());
789    }
790
791    #[test]
792    fn unknown_capability_rejected_at_issue() {
793        let auth = fresh();
794        assert!(auth.issue_bearer("x", "bogus:cap").is_err());
795    }
796
797    #[test]
798    fn malformed_bearer_rejected() {
799        let auth = fresh();
800        assert!(auth.verify_bearer("not-base64!!!").is_err());
801        assert!(auth.verify_bearer("").is_err());
802    }
803
804    #[test]
805    fn seed_based_issue_then_verify_across_instances() {
806        // CLI→daemon flow: one IjimaAuth (CLI) issues with a seed; a second
807        // (daemon) from the SAME seed verifies the grant.
808        let seed = IjimaAuth::generate_seed();
809        let issuer = IjimaAuth::from_embedded_policy_with_seed(seed).expect("issuer");
810        let bearer = issuer
811            .issue_grant_bearer("elliott", &[MEMORY_READ, MEMORY_WRITE])
812            .expect("must issue");
813        let public_key = issuer.issuer_public_key_hex();
814        assert_eq!(public_key.len(), 64);
815
816        let daemon = IjimaAuth::from_embedded_policy_with_seed(seed).expect("daemon");
817        let principal = daemon.verify_bearer(&bearer).expect("must verify");
818        assert_eq!(principal.principal.as_str(), "elliott");
819        assert_eq!(daemon.issuer_public_key_hex(), public_key);
820    }
821
822    // ---------- revocation (WS1b) ----------
823
824    #[test]
825    fn revoked_bearer_is_rejected_after_crypto_verify_passes() {
826        let auth = fresh();
827        let bearer = auth
828            .issue_bearer("elliott", MEMORY_READ)
829            .expect("must issue");
830        assert!(auth.verify_bearer(&bearer).is_ok()); // before: fine
831        auth.revoke(&bearer_hash(&bearer));
832        let err = auth.verify_bearer(&bearer).expect_err("must reject");
833        assert!(err.to_string().contains("revoked"));
834    }
835
836    #[test]
837    fn hydration_replaces_prior_set() {
838        let auth = fresh();
839        let b1 = auth.issue_bearer("a", MEMORY_READ).expect("issue");
840        let b2 = auth.issue_bearer("b", MEMORY_READ).expect("issue");
841        auth.revoke(&bearer_hash(&b1));
842        assert!(auth.is_revoked(&b1));
843        // Hydrate with only b2's revocation → b1 is live again (the store
844        // is the source of truth, not the union).
845        auth.hydrate_revocations(&[TokenRevocation {
846            token_hash: bearer_hash(&b2),
847            revoked_at_unix: 0,
848            reason: None,
849        }]);
850        assert!(!auth.is_revoked(&b1));
851        assert!(auth.is_revoked(&b2));
852    }
853
854    #[test]
855    fn bearer_hash_is_sha256_hex_of_trimmed_bearer() {
856        let h1 = bearer_hash("  abc  ");
857        let h2 = bearer_hash("abc");
858        let h3 = bearer_hash("Bearer abc");
859        assert_eq!(h1, h2); // trimmed
860        assert_eq!(h2, h3); // scheme prefix tolerated
861        assert_eq!(h1.len(), 64); // sha256 hex
862        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
863    }
864}