camel-component-wasm 0.19.0

WASM plugin component for rust-camel
Documentation
use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use camel_api::security_policy::store_principal_properties;
use camel_api::{Exchange, Message};
use camel_auth::PermissionEvaluatorRegistry;
use camel_auth::permission::{PermissionDecision, PermissionEvaluator, PermissionRequest};
use camel_auth::types::AuthError;
use camel_config::config::PermissionProviderConfig;
use camel_core::Registry;

use crate::config::WasmConfig;
use crate::error::WasmError;
use crate::security_policy_bindings::AuthorizationPolicy as AuthorizationPolicyGuest;
use crate::serde_bridge;
use crate::wasm_plugin_context::WasmPluginContext;

mod props {
    pub const RESOURCE: &str = "camel.permission.resource";
    pub const ACTION: &str = "camel.permission.action";
    pub const REQUESTED_SCOPES: &str = "camel.permission.requested_scopes";
    pub const CONTEXT: &str = "camel.permission.context";
}

pub struct WasmAuthorizationPolicyEvaluator {
    ctx: WasmPluginContext,
}

impl WasmAuthorizationPolicyEvaluator {
    pub async fn new(
        module_path: impl AsRef<std::path::Path>,
        wasm_config: WasmConfig,
        registry: Arc<std::sync::Mutex<Registry>>,
        init_config: HashMap<String, String>,
    ) -> Result<Self, WasmError> {
        let ctx = WasmPluginContext::new(module_path, wasm_config, registry, init_config).await?;
        Ok(Self { ctx })
    }

    fn build_synthetic_exchange(request: &PermissionRequest) -> Exchange {
        let mut exchange = Exchange::new(Message::default());

        store_principal_properties(&mut exchange, &request.principal);

        exchange.set_property(props::RESOURCE, request.resource.clone());
        exchange.set_property(props::ACTION, request.action.clone());
        exchange.set_property(
            props::REQUESTED_SCOPES,
            serde_json::to_string(&request.requested_scopes).unwrap_or_else(|_| "[]".to_string()),
        );
        exchange.set_property(
            props::CONTEXT,
            serde_json::to_string(&request.context).unwrap_or_else(|_| "null".to_string()),
        );

        let keys_to_remove: Vec<String> = exchange
            .properties
            .keys()
            .filter(|k| !k.starts_with("camel.auth.") && !k.starts_with("camel.permission."))
            .cloned()
            .collect();
        for key in keys_to_remove {
            exchange.properties.remove(&key);
        }

        exchange
    }
}

pub async fn build_permission_registry(
    permissions: &HashMap<String, PermissionProviderConfig>,
    registry: Arc<std::sync::Mutex<Registry>>,
) -> Result<PermissionEvaluatorRegistry, AuthError> {
    let evaluator_registry = PermissionEvaluatorRegistry::new();

    for (name, provider_config) in permissions {
        match provider_config.provider.as_str() {
            "wasm" => {
                let path = provider_config.path.as_ref().ok_or_else(|| {
                    AuthError::ConfigError(format!(
                        "permission provider '{}' requires 'path' for wasm provider",
                        name
                    ))
                })?;

                let evaluator = WasmAuthorizationPolicyEvaluator::new(
                    path,
                    WasmConfig::from_limits(&provider_config.limits),
                    registry.clone(),
                    provider_config.config.clone().unwrap_or_default(),
                )
                .await
                .map_err(|e| {
                    AuthError::ConfigError(format!(
                        "failed to create WASM permission evaluator '{}': {}",
                        name, e
                    ))
                })?;

                evaluator_registry.register(name.clone(), Arc::new(evaluator));
            }
            other => {
                return Err(AuthError::ConfigError(format!(
                    "unknown permission provider '{}'",
                    other
                )));
            }
        }
    }

    Ok(evaluator_registry)
}

impl std::fmt::Debug for WasmAuthorizationPolicyEvaluator {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WasmAuthorizationPolicyEvaluator")
            .field("module_path", &self.ctx.module_path)
            .finish_non_exhaustive()
    }
}

