act-policy 0.13.2

Capability policy decision core (PDP) for the ACT toolchain
Documentation
//! The per-run ceiling set: one compiled ceiling per capability id.
//!
//! Both hosts resolve the same set the same way — the native runtime and the
//! browser PDP. They are kept here rather than in either host because they
//! have already drifted once on which classes get resolved, and that question
//! decides whether a declared semantic class is enforceable or merely
//! documented.

use std::collections::BTreeMap;
use std::sync::Arc;

use act_types::constants::{CAP_FILESYSTEM, CAP_HTTP, CAP_SOCKETS};

use crate::grant::{GrantPolicy, PolicyError};
use crate::provider::{CompiledCeiling, ProviderRegistry};

/// Classes a host resolves whether or not the component declared them.
///
/// An undeclared one resolves against `None` and hard-denies, but it still
/// gets a row — the audit header reports a mode for every class an operator
/// might have expected to see, including the ones this artifact does not use.
///
/// This is an **audit-header-completeness** concept: which classes always get
/// a row, so the header is never missing one an operator expected. It is not
/// the same question as [`PHYSICALLY_INTERCEPTED`] below, even though the two
/// happen to name the same four classes today — see that constant's doc.
pub const ALWAYS_RESOLVED: &[&str] = &[
    CAP_FILESYSTEM,
    CAP_HTTP,
    CAP_SOCKETS,
    crate::providers::credentials::CAP_CREDENTIALS,
];

/// Classes the host enforces by interception at the wasmtime boundary — a
/// filesystem open, an outgoing HTTP request, a socket connect, a credential
/// read — rather than through `act:consent`. This is the **security**
/// predicate `act:consent`'s gate needs: a class in this set already has a
/// real boundary a component cannot talk its way around, so consent must
/// never become a second door that can answer "allow" for it. `store.rs`'s
/// `semantic_ceilings` map is built by excluding exactly this set, and
/// `ConsentGate::decide` denies on a map miss — see both modules' docs.
///
/// Equal to [`ALWAYS_RESOLVED`] today, by construction: the four classes this
/// host physically intercepts are the same four it always resolves a ceiling
/// for. Nothing enforces that the two stay equal, though — they answer
/// different questions, and a fifth class ever registered in
/// `ProviderRegistry::with_builtins` with a wasmtime hook but no update *here*
/// would silently leave consent reachable for it, with no test catching the
/// gap until someone thought to write one. Callers that care about the
/// security property (`store.rs`, `consent::gate`'s tests) depend on this
/// constant, not on `ALWAYS_RESOLVED`, so that the day the two diverge, only
/// one of them needs to change.
pub const PHYSICALLY_INTERCEPTED: &[&str] = ALWAYS_RESOLVED;

/// Resolve one ceiling per capability id: every [`ALWAYS_RESOLVED`] class,
/// plus every class the component declared.
///
/// `declared` maps a capability id to its declared constraint list; a bare
/// declaration is an entry with an empty list, which is why the map's *keys*
/// carry the declaredness rather than the values. A semantic class absent from
/// it gets no ceiling at all, and callers MUST deny an operation whose id has
/// no ceiling.
pub async fn resolve_ceilings(
    registry: &ProviderRegistry,
    declared: &BTreeMap<String, Vec<serde_json::Value>>,
    policy: &GrantPolicy,
) -> Result<BTreeMap<String, Arc<dyn CompiledCeiling>>, PolicyError> {
    let mut ids: Vec<&str> = ALWAYS_RESOLVED.to_vec();
    for id in declared.keys() {
        if !ids.contains(&id.as_str()) {
            ids.push(id);
        }
    }

    let mut out: BTreeMap<String, Arc<dyn CompiledCeiling>> = BTreeMap::new();
    for id in ids {
        let grant = policy.resolve(id);
        let ceiling = registry
            .lookup(id)
            .resolve(id, declared.get(id).map(Vec::as_slice), &grant)
            .await
            .map_err(|source| PolicyError::Capability {
                cap: id.to_string(),
                source: Box::new(source),
            })?;
        out.insert(id.to_string(), Arc::from(ceiling));
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Decision;
    use crate::provider::ResourceOp;

    #[tokio::test]
    async fn resolves_every_always_class_plus_every_declared_one() {
        let registry = ProviderRegistry::with_builtins();
        let declared = BTreeMap::from([
            (
                "db:drop".to_string(),
                vec![serde_json::json!({"key": "test_*"})],
            ),
            ("wasi:http".to_string(), Vec::new()),
        ]);
        let ceilings = resolve_ceilings(&registry, &declared, &GrantPolicy::default())
            .await
            .unwrap();

        // Every always-resolved class has a row even when undeclared, so the
        // audit header can report a mode for it.
        for id in ALWAYS_RESOLVED.iter().copied() {
            assert!(ceilings.contains_key(id), "{id} must always resolve");
        }
        // Plus the declared semantic class.
        assert!(ceilings.contains_key("db:drop"));
        assert!(ceilings["db:drop"].declared());
        // An always-resolved class the manifest never mentioned is undeclared.
        assert!(!ceilings["wasi:filesystem"].declared());
        // A declared class is resolved whether or not its provider reports it
        // as constrained. (The physical providers derive declaredness from
        // their constraint list, so a bare `wasi:http` table still reads as
        // undeclared to them — pre-existing behaviour this plan preserves.)
        assert!(ceilings.contains_key("wasi:http"));

        // The loop must hand each id *its own* declared constraints, not a
        // shared empty list. `db:drop` was declared with `[{"key": "test_*"}]`
        // and the default policy's mode is Ask, so a key matching that glob
        // asks while one outside it is denied outright — if `resolve_ceilings`
        // dropped the constraint list (e.g. passed `Some(&[])` for every id),
        // both keys would come back `Ask` because a bare declaration leaves
        // the class itself as the ceiling.
        let op = |key: &str| ResourceOp {
            cap_id: "db:drop".into(),
            key: key.into(),
            action: "request".into(),
            attrs: serde_json::Value::Null,
        };
        assert_eq!(
            ceilings["db:drop"].classify(&op("test_events")),
            Decision::Ask
        );
        assert_eq!(
            ceilings["db:drop"].classify(&op("production")),
            Decision::Deny
        );
    }

    #[tokio::test]
    async fn an_undeclared_semantic_class_gets_no_row() {
        let registry = ProviderRegistry::with_builtins();
        let ceilings = resolve_ceilings(&registry, &BTreeMap::new(), &GrantPolicy::default())
            .await
            .unwrap();
        assert!(
            !ceilings.contains_key("db:drop"),
            "a class the manifest never declared is not resolved; callers deny \
             an unresolved id outright"
        );
    }

    #[tokio::test]
    async fn a_failing_provider_names_the_capability_that_failed() {
        // A declared constraint with an invalid glob makes the provider fail.
        // The operator sees this as an instantiation failure, so it must say
        // which class was at fault — there may be dozens.
        let registry = ProviderRegistry::with_builtins();
        let declared = BTreeMap::from([(
            "db:drop".to_string(),
            vec![serde_json::json!({"key": "test_["})],
        )]);
        // `expect_err` needs `Ok`'s type to impl `Debug`, which the trait
        // object map does not — match by hand instead.
        let err = match resolve_ceilings(&registry, &declared, &GrantPolicy::default()).await {
            Ok(_) => panic!("an invalid glob must fail resolution"),
            Err(e) => e,
        };
        let rendered = err.to_string();
        assert!(
            rendered.contains("db:drop"),
            "the error must name the capability that failed: {rendered}"
        );
    }
}