agentplane 0.23.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
//! Authorization: who may do what to which resource.
//!
//! # What this is not
//!
//! It is not the information-flow lattice. Labels answer *may this value go
//! there* — a sensitivity ceiling on a sink, a taint gate on a mutation — and
//! they travel with the data. Policy answers *may this principal do this at
//! all*, and it travels with the request. Both gates exist because either one
//! alone leaves a hole: a correctly-labelled value sent by someone with no
//! authority, or an authorized caller exfiltrating a secret through a sink that
//! looks innocuous.
//!
//! # Evaluation is total and side-effect free
//!
//! [`PolicyEngine::authorize`] is synchronous and returns a [`PolicyDecision`], not a
//! `Result`. There is deliberately no way to express "the policy service was
//! unreachable", because a runtime that can fail *open* under load has no policy
//! layer — it has a policy layer that turns itself off exactly when a system is
//! under stress, which is when authorization matters most.
//!
//! This is the constraint that points at an embedded evaluator over a network
//! call: a policy set loaded into the process, evaluated against a request, with
//! no I/O in the path. Cedar is the obvious fit and this trait is shaped for it —
//! `principal`, `action`, `resource`, `context` is Cedar's vocabulary — but the
//! crate ships no engine. Picking one for the embedder would be the same mistake
//! as picking their tracing exporter.
//!
//! # Determinism, and why decisions are not journaled wholesale
//!
//! A policy decision made inside a run is a non-deterministic input in exactly
//! the sense the rest of this crate means it: the answer depends on a policy set
//! that can change between the run and its replay. The naive fixes are both
//! wrong. Journaling every permit doubles the journal to record "yes" over and
//! over. Re-evaluating on replay means a policy edit silently rewrites history —
//! last year's run is re-judged under this year's rules, and the audit trail
//! quietly becomes a lie.
//!
//! The answer is the one the effect protocol already gives, applied unchanged:
//!
//! > **Policy is evaluated only when an effect is actually dispatched.**
//!
//! A replayed effect never reaches the gate, because it never reaches the world
//! — its result comes back from the journal. So a permit needs no record: the
//! effect's own `EffectDone` *is* the record that it was allowed. What does need
//! a record is a **denial**, because a denial is a place the run stopped, and a
//! stop with no record replays as "this build performs more effects than the
//! recorded one". That is precisely why `BudgetRefused` exists, and
//! `PolicyDenied` is its twin.
//!
//! What is journaled once, at admission, is the complete immutable
//! [`PolicyBundleIdentity`]: rules, schema, static entities, adapter
//! configuration/extensions, and evaluator semantics. Per-call facts remain in
//! [`PolicyRequest::context`]; treating live identity or request data as static
//! policy would freeze the world at admission.
//!
//! Strict replay performs nothing, so it neither loads nor compares policy. An
//! open run resumed past its recorded prefix may dispatch new effects and must
//! therefore present the exact bundle recorded at admission. Bundle drift is a
//! loud refusal, never a warning followed by mixed policy semantics.

use std::fmt::Debug;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::core::{Digest, canon};

/// The complete immutable identity of an executable policy bundle.
///
/// A rules digest alone is insufficient: changing the schema, static entities,
/// enabled extensions, adapter configuration, or evaluator semantics can change
/// the answer without changing one rule. Each optional component is explicit,
/// and [`digest`](Self::digest) domain-separates the resulting structure for
/// compact indexing and comparison.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyBundleIdentity {
    rules: Digest,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    schema: Option<Digest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    entities: Option<Digest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    configuration: Option<Digest>,
    evaluator: String,
}

impl PolicyBundleIdentity {
    /// Start an identity with the rule artifact and evaluator semantics.
    ///
    /// The evaluator string is a stable semantic identifier, not a display
    /// version: it must change when an evaluator upgrade, adapter change, or
    /// extension-set change can alter decisions.
    ///
    /// # Panics
    ///
    /// If `evaluator` is empty. An unnamed evaluator makes a bundle identity
    /// incomplete and is a startup defect, not a recoverable runtime condition.
    #[must_use]
    pub fn new(rules: Digest, evaluator: impl Into<String>) -> Self {
        let evaluator = evaluator.into();
        assert!(
            !evaluator.trim().is_empty(),
            "a policy bundle must identify its evaluator semantics"
        );
        Self {
            rules,
            schema: None,
            entities: None,
            configuration: None,
            evaluator,
        }
    }

