act-policy 0.13.2

Capability policy decision core (PDP) for the ACT toolchain
Documentation
//! The pluggable capability-decision framework: providers (factories) produce
//! compiled ceilings; a registry maps capability ids to providers.

use std::sync::Arc;

use crate::Decision;
use crate::grant::{CapabilityGrant, PolicyError};

/// One operation to classify. Erased so any provider can interpret it, from an
/// intercepted WASI op or a reported `consent.request`.
#[derive(Debug, Clone)]
pub struct ResourceOp {
    pub cap_id: String,
    /// Primary subject: path / "host:port" / socket addr / semantic key.
    pub key: String,
    /// Attempted sub-operation: "read"/"write" (fs), HTTP method, etc. "" if N/A.
    pub action: String,
    /// Extra structured attributes (scheme, protocol, semantic args). Null if none.
    pub attrs: serde_json::Value,
}

/// A capability class's decision logic. Object-safe; held as `dyn` in the
/// registry. `resolve` is async so a provider can do startup work that needs
/// I/O (e.g. the sockets provider resolves hostname rules via DNS, pinning the
/// IPs once). `classify` stays sync — it runs on the hot path.
#[async_trait::async_trait]
pub trait CapabilityProvider: Send + Sync {
    /// `declared` is `None` when the capability class is absent from the
    /// component's `act:component` manifest, and `Some` when it is present —
    /// `Some(&[])` for a bare declaration that carries no constraints. Providers
    /// MUST NOT infer the *ceiling* from an empty slice: the manifest is the
    /// ceiling, so the two cases have opposite verdicts for semantic classes.
    /// (`fs`, `http` and `sockets` deliberately still report a constraint-free
    /// declaration's `declared()` as `false` in their audit output, because
    /// for those physical classes a bare declaration is equivalent to having
    /// no ceiling at all — that reporting choice is fine; only the verdict
    /// is a MUST.)
    async fn resolve(
        &self,
        cap_id: &str,
        declared: Option<&[serde_json::Value]>,
        grant: &CapabilityGrant,
    ) -> Result<Box<dyn CompiledCeiling>, PolicyError>;
}

/// A decision plus, when the provider can attribute one, the ceiling rule
/// that produced it. Used by the host's audit rollup to group permitted
/// operations under the grant that allowed them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Explained {
    pub decision: Decision,
    pub rule: Option<String>,
}

/// The compiled ceiling for one class in one run. Pure, sync, wasm-clean.
pub trait CompiledCeiling: Send + Sync {
    fn classify(&self, op: &ResourceOp) -> Decision;
    /// `classify`, plus the rule that matched. Defaulted so providers that
    /// cannot attribute a rule (or have not been updated) keep working; the
    /// audit rollup degrades to grouping by capability and action.
    fn classify_explained(&self, op: &ResourceOp) -> Explained {
        Explained {
            decision: self.classify(op),
            rule: None,
        }
    }
    fn declared(&self) -> bool;
    /// Effective policy mode for this ceiling. Used by hosts that need to
    /// know the mode for non-classify decisions (e.g. p3 preopens kill-switch).
    fn effective_mode(&self) -> crate::grant::PolicyMode {
        crate::grant::PolicyMode::Deny
    }
    /// Test/diagnostic tag; production impls keep the default.
    fn tag(&self) -> &'static str {
        ""
    }
}

/// Maps capability ids (incl. `*`-suffix globs) to providers, with a generic fallback.
pub struct ProviderRegistry {
    entries: Vec<(String, Arc<dyn CapabilityProvider>)>,
    generic: Arc<dyn CapabilityProvider>,
}

impl ProviderRegistry {
    pub fn new(generic: Arc<dyn CapabilityProvider>) -> Self {
        Self {
            entries: Vec::new(),
            generic,
        }
    }

    pub fn register(&mut self, pattern: &str, provider: Arc<dyn CapabilityProvider>) {
        self.entries.push((pattern.to_string(), provider));
    }

    /// Priority: exact id > longest matching `*`-prefix > generic fallback.
    pub fn lookup(&self, cap_id: &str) -> &Arc<dyn CapabilityProvider> {
        if let Some((_, p)) = self.entries.iter().find(|(k, _)| k == cap_id) {
            return p;
        }
        let mut best: Option<(&str, &Arc<dyn CapabilityProvider>)> = None;
        for (k, p) in &self.entries {
            if let Some(prefix) = k.strip_suffix('*')
                && cap_id.starts_with(prefix)
                && best.is_none_or(|(bk, _)| prefix.len() > bk.len() - 1)
            {
                best = Some((k, p));
            }
        }
        best.map_or(&self.generic, |(_, p)| p)
    }

