use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use arc_swap::ArcSwapOption;
use super::model::{grant_matches, CompiledSnapshot, Effect, SnapshotPayload};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenyReason {
NoBinding,
EmergencyDeny,
ExplicitDeny,
NoGrant,
}
impl DenyReason {
pub fn as_str(self) -> &'static str {
match self {
DenyReason::NoBinding => "no_binding",
DenyReason::EmergencyDeny => "emergency_deny",
DenyReason::ExplicitDeny => "explicit_deny",
DenyReason::NoGrant => "no_grant",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny { reason: DenyReason },
NeedsStepUp { min_acr: String },
NeedsQuorum,
}
impl Decision {
pub fn is_allow(&self) -> bool {
matches!(self, Decision::Allow)
}
pub fn label(&self) -> &'static str {
match self {
Decision::Allow => "allow",
Decision::Deny { .. } => "deny",
Decision::NeedsStepUp { .. } => "step_up_required",
Decision::NeedsQuorum => "quorum_required",
}
}
}
pub struct Pdp {
snapshot: ArcSwapOption<CompiledSnapshot>,
last_updated_ms: AtomicI64,
}
impl Default for Pdp {
fn default() -> Self {
Self::new()
}
}
impl Pdp {
pub fn new() -> Self {
Self {
snapshot: ArcSwapOption::empty(),
last_updated_ms: AtomicI64::new(0),
}
}
pub fn install(&self, payload: SnapshotPayload) {
let compiled = Arc::new(CompiledSnapshot::from_payload(payload));
self.snapshot.store(Some(compiled));
self.last_updated_ms.store(now_millis(), Ordering::Relaxed);
}
pub fn ready(&self) -> bool {
self.snapshot.load().is_some()
}
pub fn version(&self) -> Option<u64> {
self.snapshot.load().as_ref().map(|s| s.version)
}
pub fn last_updated_ms(&self) -> i64 {
self.last_updated_ms.load(Ordering::Relaxed)
}
pub fn has_any_binding(&self, sub: &str) -> bool {
let Some(snap) = self.snapshot.load_full() else {
return false;
};
let now = now_secs();
let mut has_role = false;
for b in snap.bindings_for(sub) {
if !b.active(now) {
continue;
}
if b.is_emergency_deny {
return false;
}
if b.role_id.is_some() {
has_role = true;
}
}
has_role
}
pub fn check(&self, sub: &str, acr: Option<&str>, permission: &str) -> Decision {
let Some(snap) = self.snapshot.load_full() else {
return Decision::Deny { reason: DenyReason::NoBinding };
};
evaluate(&snap, sub, acr, permission, now_secs())
}
}
fn evaluate(
snap: &CompiledSnapshot,
sub: &str,
acr: Option<&str>,
permission: &str,
now: i64,
) -> Decision {
let mut has_active_role = false;
let mut saw_allow = false;
for b in snap.bindings_for(sub) {
if !b.active(now) {
continue;
}
if b.is_emergency_deny {
return Decision::Deny { reason: DenyReason::EmergencyDeny };
}
let Some(role_id) = b.role_id.as_deref() else {
continue;
};
has_active_role = true;
for g in snap.grants_for_role(role_id) {
if grant_matches(&g.permission, permission) {
match g.effect {
Effect::Deny => return Decision::Deny { reason: DenyReason::ExplicitDeny },
Effect::Allow => saw_allow = true,
}
}
}
}
if !has_active_role {
return Decision::Deny { reason: DenyReason::NoBinding };
}
if !saw_allow {
return Decision::Deny { reason: DenyReason::NoGrant };
}
if let Some(meta) = snap.perm_meta(permission) {
if let Some(min) = &meta.min_acr {
if acr != Some(min.as_str()) {
return Decision::NeedsStepUp { min_acr: min.clone() };
}
}
if meta.requires_quorum {
return Decision::NeedsQuorum;
}
}
Decision::Allow
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::model::SnapshotPayload;
fn payload(json: serde_json::Value) -> SnapshotPayload {
serde_json::from_value(json).unwrap()
}
fn pdp_with(json: serde_json::Value) -> Pdp {
let p = Pdp::new();
p.install(payload(json));
p
}
#[test]
fn deny_by_default_when_no_binding() {
let p = pdp_with(serde_json::json!({
"schema": "cex.authz.snapshot.v1", "policy_version": 1,
"permissions": [], "roles": [], "grants": [], "bindings": []
}));
assert!(matches!(
p.check("sub-x", None, "finance:write"),
Decision::Deny { reason: DenyReason::NoBinding }
));
assert!(!p.has_any_binding("sub-x"));
}
#[test]
fn allow_via_role_grant_and_wildcards() {
let p = pdp_with(serde_json::json!({
"schema": "cex.authz.snapshot.v1", "policy_version": 1,
"permissions": [],
"roles": [{"id": "r1", "name": "fin"}],
"grants": [{"role_id": "r1", "permission": "finance:*", "effect": "ALLOW"}],
"bindings": [{"principal_sub": "sub-a", "binding_type": "ROLE", "role_id": "r1"}]
}));
assert_eq!(p.check("sub-a", None, "finance:write"), Decision::Allow);
assert!(p.has_any_binding("sub-a"));
assert!(matches!(
p.check("sub-a", None, "risk:write"),
Decision::Deny { reason: DenyReason::NoGrant }
));
}
#[test]
fn explicit_deny_beats_allow() {
let p = pdp_with(serde_json::json!({
"schema": "cex.authz.snapshot.v1", "policy_version": 1,
"permissions": [],
"roles": [{"id": "r1", "name": "a"}, {"id": "r2", "name": "b"}],
"grants": [
{"role_id": "r1", "permission": "*:*", "effect": "ALLOW"},
{"role_id": "r2", "permission": "finance:write", "effect": "DENY"}
],
"bindings": [
{"principal_sub": "s", "binding_type": "ROLE", "role_id": "r1"},
{"principal_sub": "s", "binding_type": "ROLE", "role_id": "r2"}
]
}));
assert!(matches!(
p.check("s", None, "finance:write"),
Decision::Deny { reason: DenyReason::ExplicitDeny }
));
assert_eq!(p.check("s", None, "risk:read"), Decision::Allow);
}
#[test]
fn emergency_deny_short_circuits() {
let p = pdp_with(serde_json::json!({
"schema": "cex.authz.snapshot.v1", "policy_version": 1,
"permissions": [],
"roles": [{"id": "r1", "name": "a"}],
"grants": [{"role_id": "r1", "permission": "*:*", "effect": "ALLOW"}],
"bindings": [
{"principal_sub": "s", "binding_type": "ROLE", "role_id": "r1"},
{"principal_sub": "s", "binding_type": "EMERGENCY_DENY", "expires_at": 99999999999i64}
]
}));
assert!(matches!(
p.check("s", None, "finance:write"),
Decision::Deny { reason: DenyReason::EmergencyDeny }
));
assert!(!p.has_any_binding("s"));
}
#[test]
fn expired_binding_ignored_locally() {
let p = pdp_with(serde_json::json!({
"schema": "cex.authz.snapshot.v1", "policy_version": 1,
"permissions": [],
"roles": [{"id": "r1", "name": "a"}],
"grants": [{"role_id": "r1", "permission": "*:*", "effect": "ALLOW"}],
"bindings": [{"principal_sub": "s", "binding_type": "ROLE", "role_id": "r1", "expires_at": 1}]
}));
assert!(matches!(
p.check("s", None, "finance:write"),
Decision::Deny { reason: DenyReason::NoBinding }
));
}
#[test]
fn step_up_required_until_acr_met() {
let p = pdp_with(serde_json::json!({
"schema": "cex.authz.snapshot.v1", "policy_version": 1,
"permissions": [{"permission": "finance:write", "min_acr": "aal2"}],
"roles": [{"id": "r1", "name": "a"}],
"grants": [{"role_id": "r1", "permission": "finance:write", "effect": "ALLOW"}],
"bindings": [{"principal_sub": "s", "binding_type": "ROLE", "role_id": "r1"}]
}));
assert_eq!(
p.check("s", None, "finance:write"),
Decision::NeedsStepUp { min_acr: "aal2".into() }
);
assert_eq!(p.check("s", Some("aal2"), "finance:write"), Decision::Allow);
}
#[test]
fn stale_snapshot_not_installed_by_check() {
let p = Pdp::new();
assert!(!p.ready());
assert!(matches!(
p.check("s", None, "x:y"),
Decision::Deny { reason: DenyReason::NoBinding }
));
}
}