agentplane 0.10.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
//! Cedar as the authorization engine.
//!
//! # Why Cedar and not a policy service
//!
//! [`PolicyEngine::authorize`](crate::core::PolicyEngine::authorize) is
//! synchronous and returns a decision rather than a `Result`, because a gate
//! that can fail open switches itself off exactly when a system is under stress.
//! That constraint rules out a network call and points at an embedded evaluator,
//! and Cedar is the one that is **total, side-effect free, and formally verified
//! in Lean** — so it can run before every effect without I/O, and
//! `cedar-policy-symcc` can *prove* properties of a policy set rather than test
//! them.
//!
//! The crate still ships no engine by default. This adapter lives behind the
//! `cedar` feature, and the seam stays the contract: an embedder with a
//! different evaluator implements the trait and nothing else changes.
//!
//! # A denial from a rule and a denial from a broken policy are different
//!
//! Cedar is total, which means a policy that *errors* during evaluation — a
//! missing attribute, a type mismatch — does not propagate an error. The request
//! is simply not satisfied, and the decision is `Deny`.
//!
//! That is the right default and a reporting trap. Both outcomes reach an
//! operator as "denied", but one means *the rules say no* and the other means
//! *the rules are broken and nobody noticed*. The second is an incident: every
//! request is being denied for a reason that has nothing to do with policy, and
//! the system looks like it is enforcing something it is not.
//!
//! So evaluation errors are pulled out of Cedar's diagnostics and reported
//! distinctly — in the reason string, and as a dedicated `tracing` event — while
//! still denying. Failing closed and saying why are not in tension.
//!
//! # Entity shape
//!
//! The runtime's vocabulary maps onto Cedar's directly, which is why the trait
//! was given that vocabulary in the first place:
//!
//! | Runtime | Cedar |
//! |---|---|
//! | `request.principal` | `Agent::"…"` |
//! | `request.action` | `Action::"effect:perform"` \| `Action::"run:admit"` |
//! | `request.resource` | `Resource::"…"` — an effect kind or a capability |
//! | `request.context` | the Cedar context record |
//!
//! A bundle may carry deliberately static Cedar entities and attributes. Live
//! identity and facts about *this request* remain in context, including the
//! delegation chain's owner and depth; copying those into static entities
//! would create a stale second source of truth.
//!
//! # The admission context
//!
//! A `run:admit` request carries the governing declaration under `agent`, which
//! a schema for that action must therefore allow:
//!
//! ```text
//! context.agent.name       the declared name — for reading, never for granting
//! context.agent.version    the declared version
//! context.agent.digest     hex over the manifest's canonical bytes
//! context.agent.publisher  the KeyId that vouched for it, or absent
//! ```
//!
//! Bind rules to `publisher` for a set of agents and to `digest` for one exact
//! revision. Not to `name`: a manifest is a file, and its name is whatever its
//! author typed, so a rule granting authority to a name grants it to anyone who
//! types that name.
//!
//! A schema declaring `run:admit` with `"additionalAttributes": false` and no
//! `agent` attribute will reject every admission. That surfaces as a *defect*
//! rather than an ordinary denial — the adapter says so in the reason, because a
//! request it cannot express is a wiring bug and not a rule firing.

use std::str::FromStr;

use cedar_policy::{
    Authorizer, Context, Entities, EntityUid, PolicySet, Request, Schema, ValidationMode, Validator,
};

use serde_json::Value;

use crate::core::{
    Digest, PolicyBundleIdentity, PolicyDecision, PolicyEngine, PolicyRequest, canon,
};
use crate::runtime::telemetry;

/// Decision semantics pinned into every Cedar bundle identity.
///
/// Cargo cannot expose a dependency's version through `env!`, so this stays
/// deliberately explicit and is guarded against `Cargo.toml` by a test. The
/// adapter revision covers entity mapping, schema-aware context parsing, and
/// the set of Cedar extensions made available by 4.12.
pub const EVALUATOR_SEMANTICS: &str =
    "cedar-policy/4.12.0;agentplane-adapter/2;extensions=all-available";

const ADAPTER_CONFIGURATION: &[u8] =
    b"principal=Agent;action=Action;resource=Resource;context=action-schema";

/// A Cedar policy set, compiled once.
///
/// Compiled at construction rather than per request: parsing on every effect
/// would put a parser in the hot path and, worse, would make a malformed policy
/// a *runtime* failure at an arbitrary moment instead of a startup one.
#[derive(Debug)]
pub struct CedarEngine {
    policies: PolicySet,
    entities: Entities,
    schema: Option<Schema>,
    bundle: PolicyBundleIdentity,
}

/// Why a policy set could not be loaded.
#[derive(Debug, thiserror::Error)]
pub enum CedarError {
    #[error("policy set does not parse: {0}")]
    Parse(String),
    #[error("Cedar schema does not parse: {0}")]
    Schema(String),
    #[error("policy set does not validate against its schema: {0}")]
    Validation(String),
    #[error("static Cedar entities do not parse against the bundle schema: {0}")]
    Entities(String),
}

