use std::sync::Arc;
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use ijima_core::{IjimaError, Result, TokenRevocation};
use schubert::{
AccessController, CapabilityId, PrincipalId,
crypto::{CapabilityIssuer, GrantPolicy, GrantToken, GrantVerifier, KeyStore},
};
use sha2::{Digest, Sha256};
const POLICY_TOML: &str = include_str!("../policy/policy.toml");
pub fn bearer_hash(bearer: &str) -> String {
let trimmed = bearer.trim();
let raw = trimmed.strip_prefix("Bearer ").unwrap_or(trimmed);
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[derive(Debug, Clone)]
pub struct AuthenticatedPrincipal {
pub principal: PrincipalId,
pub grant: GrantToken,
controller: Arc<AccessController>,
grant_verifier: Arc<GrantVerifier>,
}
impl AuthenticatedPrincipal {
pub fn may(&self, required: &str) -> bool {
match self.controller.capability(required) {
Some(cap) => self.grant_verifier.may(&self.grant, &cap.partition),
None => false,
}
}
pub fn granted_capabilities(&self) -> Vec<String> {
self.grant
.capabilities
.iter()
.map(|c| c.id.as_str().to_string())
.collect()
}
pub fn personal_namespace(&self) -> ijima_core::NamespaceId {
ijima_core::NamespaceId::new(format!("ns_{}_private", self.principal.as_str()))
}
}
#[derive(Debug)]
pub struct IjimaAuth {
controller: Arc<AccessController>,
issuer: CapabilityIssuer,
grant_verifier: Arc<GrantVerifier>,
revocations: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl IjimaAuth {
pub fn from_embedded_policy() -> Result<Self> {
Self::from_embedded_policy_with_seed(Self::generate_seed())
}
pub fn from_embedded_policy_with_seed(seed: [u8; 32]) -> Result<Self> {
let controller = AccessController::from_policy_toml(POLICY_TOML)
.map_err(|e| IjimaError::invalid_input(format!("policy load: {e}")))?;
let issuer = CapabilityIssuer::from_seed(seed);
let grant_verifier = GrantVerifier::new(issuer.public_key());
Ok(Self {
controller: Arc::new(controller),
issuer,
grant_verifier: Arc::new(grant_verifier),
revocations: std::sync::Mutex::new(std::collections::HashSet::new()),
})
}
pub fn generate_seed() -> [u8; 32] {
KeyStore::generate_seed()
}
pub fn issuer_public_key_hex(&self) -> String {
self.issuer.public_key_hex()
}
pub fn grassmannian(&self) -> (usize, usize) {
self.controller.grassmannian()
}
pub fn issue_grant_bearer(
&self,
principal: impl Into<PrincipalId>,
capabilities: &[&str],
) -> Result<String> {
if capabilities.is_empty() {
return Err(IjimaError::invalid_input(
"grant must carry at least one capability",
));
}
let entries = self.capability_entries(capabilities)?;
let grant = self
.issuer
.issue_grant(principal, &entries)
.map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
Ok(B64.encode(GrantToken::to_bytes(&grant)))
}
pub fn issue_grant_bearer_with_expiry(
&self,
principal: impl Into<PrincipalId>,
capabilities: &[&str],
expires_at_unix: u64,
) -> Result<String> {
if capabilities.is_empty() {
return Err(IjimaError::invalid_input(
"grant must carry at least one capability",
));
}
let entries = self.capability_entries(capabilities)?;
let grant = self
.issuer
.issue_grant_with_expiry(principal, &entries, expires_at_unix)
.map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
Ok(B64.encode(GrantToken::to_bytes(&grant)))
}
pub fn issue_grant_bearer_under_policy(
&self,
principal: impl Into<PrincipalId>,
capabilities: &[&str],
policy: &GrantPolicy,
expires_at: Option<u64>,
) -> Result<String> {
if capabilities.is_empty() {
return Err(IjimaError::invalid_input(
"grant must carry at least one capability",
));
}
let entries = self.capability_entries(capabilities)?;
let principal = principal.into();
policy
.may_issue(&principal, &entries)
.map_err(|e| IjimaError::invalid_input(format!("grant denied by policy: {e}")))?;
let grant = match expires_at {
Some(at) => self.issuer.issue_grant_with_expiry(principal, &entries, at),
None => self.issuer.issue_grant(principal, &entries),
}
.map_err(|e| IjimaError::invalid_input(format!("grant issue: {e}")))?;
Ok(B64.encode(GrantToken::to_bytes(&grant)))
}
fn capability_entries(&self, capabilities: &[&str]) -> Result<Vec<(CapabilityId, Vec<usize>)>> {
let mut entries = Vec::with_capacity(capabilities.len());
for cap in capabilities {
let partition = self
.controller
.capability(cap)
.map(|c| c.partition.clone())
.ok_or_else(|| IjimaError::invalid_input(format!("unknown capability: {cap}")))?;
entries.push((CapabilityId::new(*cap), partition));
}
Ok(entries)
}
pub fn grant_verifier(&self) -> &GrantVerifier {
&self.grant_verifier
}
pub fn resolve_issuance_policy(explicit: Option<&std::path::Path>) -> Result<String> {
let env_path = std::env::var_os("IJIMA_POLICY").map(std::path::PathBuf::from);
let dir = std::env::var_os("IJIMA_DIR").map(std::path::PathBuf::from);
Ok(
resolve_policy_source(explicit, env_path.as_deref(), dir.as_deref())?
.unwrap_or_else(|| POLICY_TOML.to_string()),
)
}
pub fn issuance_policy_from_source(toml_str: &str) -> Result<schubert::policy::PolicyConfig> {
#[derive(serde::Deserialize)]
struct PrincipalsOverlay {
#[serde(default)]
principals: std::collections::BTreeMap<String, schubert::policy::PrincipalConfig>,
}
if toml_str.contains("[capabilities") {
let cfg = schubert::policy::PolicyConfig::from_toml(toml_str)
.map_err(|e| IjimaError::invalid_input(format!("policy parse: {e}")))?;
cfg.validate()
.map_err(|e| IjimaError::invalid_input(format!("policy validate: {e}")))?;
return Ok(cfg);
}
let raw: toml::Value = toml::from_str(toml_str)
.map_err(|e| IjimaError::invalid_input(format!("policy overlay parse: {e}")))?;
if let Some(table) = raw.as_table() {
for key in table.keys() {
if key != "principals" {
return Err(IjimaError::invalid_input(format!(
"policy overlay may only contain [principals.*] (found `{key}`); \
a full policy must carry [capabilities] and validate as a whole"
)));
}
}
}
let overlay: PrincipalsOverlay = raw
.try_into()
.map_err(|e| IjimaError::invalid_input(format!("policy overlay parse: {e}")))?;
if overlay.principals.is_empty() {
return Err(IjimaError::invalid_input(
"policy overlay declares no principals",
));
}
let mut merged = schubert::policy::PolicyConfig::from_toml(POLICY_TOML)
.map_err(|e| IjimaError::invalid_input(format!("embedded policy: {e}")))?;
merged.principals = overlay.principals;
merged
.validate()
.map_err(|e| IjimaError::invalid_input(format!("policy validate: {e}")))?;
Ok(merged)
}
pub fn issue_bearer(
&self,
principal: impl Into<PrincipalId>,
capability: impl AsRef<str>,
) -> Result<String> {
self.issue_grant_bearer(principal, &[capability.as_ref()])
}
pub fn hydrate_revocations(&self, revocations: &[TokenRevocation]) {
let mut set = self.revocations.lock().expect("revocations poisoned");
*set = revocations.iter().map(|r| r.token_hash.clone()).collect();
}
pub fn revoke(&self, hash: &str) {
self.revocations
.lock()
.expect("revocations poisoned")
.insert(hash.to_string());
}
pub fn is_revoked(&self, bearer: &str) -> bool {
self.revocations
.lock()
.expect("revocations poisoned")
.contains(&bearer_hash(bearer))
}
pub fn verify_bearer(&self, bearer: &str) -> Result<AuthenticatedPrincipal> {
if self.is_revoked(bearer) {
return Err(IjimaError::invalid_input("token revoked"));
}
let buf = B64
.decode(bearer.trim())
.map_err(|e| IjimaError::invalid_input(format!("base64 decode: {e}")))?;
let grant = GrantToken::from_bytes(&buf)
.map_err(|e| IjimaError::invalid_input(format!("grant decode: {e}")))?;
self.grant_verifier
.verify(&grant)
.map_err(|e| IjimaError::invalid_input(format!("grant verify: {e}")))?;
Ok(AuthenticatedPrincipal {
principal: grant.principal.clone(),
grant,
controller: Arc::clone(&self.controller),
grant_verifier: Arc::clone(&self.grant_verifier),
})
}
pub fn require(&self, bearer: &str, required: &str) -> Result<AuthenticatedPrincipal> {
let principal = self.verify_bearer(bearer)?;
if principal.may(required) {
Ok(principal)
} else {
Err(IjimaError::invalid_input(format!(
"access denied: grant does not imply '{required}'"
)))
}
}
}
fn resolve_policy_source(
explicit: Option<&std::path::Path>,
env_path: Option<&std::path::Path>,
dir: Option<&std::path::Path>,
) -> Result<Option<String>> {
let read_or_err = |p: &std::path::Path, origin: &str| {
std::fs::read_to_string(p).map(Some).map_err(|e| {
IjimaError::invalid_input(format!("policy file {origin} {}: {e}", p.display()))
})
};
if let Some(p) = explicit {
return read_or_err(p, "(--policy)");
}
if let Some(p) = env_path {
return read_or_err(p, "($IJIMA_POLICY)");
}
if let Some(dir) = dir {
let candidate = dir.join("policy.toml");
if candidate.exists() {
return read_or_err(&candidate, "($IJIMA_DIR/policy.toml)");
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use ijima_core::capabilities::{ADMIN, KNOWLEDGE_READ, MEMORY_READ, MEMORY_WRITE};
fn fresh() -> IjimaAuth {
IjimaAuth::from_embedded_policy().expect("embedded policy must load")
}
#[test]
fn embedded_policy_loads_on_gr_4_8() {
let auth = fresh();
assert_eq!(auth.grassmannian(), (4, 8));
}
#[test]
fn issue_then_verify_round_trips() {
let auth = fresh();
let bearer = auth
.issue_bearer("elliott", MEMORY_READ)
.expect("must issue");
let principal = auth.verify_bearer(&bearer).expect("must verify");
assert_eq!(principal.principal.as_str(), "elliott");
assert_eq!(
principal.granted_capabilities(),
vec![MEMORY_READ.to_string()]
);
}
#[test]
fn expired_grant_is_rejected_with_expired_detail() {
let auth = fresh();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let bearer = auth
.issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ], now - 1)
.expect("issue");
let err = auth.verify_bearer(&bearer).expect_err("must be dead");
let msg = err.to_string();
assert!(msg.contains("expired"), "detail should name expiry: {msg}");
}
#[test]
fn expiry_boundary_is_inclusive_at_verify_at() {
let auth = fresh();
let bearer = auth
.issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ], 1_000_000)
.expect("issue");
let buf = B64.decode(bearer.trim()).expect("b64");
let grant = GrantToken::from_bytes(&buf).expect("grant");
assert!(auth.grant_verifier().verify_at(&grant, 999_999).is_ok());
let err = auth
.grant_verifier()
.verify_at(&grant, 1_000_000)
.expect_err("boundary is inclusive");
assert!(matches!(err, schubert::SchubertError::GrantExpired { .. }));
}
#[test]
fn unexpired_grant_with_expiry_still_verifies() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let auth = fresh();
let bearer = auth
.issue_grant_bearer_with_expiry("elliott", &[MEMORY_READ, MEMORY_WRITE], now + 3600)
.expect("issue");
let principal = auth.verify_bearer(&bearer).expect("valid for an hour");
assert!(principal.may(MEMORY_WRITE));
}
fn policy_with_alice() -> schubert::policy::PolicyConfig {
let toml = format!(
"{POLICY_TOML}\n[principals.alice]\ngrants = [\"memory:read\", \"memory:write\"]\n"
);
schubert::policy::PolicyConfig::from_toml(&toml).expect("policy parses")
}
#[test]
fn under_policy_allows_entitled_request() {
let auth = fresh();
let policy =
schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
let bearer = auth
.issue_grant_bearer_under_policy("alice", &[MEMORY_READ], &policy, None)
.expect("alice is entitled to memory:read");
let principal = auth.verify_bearer(&bearer).expect("verify");
assert_eq!(principal.principal.as_str(), "alice");
}
#[test]
fn under_policy_denies_unknown_principal() {
let auth = fresh();
let policy =
schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
let err = auth
.issue_grant_bearer_under_policy("mallory", &[MEMORY_READ], &policy, None)
.expect_err("fails closed on unknown principals");
assert!(err.to_string().contains("mallory"));
}
#[test]
fn under_policy_denies_over_entitled_request() {
let auth = fresh();
let policy =
schubert::crypto::GrantPolicy::from_policy(&policy_with_alice()).expect("grant policy");
let err = auth
.issue_grant_bearer_under_policy("alice", &[ADMIN], &policy, None)
.expect_err("alice cannot smuggle admin");
assert!(err.to_string().contains("admin"));
}
#[test]
fn principals_only_overlay_merges_onto_embedded_partitions() {
let overlay = "[principals.elliott]\ngrants = [\"memory:read\", \"memory:write\"]\n";
let cfg = IjimaAuth::issuance_policy_from_source(overlay).expect("overlay");
let read = cfg.capabilities.get(MEMORY_READ).expect("embedded cap");
assert!(!read.partition.is_empty());
let grants = cfg.grants_for("elliott");
assert_eq!(grants.len(), 2);
assert!(cfg.grants_for("mallory").is_empty());
}
#[test]
fn overlay_rejects_non_principal_sections() {
let bad = "[principals.elliott]\ngrants = [\"memory:read\"]\n\n[grassmannian]\nk = 9\n";
let err = IjimaAuth::issuance_policy_from_source(bad)
.expect_err("overlay may not touch geometry");
assert!(err.to_string().contains("grassmannian"));
}
#[test]
fn overlay_with_no_principals_is_rejected() {
let err = IjimaAuth::issuance_policy_from_source("# nothing\n").expect_err("empty overlay");
assert!(err.to_string().contains("no principals"));
}
#[test]
fn policy_source_precedence_explicit_env_dir_fallback() {
let tmp = std::env::temp_dir().join(format!("ijima-pol-{}", std::process::id()));
std::fs::create_dir_all(&tmp).expect("mkdir");
let explicit = tmp.join("explicit.toml");
let env = tmp.join("env.toml");
let dir_policy = tmp.join("policy.toml");
std::fs::write(&explicit, "# explicit").expect("w");
std::fs::write(&env, "# env").expect("w");
std::fs::write(&dir_policy, "# dir").expect("w");
assert_eq!(
resolve_policy_source(Some(&explicit), Some(&env), Some(&tmp))
.expect("res")
.as_deref(),
Some("# explicit")
);
assert_eq!(
resolve_policy_source(None, Some(&env), Some(&tmp))
.expect("res")
.as_deref(),
Some("# env")
);
assert_eq!(
resolve_policy_source(None, None, Some(&tmp))
.expect("res")
.as_deref(),
Some("# dir")
);
let empty = tmp.join("empty");
std::fs::create_dir_all(&empty).expect("mkdir");
assert_eq!(
resolve_policy_source(None, None, Some(&empty)).expect("res"),
None
);
let missing = tmp.join("missing.toml");
assert!(resolve_policy_source(Some(&missing), None, None).is_err());
}
#[test]
fn tampered_signature_is_rejected() {
let auth = fresh();
let mut buf = B64
.decode(
auth.issue_bearer("elliott", MEMORY_READ)
.expect("must issue"),
)
.unwrap();
let last = buf.len() - 1;
buf[last] ^= 0xff;
let tampered = B64.encode(&buf);
assert!(auth.verify_bearer(&tampered).is_err());
}
#[test]
fn admin_grant_implies_any_capability_via_geometry() {
let auth = fresh();
let bearer = auth.issue_bearer("root", ADMIN).expect("must issue");
let principal = auth.require(&bearer, MEMORY_READ).expect("admin may read");
assert_eq!(principal.principal.as_str(), "root");
assert!(auth.require(&bearer, MEMORY_WRITE).is_ok());
assert!(auth.require(&bearer, KNOWLEDGE_READ).is_ok());
}
#[test]
fn read_does_not_imply_write() {
let auth = fresh();
let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
assert!(auth.require(&bearer, MEMORY_WRITE).is_err());
}
#[test]
fn write_implies_read() {
let auth = fresh();
let bearer = auth.issue_bearer("bob", MEMORY_WRITE).expect("must issue");
assert!(auth.require(&bearer, MEMORY_READ).is_ok());
}
#[test]
fn unknown_required_capability_is_denied() {
let auth = fresh();
let bearer = auth.issue_bearer("alice", MEMORY_READ).expect("must issue");
let principal = auth.verify_bearer(&bearer).expect("must verify");
assert!(!principal.may("memory:nonexistent"));
}
#[test]
fn multi_capability_grant() {
let auth = fresh();
let bearer = auth
.issue_grant_bearer("pi", &[MEMORY_READ, MEMORY_WRITE, KNOWLEDGE_READ])
.expect("must issue");
let principal = auth.verify_bearer(&bearer).expect("must verify");
assert_eq!(principal.principal.as_str(), "pi");
assert!(principal.may(MEMORY_READ));
assert!(principal.may(MEMORY_WRITE));
assert!(principal.may(KNOWLEDGE_READ));
}
#[test]
fn empty_grant_rejected() {
let auth = fresh();
assert!(auth.issue_grant_bearer("x", &[]).is_err());
}
#[test]
fn unknown_capability_rejected_at_issue() {
let auth = fresh();
assert!(auth.issue_bearer("x", "bogus:cap").is_err());
}
#[test]
fn malformed_bearer_rejected() {
let auth = fresh();
assert!(auth.verify_bearer("not-base64!!!").is_err());
assert!(auth.verify_bearer("").is_err());
}
#[test]
fn seed_based_issue_then_verify_across_instances() {
let seed = IjimaAuth::generate_seed();
let issuer = IjimaAuth::from_embedded_policy_with_seed(seed).expect("issuer");
let bearer = issuer
.issue_grant_bearer("elliott", &[MEMORY_READ, MEMORY_WRITE])
.expect("must issue");
let public_key = issuer.issuer_public_key_hex();
assert_eq!(public_key.len(), 64);
let daemon = IjimaAuth::from_embedded_policy_with_seed(seed).expect("daemon");
let principal = daemon.verify_bearer(&bearer).expect("must verify");
assert_eq!(principal.principal.as_str(), "elliott");
assert_eq!(daemon.issuer_public_key_hex(), public_key);
}
#[test]
fn revoked_bearer_is_rejected_after_crypto_verify_passes() {
let auth = fresh();
let bearer = auth
.issue_bearer("elliott", MEMORY_READ)
.expect("must issue");
assert!(auth.verify_bearer(&bearer).is_ok()); auth.revoke(&bearer_hash(&bearer));
let err = auth.verify_bearer(&bearer).expect_err("must reject");
assert!(err.to_string().contains("revoked"));
}
#[test]
fn hydration_replaces_prior_set() {
let auth = fresh();
let b1 = auth.issue_bearer("a", MEMORY_READ).expect("issue");
let b2 = auth.issue_bearer("b", MEMORY_READ).expect("issue");
auth.revoke(&bearer_hash(&b1));
assert!(auth.is_revoked(&b1));
auth.hydrate_revocations(&[TokenRevocation {
token_hash: bearer_hash(&b2),
revoked_at_unix: 0,
reason: None,
}]);
assert!(!auth.is_revoked(&b1));
assert!(auth.is_revoked(&b2));
}
#[test]
fn bearer_hash_is_sha256_hex_of_trimmed_bearer() {
let h1 = bearer_hash(" abc ");
let h2 = bearer_hash("abc");
let h3 = bearer_hash("Bearer abc");
assert_eq!(h1, h2); assert_eq!(h2, h3); assert_eq!(h1.len(), 64); assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
}
}