use std::sync::{Arc, Mutex, OnceLock};
use chrono::{Duration, Utc};
use tracing::{Event, Subscriber};
use tracing_subscriber::{
Layer, Registry,
layer::{Context, SubscriberExt},
};
use typesec_agent::SecureAgent;
use typesec_core::{
Capability, Credentials, PolicyEngine,
combinator::{CombineStrategy, PolicyEngineBuilder},
lattice::LatticeEngine,
permissions::{CanRead, CanWrite},
policy::{PolicyResult, mint_capability},
resource::GenericResource,
};
use typesec_odrl::{OdrlEngine, constraint::ConstraintContext};
use typesec_rbac::RbacEngine;
const RBAC_ANALYST: &str = r#"
roles:
- name: analyst
permissions: [read]
resources: ["reports/*"]
assignments:
- subject: "agent:analyst"
roles: [analyst]
"#;
const RBAC_WRITER_ONLY: &str = r#"
roles:
- name: writer
permissions: [write]
resources: ["data/*"]
assignments:
- subject: "agent:writer"
roles: [writer]
"#;
fn odrl_with_expiry(expiry_date: &str) -> String {
format!(
r#"
policies:
- uid: "policy:timed-read"
type: Set
rules:
- type: permission
assigner: "org:acme"
assignee: "agent:reader"
action: read
target: "reports/q1"
constraints:
- leftOperand: dateTime
operator: lt
rightOperand: "{expiry_date}"
"#
)
}
const ODRL_PURPOSE: &str = r#"
policies:
- uid: "policy:purpose-read"
type: Set
rules:
- type: permission
assigner: "org:acme"
assignee: "agent:analyst"
action: read
target: "reports/q1"
constraints:
- leftOperand: purpose
operator: eq
rightOperand: "analytics"
"#;
const RBAC_ALLOW_READ: &str = r#"
roles:
- name: reader
permissions: [read]
resources: ["shared/*"]
assignments:
- subject: "agent:combinator"
roles: [reader]
"#;
const ODRL_PROHIBIT_READ: &str = r#"
policies:
- uid: "policy:prohibit-read"
type: Set
rules:
- type: prohibition
assignee: "agent:combinator"
action: read
target: "shared/data"
"#;
const ODRL_PERMIT_READ: &str = r#"
policies:
- uid: "policy:odrl-permit"
type: Set
rules:
- type: permission
assigner: "org:acme"
assignee: "agent:odrl-only"
action: read
target: "private/data"
"#;
const RBAC_NO_RULES: &str = r#"
roles: []
assignments: []
"#;
type EventRecord = String;
#[derive(Default, Clone)]
struct AuditCapture(Arc<Mutex<Vec<EventRecord>>>);
impl AuditCapture {
fn new() -> Self {
Self::default()
}
fn records(&self) -> Vec<EventRecord> {
self.0.lock().unwrap().clone()
}
fn has_field(&self, key: &str, value: &str) -> bool {
let target = format!("{key}={value}");
self.records().iter().any(|r| r.contains(&target))
}
}
struct CaptureLayer(AuditCapture);
impl<S: Subscriber> Layer<S> for CaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
struct Visitor(Vec<(String, String)>);
impl tracing::field::Visit for Visitor {
fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
self.0.push((f.name().to_owned(), format!("{v:?}")));
}
fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
self.0.push((f.name().to_owned(), v.to_owned()));
}
}
let mut vis = Visitor(vec![]);
event.record(&mut vis);
let record = vis
.0
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(";");
self.0.0.lock().unwrap().push(record);
}
}
fn install_capture_subscriber() -> AuditCapture {
static CAPTURE: OnceLock<AuditCapture> = OnceLock::new();
let capture = CAPTURE
.get_or_init(|| {
let capture = AuditCapture::new();
let layer = CaptureLayer(capture.clone());
let subscriber = Registry::default().with(layer);
let _ = tracing::subscriber::set_global_default(subscriber);
tracing::callsite::rebuild_interest_cache();
capture
})
.clone();
capture.0.lock().unwrap().clear();
capture
}
#[tokio::test]
async fn test_01_rbac_allow_analyst_reads_report() {
let engine = Arc::new(RbacEngine::from_yaml(RBAC_ANALYST).expect("parse rbac"));
let agent = SecureAgent::new(engine)
.authenticate_unverified(Credentials::new("agent:analyst", "tok"))
.expect("auth ok");
let resource = GenericResource::new("reports/q1", "report");
let cap = agent
.request_capability::<CanRead, _>(&resource)
.await
.expect("analyst should be allowed to read reports/q1");
assert_eq!(cap.subject(), "agent:analyst");
assert_eq!(cap.resource_id(), "reports/q1");
}
#[tokio::test]
async fn test_02_rbac_deny_analyst_writes_report() {
let engine = Arc::new(RbacEngine::from_yaml(RBAC_ANALYST).expect("parse rbac"));
let agent = SecureAgent::new(engine)
.authenticate_unverified(Credentials::new("agent:analyst", "tok"))
.expect("auth ok");
let resource = GenericResource::new("reports/q1", "report");
let result = agent.request_capability::<CanWrite, _>(&resource).await;
assert!(
result.is_err(),
"analyst should NOT be allowed to write reports/q1"
);
}
#[tokio::test]
async fn test_03_lattice_promotes_write_to_read() {
let inner = Arc::new(RbacEngine::from_yaml(RBAC_WRITER_ONLY).expect("parse rbac"));
let lattice_engine: Arc<dyn typesec_core::PolicyEngine> = Arc::new(LatticeEngine::new(inner));
let agent = SecureAgent::new(lattice_engine)
.authenticate_unverified(Credentials::new("agent:writer", "tok"))
.expect("auth ok");
let resource = GenericResource::new("data/file.csv", "data");
let cap = agent
.request_capability::<CanRead, _>(&resource)
.await
.expect("lattice should grant read because write is allowed");
assert_eq!(cap.subject(), "agent:writer");
assert_eq!(
Capability::<CanRead, GenericResource>::permission_name(),
"read"
);
}
#[tokio::test]
async fn test_04_odrl_time_constraint() {
let future_expiry = (Utc::now() + Duration::days(365))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();
let yaml = odrl_with_expiry(&future_expiry);
let engine = OdrlEngine::from_yaml(&yaml).expect("parse odrl");
let now_ok = ConstraintContext::default().with_time(Utc::now() - Duration::days(1));
let result = engine.check_with_context("agent:reader", "read", "reports/q1", &now_ok);
assert_eq!(result, PolicyResult::Allow, "should allow before expiry");
let now_expired = ConstraintContext::default().with_time(Utc::now() + Duration::days(730)); let result_expired =
engine.check_with_context("agent:reader", "read", "reports/q1", &now_expired);
assert!(
!matches!(result_expired, PolicyResult::Allow),
"should not allow after expiry"
);
}
#[tokio::test]
async fn test_05_odrl_purpose_constraint() {
let engine = OdrlEngine::from_yaml(ODRL_PURPOSE).expect("parse odrl");
let ctx_ok = ConstraintContext::default().with_purpose("analytics");
let result_ok = engine.check_with_context("agent:analyst", "read", "reports/q1", &ctx_ok);
assert_eq!(
result_ok,
PolicyResult::Allow,
"correct purpose should allow"
);
let ctx_bad = ConstraintContext::default().with_purpose("billing");
let result_bad = engine.check_with_context("agent:analyst", "read", "reports/q1", &ctx_bad);
assert!(
!matches!(result_bad, PolicyResult::Allow),
"wrong purpose must not allow"
);
}
#[tokio::test]
async fn test_06_combinator_deny_overrides() {
let rbac: Arc<dyn typesec_core::PolicyEngine> =
Arc::new(RbacEngine::from_yaml(RBAC_ALLOW_READ).expect("rbac"));
let odrl: Arc<dyn typesec_core::PolicyEngine> =
Arc::new(OdrlEngine::from_yaml(ODRL_PROHIBIT_READ).expect("odrl"));
let composed = PolicyEngineBuilder::new()
.add_engine(rbac)
.add_engine(odrl)
.strategy(CombineStrategy::DenyOverrides)
.build();
let result = composed.check("agent:combinator", "read", "shared/data");
assert!(
matches!(result, PolicyResult::Deny(_)),
"DenyOverrides must yield Deny when any engine prohibits: {result:?}"
);
}
#[tokio::test]
async fn test_07_combinator_allow_if_any() {
let rbac: Arc<dyn typesec_core::PolicyEngine> =
Arc::new(RbacEngine::from_yaml(RBAC_NO_RULES).expect("rbac"));
let odrl: Arc<dyn typesec_core::PolicyEngine> =
Arc::new(OdrlEngine::from_yaml(ODRL_PERMIT_READ).expect("odrl"));
let composed = PolicyEngineBuilder::new()
.add_engine(rbac)
.add_engine(odrl)
.strategy(CombineStrategy::AllowIfAny)
.build();
let result = composed.check("agent:odrl-only", "read", "private/data");
assert_eq!(
result,
PolicyResult::Allow,
"AllowIfAny must yield Allow when at least one engine permits: {result:?}"
);
}
#[test]
fn test_08_typestate_enforcement_is_compile_time() {
println!("Typestate enforcement is guaranteed by the type system — see doc comment above.");
}
#[test]
fn test_09_audit_log_captures_decisions() {
let capture = install_capture_subscriber();
let engine = Arc::new(RbacEngine::from_yaml(RBAC_ANALYST).expect("rbac"));
let resource = GenericResource::new("reports/q1", "report");
let _ = mint_capability::<CanRead, _>(engine.as_ref(), "agent:analyst", &resource);
let _ = mint_capability::<CanWrite, _>(engine.as_ref(), "agent:analyst", &resource);
let records = capture.records();
assert!(!records.is_empty(), "expected audit events to be captured");
assert!(
capture.has_field("verdict", "allow"),
"no 'allow' verdict found in audit records:\n{records:#?}"
);
assert!(
capture.has_field("verdict", "deny"),
"no 'deny' verdict found in audit records:\n{records:#?}"
);
assert!(
records.iter().any(|r| r.contains("agent:analyst")),
"subject not found in audit records"
);
assert!(
records.iter().any(|r| r.contains("read")),
"action 'read' not found in audit records"
);
}