klieo-ops 0.2.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! Cedar-backed policy gate.
//!
//! `PolicyGate::from_cedar_source` accepts a Cedar policy source string and
//! evaluates it for every effectful tool invocation. Each `GateRequest` is
//! lifted into a Cedar `Request` of shape:
//!   - principal: `Tool::"<tool_name>"`
//!   - action:    `Action::"invoke"`
//!   - resource:  `Args::"<tool_name>"` with the JSON args as Cedar attrs.
//!
//! Phase A scope: source-string load + evaluate. Hot-reload, bundle sha
//! digest in audit, and per-tenant bundle scoping land in Phase B.

use super::trait_::{Gate, GateDecision, GateRequest};
use async_trait::async_trait;
use cedar_policy::{Authorizer, Context, Entities, EntityUid, PolicySet, Request};
use std::str::FromStr;
use thiserror::Error;

/// Errors raised by `PolicyGate::from_cedar_source`.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum PolicyGateError {
    /// The Cedar source did not parse.
    #[error("cedar policy parse: {0}")]
    Parse(String),
}

/// Cedar policy bundle gate.
pub struct PolicyGate {
    policies: PolicySet,
    authorizer: Authorizer,
}

impl PolicyGate {
    /// Construct from a Cedar source string.
    pub fn from_cedar_source(src: &str) -> Result<Self, PolicyGateError> {
        let policies =
            PolicySet::from_str(src).map_err(|e| PolicyGateError::Parse(e.to_string()))?;
        Ok(Self {
            policies,
            authorizer: Authorizer::new(),
        })
    }

    fn build_uid(prefix: &str, name: &str) -> Result<EntityUid, GateDecision> {
        EntityUid::from_str(&format!("{prefix}::\"{name}\"")).map_err(|e| GateDecision::Deny {
            code: format!("cedar.{}.invalid", prefix.to_ascii_lowercase()),
            reason: format!("could not form {prefix} uid: {e}"),
        })
    }
}

#[async_trait]
impl Gate for PolicyGate {
    async fn evaluate(&self, req: GateRequest) -> GateDecision {
        let principal = match Self::build_uid("Tool", &req.tool_name) {
            Ok(u) => u,
            Err(d) => return d,
        };
        let action = match EntityUid::from_str("Action::\"invoke\"") {
            Ok(u) => u,
            Err(e) => {
                return GateDecision::Deny {
                    code: "cedar.action.invalid".into(),
                    reason: format!("could not form action uid: {e}"),
                };
            }
        };
        let resource = match Self::build_uid("Args", &req.tool_name) {
            Ok(u) => u,
            Err(d) => return d,
        };

        // Lift top-level scalar args into Cedar context.
        // Cedar 4.10: Context::from_pairs takes only the pairs iterator (no schema arg).
        let ctx_pairs: Vec<(String, cedar_policy::RestrictedExpression)> = match &req.args {
            serde_json::Value::Object(map) => map
                .iter()
                .filter_map(|(k, v)| json_to_cedar_expr(v).map(|expr| (k.clone(), expr)))
                .collect(),
            _ => Vec::new(),
        };

        let context = match Context::from_pairs(ctx_pairs) {
            Ok(c) => c,
            Err(e) => {
                return GateDecision::Deny {
                    code: "cedar.context.invalid".into(),
                    reason: format!("context build: {e}"),
                };
            }
        };

        let request = match Request::new(principal, action, resource, context, None) {
            Ok(r) => r,
            Err(e) => {
                return GateDecision::Deny {
                    code: "cedar.request.invalid".into(),
                    reason: format!("request build: {e}"),
                };
            }
        };

        let entities = Entities::empty();
        let response = self
            .authorizer
            .is_authorized(&request, &self.policies, &entities);
        match response.decision() {
            cedar_policy::Decision::Allow => GateDecision::Allow,
            cedar_policy::Decision::Deny => GateDecision::Deny {
                code: "cedar.deny".into(),
                reason: "cedar deny".to_string(),
            },
        }
    }

    fn name(&self) -> &'static str {
        "PolicyGate"
    }
}

fn json_to_cedar_expr(v: &serde_json::Value) -> Option<cedar_policy::RestrictedExpression> {
    use cedar_policy::RestrictedExpression as RE;
    match v {
        serde_json::Value::Bool(b) => Some(RE::new_bool(*b)),
        serde_json::Value::Number(n) if n.is_i64() => Some(RE::new_long(n.as_i64().unwrap())),
        serde_json::Value::String(s) => Some(RE::new_string(s.clone())),
        _ => None,
    }
}