impl CedarEngine {
    /// Compile a policy set from Cedar source.
    ///
    /// # Errors
    ///
    /// If the source does not parse. Failing here — at startup, loudly — is the
    /// point: a policy set that cannot be compiled must never become a plane
    /// that denies everything for reasons nobody can see.
    pub fn new(source: &str) -> Result<Self, CedarError> {
        Self::from_bundle(source, None, None)
    }

    /// Compile a complete Cedar policy bundle.
    ///
    /// A declared schema is used to validate the policies at startup, parse
    /// static entities, parse each request context, and construct the request.
    /// Static entities are the facts intentionally frozen into this bundle;
    /// per-call facts remain in [`PolicyRequest::context`].
    ///
    /// # Errors
    ///
    /// If rules/schema/entities do not parse, or the policies do not validate
    /// strictly against the schema. Every failure is a startup refusal.
    pub fn from_bundle(
        source: &str,
        schema_json: Option<&str>,
        entities_json: Option<&str>,
    ) -> Result<Self, CedarError> {
        let policies = PolicySet::from_str(source).map_err(|e| CedarError::Parse(e.to_string()))?;

        let (schema, schema_digest) = match schema_json {
            Some(json) => {
                let value: serde_json::Value =
                    serde_json::from_str(json).map_err(|e| CedarError::Schema(e.to_string()))?;
                let schema = Schema::from_json_value(value.clone())
                    .map_err(|e| CedarError::Schema(e.to_string()))?;
                let validation =
                    Validator::new(schema.clone()).validate(&policies, ValidationMode::Strict);
                if !validation.validation_passed() {
                    let errors = validation
                        .validation_errors()
                        .map(ToString::to_string)
                        .collect::<Vec<_>>()
                        .join("; ");
                    return Err(CedarError::Validation(errors));
                }
                (Some(schema), Some(Digest::of(&canon::value_bytes(&value))))
            }
            None => (None, None),
        };

        let (entities, entities_digest) = match entities_json {
            Some(json) => {
                let value: serde_json::Value =
                    serde_json::from_str(json).map_err(|e| CedarError::Entities(e.to_string()))?;
                let entities = Entities::from_json_value(value.clone(), schema.as_ref())
                    .map_err(|e| CedarError::Entities(e.to_string()))?;
                (entities, Some(Digest::of(&canon::value_bytes(&value))))
            }
            None => (Entities::empty(), None),
        };

        let mut bundle =
            PolicyBundleIdentity::new(Digest::of(source.as_bytes()), EVALUATOR_SEMANTICS)
                .with_configuration(Digest::of(ADAPTER_CONFIGURATION));
        if let Some(digest) = schema_digest {
            bundle = bundle.with_schema(digest);
        }
        if let Some(digest) = entities_digest {
            bundle = bundle.with_entities(digest);
        }

        Ok(Self {
            policies,
            entities,
            schema,
            bundle,
        })
    }

    /// Build the Cedar request, or say why the runtime's strings could not be
    /// expressed as entities.
    fn request(&self, r: &PolicyRequest<'_>) -> Result<Request, String> {
        let principal = uid("Agent", r.principal)?;
        let action = uid("Action", r.action)?;
        let resource = uid("Resource", r.resource)?;
        let context = Context::from_json_value(
            without_nulls(r.context.clone()),
            self.schema.as_ref().map(|schema| (schema, &action)),
        )
        .map_err(|e| format!("context is not a Cedar record: {e}"))?;
        Request::new(principal, action, resource, context, self.schema.as_ref())
            .map_err(|e| format!("request is not well formed: {e}"))
    }
}

