use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::evaluator::Decision;
use crate::pipeline::{TaintEvent, TaintScope};
use crate::rules::Rule;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Step {
Rule(Rule),
Pdp {
call: PdpCall,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
on_deny: Vec<Step>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
on_allow: Vec<Step>,
},
Plugin { name: String },
Delegate(DelegateStep),
Taint {
label: String,
scopes: Vec<TaintScope>,
},
Elicit(ElicitStep),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DelegateStep {
pub plugin_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_override: Option<serde_yaml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_error: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ElicitKind {
Approval,
Confirm,
StepUp,
Attestation,
Info,
Review,
}
impl ElicitKind {
pub fn as_str(&self) -> &'static str {
match self {
ElicitKind::Approval => "approval",
ElicitKind::Confirm => "confirm",
ElicitKind::StepUp => "step_up",
ElicitKind::Attestation => "attestation",
ElicitKind::Info => "info",
ElicitKind::Review => "review",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ElicitStep {
pub kind: ElicitKind,
pub plugin_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<String>,
pub from: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_override: Option<serde_yaml::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_error: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PdpCall {
pub dialect: PdpDialect,
pub args: serde_yaml::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PdpDialect {
Cedar,
Opa,
AuthZen,
NeMo,
Cel,
#[serde(untagged)]
Custom(String),
}
impl PdpDialect {
pub fn from_key(key: &str) -> Self {
match key {
"cedar" => Self::Cedar,
"opa" => Self::Opa,
"authzen" => Self::AuthZen,
"nemo" => Self::NeMo,
"cel" => Self::Cel,
other => Self::Custom(other.to_string()),
}
}
}
#[async_trait]
pub trait PdpResolver: Send + Sync {
fn dialect(&self) -> PdpDialect;
async fn evaluate(
&self,
call: &PdpCall,
bag: &crate::attributes::AttributeBag,
) -> Result<PdpDecision, PdpError>;
}
pub trait PdpFactory: Send + Sync {
fn kind(&self) -> &str;
fn build(
&self,
config: &serde_yaml::Value,
) -> Result<std::sync::Arc<dyn PdpResolver>, Box<dyn std::error::Error + Send + Sync>>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispatchPhase {
Pre,
Post,
}
#[derive(Debug, Clone, Copy)]
pub enum PluginInvocation<'a> {
Step { phase: DispatchPhase },
Field {
name: &'a str,
value: &'a serde_json::Value,
phase: DispatchPhase,
},
}
impl<'a> PluginInvocation<'a> {
pub fn phase(&self) -> DispatchPhase {
match self {
PluginInvocation::Step { phase } => *phase,
PluginInvocation::Field { phase, .. } => *phase,
}
}
}
#[async_trait]
pub trait PluginInvoker: Send + Sync {
async fn invoke(
&self,
name: &str,
bag: &crate::attributes::AttributeBag,
invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError>;
}
#[async_trait]
pub trait DelegationInvoker: Send + Sync {
async fn delegate(&self, step: &DelegateStep) -> Result<DelegationOutcome, DelegationError>;
}
#[derive(Debug, Clone)]
pub struct DelegationOutcome {
pub decision: Decision,
pub granted_permissions: Vec<String>,
pub granted_audience: Option<String>,
pub granted_expires_at: Option<String>,
}
impl DelegationOutcome {
pub fn deny(decision: Decision) -> Self {
Self {
decision,
granted_permissions: Vec::new(),
granted_audience: None,
granted_expires_at: None,
}
}
}
#[derive(Debug, Error)]
pub enum DelegationError {
#[error("no delegation invoker available for plugin `{0}`")]
NotFound(String),
#[error("delegation dispatch failed: {0}")]
Dispatch(String),
}
pub struct NoopDelegationInvoker;
#[async_trait]
impl DelegationInvoker for NoopDelegationInvoker {
async fn delegate(&self, step: &DelegateStep) -> Result<DelegationOutcome, DelegationError> {
Err(DelegationError::NotFound(step.plugin_name.clone()))
}
}
#[async_trait]
pub trait ElicitationInvoker: Send + Sync {
async fn dispatch(
&self,
step: &ElicitStep,
resolved_from: &str,
) -> Result<ElicitationDispatch, ElicitationError>;
async fn check(
&self,
step: &ElicitStep,
id: &str,
) -> Result<ElicitationStatus, ElicitationError>;
async fn validate(
&self,
step: &ElicitStep,
id: &str,
) -> Result<ElicitationValidation, ElicitationError>;
}
#[derive(Debug, Clone)]
pub struct ElicitationDispatch {
pub id: String,
pub approver: Option<String>,
pub intent_id: Option<String>,
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ElicitationStatus {
Pending,
Resolved { outcome: ElicitationOutcome },
Expired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElicitationOutcome {
Approved,
Denied,
}
#[derive(Debug, Clone)]
pub struct ElicitationValidation {
pub valid: bool,
pub approver: Option<String>,
pub intent_id: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PendingElicitation {
pub id: String,
pub plugin_name: String,
pub approver: Option<String>,
pub intent_id: Option<String>,
pub channel: Option<String>,
pub expires_at: Option<String>,
pub source: String,
}
#[derive(Debug, Error)]
pub enum ElicitationError {
#[error("no elicitation invoker available for plugin `{0}`")]
NotFound(String),
#[error("elicitation handler error: {0}")]
Handler(String),
}
pub struct NoopElicitationInvoker;
#[async_trait]
impl ElicitationInvoker for NoopElicitationInvoker {
async fn dispatch(
&self,
step: &ElicitStep,
_resolved_from: &str,
) -> Result<ElicitationDispatch, ElicitationError> {
Err(ElicitationError::NotFound(step.plugin_name.clone()))
}
async fn check(
&self,
_step: &ElicitStep,
id: &str,
) -> Result<ElicitationStatus, ElicitationError> {
Err(ElicitationError::NotFound(id.to_string()))
}
async fn validate(
&self,
_step: &ElicitStep,
id: &str,
) -> Result<ElicitationValidation, ElicitationError> {
Err(ElicitationError::NotFound(id.to_string()))
}
}
#[derive(Default)]
pub struct AutoApprovingElicitor;
#[async_trait]
impl ElicitationInvoker for AutoApprovingElicitor {
async fn dispatch(
&self,
step: &ElicitStep,
resolved_from: &str,
) -> Result<ElicitationDispatch, ElicitationError> {
Ok(ElicitationDispatch {
id: format!("auto-{}", step.plugin_name),
approver: Some(resolved_from.to_string()),
intent_id: Some("auto-intent".to_string()),
expires_at: None,
})
}
async fn check(
&self,
_step: &ElicitStep,
_id: &str,
) -> Result<ElicitationStatus, ElicitationError> {
Ok(ElicitationStatus::Resolved {
outcome: ElicitationOutcome::Approved,
})
}
async fn validate(
&self,
_step: &ElicitStep,
_id: &str,
) -> Result<ElicitationValidation, ElicitationError> {
Ok(ElicitationValidation {
valid: true,
approver: None,
intent_id: Some("auto-intent".to_string()),
reason: None,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PdpDecision {
pub decision: Decision,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PluginOutcome {
pub decision: Decision,
pub taints: Vec<TaintEvent>,
pub modified_value: Option<serde_json::Value>,
}
impl PluginOutcome {
pub fn allow() -> Self {
Self {
decision: Decision::Allow,
taints: vec![],
modified_value: None,
}
}
}
#[derive(Debug, Error)]
pub enum PdpError {
#[error("no PDP resolver registered for dialect {0:?}")]
NoResolver(PdpDialect),
#[error("PDP dispatch failed: {0}")]
Dispatch(String),
}
#[derive(Debug, Error)]
pub enum PluginError {
#[error("no plugin invoker available for `{0}`")]
NotFound(String),
#[error("plugin dispatch failed: {0}")]
Dispatch(String),
}
impl Step {
pub fn rule(r: Rule) -> Self {
Step::Rule(r)
}
pub fn is_rule(&self) -> bool {
matches!(self, Step::Rule(_))
}
}
pub mod delegation_bag_keys {
pub const GRANTED_PERMISSIONS: &str = "delegation.granted.permissions";
pub const GRANTED_AUDIENCE: &str = "delegation.granted.audience";
pub const GRANTED_EXPIRES_AT: &str = "delegation.granted.expires_at";
pub const GRANTED: &str = "delegation.granted";
}
pub mod elicitation_bag_keys {
pub const ID: &str = "elicitation.id";
pub const STATUS: &str = "elicitation.status";
pub const APPROVER: &str = "elicitation.approver";
pub const OUTCOME: &str = "elicitation.outcome";
pub const INTENT_ID: &str = "elicitation.intent_id";
pub const CHANNEL: &str = "elicitation.channel";
pub const EXPIRES_AT: &str = "elicitation.expires_at";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_key_maps_known_dialects() {
assert_eq!(PdpDialect::from_key("cedar"), PdpDialect::Cedar);
assert_eq!(PdpDialect::from_key("opa"), PdpDialect::Opa);
assert_eq!(PdpDialect::from_key("authzen"), PdpDialect::AuthZen);
assert_eq!(PdpDialect::from_key("nemo"), PdpDialect::NeMo);
assert_eq!(PdpDialect::from_key("cel"), PdpDialect::Cel);
}
#[test]
fn from_key_unknown_is_custom() {
assert_eq!(
PdpDialect::from_key("rego-remote"),
PdpDialect::Custom("rego-remote".to_string())
);
}
#[tokio::test]
async fn noop_elicitation_invoker_is_not_found_for_every_method() {
let inv = NoopElicitationInvoker;
let step = ElicitStep {
kind: ElicitKind::Approval,
plugin_name: "manager-approver".to_string(),
channel: Some("ciba".to_string()),
from: "user.manager".to_string(),
purpose: None,
scope: None,
timeout: None,
config_override: None,
on_error: None,
source: "route.test.policy[0]".to_string(),
};
let d = inv.dispatch(&step, "alice@example.com").await;
assert!(matches!(d, Err(ElicitationError::NotFound(c)) if c == "manager-approver"));
let c = inv.check(&step, "elic-123").await;
assert!(matches!(c, Err(ElicitationError::NotFound(id)) if id == "elic-123"));
let v = inv.validate(&step, "elic-123").await;
assert!(matches!(v, Err(ElicitationError::NotFound(id)) if id == "elic-123"));
}
#[test]
fn elicitation_status_resolved_carries_outcome() {
let approved = ElicitationStatus::Resolved {
outcome: ElicitationOutcome::Approved,
};
let denied = ElicitationStatus::Resolved {
outcome: ElicitationOutcome::Denied,
};
assert_ne!(approved, denied);
assert_ne!(approved, ElicitationStatus::Pending);
assert_ne!(denied, ElicitationStatus::Expired);
}
#[test]
fn cel_dialect_serde_roundtrips_as_snake_case() {
let json = serde_json::to_string(&PdpDialect::Cel).unwrap();
assert_eq!(json, "\"cel\"");
let back: PdpDialect = serde_json::from_str(&json).unwrap();
assert_eq!(back, PdpDialect::Cel);
}
}