use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::compiled::ApprovalScope;
use crate::context::EvalContext;
use crate::types::{CanonicalCapability, CanonicalDid};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalAttestation {
pub jti: String,
pub approver_did: CanonicalDid,
pub request_hash: [u8; 32],
pub expires_at: DateTime<Utc>,
pub approved_capabilities: Vec<CanonicalCapability>,
}
pub fn compute_request_hash(ctx: &EvalContext, scope: ApprovalScope) -> [u8; 32] {
let hash_input = match scope {
ApprovalScope::Identity => {
let mut caps: Vec<&str> = ctx.capabilities.iter().map(|c| c.as_str()).collect();
caps.sort();
serde_json::json!({
"capabilities": caps,
"issuer": ctx.issuer.as_str(),
"subject": ctx.subject.as_str(),
})
}
ApprovalScope::Scoped => {
let mut caps: Vec<&str> = ctx.capabilities.iter().map(|c| c.as_str()).collect();
caps.sort();
serde_json::json!({
"capabilities": caps,
"environment": ctx.environment,
"issuer": ctx.issuer.as_str(),
"repo": ctx.repo,
"subject": ctx.subject.as_str(),
})
}
ApprovalScope::Full => {
let mut caps: Vec<&str> = ctx.capabilities.iter().map(|c| c.as_str()).collect();
caps.sort();
let mut attrs: Vec<(&str, &str)> = ctx
.attrs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
attrs.sort();
let mut workload_claims: Vec<(&str, &str)> = ctx
.workload_claims
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
workload_claims.sort();
serde_json::json!({
"attrs": attrs,
"capabilities": caps,
"chain_depth": ctx.chain_depth,
"delegated_by": ctx.delegated_by.as_ref().map(|d| d.as_str()),
"environment": ctx.environment,
"git_ref": ctx.git_ref,
"issuer": ctx.issuer.as_str(),
"paths": ctx.paths,
"repo": ctx.repo,
"revoked": ctx.revoked,
"role": ctx.role,
"subject": ctx.subject.as_str(),
"workload_claims": workload_claims,
"workload_issuer": ctx.workload_issuer.as_ref().map(|d| d.as_str()),
})
}
};
let canonical = serde_json::to_string(&hash_input).unwrap_or_default();
let hash = blake3::hash(canonical.as_bytes());
*hash.as_bytes()
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use crate::types::CanonicalDid;
use chrono::Utc;
fn did(s: &str) -> CanonicalDid {
CanonicalDid::parse(s).unwrap()
}
#[test]
fn hash_determinism() {
let now = Utc::now();
let ctx = EvalContext::new(now, did("did:keri:issuer"), did("did:keri:subject"));
let hash1 = compute_request_hash(&ctx, ApprovalScope::Identity);
let hash2 = compute_request_hash(&ctx, ApprovalScope::Identity);
assert_eq!(hash1, hash2);
}
#[test]
fn hash_scope_differentiation() {
let now = Utc::now();
let ctx = EvalContext::new(now, did("did:keri:issuer"), did("did:keri:subject"))
.repo("org/repo")
.environment("production");
let identity_hash = compute_request_hash(&ctx, ApprovalScope::Identity);
let scoped_hash = compute_request_hash(&ctx, ApprovalScope::Scoped);
let full_hash = compute_request_hash(&ctx, ApprovalScope::Full);
assert_ne!(identity_hash, scoped_hash);
assert_ne!(scoped_hash, full_hash);
assert_ne!(identity_hash, full_hash);
}
#[test]
fn identity_scope_ignores_repo() {
let now = Utc::now();
let ctx1 = EvalContext::new(now, did("did:keri:issuer"), did("did:keri:subject"))
.repo("org/repo-a");
let ctx2 = EvalContext::new(now, did("did:keri:issuer"), did("did:keri:subject"))
.repo("org/repo-b");
assert_eq!(
compute_request_hash(&ctx1, ApprovalScope::Identity),
compute_request_hash(&ctx2, ApprovalScope::Identity),
);
}
#[test]
fn scoped_scope_includes_repo() {
let now = Utc::now();
let ctx1 = EvalContext::new(now, did("did:keri:issuer"), did("did:keri:subject"))
.repo("org/repo-a");
let ctx2 = EvalContext::new(now, did("did:keri:issuer"), did("did:keri:subject"))
.repo("org/repo-b");
assert_ne!(
compute_request_hash(&ctx1, ApprovalScope::Scoped),
compute_request_hash(&ctx2, ApprovalScope::Scoped),
);
}
}