/// Cedar has no `null`, so an absent value must be spelled *absent*.
///
/// # Why this belongs in the adapter
///
/// `Context::from_json_value` refuses a document containing a JSON `null`
/// anywhere — not the field, **the whole record** — and the consequence is
/// severe and quiet: the request never reaches a rule, so the answer is a clean
/// `Deny`, and an operator reading "denied" hunts for the rule that said no
/// while nothing was evaluated at all. This adapter reports that as *malformed*
/// rather than as a refusal precisely because the two are different situations.
///
/// It was not hypothetical. Two contexts the runtime sends carried nulls, and
/// between them they denied everything a Cedar plane did:
///
/// * `context.agent.publisher` — `None` for every unpublished manifest, which
///   is most of them, so **every admission** was malformed. Fixed at the source
///   too, since the adapter's own documentation already said *"or absent"*.
/// * `context.args` — the effect's own arguments, which are **arbitrary caller
///   JSON**. A model call's request profile carries `null` for every optional
///   knob nobody set. No amount of fixing at the source closes this one: the
///   runtime cannot promise that data it did not author contains no nulls.
///
/// That second case is why the fix lives here rather than only at the callers.
/// A control implemented once per producer is one the next producer does not
/// have, and the producer here is *the user's own arguments*.
///
/// # What the mapping means
///
/// A null-valued object member becomes an **absent attribute**, which is
/// Cedar's own idiom for optional data — `context.agent has publisher` is how a
/// policy asks, and it now answers correctly instead of failing to parse.
///
/// This cannot weaken a rule. Cedar has no null literal, so no policy can match
/// on one; stripping removes only values that were already unmatchable, and the
/// sole observable change is that `has` answers `false` rather than the request
/// being unevaluable.
///
/// # The one lossy case, stated
///
/// A `null` **inside an array** cannot become an absent attribute — there is no
/// key to omit — so the element is dropped, which shortens the array and shifts
/// what follows it. A policy indexing a fixed position in caller-supplied data
/// is fragile regardless, and the alternative is refusing the request, which
/// would reinstate the outage for data the runtime does not control.
fn without_nulls(value: Value) -> Value {
    match value {
        Value::Object(map) => Value::Object(
            map.into_iter()
                .filter(|(_, v)| !v.is_null())
                .map(|(k, v)| (k, without_nulls(v)))
                .collect(),
        ),
        Value::Array(items) => Value::Array(
            items
                .into_iter()
                .filter(|v| !v.is_null())
                .map(without_nulls)
                .collect(),
        ),
        other => other,
    }
}

/// `Type::"id"`, with the id quoted so a name containing `.` or `/` survives.
///
/// Effect kinds look like `tool.call`, which is not a bare Cedar
/// identifier. Quoting is not cosmetic: without it the parse fails and every
/// request involving that effect would be denied for a reason that reads like a
/// policy decision.
fn uid(kind: &str, id: &str) -> Result<EntityUid, String> {
    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
    EntityUid::from_str(&format!("{kind}::\"{escaped}\""))
        .map_err(|e| format!("'{id}' is not a usable Cedar entity id: {e}"))
}

impl PolicyEngine for CedarEngine {
    fn authorize(&self, request: &PolicyRequest<'_>) -> PolicyDecision {
        // A request the adapter cannot even express is a denial, and a loud one:
        // it is a bug here or in the caller, not a rule firing.
        let req = match self.request(request) {
            Ok(r) => r,
            Err(why) => {
                tracing::error!(
                    target: telemetry::POLICY_DENIED,
                    action = %request.action,
                    resource = %request.resource,
                    malformed = true,
                    %why,
                );
                return PolicyDecision::deny(format!(
                    "the authorization request could not be expressed for evaluation \
                     ({why}) — this is a defect, not a rule: every request of this \
                     shape is being denied"
                ));
            }
        };

        let answer = Authorizer::new().is_authorized(&req, &self.policies, &self.entities);

        // Errors first, whatever the decision. Cedar is total: a policy that
        // fails to evaluate simply does not contribute, so a broken rule set
        // yields a clean-looking `Deny`. Reporting that as an ordinary refusal
        // is how a plane spends a week enforcing nothing.
        let errors: Vec<String> = answer
            .diagnostics()
            .errors()
            .map(ToString::to_string)
            .collect();
        if !errors.is_empty() {
            tracing::error!(
                target: telemetry::POLICY_DENIED,
                action = %request.action,
                resource = %request.resource,
                policy_error = true,
                detail = %errors.join("; "),
            );
        }

        match answer.decision() {
            cedar_policy::Decision::Allow if errors.is_empty() => PolicyDecision::Permit,
            // Permitted *and* something failed to evaluate. The permit stands —
            // a policy that did evaluate said yes — but the broken rule is
            // reported, because the next thing it fails to evaluate may be a
            // `forbid`.
            cedar_policy::Decision::Allow => PolicyDecision::Permit,
            cedar_policy::Decision::Deny if !errors.is_empty() => PolicyDecision::deny(format!(
                "denied while {} policy error(s) went unevaluated: {} — fix the \
                 policy set; this denial may not mean what it appears to",
                errors.len(),
                errors.join("; ")
            )),
            cedar_policy::Decision::Deny => {
                let determining: Vec<String> = answer
                    .diagnostics()
                    .reason()
                    .map(ToString::to_string)
                    .collect();
                if determining.is_empty() {
                    // Cedar denies by default when nothing permits. Saying so
                    // beats "denied by policy", which sends someone hunting for
                    // a `forbid` that does not exist.
                    PolicyDecision::deny(format!(
                        "no policy permits '{}' on '{}'",
                        request.action, request.resource
                    ))
                } else {
                    PolicyDecision::deny(format!(
                        "'{}' on '{}' refused by {}",
                        request.action,
                        request.resource,
                        determining.join(", ")
                    ))
                }
            }
        }
    }

    fn bundle(&self) -> PolicyBundleIdentity {
        self.bundle.clone()
    }
}