#[async_trait]
impl PermissionEvaluator for WasmAuthorizationPolicyEvaluator {
    async fn evaluate(&self, request: PermissionRequest) -> Result<PermissionDecision, AuthError> {
        let synthetic = Self::build_synthetic_exchange(&request);

        let mut store = self.ctx.create_store(synthetic.properties.clone());

        let plugin = AuthorizationPolicyGuest::instantiate_async(
            &mut store,
            &self.ctx.component,
            &self.ctx.linker,
        )
        .await
        .map_err(|e| {
            AuthError::ProviderUnavailable(format!(
                "wasm authorization policy instantiation failed: {e}"
            ))
        })?;

        let wasm_exchange = serde_bridge::exchange_to_wasm(&synthetic).map_err(|e| {
            AuthError::ProviderUnavailable(format!("wasm exchange serialization failed: {e}"))
        })?;
        let ap_exchange: crate::security_policy_bindings::camel::plugin::types::WasmExchange =
            wasm_exchange.into();

        let result = plugin
            .call_evaluate(&mut store, &ap_exchange)
            .await
            .map_err(|e| {
                let wasm_err = self.ctx.classify_error(e);
                AuthError::ProviderUnavailable(format!(
                    "wasm authorization policy evaluate failed: {wasm_err:?}"
                ))
            })?;

        match result {
            Ok(None) => Ok(PermissionDecision::Granted),
            Ok(Some(reason)) => Ok(PermissionDecision::Denied { reason }),
            Err(wasm_err) => Err(AuthError::ProviderUnavailable(format!(
                "wasm authorization policy returned error: {wasm_err:?}"
            ))),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use camel_api::security_policy::Principal;
    use camel_config::config::PermissionCacheConfig;
    use camel_config::wasm_limits::WasmLimitsConfig;
    use serde_json::json;

    // Note: WasmAuthorizationPolicyEvaluator::new requires a real WASM file.
    // The WasmConfig propagation chain (limits → from_limits → WasmConfig →
    // create_host_state) is tested at the runtime layer (memory_growth_*,
    // timeout_kills_infinite_loop_guest). All plugin types share that runtime
    // layer, so coverage is implicit.

    fn test_principal() -> Principal {
        Principal {
            subject: "billing-worker".into(),
            issuer: "https://kc.example.com".into(),
            audience: vec!["camel-api".into()],
            scopes: vec!["read".into()],
            roles: vec!["billing".into()],
            claims: json!({}),
        }
    }

    #[test]
    fn synthetic_exchange_contains_only_allowed_properties() {
        let request = PermissionRequest {
            principal: test_principal(),
            resource: "invoice:123".into(),
            action: "read".into(),
            requested_scopes: vec!["read".into()],
            context: json!({"tenant": "acme"}),
        };
        let exchange = WasmAuthorizationPolicyEvaluator::build_synthetic_exchange(&request);

        assert!(exchange.input.body.is_empty());
        assert!(
            exchange
                .properties
                .keys()
                .all(|k| k.starts_with("camel.auth.") || k.starts_with("camel.permission."))
        );
        assert_eq!(
            exchange.property(props::RESOURCE).and_then(|v| v.as_str()),
            Some("invoice:123")
        );
        assert_eq!(
            exchange.property(props::ACTION).and_then(|v| v.as_str()),
            Some("read")
        );
    }

    #[test]
    fn synthetic_exchange_no_secret_leakage() {
        let mut principal = test_principal();
        principal.claims = json!({
            "sub": "alice",
            "access_token": "super-secret-token",
            "client_secret": "should-not-appear"
        });
        let request = PermissionRequest {
            principal,
            resource: "res".into(),
            action: "read".into(),
            requested_scopes: vec![],
            context: json!({}),
        };
        let exchange = WasmAuthorizationPolicyEvaluator::build_synthetic_exchange(&request);

        assert!(
            exchange
                .properties
                .keys()
                .all(|k| k.starts_with("camel.auth.") || k.starts_with("camel.permission."))
        );

        let secret_present = exchange
            .properties
            .keys()
            .any(|k| k.contains("token") || k.contains("secret") || k.contains("rpt"));
        assert!(
            !secret_present,
            "no secret/token property keys should appear"
        );
    }

    #[tokio::test]
    async fn wasm_evaluator_new_missing_file_returns_error() {
        let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
        let result = WasmAuthorizationPolicyEvaluator::new(
            "/nonexistent/policy.wasm",
            WasmConfig::from_limits(&WasmLimitsConfig::default()),
            registry,
            HashMap::new(),
        )
        .await;
        assert!(result.is_err(), "expected error for nonexistent module");
    }

    #[test]
    fn debug_hides_internal_state() {
        let request = PermissionRequest {
            principal: test_principal(),
            resource: "invoice:123".into(),
            action: "read".into(),
            requested_scopes: vec!["read".into()],
            context: json!({"tenant": "acme"}),
        };
        let exchange = WasmAuthorizationPolicyEvaluator::build_synthetic_exchange(&request);
        let out = format!("{exchange:?}");
        assert!(out.contains("properties"));
    }

    #[test]
    fn build_permission_registry_empty() {
        let perms = HashMap::new();
        let rt = tokio::runtime::Runtime::new().expect("runtime");
        let registry = rt
            .block_on(async {
                build_permission_registry(&perms, Arc::new(std::sync::Mutex::new(Registry::new())))
                    .await
            })
            .expect("empty permissions should succeed");
        assert!(registry.get("missing").is_none());
    }

    #[tokio::test]
    async fn build_permission_registry_wasm_missing_path_returns_error() {
        let mut perms = HashMap::new();
        perms.insert(
            "test".into(),
            PermissionProviderConfig {
                provider: "wasm".into(),
                path: None,
                config: None,
                cache: PermissionCacheConfig::default(),
                limits: Default::default(),
            },
        );
        let result =
            build_permission_registry(&perms, Arc::new(std::sync::Mutex::new(Registry::new())))
                .await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn build_permission_registry_unknown_provider_returns_error() {
        let mut perms = HashMap::new();
        perms.insert(
            "test".into(),
            PermissionProviderConfig {
                provider: "unknown".into(),
                path: None,
                config: None,
                cache: PermissionCacheConfig::default(),
                limits: Default::default(),
            },
        );
        let result =
            build_permission_registry(&perms, Arc::new(std::sync::Mutex::new(Registry::new())))
                .await;
        assert!(result.is_err());
    }
}