    #[must_use]
    pub const fn with_schema(mut self, schema: Digest) -> Self {
        self.schema = Some(schema);
        self
    }

    #[must_use]
    pub const fn with_entities(mut self, entities: Digest) -> Self {
        self.entities = Some(entities);
        self
    }

    /// Bind templates, extensions, adapter options, and other evaluator input
    /// not already represented by rules/schema/entities.
    #[must_use]
    pub const fn with_configuration(mut self, configuration: Digest) -> Self {
        self.configuration = Some(configuration);
        self
    }

    #[must_use]
    pub const fn rules(&self) -> Digest {
        self.rules
    }

    #[must_use]
    pub const fn schema(&self) -> Option<Digest> {
        self.schema
    }

    #[must_use]
    pub const fn entities(&self) -> Option<Digest> {
        self.entities
    }

    #[must_use]
    pub const fn configuration(&self) -> Option<Digest> {
        self.configuration
    }

    #[must_use]
    pub fn evaluator(&self) -> &str {
        &self.evaluator
    }

    /// Compact identity over the complete structured bundle.
    #[must_use]
    pub fn digest(&self) -> Digest {
        let value = serde_json::to_value(self)
            .expect("PolicyBundleIdentity contains only infallibly serializable fields");
        let mut framed = b"agentplane.policy.bundle.v1\0".to_vec();
        framed.extend_from_slice(&canon::value_bytes(&value));
        Digest::of(&framed)
    }
}

/// What is being asked.
///
/// Borrowed rather than owned: this is built at every effect dispatch, and a
/// gate that allocates four strings per call is a gate people turn off.
#[derive(Debug, Clone, Copy)]
pub struct PolicyRequest<'a> {
    /// Who is acting — the agent, or an operator on whose behalf it runs.
    pub principal: &'a str,
    /// What they are doing, e.g. `"effect:perform"`, `"run:admit"`.
    pub action: &'a str,
    /// What they are doing it to, e.g. an effect kind or a capability.
    pub resource: &'a str,
    /// Everything else the rules may read: labels, amounts, the case kind.
    ///
    /// Opaque to the engine's caller. Whether a rule keys on `amount_eur > 5000`
    /// is the deployment's business, not this crate's.
    pub context: &'a Value,
}

/// The answer.
///
/// Not a `Result`, on purpose: there is no error case. See the module docs on
/// why a policy layer that can fail open is not a policy layer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyDecision {
    Permit,
    /// Refused, with a reason an operator can act on.
    ///
    /// The reason is required. "Denied by policy" sends someone to read a policy
    /// set looking for which of forty rules fired, which is how an authorization
    /// layer becomes something people route around.
    Deny {
        reason: String,
    },
    /// The rules could not be evaluated, so nothing may be concluded from
    /// them — refused, but not by a rule.
    ///
    /// A separate variant because *the rules say no* and *the rules are
    /// broken* call for opposite responses, and while both were spelled
    /// `Deny` the difference existed only inside a reason string. Nothing
    /// could branch on it without matching on message text, which is how a
    /// reworded sentence changes behaviour — so a deployment whose policy set
    /// had begun erroring on every request read it as ordinary refusals and
    /// spent an afternoon looking for the rule that fired.
    ///
    /// It is still a refusal at the gate: an unevaluable rule may be exactly
    /// the `forbid` that would have stopped this call. What changes is who is
    /// told and what they are told to fix — the policy set, not the request.
    Malformed {
        reason: String,
    },
}

impl PolicyDecision {
    /// Deny with a reason.
    pub fn deny(reason: impl Into<String>) -> Self {
        Self::Deny {
            reason: reason.into(),
        }
    }

    /// Refuse because the rules themselves could not be evaluated.
    pub fn malformed(reason: impl Into<String>) -> Self {
        Self::Malformed {
            reason: reason.into(),
        }
    }

    #[must_use]
    pub const fn is_permit(&self) -> bool {
        matches!(self, Self::Permit)
    }

    /// Whether this refusal is a defect in the rules rather than a rule
    /// firing. The distinction a boot-time check reads, and the one an
    /// operator's alerting should treat as an incident.
    #[must_use]
    pub const fn is_malformed(&self) -> bool {
        matches!(self, Self::Malformed { .. })
    }

    /// The reason, whichever kind of refusal this is.
    #[must_use]
    pub fn reason(&self) -> Option<&str> {
        match self {
            Self::Permit => None,
            Self::Deny { reason } | Self::Malformed { reason } => Some(reason),
        }
    }
}

