use async_trait::async_trait;
use crate::runtime::security::PolicyLintFinding;
use super::bundle::{PolicyBundleConfig, SignedBundle};
use super::{AuthzQuery, AuthzSnapshot, Decision};
#[async_trait]
pub trait PolicyEngine: Send + Sync {
async fn decide(&self, req: &AuthzQuery<'_>) -> Decision;
async fn explain(&self, req: &AuthzQuery<'_>) -> Decision {
self.decide(req).await
}
async fn lint(&self) -> Vec<PolicyLintFinding>;
async fn bundle(
&self,
cfg: &PolicyBundleConfig,
tenant_id: &str,
project_id: &str,
now_unix: i64,
) -> Option<SignedBundle>;
fn bundle_version(&self) -> String;
fn relationship_version(&self) -> String {
String::new()
}
}
#[async_trait]
impl PolicyEngine for AuthzSnapshot {
#[tracing::instrument(skip_all, name = "authz.decide")]
async fn decide(&self, req: &AuthzQuery<'_>) -> Decision {
self.casbin_authorize(req).await
}
async fn lint(&self) -> Vec<PolicyLintFinding> {
lint_snapshot(self)
}
async fn bundle(
&self,
cfg: &PolicyBundleConfig,
tenant_id: &str,
project_id: &str,
now_unix: i64,
) -> Option<SignedBundle> {
cfg.sign(self, tenant_id, project_id, now_unix)
}
fn bundle_version(&self) -> String {
self.version.clone()
}
fn relationship_version(&self) -> String {
self.relationship_version.clone()
}
}
fn wild_covers(deny: &str, allow: &str) -> bool {
let deny = deny.trim();
deny.is_empty() || deny == "*" || deny == allow.trim()
}
fn selector_covers(deny: &str, allow: &str) -> bool {
let deny = deny.trim();
let allow = allow.trim();
if deny.is_empty() || deny == "*" || deny == allow {
return true;
}
if let Some(prefix) = deny.strip_suffix(".*") {
let allow_base = allow.strip_suffix(".*").unwrap_or(allow);
return allow_base == prefix || allow_base.starts_with(&format!("{prefix}."));
}
false
}
fn lint_snapshot(snapshot: &AuthzSnapshot) -> Vec<PolicyLintFinding> {
use super::Effect;
if snapshot.policies.is_empty() {
return crate::runtime::security::lint_policies(&[]);
}
let mut findings = Vec::new();
let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (idx, p) in snapshot.policies.iter().enumerate() {
if p.id.trim().is_empty() {
findings.push(PolicyLintFinding {
severity: "error".to_string(),
category: "empty_policy_id".to_string(),
message: format!("Policy #{idx} has an empty id; audits cannot reference it."),
policy_index: Some(idx),
});
} else if !seen_ids.insert(p.id.as_str()) {
findings.push(PolicyLintFinding {
severity: "warning".to_string(),
category: "duplicate_policy_id".to_string(),
message: format!(
"Policy #{idx} duplicates id '{}'; decision audits become ambiguous.",
p.id
),
policy_index: Some(idx),
});
}
if !p.enabled {
findings.push(PolicyLintFinding {
severity: "warning".to_string(),
category: "disabled_policy".to_string(),
message: format!("Policy {} (#{idx}) is disabled and will be ignored.", p.id),
policy_index: Some(idx),
});
}
if p.effect == Effect::Allow
&& wild_covers(&p.subject, "?")
&& wild_covers(&p.role, "?")
&& selector_covers(&p.action, "?")
&& selector_covers(&p.resource, "?")
{
findings.push(PolicyLintFinding {
severity: "warning".to_string(),
category: "broad_wildcard".to_string(),
message: format!(
"Policy {} (#{idx}) allows any subject/role/action/resource (overly broad).",
p.id
),
policy_index: Some(idx),
});
}
}
for (idx, allow) in snapshot.policies.iter().enumerate() {
if allow.effect != Effect::Allow || !allow.enabled {
continue;
}
let shadowing = snapshot.policies.iter().enumerate().find(|(_, deny)| {
deny.effect == Effect::Deny
&& deny.enabled
&& deny.conditions.is_empty()
&& deny.relationship.trim().is_empty()
&& wild_covers(&deny.tenant, &allow.tenant)
&& wild_covers(&deny.project, &allow.project)
&& wild_covers(&deny.subject, &allow.subject)
&& wild_covers(&deny.role, &allow.role)
&& wild_covers(&deny.purpose, &allow.purpose)
&& selector_covers(&deny.action, &allow.action)
&& selector_covers(&deny.resource, &allow.resource)
});
if let Some((deny_idx, deny)) = shadowing {
findings.push(PolicyLintFinding {
severity: "warning".to_string(),
category: "shadowed_allow".to_string(),
message: format!(
"Allow policy {} (#{idx}) is unreachable: deny-override policy {} \
(#{deny_idx}) covers its entire subject/role/domain/action/resource scope.",
allow.id, deny.id
),
policy_index: Some(idx),
});
}
}
let bound_roles: std::collections::HashSet<&str> = snapshot
.role_bindings
.iter()
.map(|b| b.role.as_str())
.collect();
for (idx, p) in snapshot.policies.iter().enumerate() {
let role = p.role.trim();
if !role.is_empty() && role != "*" && !bound_roles.contains(role) {
findings.push(PolicyLintFinding {
severity: "warning".to_string(),
category: "dangling_role".to_string(),
message: format!(
"Policy {} (#{idx}) requires role '{role}' but no role binding grants it \
(only token-claim roles could ever match).",
p.id
),
policy_index: Some(idx),
});
}
}
let mut seen_tuples: std::collections::HashSet<String> = std::collections::HashSet::new();
for t in &snapshot.tuples {
let key = format!(
"{}|{}|{}|{}|{}",
t.subject, t.relation, t.object, t.tenant, t.project
);
if !seen_tuples.insert(key) {
findings.push(PolicyLintFinding {
severity: "warning".to_string(),
category: "duplicate_tuple".to_string(),
message: format!(
"Duplicate relationship tuple ({} {} {}) in tenant '{}' project '{}'.",
t.subject, t.relation, t.object, t.tenant, t.project
),
policy_index: None,
});
}
}
findings
}
#[cfg(test)]
mod tests {
use super::super::{AuthzPolicy, Effect, RelationshipTuple, RoleBinding};
use super::*;
fn categories(findings: &[PolicyLintFinding]) -> Vec<&str> {
findings.iter().map(|f| f.category.as_str()).collect()
}
#[tokio::test]
async fn lint_empty_snapshot_reports_deny_by_default() {
let snap = AuthzSnapshot::default();
let findings = PolicyEngine::lint(&snap).await;
assert!(categories(&findings).contains(&"deny_by_default"));
}
#[tokio::test]
async fn lint_flags_allow_shadowed_by_unconditional_deny() {
let mut snap = AuthzSnapshot::default();
snap.policies.push(AuthzPolicy {
id: "allow-select".to_string(),
effect: Effect::Allow,
tenant: "acme".to_string(),
action: "data.select".to_string(),
resource: "invoice".to_string(),
..Default::default()
});
snap.policies.push(AuthzPolicy {
id: "deny-all-data".to_string(),
effect: Effect::Deny,
action: "data.*".to_string(),
..Default::default()
});
let findings = PolicyEngine::lint(&snap).await;
let shadowed: Vec<_> = findings
.iter()
.filter(|f| f.category == "shadowed_allow")
.collect();
assert_eq!(shadowed.len(), 1, "exactly the covered Allow is flagged");
assert_eq!(shadowed[0].policy_index, Some(0));
snap.policies[1]
.conditions
.insert("env".to_string(), "prod".to_string());
let findings = PolicyEngine::lint(&snap).await;
assert!(
!categories(&findings).contains(&"shadowed_allow"),
"conditional denies must not flag allows as unreachable"
);
}
#[tokio::test]
async fn lint_flags_dangling_role_and_duplicate_tuple() {
let mut snap = AuthzSnapshot::default();
snap.policies.push(AuthzPolicy {
id: "role-gated".to_string(),
effect: Effect::Allow,
role: "auditor".to_string(),
action: "data.select".to_string(),
resource: "invoice".to_string(),
..Default::default()
});
let tuple = RelationshipTuple {
subject: "alice".to_string(),
relation: "owner".to_string(),
object: "invoice/1".to_string(),
tenant: "acme".to_string(),
project: String::new(),
};
snap.tuples.push(tuple.clone());
snap.tuples.push(tuple);
let findings = PolicyEngine::lint(&snap).await;
let cats = categories(&findings);
assert!(cats.contains(&"dangling_role"), "unbound role is flagged");
assert!(cats.contains(&"duplicate_tuple"), "exact dup is flagged");
snap.role_bindings.push(RoleBinding {
subject: "alice".to_string(),
role: "auditor".to_string(),
tenant: "acme".to_string(),
project: String::new(),
});
let findings = PolicyEngine::lint(&snap).await;
assert!(!categories(&findings).contains(&"dangling_role"));
}
#[tokio::test]
async fn lint_flags_ids_disabled_and_broad_allow() {
let mut snap = AuthzSnapshot::default();
snap.policies.push(AuthzPolicy {
id: "p1".to_string(),
effect: Effect::Allow,
..Default::default() });
snap.policies.push(AuthzPolicy {
id: "p1".to_string(), effect: Effect::Allow,
action: "data.select".to_string(),
resource: "invoice".to_string(),
enabled: false, ..Default::default()
});
snap.policies.push(AuthzPolicy {
id: " ".to_string(), effect: Effect::Allow,
action: "data.select".to_string(),
resource: "invoice".to_string(),
..Default::default()
});
let findings = PolicyEngine::lint(&snap).await;
let cats = categories(&findings);
for expect in [
"broad_wildcard",
"duplicate_policy_id",
"disabled_policy",
"empty_policy_id",
] {
assert!(cats.contains(&expect), "missing finding {expect}");
}
}
#[test]
fn deny_selector_coverage_rules() {
assert!(selector_covers("*", "data.select"));
assert!(selector_covers("", "data.select"));
assert!(selector_covers("data.select", "data.select"));
assert!(!selector_covers("data.select", "data.delete"));
assert!(selector_covers("data.*", "data.select"));
assert!(selector_covers("data.*", "data.*"));
assert!(!selector_covers("data.*", "*"));
assert!(!selector_covers("data.*", "vector.search"));
assert!(wild_covers("*", "alice"));
assert!(wild_covers("", "alice"));
assert!(wild_covers("alice", "alice"));
assert!(!wild_covers("alice", "bob"));
}
}