    /// Build a registry pre-loaded with the built-in fs/http/sockets providers
    /// and the generic fallback.
    pub fn with_builtins() -> Self {
        let mut r = Self::new(Arc::new(crate::providers::generic::GenericProvider));
        r.register(
            "wasi:filesystem",
            Arc::new(crate::providers::fs::FsProvider),
        );
        r.register("wasi:http", Arc::new(crate::providers::http::HttpProvider));
        r.register(
            "wasi:sockets",
            Arc::new(crate::providers::sockets::SocketsProvider),
        );
        r.register(
            crate::providers::credentials::CAP_CREDENTIALS,
            Arc::new(crate::providers::credentials::CredentialsProvider),
        );
        r
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;

    struct Tagged(&'static str);
    #[async_trait::async_trait]
    impl CapabilityProvider for Tagged {
        async fn resolve(
            &self,
            _id: &str,
            _declared: Option<&[serde_json::Value]>,
            _grant: &crate::grant::CapabilityGrant,
        ) -> Result<Box<dyn CompiledCeiling>, crate::grant::PolicyError> {
            Ok(Box::new(TagCeiling(self.0)))
        }
    }
    struct TagCeiling(&'static str);
    impl CompiledCeiling for TagCeiling {
        fn classify(&self, _op: &ResourceOp) -> crate::Decision {
            crate::Decision::Deny
        }
        fn declared(&self) -> bool {
            true
        }
        // `&'static str`, not `&str`: the trait says `'static`, and
        // `clippy::unnecessary_literal_bound` does not read the trait.
        fn tag(&self) -> &'static str {
            self.0
        }
    }

    #[tokio::test]
    async fn lookup_prefers_exact_then_longest_prefix_then_generic() {
        let mut r = ProviderRegistry::new(Arc::new(Tagged("generic")));
        r.register("wasi:http", Arc::new(Tagged("http")));
        r.register("db:*", Arc::new(Tagged("db-wild")));
        r.register("db:drop-*", Arc::new(Tagged("db-drop")));
        async fn tag(r: &ProviderRegistry, id: &str) -> String {
            r.lookup(id)
                .resolve(id, None, &Default::default())
                .await
                .unwrap()
                .tag()
                .to_string()
        }
        assert_eq!(tag(&r, "wasi:http").await, "http"); // exact
        assert_eq!(tag(&r, "db:truncate").await, "db-wild"); // *-prefix
        assert_eq!(tag(&r, "db:drop-database").await, "db-drop"); // longest *-prefix
        assert_eq!(tag(&r, "email:send").await, "generic"); // fallback
    }

    #[tokio::test]
    async fn credentials_provider_is_registered_not_generic_fallback() {
        let r = ProviderRegistry::with_builtins();
        let provider = r.lookup(crate::providers::credentials::CAP_CREDENTIALS);
        // Both `CredentialsCeiling` and `GenericCeiling` now deny undeclared
        // access in Ask mode, so the *decision* alone can't tell them apart —
        // deleting the `with_builtins` registration would leave this green
        // either way. Discriminate on the attributed rule instead:
        // `CredentialsCeiling::classify_explained` names the specific class
        // in its rule for exactly this case (`providers/credentials.rs:60-66`,
        // "act:credentials not declared in act:component"). `GenericCeiling`'s
        // undeclared-deny path also attributes a rule, but a capability-agnostic
        // one — `_cap_id` never reaches the ceiling, so it cannot reproduce the
        // credentials-specific text (`providers/generic.rs`'s first `matched`
        // arm, "not declared in act:component"). The two strings are checked
        // for equality below, so only the credentials provider's exact text
        // proves the lookup resolved to it, not the generic fallback.
        let ask_grant = crate::grant::CapabilityGrant {
            mode: crate::grant::PolicyMode::Ask,
            allow: vec![],
            deny: vec![],
        };
        let ceiling = provider
            .resolve(
                crate::providers::credentials::CAP_CREDENTIALS,
                None,
                &ask_grant,
            )
            .await
            .unwrap();
        let cred_op = ResourceOp {
            cap_id: crate::providers::credentials::CAP_CREDENTIALS.into(),
            key: "test-cred".into(),
            action: "get".into(),
            attrs: serde_json::Value::Null,
        };
        let explained = ceiling.classify_explained(&cred_op);
        assert_eq!(explained.decision, crate::Decision::Deny);
        assert_eq!(
            explained.rule,
            Some("act:credentials not declared in act:component".to_string()),
            "this exact, capability-named rule is only attributed by \
             CredentialsCeiling; the generic fallback's undeclared-deny path \
             attributes a different, capability-agnostic rule"
        );
    }
}