/// Decides whether an action is allowed.
///
/// Implementations must be **total** — every request gets an answer — and
/// **pure**: no I/O, no clock, no randomness. Two calls with the same request
/// against the same policy set must return the same decision, or a run stops
/// being replayable for reasons nobody can see.
pub trait PolicyEngine: Send + Sync + Debug {
    fn authorize(&self, request: &PolicyRequest<'_>) -> PolicyDecision;

    /// Can this engine evaluate the requests this plane is about to make?
    ///
    /// Asked once, at build, with a canonical request of each shape the plane
    /// will issue. Each returned string is a problem an operator must fix
    /// before the plane runs; an empty vector means nothing to report.
    ///
    /// The default reports nothing, and that is honest rather than lax: the
    /// failure this exists to catch belongs to *total* evaluators. Cedar
    /// answers every request, so a rule reading an attribute a request does
    /// not carry does not fail to match — it errors, and an unevaluable rule
    /// may be the `forbid` that would have stopped the call, so the gate
    /// refuses. One unguarded rule therefore denies every effect of every run
    /// from a policy set that compiled cleanly. An engine written as Rust code
    /// has no such trap and has nothing to say here.
    ///
    /// Implementations must keep the contract the trait already demands —
    /// total, pure, no I/O — because this runs during `build`, where a
    /// blocking call would make assembling a plane depend on a network.
    ///
    /// What a caller may **not** conclude from an empty answer: that the rules
    /// are correct. A set permitting everything reports nothing here.
    fn preflight(&self, requests: &[PolicyRequest<'_>]) -> Vec<String> {
        let _ = requests;
        Vec::new()
    }

    /// Identifies every static input that can affect evaluation.
    ///
    /// Journaled at admission, so the rules, schema, static entities,
    /// configuration/extensions, and evaluator semantics that governed a run
    /// remain answerable after any of them change.
    fn bundle(&self) -> PolicyBundleIdentity;

    /// Compact digest of [`bundle`](Self::bundle).
    fn digest(&self) -> Digest {
        self.bundle().digest()
    }
}

/// Refuses everything, naming itself.
///
/// Exists for tests and as the thing to reach for when wiring a policy layer
/// before its rules are written: starting closed and opening deliberately is the
/// order that fails safe. There is deliberately **no** `AllowAll` counterpart —
/// a permissive engine and no engine at all are the same behaviour, and having
/// two ways to spell it is how a plane ends up with a policy layer that
/// everybody believes is switched on.
#[derive(Debug, Clone, Copy, Default)]
pub struct DenyAll;

impl PolicyEngine for DenyAll {
    fn authorize(&self, request: &PolicyRequest<'_>) -> PolicyDecision {
        PolicyDecision::deny(format!(
            "no policy set is configured; '{}' on '{}' is refused by default",
            request.action, request.resource
        ))
    }

    fn bundle(&self) -> PolicyBundleIdentity {
        PolicyBundleIdentity::new(
            Digest::of(b"agentplane.policy.deny-all"),
            "agentplane/deny-all-v1",
        )
    }
}

/// The action string for performing an effect.
pub const ACTION_PERFORM: &str = "effect:perform";
/// The action string for starting a run.
pub const ACTION_ADMIT: &str = "run:admit";
/// The action string for removing an information-flow label.
///
/// A release changes what data may influence and where it may flow. Treating it
/// as a logging helper would let any skill erase the lattice immediately before
/// a privileged sink, so deployments with policy must authorize it explicitly.
pub const ACTION_RELEASE: &str = "data:release";
/// The action string for an effect refused by the agent's own manifest.
///
/// Distinct from [`ACTION_PERFORM`] so an auditor can tell the two refusals
/// apart, because they mean different things and call for different responses.
/// A policy denial is the deployment's rules saying no to something the agent
/// was built to do; a manifest refusal is the agent doing something its own
/// reviewed declaration never mentioned — which is a defect in the code, not a
/// tightening of the rules.
pub const ACTION_DECLARED: &str = "effect:declared";
/// The action string for a value refused at a sink's information-flow gates.
///
/// Distinct from both of the above because the response is different again: a
/// sink refusal is about the *data* — its sensitivity crossed a ceiling, or an
/// untrusted value reached an authority-bearing position — and the remedy is a
/// journaled release or a different source, never a rule change or a code fix.
/// Recorded under the refused effect's key like the other refusals, so a
/// replay consumes the recorded verdict instead of reporting that this build
/// performs more effects than the record.
pub const ACTION_EGRESS: &str = "effect:egress";