diff --git a/Cargo.lock b/Cargo.lock index 3e129c17..ac8f39b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", + "const-random", "getrandom", "once_cell", "version_check", @@ -811,6 +812,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -989,6 +1010,12 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -3718,6 +3745,7 @@ dependencies = [ "pprof", "rayon", "regex", + "rhai", "rstest", "rustls-pki-types", "semver", @@ -4124,9 +4152,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ "bitflags 2.6.0", ] @@ -4259,6 +4287,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "rhai" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61797318be89b1a268a018a92a7657096d83f3ecb31418b9e9c16dcbb043b702" +dependencies = [ + "ahash 0.8.11", + "bitflags 2.6.0", + "instant", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", +] + +[[package]] +name = "rhai_codegen" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.71", +] + [[package]] name = "ring" version = "0.17.8" @@ -4878,6 +4934,17 @@ dependencies = [ "serde", ] +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "snafu" version = "0.8.4" @@ -5073,6 +5140,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "thin-vec" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" + [[package]] name = "thiserror" version = "1.0.62" @@ -5165,6 +5238,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinyvec" version = "1.8.0" diff --git a/Cargo.toml b/Cargo.toml index 53304bee..5835a14e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ tikv-jemallocator = { version = "0.5.4", features = [ ] } jemalloc_pprof = "0.4.1" tikv-jemalloc-ctl = "0.5.4" +rhai = { version = "1.19.0", features = ["sync"] } [dev-dependencies] mockall = "0.12" diff --git a/README.md b/README.md index f3474e14..c16921e4 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,12 @@ through its web interface. Policies are exposed under `/validate/. For example, given the configuration file from above, the following API endpoint would be created: - * `/validate/psp-apparmor`: this exposes the `psp-apparmor:v0.1.3` - policy. The Wasm module is downloaded from the OCI registry of GitHub. - * `/validate/psp-capabilities`: this exposes the `psp-capabilities:v0.1.3` - policy. The Wasm module is downloaded from the OCI registry of GitHub. - * `/validate/namespace_simple`: this exposes the `namespace-validate-policy` - policy. The Wasm module is loaded from a local file located under `/tmp/namespace-validate-policy.wasm`. +- `/validate/psp-apparmor`: this exposes the `psp-apparmor:v0.1.3` + policy. The Wasm module is downloaded from the OCI registry of GitHub. +- `/validate/psp-capabilities`: this exposes the `psp-capabilities:v0.1.3` + policy. The Wasm module is downloaded from the OCI registry of GitHub. +- `/validate/namespace_simple`: this exposes the `namespace-validate-policy` + policy. The Wasm module is loaded from a local file located under `/tmp/namespace-validate-policy.wasm`. It's common for policies to allow users to tune their behaviour via ad-hoc settings. These customization parameters are provided via the `settings` dictionary. @@ -72,12 +72,104 @@ The Wasm file providing the Kubewarden Policy can be either loaded from the local filesystem or it can be fetched from a remote location. The behaviour depends on the URL format provided by the user: -* `file:///some/local/program.wasm`: load the policy from the local filesystem -* `https://some-host.com/some/remote/program.wasm`: download the policy from the +- `file:///some/local/program.wasm`: load the policy from the local filesystem +- `https://some-host.com/some/remote/program.wasm`: download the policy from the remote http(s) server -* `registry://localhost:5000/project/artifact:some-version` download the policy +- `registry://localhost:5000/project/artifact:some-version` download the policy from a OCI registry. The policy must have been pushed as an OCI artifact +### Policy Group + +Multiple policies can be grouped together and are evaluated using a user provided boolean expression. + +The motivation for this feature is to enable users to create complex policies by combining simpler ones. +This allows users to avoid the need to create custom policies from scratch and instead leverage existing policies. +This reduces the need to duplicate policy logic across different policies, increases reusability, removes +the cognitive load of managing complex policy logic, and enables the creation of custom policies using +a DSL-like configuration. + +Policy groups are added to the same policy configuration file as individual policies. + +This is an example of the policies file with a policy group: + +```yml +pod-image-signatures: # policy group + policies: + - name: sigstore_pgp + url: ghcr.io/kubewarden/policies/verify-image-signatures:v0.2.8 + settings: + signatures: + - image: "*" + pubKeys: + - "-----BEGIN PUBLIC KEY-----xxxxx-----END PUBLIC KEY-----" + - "-----BEGIN PUBLIC KEY-----xxxxx-----END PUBLIC KEY-----" + - name: sigstore_gh_action + url: ghcr.io/kubewarden/policies/verify-image-signatures:v0.2.8 + settings: + signatures: + - image: "*" + githubActions: + owner: "kubewarden" + - name: reject_latest_tag + url: ghcr.io/kubewarden/policies/trusted-repos-policy:v0.1.12 + settings: + tags: + reject: + - latest + expression: "sigstore_pgp() || (sigstore_gh_action() && reject_latest_tag())" + message: "The group policy is rejected." +``` + +This will lead to the exposure of a validation endpoint `/validate/pod-image-signatures` +that will accept the incoming request if the image is signed with the given public keys or +if the image is built by the given GitHub Actions and the image tag is not `latest`. + +Each policy in the group can have its own settings and its own list of Kubernetes resources +that is allowed to access: + +```yml +strict-ingress-checks: + policies: + - name: unique_ingress + url: ghcr.io/kubewarden/policies/cel-policy:latest + contextAwareResources: + - apiVersion: networking.k8s.io/v1 + kind: Ingress + settings: + variables: + - name: knownIngresses + expression: kw.k8s.apiVersion("networking.k8s.io/v1").kind("Ingress").list().items + - name: knownHosts + expression: | + variables.knownIngresses + .filter(i, (i.metadata.name != object.metadata.name) && (i.metadata.namespace != object.metadata.namespace)) + .map(i, i.spec.rules.map(r, r.host)) + - name: desiredHosts + expression: | + object.spec.rules.map(r, r.host) + validations: + - expression: | + !variables.knownHost.exists_one(hosts, sets.intersects(hosts, variables.desiredHosts)) + message: "Cannot reuse a host across multiple ingresses" + - name: https_only + url: ghcr.io/kubewarden/policies/ingress:latest + settings: + requireTLS: true + allowPorts: [443] + denyPorts: [80] + - name: http_only + url: ghcr.io/kubewarden/policies/ingress:latest + settings: + requireTLS: false + allowPorts: [80] + denyPorts: [443] + + expression: "unique_ingress() && (https_only() || http_only())" + message: "The group policy is rejected." +``` + +For more details, please refer to the Kubewarden documentation. + ## Logging and distributed tracing The verbosity of policy-server can be configured via the `--log-level` flag. @@ -103,14 +195,14 @@ Policy server can send trace events to the Open Telemetry Collector using the Current limitations: - * Traces can be sent to the collector only via grpc. The HTTP transport - layer is not supported. - * The Open Telemetry Collector must be listening on localhost. When deployed - on Kubernetes, policy-server must have the Open Telemetry Collector - running as a sidecar. - * Policy server doesn't expose any configuration setting for Open Telemetry - (e.g.: endpoint URL, encryption, authentication,...). All of the tuning - has to be done on the collector process that runs as a sidecar. +- Traces can be sent to the collector only via grpc. The HTTP transport + layer is not supported. +- The Open Telemetry Collector must be listening on localhost. When deployed + on Kubernetes, policy-server must have the Open Telemetry Collector + running as a sidecar. +- Policy server doesn't expose any configuration setting for Open Telemetry + (e.g.: endpoint URL, encryption, authentication,...). All of the tuning + has to be done on the collector process that runs as a sidecar. More details about OpenTelemetry and tracing can be found inside of our [official docs](https://docs.kubewarden.io/operator-manual/tracing/01-quickstart.html). diff --git a/policies.yml.example b/policies.yml.example index 0e20ce01..c7fee471 100644 --- a/policies.yml.example +++ b/policies.yml.example @@ -6,3 +6,28 @@ psp-capabilities: settings: allowed_capabilities: ["*"] required_drop_capabilities: ["KILL"] +pod-image-signatures: # policy group + policies: + - name: sigstore_pgp + url: ghcr.io/kubewarden/policies/verify-image-signatures:v0.2.8 + settings: + signatures: + - image: "*" + pubKeys: + - "-----BEGIN PUBLIC KEY-----xxxxx-----END PUBLIC KEY-----" + - "-----BEGIN PUBLIC KEY-----xxxxx-----END PUBLIC KEY-----" + - name: sigstore_gh_action + url: ghcr.io/kubewarden/policies/verify-image-signatures:v0.2.8 + settings: + signatures: + - image: "*" + githubActions: + owner: "kubewarden" + - name: reject_latest_tag + url: ghcr.io/kubewarden/policies/trusted-repos-policy:v0.1.12 + settings: + tags: + reject: + - latest + expression: "sigstore_pgp() || (sigstore_gh_action() && reject_latest_tag())" + message: "The group policy is rejected." diff --git a/src/api/handlers.rs b/src/api/handlers.rs index f98754dc..9b23851c 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -269,10 +269,9 @@ async fn acquire_semaphore_and_evaluate( let span = Span::current(); let response = task::spawn_blocking(move || { let _enter = span.enter(); - let evaluation_environment = &state.evaluation_environment; evaluate( - evaluation_environment, + state.evaluation_environment.clone(), &policy_id, &validate_request, request_origin, diff --git a/src/api/service.rs b/src/api/service.rs index 02cefd91..e980f98b 100644 --- a/src/api/service.rs +++ b/src/api/service.rs @@ -1,15 +1,15 @@ -use std::fmt; - use policy_evaluator::{ admission_response::{AdmissionResponse, AdmissionResponseStatus}, policy_evaluator::ValidateRequest, }; +use std::fmt; +use std::sync::Arc; use tokio::time::Instant; use tracing::info; use crate::{ config::PolicyMode, - evaluation::{errors::EvaluationError, EvaluationEnvironment}, + evaluation::{errors::EvaluationError, EvaluationEnvironment, PolicyID}, metrics, }; @@ -28,37 +28,39 @@ impl fmt::Display for RequestOrigin { } pub(crate) fn evaluate( - evaluation_environment: &EvaluationEnvironment, + evaluation_environment: Arc, policy_id: &str, validate_request: &ValidateRequest, request_origin: RequestOrigin, ) -> Result { let start_time = Instant::now(); + let policy_id: PolicyID = policy_id.parse()?; + + let vanilla_validation_response = match evaluation_environment + .clone() + .validate(&policy_id, validate_request) + { + Ok(validation_response) => validation_response, + Err(EvaluationError::PolicyInitialization(error)) => { + let policy_initialization_error_metric = metrics::PolicyInitializationError { + policy_name: policy_id.to_string(), + initialization_error: error.to_string(), + }; - let policy_name = policy_id.to_owned(); - let vanilla_validation_response = - match evaluation_environment.validate(policy_id, validate_request) { - Ok(validation_response) => validation_response, - Err(EvaluationError::PolicyInitialization(error)) => { - let policy_initialization_error_metric = metrics::PolicyInitializationError { - policy_name: policy_id.to_string(), - initialization_error: error.to_string(), - }; - - metrics::add_policy_evaluation(&policy_initialization_error_metric); - - return Ok(AdmissionResponse::reject( - validate_request.uid().to_owned(), - error.to_string(), - 500, - )); - } + metrics::add_policy_evaluation(&policy_initialization_error_metric); - Err(error) => return Err(error), - }; + return Ok(AdmissionResponse::reject( + validate_request.uid().to_owned(), + error.to_string(), + 500, + )); + } - let policy_mode = evaluation_environment.get_policy_mode(policy_id)?; - let allowed_to_mutate = evaluation_environment.get_policy_allowed_to_mutate(policy_id)?; + Err(error) => return Err(error), + }; + + let policy_mode = evaluation_environment.get_policy_mode(&policy_id)?; + let allowed_to_mutate = evaluation_environment.get_policy_allowed_to_mutate(&policy_id)?; let policy_evaluation_duration = start_time.elapsed(); let accepted = vanilla_validation_response.allowed; @@ -71,7 +73,7 @@ pub(crate) fn evaluate( let mut validation_response = match request_origin { RequestOrigin::Validate => validation_response_with_constraints( - policy_id, + &policy_id, &policy_mode, allowed_to_mutate, vanilla_validation_response.clone(), @@ -105,7 +107,7 @@ pub(crate) fn evaluate( } } let policy_evaluation_metric = metrics::PolicyEvaluation { - policy_name, + policy_name: policy_id.to_string(), policy_mode: policy_mode.into(), resource_namespace: adm_req.clone().namespace, resource_kind: adm_req.clone().request_kind.unwrap_or_default().kind, @@ -122,7 +124,7 @@ pub(crate) fn evaluate( let accepted = vanilla_validation_response.allowed; let mutated = vanilla_validation_response.patch.is_some(); let raw_policy_evaluation_metric = metrics::RawPolicyEvaluation { - policy_name, + policy_name: policy_id.to_string(), policy_mode: policy_mode.into(), accepted, mutated, @@ -145,7 +147,7 @@ pub(crate) fn evaluate( // - A policy might be running in "Monitor" mode, that always // accepts the request (without mutation), logging the answer fn validation_response_with_constraints( - policy_id: &str, + policy_id: &PolicyID, policy_mode: &PolicyMode, allowed_to_mutate: bool, validation_response: AdmissionResponse, @@ -177,7 +179,7 @@ fn validation_response_with_constraints( // overriden, as it's only taken into // account when a request is rejected. info!( - policy_id = policy_id, + policy_id = policy_id.to_string(), allowed_to_mutate = allowed_to_mutate, response = format!("{validation_response:?}").as_str(), "policy evaluation (monitor mode)", @@ -196,11 +198,14 @@ fn validation_response_with_constraints( #[cfg(test)] mod tests { use crate::test_utils::build_admission_review_request; + use lazy_static::lazy_static; use rstest::*; use super::*; - const POLICY_ID: &str = "policy-id"; + lazy_static! { + static ref POLICY_ID: PolicyID = PolicyID::Policy("policy-id".to_string()); + } fn create_evaluation_environment_that_accepts_request( policy_mode: PolicyMode, @@ -215,6 +220,7 @@ mod tests { ..Default::default() }) }); + mock_evaluation_environment .expect_get_policy_mode() .returning(move |_policy_id| Ok(policy_mode.clone())); @@ -229,14 +235,14 @@ mod tests { } #[derive(Clone)] - struct EvaluationEnvironmentRejectionDetails { + struct RejectionDetails { message: String, code: u16, } fn create_evaluation_environment_that_reject_request( policy_mode: PolicyMode, - rejection_details: EvaluationEnvironmentRejectionDetails, + rejection_details: RejectionDetails, allowed_namespace: String, ) -> EvaluationEnvironment { let mut mock_evaluation_environment = EvaluationEnvironment::default(); @@ -281,7 +287,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, false, AdmissionResponse { @@ -296,7 +302,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, false, AdmissionResponse { @@ -319,7 +325,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, true, AdmissionResponse { @@ -335,7 +341,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, false, AdmissionResponse { @@ -350,7 +356,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, true, AdmissionResponse { @@ -364,7 +370,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, true, AdmissionResponse { @@ -381,7 +387,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, false, AdmissionResponse { @@ -394,7 +400,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Monitor, false, AdmissionResponse { @@ -419,7 +425,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, true, AdmissionResponse { @@ -440,7 +446,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, false, AdmissionResponse { @@ -465,7 +471,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, true, AdmissionResponse { @@ -479,7 +485,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, true, AdmissionResponse { @@ -503,7 +509,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, false, AdmissionResponse { @@ -516,7 +522,7 @@ mod tests { assert_eq!( validation_response_with_constraints( - POLICY_ID, + &POLICY_ID, &PolicyMode::Protect, false, AdmissionResponse { @@ -556,7 +562,7 @@ mod tests { ValidateRequest::AdmissionRequest(build_admission_review_request().request); let response = evaluate( - &evaluation_environment, + Arc::new(evaluation_environment), policy_id, &validate_request, request_origin, @@ -576,7 +582,7 @@ mod tests { #[case] request_origin: RequestOrigin, #[case] accept: bool, ) { - let rejection_details = EvaluationEnvironmentRejectionDetails { + let rejection_details = RejectionDetails { message: "boom".to_string(), code: 500, }; @@ -590,7 +596,7 @@ mod tests { let policy_id = "test_policy1"; let response = evaluate( - &evaluation_environment, + Arc::new(evaluation_environment), policy_id, &validate_request, request_origin, @@ -617,7 +623,7 @@ mod tests { let policy_id = "test_policy1"; let response = evaluate( - &evaluation_environment, + Arc::new(evaluation_environment), policy_id, &validate_request, RequestOrigin::Validate, @@ -629,7 +635,7 @@ mod tests { #[test] fn evaluate_policy_evaluator_rejects_request_raw() { - let rejection_details = EvaluationEnvironmentRejectionDetails { + let rejection_details = RejectionDetails { message: "boom".to_string(), code: 500, }; @@ -643,7 +649,7 @@ mod tests { let policy_id = "test_policy1"; let response = evaluate( - &evaluation_environment, + Arc::new(evaluation_environment), policy_id, &validate_request, RequestOrigin::Validate, @@ -664,7 +670,7 @@ mod tests { #[case] request_origin: RequestOrigin, ) { let allowed_namespace = "kubewarden_special".to_string(); - let rejection_details = EvaluationEnvironmentRejectionDetails { + let rejection_details = RejectionDetails { message: "boom".to_string(), code: 500, }; @@ -680,7 +686,7 @@ mod tests { let policy_id = "test_policy1"; let response = evaluate( - &evaluation_environment, + Arc::new(evaluation_environment), policy_id, &validate_request, request_origin, diff --git a/src/api/state.rs b/src/api/state.rs index 87394123..7db73a02 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -1,8 +1,9 @@ use tokio::sync::Semaphore; use crate::evaluation::EvaluationEnvironment; +use std::sync::Arc; pub(crate) struct ApiServerState { pub(crate) semaphore: Semaphore, - pub(crate) evaluation_environment: EvaluationEnvironment, + pub(crate) evaluation_environment: Arc, } diff --git a/src/config.rs b/src/config.rs index 99b0d1d7..1b2e9bbf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,20 +1,21 @@ use anyhow::{anyhow, Result}; use clap::ArgMatches; use lazy_static::lazy_static; -use policy_evaluator::policy_evaluator::PolicySettings; -use policy_evaluator::policy_fetcher::sources::{read_sources_file, Sources}; -use policy_evaluator::policy_fetcher::verify::config::{ - read_verification_file, LatestVerificationConfig, VerificationConfigV1, +use policy_evaluator::{ + policy_fetcher::{ + sources::{read_sources_file, Sources}, + verify::config::{read_verification_file, LatestVerificationConfig, VerificationConfigV1}, + }, + policy_metadata::ContextAwareResource, }; -use policy_evaluator::policy_metadata::ContextAwareResource; use serde::Deserialize; -use serde_yaml::Value; -use std::collections::{BTreeSet, HashMap}; -use std::env; -use std::fs::File; -use std::iter::FromIterator; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::{ + collections::{BTreeSet, HashMap}, + env, + fs::File, + net::SocketAddr, + path::{Path, PathBuf}, +}; pub static SERVICE_NAME: &str = "kubewarden-policy-server"; const DOCKER_CONFIG_ENV_VAR: &str = "DOCKER_CONFIG"; @@ -27,7 +28,7 @@ lazy_static! { pub struct Config { pub addr: SocketAddr, pub sources: Option, - pub policies: HashMap, + pub policies: HashMap, pub policies_download_dir: PathBuf, pub ignore_kubernetes_connection_failure: bool, pub always_accept_admission_reviews_on_namespace: Option, @@ -189,15 +190,45 @@ fn tls_files(matches: &clap::ArgMatches) -> Result<(String, String)> { } } -fn policies(matches: &clap::ArgMatches) -> Result> { +fn policies(matches: &clap::ArgMatches) -> Result> { let policies_file = Path::new(matches.get_one::("policies").unwrap()); - read_policies_file(policies_file).map_err(|e| { + let policies = read_policies_file(policies_file).map_err(|e| { anyhow!( "error while loading policies from {:?}: {}", policies_file, e ) - }) + })?; + + validate_policies(&policies)?; + + Ok(policies) +} + +// Validate the policies and policy groups: +// - ensure policy names do not contain a '/' character +// - ensure names of policy group's policies do not contain a '/' character +fn validate_policies(policies: &HashMap) -> Result<()> { + for (name, policy) in policies.iter() { + if name.contains('/') { + return Err(anyhow!("policy name '{}' contains a '/' character", name)); + } + if let PolicyOrPolicyGroup::PolicyGroup { policies, .. } = policy { + let policies_with_invalid_name: Vec = policies + .iter() + .filter_map(|(id, _)| if id.contains('/') { Some(id) } else { None }) + .cloned() + .collect(); + if !policies_with_invalid_name.is_empty() { + return Err(anyhow!( + "policy group '{}' contains policies with invalid names: {:?}", + name, + policies_with_invalid_name + )); + } + } + } + Ok(()) } fn verification_config(matches: &clap::ArgMatches) -> Result> { @@ -245,31 +276,112 @@ impl From for String { } } +/// Settings specified by the user for a given policy. +#[derive(Debug, Clone, Default)] +pub struct SettingsJSON(serde_json::Map); + +impl From for serde_json::Map { + fn from(val: SettingsJSON) -> Self { + val.0 + } +} + +impl TryFrom> for SettingsJSON { + type Error = anyhow::Error; + + fn try_from(settings: HashMap) -> Result { + let settings_yaml = serde_yaml::Mapping::from_iter( + settings + .iter() + .map(|(key, value)| (serde_yaml::Value::String(key.to_string()), value.clone())), + ); + let settings_json = convert_yaml_map_to_json(settings_yaml) + .map_err(|e| anyhow!("cannot convert YAML settings to JSON: {:?}", e))?; + Ok(SettingsJSON(settings_json)) + } +} + +#[derive(Debug, Clone)] +pub enum PolicyOrPolicyGroupSettings { + Policy(SettingsJSON), + PolicyGroup { + expression: String, + message: String, + policies: Vec, + }, +} + +/// `PolicyGroupMember` represents a single policy that is part of a policy group. #[derive(Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct Policy { +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PolicyGroupMember { + /// Thge URL where the policy is located pub url: String, - #[serde(default)] - pub policy_mode: PolicyMode, - pub allowed_to_mutate: Option, - pub settings: Option>, + /// The settings for the policy + pub settings: Option>, + /// The list of Kubernetes resources the policy is allowed to access #[serde(default)] pub context_aware_resources: BTreeSet, } -impl Policy { - pub fn settings_to_json(&self) -> Result> { - match self.settings.as_ref() { - None => Ok(None), - Some(settings) => { - let settings = - serde_yaml::Mapping::from_iter(settings.iter().map(|(key, value)| { - (serde_yaml::Value::String(key.to_string()), value.clone()) - })); - Ok(Some(convert_yaml_map_to_json(settings).map_err(|e| { - anyhow!("cannot convert YAML settings to JSON: {:?}", e) - })?)) +impl PolicyGroupMember { + pub fn settings(&self) -> Result { + let settings = SettingsJSON::try_from(self.settings.clone().unwrap_or_default())?; + Ok(PolicyOrPolicyGroupSettings::Policy(settings)) + } +} + +/// Describes a policy that can be either an individual policy or a group policy. +#[derive(Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields, untagged, rename_all = "camelCase")] +pub enum PolicyOrPolicyGroup { + /// An individual policy + Policy { + /// The URL where the policy is located + url: String, + #[serde(default)] + /// The mode of the policy + policy_mode: PolicyMode, + /// Whether the policy is allowed to mutate the request + allowed_to_mutate: Option, + /// The settings for the policy, as provided by the user + settings: Option>, + #[serde(default)] + /// The list of Kubernetes resources the policy is allowed to access + context_aware_resources: BTreeSet, + }, + /// A group of policies that are evaluated together using a given expression + PolicyGroup { + /// The mode of the policy + #[serde(default)] + policy_mode: PolicyMode, + /// The policies that make up for this group + /// Key is a unique identifier + policies: HashMap, + /// The expression that is used to evaluate the group of policies + expression: String, + /// The message that is returned when the group of policies evaluates to false + message: String, + }, +} + +impl PolicyOrPolicyGroup { + pub fn settings(&self) -> Result { + match self { + PolicyOrPolicyGroup::Policy { settings, .. } => { + let settings = SettingsJSON::try_from(settings.clone().unwrap_or_default())?; + Ok(PolicyOrPolicyGroupSettings::Policy(settings)) } + PolicyOrPolicyGroup::PolicyGroup { + expression, + message, + policies, + .. + } => Ok(PolicyOrPolicyGroupSettings::PolicyGroup { + expression: expression.clone(), + message: message.clone(), + policies: policies.keys().cloned().collect(), + }), } } } @@ -306,9 +418,9 @@ fn convert_yaml_map_to_json( /// and Policy as values. The key is the name of the policy as provided by the user /// inside of the configuration file. This name is used to build the API path /// exposing the policy. -fn read_policies_file(path: &Path) -> Result> { +fn read_policies_file(path: &Path) -> Result> { let settings_file = File::open(path)?; - let ps: HashMap = serde_yaml::from_reader(&settings_file)?; + let ps: HashMap = serde_yaml::from_reader(&settings_file)?; Ok(ps) } @@ -316,159 +428,58 @@ fn read_policies_file(path: &Path) -> Result> { mod tests { use super::*; use crate::cli; + use rstest::*; + use serde_json::json; use std::io::Write; use tempfile::NamedTempFile; - #[test] - fn get_settings_when_data_is_provided() { - let input = r#" ---- -example: - url: file:///tmp/namespace-validate-policy.wasm - settings: - valid_namespace: valid -"#; - let policies: HashMap = serde_yaml::from_str(input).unwrap(); - assert!(!policies.is_empty()); - - let policy = policies.get("example").unwrap(); - assert!(policy.allowed_to_mutate.is_none()); - assert!(policy.settings.is_some()); - } - - #[test] - fn test_allowed_to_mutate_settings() { - let input = r#" ---- -example: - url: file:///tmp/namespace-validate-policy.wasm - allowedToMutate: true - settings: - valid_namespace: valid -"#; - let policies: HashMap = serde_yaml::from_str(input).unwrap(); - assert!(!policies.is_empty()); - - let policy = policies.get("example").unwrap(); - assert!(policy.allowed_to_mutate.unwrap()); - assert!(policy.settings.is_some()); - - let input2 = r#" ---- -example: - url: file:///tmp/namespace-validate-policy.wasm - allowedToMutate: false - settings: - valid_namespace: valid -"#; - let policies2: HashMap = serde_yaml::from_str(input2).unwrap(); - assert!(!policies2.is_empty()); - - let policy2 = policies2.get("example").unwrap(); - assert!(!policy2.allowed_to_mutate.unwrap()); - assert!(policy2.settings.is_some()); - } - - #[test] - fn get_settings_when_empty_map_is_provided() { - let input = r#" + #[rstest] + #[case::settings_empty( + r#" --- example: url: file:///tmp/namespace-validate-policy.wasm settings: {} -"#; - - let policies: HashMap = serde_yaml::from_str(input).unwrap(); - assert!(!policies.is_empty()); - - let policy = policies.get("example").unwrap(); - assert!(policy.settings.is_some()); - } - - #[test] - fn get_settings_when_no_settings_are_provided() { - let input = r#" +"#, json!({}) + )] + #[case::settings_missing( + r#" --- example: url: file:///tmp/namespace-validate-policy.wasm -"#; - - let policies: HashMap = serde_yaml::from_str(input).unwrap(); - assert!(!policies.is_empty()); - - let policy = policies.get("example").unwrap(); - assert!(policy.settings.is_none()); - } - - #[test] - fn get_settings_when_settings_is_null() { - let input = r#" -{ - "privileged-pods": { - "url": "registry://ghcr.io/kubewarden/policies/pod-privileged:v0.1.5", - "settings": null - } -} -"#; - - let policies: HashMap = serde_yaml::from_str(input).unwrap(); - assert!(!policies.is_empty()); - - let policy = policies.get("privileged-pods").unwrap(); - assert!(policy.settings.is_none()); - } - - #[test] - fn handle_yaml_map_with_data() { - let input = r#" +"#, json!({}) + )] + #[case::settings_null( + r#" --- example: url: file:///tmp/namespace-validate-policy.wasm - settings: - valid_namespace: valid -"#; - let policies: HashMap = serde_yaml::from_str(input).unwrap(); - assert!(!policies.is_empty()); - - let policy = policies.get("example").unwrap(); - let json_data = convert_yaml_map_to_json(serde_yaml::Mapping::from_iter( - policy - .settings - .as_ref() - .unwrap() - .iter() - .map(|(key, value)| (serde_yaml::Value::String(key.clone()), value.clone())), - )); - assert!(json_data.is_ok()); - - let settings = json_data.unwrap(); - assert_eq!(settings.get("valid_namespace").unwrap(), "valid"); - } - - #[test] - fn handle_yaml_map_with_no_data() { - let input = r#" + settings: null +"#, json!({}) + )] + #[case::settings_provided( + r#" --- example: url: file:///tmp/namespace-validate-policy.wasm - settings: {} -"#; - let policies: HashMap = serde_yaml::from_str(input).unwrap(); + settings: + "counter": 1 + "items": ["a", "b"] + "nested": {"key": "value"} +"#, json!({"counter": 1, "items": ["a", "b"], "nested": {"key": "value"}}) + )] + fn handle_settings_conversion(#[case] input: &str, #[case] expected: serde_json::Value) { + let policies: HashMap = serde_yaml::from_str(input).unwrap(); assert!(!policies.is_empty()); let policy = policies.get("example").unwrap(); - let json_data = convert_yaml_map_to_json(serde_yaml::Mapping::from_iter( - policy - .settings - .as_ref() - .unwrap() - .iter() - .map(|(key, value)| (serde_yaml::Value::String(key.clone()), value.clone())), - )); - assert!(json_data.is_ok()); - - let settings = json_data.unwrap(); - assert!(settings.is_empty()); + let settings = policy.settings().unwrap(); + match settings { + PolicyOrPolicyGroupSettings::Policy(settings) => { + assert_eq!(serde_json::Value::Object(settings.0), expected); + } + _ => panic!("Expected an Individual policy"), + } } #[test] @@ -507,4 +518,60 @@ example: assert_eq!(provide_flag, config.metrics_enabled); } } + + #[rstest] + #[case::all_good( + r#" +--- +example: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} +group_policy: + expression: "true" + message: "group policy message" + policies: + policy1: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} + policy2: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} +"#, + true + )] + #[case::policy_with_invalid_name( + r#" +--- +example/invalid: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} +"#, + false + )] + #[case::policy_group_member_with_invalid_name( + r#" +--- +example: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} +group_policy: + expression: "true" + message: "group policy message" + policies: + policy1/a: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} + policy2: + url: file:///tmp/namespace-validate-policy.wasm + settings: {} +"#, + false + )] + fn policy_validation(#[case] policies_yaml: &str, #[case] is_valid: bool) { + let policies: HashMap = + serde_yaml::from_str(policies_yaml).unwrap(); + + let validation_result = validate_policies(&policies); + assert_eq!(is_valid, validation_result.is_ok()); + } } diff --git a/src/evaluation.rs b/src/evaluation.rs index b6b8943a..96bdd7fb 100644 --- a/src/evaluation.rs +++ b/src/evaluation.rs @@ -6,3 +6,8 @@ pub(crate) mod precompiled_policy; // This is required to mock the `EvaluationEnvironment` inside of our tests #[mockall_double::double] pub(crate) use evaluation_environment::EvaluationEnvironment; + +pub(crate) use evaluation_environment::EvaluationEnvironmentBuilder; + +pub(crate) mod policy_id; +pub(crate) use policy_id::PolicyID; diff --git a/src/evaluation/errors.rs b/src/evaluation/errors.rs index 460f6ef7..65465bbc 100644 --- a/src/evaluation/errors.rs +++ b/src/evaluation/errors.rs @@ -4,6 +4,9 @@ pub type Result = std::result::Result; #[derive(Debug, Error)] pub enum EvaluationError { + #[error("Not a valid Policy ID: {0}")] + InvalidPolicyId(String), + #[error("{0}")] PolicyInitialization(String), @@ -16,6 +19,9 @@ pub enum EvaluationError { #[error("WebAssembly failure: {0}")] WebAssemblyError(String), - #[error("{0}")] - InternalError(String), + #[error("Attempted to rehydrated policy group '{0}'")] + CannotRehydratePolicyGroup(String), + + #[error("Policy group evaluation error: '{0}'")] + PolicyGroupRuntimeError(#[from] Box), } diff --git a/src/evaluation/evaluation_environment.rs b/src/evaluation/evaluation_environment.rs index 0873d939..543d65d4 100644 --- a/src/evaluation/evaluation_environment.rs +++ b/src/evaluation/evaluation_environment.rs @@ -1,5 +1,5 @@ use policy_evaluator::{ - admission_response::AdmissionResponse, + admission_response::{AdmissionResponse, AdmissionResponseStatus}, callback_requests::CallbackRequest, evaluation_context::EvaluationContext, kubewarden_policy_sdk::settings::SettingsValidationResponse, @@ -7,18 +7,63 @@ use policy_evaluator::{ policy_evaluator_builder::PolicyEvaluatorBuilder, wasmtime, }; -use std::collections::HashMap; +use rhai::EvalAltResult; +use std::{ + collections::{HashMap, HashSet}, + fmt, + sync::{Arc, Mutex}, +}; use tokio::sync::mpsc; use tracing::debug; -use crate::config::PolicyMode; -use crate::evaluation::errors::{EvaluationError, Result}; -use crate::evaluation::policy_evaluation_settings::PolicyEvaluationSettings; -use crate::evaluation::precompiled_policy::{PrecompiledPolicies, PrecompiledPolicy}; +use crate::{ + config::{PolicyMode, PolicyOrPolicyGroup, PolicyOrPolicyGroupSettings}, + evaluation::{ + errors::{EvaluationError, Result}, + policy_evaluation_settings::PolicyEvaluationSettings, + precompiled_policy::{PrecompiledPolicies, PrecompiledPolicy}, + PolicyID, + }, +}; #[cfg(test)] use mockall::automock; +/// This holds the a summary of the evaluation results of a policy group member +struct PolicyGroupMemberEvaluationResult { + /// whether the request is allowed or not + allowed: bool, + /// the optional message included inside of the evaluation result of the policy + message: Option, +} + +impl From for PolicyGroupMemberEvaluationResult { + fn from(response: AdmissionResponse) -> Self { + Self { + allowed: response.allowed, + message: response.status.and_then(|status| status.message), + } + } +} + +impl fmt::Display for PolicyGroupMemberEvaluationResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.allowed { + write!(f, "[ALLOWED]")?; + } else { + write!(f, "[DENIED]")?; + } + if let Some(message) = &self.message { + write!(f, " - {}", message)?; + } + + Ok(()) + } +} + +/// The digest of a WebAssembly module +type ModuleDigest = String; + /// This structure contains all the policies defined by the user inside of the `policies.yml`. /// It also provides helper methods to perform the validation of a request and the validation /// of the settings provided by the user. @@ -36,7 +81,6 @@ use mockall::automock; /// To reduce the creation time, this code makes use of `PolicyEvaluatorPre` which are created /// only once, during the bootstrap phase. #[derive(Default)] -#[cfg_attr(test, allow(dead_code))] pub(crate) struct EvaluationEnvironment { /// The name of the Namespace where Policy Server doesn't operate. All the requests /// involving this Namespace are going to be accepted. This is usually done to prevent user @@ -46,81 +90,270 @@ pub(crate) struct EvaluationEnvironment { /// A map with the module digest as key, and the associated `PolicyEvaluatorPre` /// as value - module_digest_to_policy_evaluator_pre: HashMap, + module_digest_to_policy_evaluator_pre: HashMap, /// A map with the ID of the policy as value, and the associated `EvaluationContext` as /// value. - /// In this case, `policy_id` is the name of the policy as declared inside of the - /// `policies.yml` file. These names are guaranteed to be unique. - policy_id_to_eval_ctx: HashMap, + policy_id_to_eval_ctx: HashMap, - /// Map a `policy_id` (the name given by the user inside of `policies.yml`) to the - /// module's digest. This allows us to deduplicate the Wasm modules defined by the user. - policy_id_to_module_digest: HashMap, + /// Map a `policy_id` to the module's digest. + /// This allows us to deduplicate the Wasm modules defined by the user. + policy_id_to_module_digest: HashMap, /// Map a `policy_id` to the `PolicyEvaluationSettings` instance. This allows us to obtain /// the list of settings to be used when evaluating a given policy. - policy_id_to_settings: HashMap, + policy_id_to_settings: HashMap, /// A map with the policy ID as key, and the error message as value. /// This is used to store the errors that occurred during policies initialization. /// The errors can occur in the fetching of the policy, or in the validation of the settings. - policy_initialization_errors: HashMap, + policy_initialization_errors: HashMap, + + /// A Set containing the IDs of the policy groups. + policy_groups: HashSet, } -#[cfg_attr(test, automock)] -#[cfg_attr(test, allow(dead_code))] -impl EvaluationEnvironment { - /// Creates a new `EvaluationEnvironment` - pub(crate) fn new( - engine: &wasmtime::Engine, - policies: &HashMap, - precompiled_policies: &PrecompiledPolicies, - always_accept_admission_reviews_on_namespace: Option, - policy_evaluation_limit_seconds: Option, +/// This structure is used to build the `EvaluationEnvironment` instance. +pub(crate) struct EvaluationEnvironmentBuilder<'engine, 'precompiled_policies> { + engine: &'engine wasmtime::Engine, + precompiled_policies: &'precompiled_policies PrecompiledPolicies, + callback_handler_tx: mpsc::Sender, + continue_on_errors: bool, + policy_evaluation_limit_seconds: Option, + always_accept_admission_reviews_on_namespace: Option, +} + +impl<'engine, 'precompiled_policies> EvaluationEnvironmentBuilder<'engine, 'precompiled_policies> { + /// Prepare a new `EvaluationEnvironmentBuilder` instance. + pub fn new( + engine: &'engine wasmtime::Engine, + precompiled_policies: &'precompiled_policies PrecompiledPolicies, callback_handler_tx: mpsc::Sender, - continue_on_errors: bool, - ) -> Result { - let mut eval_env = Self { - always_accept_admission_reviews_on_namespace, + ) -> Self { + EvaluationEnvironmentBuilder { + engine, + precompiled_policies, + callback_handler_tx, + continue_on_errors: false, + policy_evaluation_limit_seconds: None, + always_accept_admission_reviews_on_namespace: None, + } + } + + /// Enable policy evaluatation timeout feature + pub fn with_policy_evaluation_limit_seconds( + mut self, + policy_evaluation_limit_seconds: u64, + ) -> Self { + self.policy_evaluation_limit_seconds = Some(policy_evaluation_limit_seconds); + self + } + + /// Do not fail when a policy initialization error occurs + pub fn with_continue_on_errors(mut self, continue_on_errors: bool) -> Self { + self.continue_on_errors = continue_on_errors; + self + } + + /// Set the namespace where all the requests are going to be accepted + pub fn with_always_accept_admission_reviews_on_namespace(mut self, namespace: String) -> Self { + self.always_accept_admission_reviews_on_namespace = Some(namespace); + self + } + + // Because of automock, we have to provide a tailored build method between test and production + // code + #[cfg(test)] + pub fn build( + &self, + _policies: &HashMap, + ) -> Result { + Ok(MockEvaluationEnvironment::new()) + } + + /// Build the `EvaluationEnvironment` instance + #[cfg(not(test))] + pub fn build( + &self, + policies: &HashMap, + ) -> Result { + self.build_evaluation_environment(policies) + } + + /// Internal method to build the `EvaluationEnvironment` instance that is used by production + /// code. We need this method inside of the unit tests + fn build_evaluation_environment( + &self, + policies: &HashMap, + ) -> Result { + let mut eval_env = EvaluationEnvironment { + always_accept_admission_reviews_on_namespace: self + .always_accept_admission_reviews_on_namespace + .clone(), ..Default::default() }; - for (policy_id, policy) in policies { - let precompiled_policy = precompiled_policies.get(&policy.url).ok_or_else(|| { - EvaluationError::BootstrapFailure(format!( - "cannot find policy settings of {}", - policy_id - )) - })?; + for (policy_name, policy) in policies { + // there's no way to recover from a parse error, so we just return it + let id: PolicyID = policy_name.parse()?; - let precompiled_policy = match precompiled_policy.as_ref() { - Ok(precompiled_policy) => precompiled_policy, + let settings = match policy.settings() { + Ok(s) => s, Err(e) => { + if !self.continue_on_errors { + return Err(EvaluationError::BootstrapFailure(format!( + "cannot extract settings from policy: {e}" + ))); + } eval_env .policy_initialization_errors - .insert(policy_id.clone(), e.to_string()); + .insert(id.to_owned(), e.to_string()); continue; } }; - eval_env - .register( - engine, - policy_id, - precompiled_policy, - policy, - callback_handler_tx.clone(), - policy_evaluation_limit_seconds, - ) - .map_err(|e| EvaluationError::BootstrapFailure(e.to_string()))?; - - eval_env.validate_settings(policy_id, continue_on_errors)?; + match policy { + PolicyOrPolicyGroup::Policy { + url, + policy_mode, + allowed_to_mutate, + context_aware_resources, + .. + } => { + let policy_evaluation_settings = PolicyEvaluationSettings { + policy_mode: policy_mode.to_owned(), + allowed_to_mutate: allowed_to_mutate.unwrap_or(false), + settings, + }; + + let eval_ctx = EvaluationContext { + policy_id: id.to_string(), + callback_channel: Some(self.callback_handler_tx.clone()), + ctx_aware_resources_allow_list: context_aware_resources.to_owned(), + }; + + if let Err(e) = self.bootstrap_policy( + &mut eval_env, + id.clone(), + url, + policy_evaluation_settings, + eval_ctx, + ) { + if !self.continue_on_errors { + return Err(e); + } + eval_env + .policy_initialization_errors + .insert(id.to_owned(), e.to_string()); + continue; + } + } + PolicyOrPolicyGroup::PolicyGroup { + policy_mode, + policies, + .. + } => { + let policy_evaluation_settings = PolicyEvaluationSettings { + policy_mode: policy_mode.to_owned(), + allowed_to_mutate: false, // Group policies are not allowed to mutate + settings, + }; + eval_env.register_policy_group(&id, policy_evaluation_settings); + + for (policy_name, policy) in policies { + let policy_id = PolicyID::PolicyGroupPolicy { + group: id.to_string(), + name: policy_name.clone(), + }; + let settings = match policy.settings() { + Ok(s) => s, + Err(e) => { + if !self.continue_on_errors { + return Err(EvaluationError::BootstrapFailure(format!( + "cannot extract settings from policy: {e}" + ))); + } + eval_env + .policy_initialization_errors + .insert(policy_id, e.to_string()); + continue; + } + }; + + let policy_evaluation_settings = PolicyEvaluationSettings { + policy_mode: PolicyMode::Protect, + allowed_to_mutate: false, + settings, + }; + + let eval_ctx = EvaluationContext { + policy_id: policy_id.to_string(), + callback_channel: Some(self.callback_handler_tx.clone()), + ctx_aware_resources_allow_list: policy + .context_aware_resources + .to_owned(), + }; + + if let Err(e) = self.bootstrap_policy( + &mut eval_env, + policy_id.clone(), + &policy.url, + policy_evaluation_settings, + eval_ctx, + ) { + if !self.continue_on_errors { + return Err(e); + } + eval_env + .policy_initialization_errors + .insert(policy_id, e.to_string()); + continue; + } + } + } + } } Ok(eval_env) } + /// Internal method used to bootstrap a policy. The policy is either a single policy or a + /// children of a policy group. + fn bootstrap_policy( + &self, + eval_env: &mut EvaluationEnvironment, + id: PolicyID, + url: &str, + policy_evaluation_settings: PolicyEvaluationSettings, + eval_ctx: EvaluationContext, + ) -> Result<()> { + let precompiled_policy = self + .precompiled_policies + .get(url) + .ok_or_else(|| { + EvaluationError::BootstrapFailure(format!("cannot find precompiled policy of {id}")) + })? + .as_ref() + .map_err(|e| EvaluationError::BootstrapFailure(format!("{id}: {e}")))?; + + eval_env + .register( + self.engine, + &id, + policy_evaluation_settings, + eval_ctx, + precompiled_policy, + self.policy_evaluation_limit_seconds, + ) + .map_err(|e| EvaluationError::BootstrapFailure(e.to_string()))?; + + eval_env.validate_settings(&id) + } +} + +#[cfg_attr(test, automock)] +#[cfg_attr(test, allow(dead_code))] +impl EvaluationEnvironment { /// Returns `true` if the given `namespace` is the special Namespace that is ignored by all /// the policies pub(crate) fn should_always_accept_requests_made_inside_of_namespace( @@ -131,15 +364,15 @@ impl EvaluationEnvironment { } /// Register a new policy. It takes care of creating a new `PolicyEvaluator` (when needed). + /// This is used to register both individual policies and the ones that are part of a group + /// policy. /// /// Params: /// - `engine`: the `wasmtime::Engine` to be used when creating the `PolicyEvaluator` - /// - `policy_id`: the ID of the policy, as specified inside of the `policies.yml` by the - /// user + /// - `policy_id`: the unique identifier of the policy + /// - `policy_evaluation_settings`: the settings associated with the policy /// - `precompiled_policy`: the `PrecompiledPolicy` associated with the Wasm module referenced /// by the policy - /// - `policy`: a data structure that maps all the information defined inside of - /// `policies.yml` for the given policy /// - `callback_handler_tx`: the transmission end of a channel that connects the worker /// with the asynchronous world /// - `policy_evaluation_limit_seconds`: when set, defines after how many seconds the @@ -147,10 +380,10 @@ impl EvaluationEnvironment { fn register( &mut self, engine: &wasmtime::Engine, - policy_id: &str, + policy_id: &PolicyID, + policy_evaluation_settings: PolicyEvaluationSettings, + eval_ctx: EvaluationContext, precompiled_policy: &PrecompiledPolicy, - policy: &crate::config::Policy, - callback_handler_tx: mpsc::Sender, policy_evaluation_limit_seconds: Option, ) -> Result<()> { let module_digest = &precompiled_policy.digest; @@ -159,9 +392,9 @@ impl EvaluationEnvironment { .module_digest_to_policy_evaluator_pre .contains_key(module_digest) { - debug!(policy_id = policy.url, "create wasmtime::Module"); - let module = create_wasmtime_module(&policy.url, engine, precompiled_policy)?; - debug!(policy_id = policy.url, "create PolicyEvaluatorPre"); + debug!(?policy_id, "create wasmtime::Module"); + let module = create_wasmtime_module(policy_id, engine, precompiled_policy)?; + debug!(?policy_id, "create PolicyEvaluatorPre"); let pol_eval_pre = create_policy_evaluator_pre( engine, &module, @@ -175,30 +408,28 @@ impl EvaluationEnvironment { self.policy_id_to_module_digest .insert(policy_id.to_owned(), module_digest.to_owned()); - let policy_eval_settings = PolicyEvaluationSettings { - policy_mode: policy.policy_mode.clone(), - allowed_to_mutate: policy.allowed_to_mutate.unwrap_or(false), - settings: policy - .settings_to_json() - .map_err(|e| EvaluationError::InternalError(e.to_string()))? - .unwrap_or_default(), - }; self.policy_id_to_settings - .insert(policy_id.to_owned(), policy_eval_settings); + .insert(policy_id.to_owned(), policy_evaluation_settings); - let eval_ctx = EvaluationContext { - policy_id: policy_id.to_owned(), - callback_channel: Some(callback_handler_tx.clone()), - ctx_aware_resources_allow_list: policy.context_aware_resources.clone(), - }; self.policy_id_to_eval_ctx .insert(policy_id.to_owned(), eval_ctx); Ok(()) } + /// Register a policy group + fn register_policy_group( + &mut self, + policy_id: &PolicyID, + policy_evaluation_settings: PolicyEvaluationSettings, + ) { + self.policy_id_to_settings + .insert(policy_id.to_owned(), policy_evaluation_settings); + self.policy_groups.insert(policy_id.to_owned()); + } + /// Given a policy ID, return how the policy operates - pub fn get_policy_mode(&self, policy_id: &str) -> Result { + pub(crate) fn get_policy_mode(&self, policy_id: &PolicyID) -> Result { self.policy_id_to_settings .get(policy_id) .map(|settings| settings.policy_mode.clone()) @@ -206,7 +437,7 @@ impl EvaluationEnvironment { } /// Given a policy ID, returns true if the policy is allowed to mutate - pub fn get_policy_allowed_to_mutate(&self, policy_id: &str) -> Result { + pub(crate) fn get_policy_allowed_to_mutate(&self, policy_id: &PolicyID) -> Result { self.policy_id_to_settings .get(policy_id) .map(|settings| settings.allowed_to_mutate) @@ -214,7 +445,7 @@ impl EvaluationEnvironment { } /// Given a policy ID, returns the settings provided by the user inside of `policies.yml` - fn get_policy_settings(&self, policy_id: &str) -> Result { + fn get_policy_settings(&self, policy_id: &PolicyID) -> Result { let settings = self .policy_id_to_settings .get(policy_id) @@ -224,55 +455,70 @@ impl EvaluationEnvironment { Ok(settings) } - /// Perform a request validation - pub fn validate(&self, policy_id: &str, req: &ValidateRequest) -> Result { - if let Some(error) = self.policy_initialization_errors.get(policy_id) { - return Err(EvaluationError::PolicyInitialization(error.to_string())); - } - - let settings = self.get_policy_settings(policy_id)?; - let mut evaluator = self.rehydrate(policy_id)?; - - Ok(evaluator.validate(req.clone(), &settings.settings)) - } - /// Validate the settings the user provided for the given policy - fn validate_settings( - &mut self, - policy_id: &str, - continue_on_policy_initialization_errors: bool, - ) -> Result<()> { + fn validate_settings(&mut self, policy_id: &PolicyID) -> Result<()> { let settings = self.get_policy_settings(policy_id)?; - let mut evaluator = self.rehydrate(policy_id)?; - match evaluator.validate_settings(&settings.settings) { - SettingsValidationResponse { - valid: true, - message: _, - } => return Ok(()), - SettingsValidationResponse { - valid: false, - message, + match &settings.settings { + PolicyOrPolicyGroupSettings::Policy(settings) => { + let mut evaluator = self.rehydrate(policy_id)?; + match evaluator.validate_settings(&settings.clone().into()) { + SettingsValidationResponse { + valid: true, + message: _, + } => {} + SettingsValidationResponse { + valid: false, + message, + } => { + let error_message = format!( + "Policy settings are invalid: {}", + message.unwrap_or("no message".to_owned()) + ); + + return Err(EvaluationError::PolicyInitialization(error_message)); + } + }; + } + PolicyOrPolicyGroupSettings::PolicyGroup { + policies: policy_group_policies, + expression, + .. } => { - let error_message = format!( - "Policy settings are invalid: {}", - message.unwrap_or("no message".to_owned()) - ); + let mut rhai_engine = rhai::Engine::new_raw(); + + for sub_policy_name in policy_group_policies { + let sub_policy_id: PolicyID = PolicyID::PolicyGroupPolicy { + group: policy_id.to_string(), + name: sub_policy_name.clone(), + }; + + self.validate_settings(&sub_policy_id)?; - if !continue_on_policy_initialization_errors { - return Err(EvaluationError::PolicyInitialization(error_message)); + rhai_engine.register_fn(sub_policy_name.as_str(), || true); } - self.policy_initialization_errors - .insert(policy_id.to_string(), error_message.clone()); + // Make sure: + // - the expression is valid + // - TODO: make sure the expression returns a boolean, we don't care about the actual result. + // Note about that, the expressions are also going to be validated by the + // Kubewarden controller when the GroupPolicy is created. Here we will leverage + // CEL to perform the validation, which makes that possible. + rhai_engine.eval_expression::(expression.as_str())?; } - }; + } Ok(()) } /// Internal method, create a `PolicyEvaluator` by using a pre-initialized instance - fn rehydrate(&self, policy_id: &str) -> Result { + fn rehydrate(&self, policy_id: &PolicyID) -> Result { + if self.policy_groups.contains(policy_id) { + return Err(EvaluationError::CannotRehydratePolicyGroup( + policy_id.to_string(), + )); + } + let module_digest = self .policy_id_to_module_digest .get(policy_id) @@ -291,10 +537,156 @@ impl EvaluationEnvironment { EvaluationError::WebAssemblyError(format!("cannot rehydrate PolicyEvaluatorPre: {e}")) }) } + + /// Perform a request validation + pub fn validate( + self: Arc, + policy_id: &PolicyID, + req: &ValidateRequest, + ) -> Result { + if self.policy_groups.contains(policy_id) { + self.validate_policy_group(policy_id, req) + } else { + self.validate_policy(policy_id, req) + } + } + + /// Validate a policy. + /// + /// Note, `self` is wrapped inside of `Arc` because this method is called from within a Rhai engine closure that + /// requires `+send` and `+sync`. + fn validate_policy( + self: Arc, + policy_id: &PolicyID, + req: &ValidateRequest, + ) -> Result { + debug!(?policy_id, "validate individual policy"); + + if let Some(error) = self.policy_initialization_errors.get(policy_id) { + return Err(EvaluationError::PolicyInitialization(error.to_string())); + } + + let settings: serde_json::Map = + match self.get_policy_settings(policy_id)?.settings { + PolicyOrPolicyGroupSettings::Policy(settings) => settings.into(), + _ => unreachable!(), + }; + let mut evaluator = self.rehydrate(policy_id)?; + + Ok(evaluator.validate(req.clone(), &settings)) + } + + /// Validate a policy group + /// + /// Note, `self` is wrapped inside of `Arc` because the Rhai engine closure requires + /// `+send` and `+sync`. + fn validate_policy_group( + self: Arc, + policy_id: &PolicyID, + req: &ValidateRequest, + ) -> Result { + let (expression, message, policies) = match self.get_policy_settings(policy_id)?.settings { + PolicyOrPolicyGroupSettings::PolicyGroup { + expression, + message, + policies, + } => (expression, message, policies), + _ => unreachable!(), + }; + + // We create a RAW engine, which has a really limited set of built-ins available + let mut rhai_engine = rhai::Engine::new_raw(); + + // Keep track of all the evaluation results of the member policies + let policies_evaluation_results: Arc< + Mutex>, + > = Arc::new(Mutex::new(HashMap::new())); + + for sub_policy_name in policies { + let sub_policy_id = PolicyID::PolicyGroupPolicy { + group: policy_id.to_string(), + name: sub_policy_name.clone(), + }; + let rhai_eval_env = self.clone(); + let evaluation_results = policies_evaluation_results.clone(); + + let validate_request = req.clone(); + rhai_engine.register_fn( + sub_policy_name.clone().as_str(), + move || -> std::result::Result> { + let response = Self::validate_policy( + rhai_eval_env.clone(), + &sub_policy_id, + &validate_request, + ) + .map_err(|e| { + EvalAltResult::ErrorSystem( + format!("error invoking #{sub_policy_id}"), + Box::new(e), + ) + })?; + + if response.patch.is_some() { + // mutation is not allowed inside of group policies + let mut results = evaluation_results.lock().unwrap(); + results.insert( + sub_policy_name.clone(), + PolicyGroupMemberEvaluationResult { + allowed: false, + message: Some( + "mutation is not allowed inside of policy group".to_string(), + ), + }, + ); + return Ok(false); + } + + let allowed = response.allowed; + + let mut results = evaluation_results.lock().unwrap(); + results.insert(sub_policy_name.clone(), response.into()); + + Ok(allowed) + }, + ); + } + + let rhai_engine = rhai_engine; + + // Note: we use `eval_expression` to limit even further what the user is allowed + // to define inside of the expression + let allowed = rhai_engine.eval_expression::(expression.as_str())?; + + let status = if allowed { + None + } else { + Some(AdmissionResponseStatus { + message: Some(message), + code: None, + }) + }; + + // Provide some feedback to the end user about the single policy evaluation results + let evaluation_results = policies_evaluation_results.lock().unwrap(); + let warnings: Vec = evaluation_results + .iter() + .map(|(policy, result)| format!("{}: {}", policy, result)) + .collect(); + + Ok(AdmissionResponse { + uid: req.uid().to_string(), + allowed, + patch_type: None, + patch: None, + status, + audit_annotations: None, + warnings: Some(warnings), + }) + } } fn create_wasmtime_module( - policy_url: &str, + policy_id: &PolicyID, engine: &wasmtime::Engine, precompiled_policy: &PrecompiledPolicy, ) -> Result { @@ -305,7 +697,7 @@ fn create_wasmtime_module( unsafe { wasmtime::Module::deserialize(engine, &precompiled_policy.precompiled_module) } .map_err(|e| { EvaluationError::WebAssemblyError(format!( - "could not rehydrate wasmtime::Module {policy_url}: {e:?}" + "could not rehydrate wasmtime::Module {policy_id}: {e:?}" )) }) } @@ -339,30 +731,32 @@ mod tests { use std::collections::BTreeSet; use super::*; - use crate::config::Policy; + use crate::config::{PolicyGroupMember, PolicyOrPolicyGroup}; use crate::test_utils::build_admission_review_request; - fn build_evaluation_environment() -> Result { + fn build_evaluation_environment() -> EvaluationEnvironment { let engine = wasmtime::Engine::default(); let policy_ids = vec!["policy_1", "policy_2"]; - let module = wasmtime::Module::new(&engine, "(module (func))") + let module_bytes = include_bytes!("../../tests/data/gatekeeper_always_happy_policy.wasm"); + + let module = wasmtime::Module::new(&engine, module_bytes) .expect("should be able to build the smallest wasm module ever"); let (callback_handler_tx, _) = mpsc::channel(10); let precompiled_policy = PrecompiledPolicy { precompiled_module: module.serialize().unwrap(), - execution_mode: policy_evaluator::policy_evaluator::PolicyExecutionMode::Wasi, + execution_mode: policy_evaluator::policy_evaluator::PolicyExecutionMode::OpaGatekeeper, digest: "unique-digest".to_string(), }; - let mut policies: HashMap = HashMap::new(); + let mut policies: HashMap = HashMap::new(); let mut precompiled_policies: PrecompiledPolicies = PrecompiledPolicies::new(); for policy_id in &policy_ids { let policy_url = format!("file:///tmp/{policy_id}.wasm"); policies.insert( policy_id.to_string(), - Policy { + PolicyOrPolicyGroup::Policy { url: policy_url.clone(), policy_mode: PolicyMode::Protect, allowed_to_mutate: None, @@ -373,49 +767,130 @@ mod tests { precompiled_policies.insert(policy_url, Ok(precompiled_policy.clone())); } - EvaluationEnvironment::new( - &engine, - &policies, - &precompiled_policies, - None, - None, - callback_handler_tx, - true, - ) + // add poliy group policies + policies.insert( + "group_policy_valid_expression_with_single_member".to_string(), + PolicyOrPolicyGroup::PolicyGroup { + policy_mode: PolicyMode::Protect, + policies: vec![( + "policy_1".to_string(), + PolicyGroupMember { + url: "file:///tmp/policy_1.wasm".to_string(), + settings: None, + context_aware_resources: BTreeSet::new(), + }, + )] + .into_iter() + .collect(), + expression: "true || policy_1()".to_string(), + message: "something went wrong".to_string(), + }, + ); + policies.insert( + "group_policy_valid_expression_just_rhai".to_string(), + PolicyOrPolicyGroup::PolicyGroup { + policy_mode: PolicyMode::Protect, + expression: "2 > 1".to_string(), + message: "something went wrong".to_string(), + policies: HashMap::new(), + }, + ); + policies.insert( + "group_policy_not_valid_expression_because_of_unregistered_function".to_string(), + PolicyOrPolicyGroup::PolicyGroup { + policy_mode: PolicyMode::Protect, + policies: vec![( + "policy_1".to_string(), + PolicyGroupMember { + url: "file:///tmp/policy_1.wasm".to_string(), + settings: None, + context_aware_resources: BTreeSet::new(), + }, + )] + .into_iter() + .collect(), + expression: "not_a_known_policy() || policy_1()".to_string(), + message: "something went wrong".to_string(), + }, + ); + policies.insert( + "group_policy_not_valid_expression_because_of_typos".to_string(), + PolicyOrPolicyGroup::PolicyGroup { + policy_mode: PolicyMode::Protect, + expression: "something that doesn't make sense".to_string(), + message: "something went wrong".to_string(), + policies: HashMap::new(), + }, + ); + policies.insert( + "group_policy_not_valid_expression_because_of_does_not_return_boolean".to_string(), + PolicyOrPolicyGroup::PolicyGroup { + policy_mode: PolicyMode::Protect, + expression: "1 + 1".to_string(), + message: "something went wrong".to_string(), + policies: HashMap::new(), + }, + ); + policies.insert( + "group_policy_not_valid_expression_because_doing_operations_with_booleans_is_wrong" + .to_string(), + PolicyOrPolicyGroup::PolicyGroup { + policy_mode: PolicyMode::Protect, + policies: vec![( + "policy_1".to_string(), + PolicyGroupMember { + url: "file:///tmp/policy_1.wasm".to_string(), + settings: None, + context_aware_resources: BTreeSet::new(), + }, + )] + .into_iter() + .collect(), + expression: "policy_1() + 1".to_string(), + message: "something went wrong".to_string(), + }, + ); + + let eval_env_builder = + EvaluationEnvironmentBuilder::new(&engine, &precompiled_policies, callback_handler_tx); + eval_env_builder + .build_evaluation_environment(&policies) + .unwrap() } #[rstest] - #[case("policy_not_defined", true)] - #[case("policy_1", false)] - fn return_policy_not_found_error(#[case] policy_id: &str, #[case] expect_error: bool) { - let evaluation_environment = build_evaluation_environment().unwrap(); + #[case::policy_not_defined("policy_not_defined", true)] + #[case::policy_known("policy_1", false)] + fn lookup_policy(#[case] policy_id: &str, #[case] expect_error: bool) { + let policy_id = PolicyID::Policy(policy_id.to_string()); + let evaluation_environment = Arc::new(build_evaluation_environment()); let validate_request = ValidateRequest::AdmissionRequest(build_admission_review_request().request); if expect_error { assert!(matches!( - evaluation_environment.get_policy_mode(policy_id), + evaluation_environment.get_policy_mode(&policy_id), Err(EvaluationError::PolicyNotFound(_)) )); assert!(matches!( - evaluation_environment.get_policy_allowed_to_mutate(policy_id), + evaluation_environment.get_policy_allowed_to_mutate(&policy_id), Err(EvaluationError::PolicyNotFound(_)) )); assert!(matches!( - evaluation_environment.get_policy_settings(policy_id), + evaluation_environment.get_policy_settings(&policy_id), Err(EvaluationError::PolicyNotFound(_)) )); assert!(matches!( - evaluation_environment.validate(policy_id, &validate_request), + evaluation_environment.validate(&policy_id, &validate_request), Err(EvaluationError::PolicyNotFound(_)) )); } else { - assert!(evaluation_environment.get_policy_mode(policy_id).is_ok()); + assert!(evaluation_environment.get_policy_mode(&policy_id).is_ok()); assert!(evaluation_environment - .get_policy_allowed_to_mutate(policy_id) + .get_policy_allowed_to_mutate(&policy_id) .is_ok()); assert!(evaluation_environment - .get_policy_settings(policy_id) + .get_policy_settings(&policy_id) .is_ok()); // note: we do not test `validate` with a known policy because this would // cause another error. The test policy we're using is just an empty Wasm @@ -427,7 +902,7 @@ mod tests { /// created #[test] fn avoid_duplicated_instaces_of_policy_evaluator() { - let evaluation_environment = build_evaluation_environment().unwrap(); + let evaluation_environment = build_evaluation_environment(); assert_eq!( evaluation_environment @@ -439,17 +914,57 @@ mod tests { #[test] fn validate_policy_with_initialization_error() { - let mut evaluation_environment = build_evaluation_environment().unwrap(); - let policy_id = "policy_3"; + let mut evaluation_environment = build_evaluation_environment(); + let policy_id = PolicyID::Policy("policy_3".to_string()); evaluation_environment .policy_initialization_errors - .insert(policy_id.to_string(), "error".to_string()); + .insert(policy_id.clone(), "error".to_string()); + let evaluation_environment = Arc::new(evaluation_environment); let validate_request = ValidateRequest::AdmissionRequest(build_admission_review_request().request); assert!(matches!( - evaluation_environment.validate(policy_id, &validate_request).unwrap_err(), + evaluation_environment.validate(&policy_id, &validate_request).unwrap_err(), EvaluationError::PolicyInitialization(error) if error == "error" )); } + + #[rstest] + #[case::valid_expression_with_single_policy( + "group_policy_valid_expression_with_single_member", + true + )] + #[case::valid_expression_with_just_rhai("group_policy_valid_expression_just_rhai", true)] + #[case::not_valid_expression_because_of_unregistered_function( + "group_policy_not_valid_expression_because_of_unregistered_function", + false + )] + #[case::not_valid_expression_because_of_typos( + "group_policy_not_valid_expression_because_of_typos", + false + )] + #[case::not_valid_expression_because_doing_operations_with_booleans_is_wrong( + "group_policy_not_valid_expression_because_doing_operations_with_booleans_is_wrong", + false + )] + // This doesn't test doesn't pass: the int is automatically converted to boolean + // #[case::not_valid_expression_because_does_not_return_boolean( + // "group_policy_not_valid_expression_because_does_not_return_boolean", + // false + // )] + fn validate_policy_settings_of_policy_group( + #[case] policy_id: &str, + #[case] expression_is_valid: bool, + ) { + let policy_id = PolicyID::Policy(policy_id.to_string()); + // Note, the validations of the other non-group policies, and the members of the group + // policies, are going to fail because we are not running a proper wasm module. + // However we ignore these errors because we are only interested in the validation of the + // expression of the group policy + + let mut evaluation_environment = build_evaluation_environment(); + let validation_result = evaluation_environment.validate_settings(&policy_id); + + assert_eq!(expression_is_valid, validation_result.is_ok()); + } } diff --git a/src/evaluation/policy_evaluation_settings.rs b/src/evaluation/policy_evaluation_settings.rs index aba66b2b..953eff7e 100644 --- a/src/evaluation/policy_evaluation_settings.rs +++ b/src/evaluation/policy_evaluation_settings.rs @@ -1,6 +1,4 @@ -use policy_evaluator::policy_evaluator::PolicySettings; - -use crate::config::PolicyMode; +use crate::config::{PolicyMode, PolicyOrPolicyGroupSettings}; /// Holds the evaluation settings of loaded Policy. These settings are taken straight from the /// `policies.yml` file provided by the user @@ -12,5 +10,5 @@ pub(crate) struct PolicyEvaluationSettings { /// Determines if a mutating policy is actually allowed to mutate pub(crate) allowed_to_mutate: bool, /// The policy-specific settings provided by the user - pub(crate) settings: PolicySettings, + pub(crate) settings: PolicyOrPolicyGroupSettings, } diff --git a/src/evaluation/policy_id.rs b/src/evaluation/policy_id.rs new file mode 100644 index 00000000..34753120 --- /dev/null +++ b/src/evaluation/policy_id.rs @@ -0,0 +1,74 @@ +use std::{fmt, str::FromStr}; + +use crate::evaluation::errors::{EvaluationError, Result}; + +/// A unique identifier for a policy. +#[derive(Hash, Eq, PartialEq, Clone, Debug)] +pub(crate) enum PolicyID { + /// This is the identifier for "individual" policies and for "parent group" policies. + /// In both cases, this is the name of the policy as seen inside of the `policy.yml` file. + Policy(String), + /// This is the identifier of a member of a group policy + PolicyGroupPolicy { + /// The name of the group policy, which is also the ID of the parent policy + group: String, + /// The name of the policy inside of the group. This is guaranteed to be unique + name: String, + }, +} + +impl fmt::Display for PolicyID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PolicyID::Policy(name) => write!(f, "{}", name), + PolicyID::PolicyGroupPolicy { group, name } => write!(f, "{}/{}", group, name), + } + } +} + +impl FromStr for PolicyID { + type Err = EvaluationError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(EvaluationError::InvalidPolicyId(s.to_string())); + } + + let parts: Vec<&str> = s.split('/').collect(); + match parts.len() { + 1 => Ok(PolicyID::Policy(s.to_string())), + 2 => Ok(PolicyID::PolicyGroupPolicy { + group: parts[0].to_string(), + name: parts[1].to_string(), + }), + _ => Err(EvaluationError::InvalidPolicyId(s.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use rstest::*; + + use super::*; + + #[rstest] + #[case::valid_policy("policy1", Ok(PolicyID::Policy("policy1".to_string())))] + #[case::valid_member_of_policy_group("group1/policy1", + Ok( + PolicyID::PolicyGroupPolicy{ + group: "group1".to_string(), + name: "policy1".to_string(), + } + ))] + #[case::empty_policy("", Err(EvaluationError::InvalidPolicyId("".to_string())))] + #[case::too_many_separators("a/b/c", Err(EvaluationError::InvalidPolicyId("a/b/c".to_string())))] + fn create_policy_id_by_parsing_string(#[case] input: &str, #[case] expected: Result) { + let actual = input.parse::(); + + match actual { + Ok(id) => assert_eq!(id, expected.unwrap()), + Err(e) => assert_eq!(e.to_string(), expected.unwrap_err().to_string()), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 6330ae20..619c0418 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ use axum::{ Router, }; use axum_server::tls_rustls::RustlsConfig; +use evaluation::EvaluationEnvironmentBuilder; use policy_evaluator::{ callback_handler::{CallbackHandler, CallbackHandlerBuilder}, kube, @@ -43,10 +44,7 @@ use crate::api::handlers::{ validate_raw_handler, }; use crate::api::state::ApiServerState; -use crate::evaluation::{ - precompiled_policy::{PrecompiledPolicies, PrecompiledPolicy}, - EvaluationEnvironment, -}; +use crate::evaluation::precompiled_policy::{PrecompiledPolicies, PrecompiledPolicy}; use crate::policy_downloader::{Downloader, FetchedPolicies}; use config::Config; @@ -155,15 +153,21 @@ impl PolicyServer { } } - let evaluation_environment = EvaluationEnvironment::new( + let mut evaluation_environment_builder = EvaluationEnvironmentBuilder::new( &engine, - &config.policies, &precompiled_policies, - config.always_accept_admission_reviews_on_namespace, - config.policy_evaluation_limit_seconds, callback_sender_channel.clone(), - config.continue_on_errors, - )?; + ) + .with_continue_on_errors(config.continue_on_errors); + if let Some(namespace) = config.always_accept_admission_reviews_on_namespace { + evaluation_environment_builder = evaluation_environment_builder + .with_always_accept_admission_reviews_on_namespace(namespace); + } + if let Some(limit) = config.policy_evaluation_limit_seconds { + evaluation_environment_builder = + evaluation_environment_builder.with_policy_evaluation_limit_seconds(limit); + } + let evaluation_environment = evaluation_environment_builder.build(&config.policies)?; if let Some(limit) = config.policy_evaluation_limit_seconds { info!( @@ -185,7 +189,7 @@ impl PolicyServer { let state = Arc::new(ApiServerState { semaphore: Semaphore::new(config.pool_size), - evaluation_environment, + evaluation_environment: Arc::new(evaluation_environment), }); let tls_config = if let Some(tls_config) = config.tls_config { diff --git a/src/policy_downloader.rs b/src/policy_downloader.rs index ccdb4779..794002d4 100644 --- a/src/policy_downloader.rs +++ b/src/policy_downloader.rs @@ -16,7 +16,7 @@ use std::{ }; use tracing::{debug, error, info}; -use crate::config::Policy; +use crate::config::PolicyOrPolicyGroup; /// A Map with the `policy.url` as key, /// and a `PathBuf` as value. The `PathBuf` points to the location where @@ -52,10 +52,11 @@ impl Downloader { /// Download all the policies to the given destination pub async fn download_policies( &mut self, - policies: &HashMap, + policies: &HashMap, destination: impl AsRef, verification_config: Option<&LatestVerificationConfig>, ) -> FetchedPolicies { + let policies = policies_to_download(policies); let policies_total = policies.len(); info!( download_dir = destination @@ -84,9 +85,9 @@ impl Downloader { // This can be a subset of `processed_policies` let mut fetched_policies: FetchedPolicies = HashMap::new(); - for (name, policy) in policies.iter() { + for (name, policy_url) in policies.iter() { debug!(policy = name.as_str(), "download"); - if !processed_policies.insert(policy.url.as_str()) { + if !processed_policies.insert(policy_url) { debug!( policy = name.as_str(), "skipping, wasm module alredy processed" @@ -102,13 +103,12 @@ impl Downloader { policy = name.as_str(), "verifying policy authenticity and integrity using sigstore" ); - verified_manifest_digest = match ver.verify(&policy.url, verification_config).await - { + verified_manifest_digest = match ver.verify(policy_url, verification_config).await { Ok(d) => Some(d), Err(e) => { error!(policy = name.as_str(), error =?e, "policy cannot be verified"); fetched_policies.insert( - policy.url.clone(), + policy_url.to_owned(), Err(anyhow!("Policy '{}' cannot be verified: {}", name, e)), ); @@ -127,7 +127,7 @@ impl Downloader { } let fetched_policy = match policy_fetcher::fetch_policy( - &policy.url, + policy_url, policy_fetcher::PullDestination::Store(destination.as_ref().to_path_buf()), self.sources.as_ref(), ) @@ -141,11 +141,11 @@ impl Downloader { "policy download failed" ); fetched_policies.insert( - policy.url.clone(), + policy_url.to_owned(), Err(anyhow!( "Error while downloading policy '{}' from {}: {}", name, - policy.url, + policy_url, e )), ); @@ -169,7 +169,7 @@ impl Downloader { ); fetched_policies.insert( - policy.url.clone(), + policy_url.to_owned(), Err(anyhow!("Verification of policy {} failed: {}", name, e)), ); continue; @@ -209,7 +209,7 @@ impl Downloader { ); } - fetched_policies.insert(policy.url.clone(), Ok(fetched_policy.local_path)); + fetched_policies.insert(policy_url.to_owned(), Ok(fetched_policy.local_path)); } fetched_policies @@ -227,6 +227,34 @@ async fn create_verifier( Ok(verifier) } +/// Group policies need to be flattened into a single list of policies to download +/// +/// Return a map with the name of the policy as key, and the its download url as value. +/// Sub-policies are named as `group_name/sub_policy_name` +fn policies_to_download( + policies: &HashMap, +) -> HashMap { + let mut flattened_policies: HashMap = HashMap::new(); + + for (name, policy) in policies { + match policy { + PolicyOrPolicyGroup::Policy { url, .. } => { + flattened_policies.insert(name.to_owned(), url.to_owned()); + } + PolicyOrPolicyGroup::PolicyGroup { policies, .. } => { + for (sub_policy_name, sub_policy) in policies { + flattened_policies.insert( + format!("{name}/#{sub_policy_name}"), + sub_policy.url.to_owned(), + ); + } + } + } + } + + flattened_policies +} + #[cfg(test)] mod tests { use super::*; @@ -268,7 +296,7 @@ mod tests { url: registry://ghcr.io/kubewarden/tests/pod-privileged:v0.1.9 "#; - let policies: HashMap = + let policies: HashMap = serde_yaml::from_str(policies_cfg).expect("Cannot parse policy cfg"); let policy_download_dir = TempDir::new().expect("Cannot create temp dir"); @@ -310,7 +338,7 @@ mod tests { url: registry://ghcr.io/kubewarden/tests/pod-privileged:v0.1.9 "#; - let policies: HashMap = + let policies: HashMap = serde_yaml::from_str(policies_cfg).expect("Cannot parse policy cfg"); let policy_download_dir = TempDir::new().expect("Cannot create temp dir"); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e865956f..ecbcacd2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,6 +1,6 @@ use axum::Router; use policy_server::{ - config::{Config, Policy, PolicyMode}, + config::{Config, PolicyGroupMember, PolicyMode, PolicyOrPolicyGroup}, PolicyServer, }; use std::{ @@ -13,7 +13,7 @@ pub(crate) fn default_test_config() -> Config { let policies = HashMap::from([ ( "pod-privileged".to_owned(), - Policy { + PolicyOrPolicyGroup::Policy { url: "ghcr.io/kubewarden/tests/pod-privileged:v0.2.1".to_owned(), policy_mode: PolicyMode::Protect, allowed_to_mutate: None, @@ -23,7 +23,7 @@ pub(crate) fn default_test_config() -> Config { ), ( "raw-mutation".to_owned(), - Policy { + PolicyOrPolicyGroup::Policy { url: "ghcr.io/kubewarden/tests/raw-mutation-policy:v0.1.0".to_owned(), policy_mode: PolicyMode::Protect, allowed_to_mutate: Some(true), @@ -39,7 +39,7 @@ pub(crate) fn default_test_config() -> Config { ), ( "sleep".to_owned(), - Policy { + PolicyOrPolicyGroup::Policy { url: "ghcr.io/kubewarden/tests/sleeping-policy:v0.1.0".to_owned(), policy_mode: PolicyMode::Protect, allowed_to_mutate: None, @@ -47,6 +47,44 @@ pub(crate) fn default_test_config() -> Config { context_aware_resources: BTreeSet::new(), }, ), + ( + "group-policy-just-pod-privileged".to_owned(), + PolicyOrPolicyGroup::PolicyGroup { + expression: "pod_privileged() && true".to_string(), + message: "The group policy rejected your request".to_string(), + policy_mode: PolicyMode::Protect, + policies: HashMap::from([( + "pod_privileged".to_string(), + PolicyGroupMember { + url: "ghcr.io/kubewarden/tests/pod-privileged:v0.2.1".to_owned(), + settings: None, + context_aware_resources: BTreeSet::new(), + }, + )]), + }, + ), + ( + "group-policy-just-raw-mutation".to_owned(), + PolicyOrPolicyGroup::PolicyGroup { + expression: "raw_mutation() && true".to_string(), + message: "The group policy rejected your request".to_string(), + policy_mode: PolicyMode::Protect, + policies: HashMap::from([( + "raw_mutation".to_string(), + PolicyGroupMember { + url: "ghcr.io/kubewarden/tests/raw-mutation-policy:v0.1.0".to_owned(), + settings: Some(HashMap::from([ + ( + "forbiddenResources".to_owned(), + vec!["banana", "carrot"].into(), + ), + ("defaultResource".to_owned(), "hay".into()), + ])), + context_aware_resources: BTreeSet::new(), + }, + )]), + }, + ), ]); Config { diff --git a/tests/data/gatekeeper_always_happy_policy.wasm b/tests/data/gatekeeper_always_happy_policy.wasm new file mode 100644 index 00000000..05b62a85 Binary files /dev/null and b/tests/data/gatekeeper_always_happy_policy.wasm differ diff --git a/tests/data/pod_without_privileged_containers.json b/tests/data/pod_without_privileged_containers.json new file mode 100644 index 00000000..20316f95 --- /dev/null +++ b/tests/data/pod_without_privileged_containers.json @@ -0,0 +1,183 @@ +{ + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "request": { + "uid": "1299d386-525b-4032-98ae-1949f69f9cfc", + "kind": { + "group": "", + "version": "v1", + "kind": "Pod" + }, + "resource": { + "group": "", + "version": "v1", + "resource": "pods" + }, + "requestKind": { + "group": "", + "version": "v1", + "kind": "Pod" + }, + "requestResource": { + "group": "", + "version": "v1", + "resource": "pods" + }, + "name": "nginx", + "namespace": "default", + "operation": "CREATE", + "userInfo": { + "username": "kubernetes-admin", + "groups": [ + "system:masters", + "system:authenticated" + ] + }, + "object": { + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "nginx", + "namespace": "default", + "uid": "04dc7a5e-e1f1-4e34-8d65-2c9337a43e64", + "creationTimestamp": "2020-11-12T15:18:36Z", + "labels": { + "env": "test" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Pod\",\"metadata\":{\"annotations\":{},\"labels\":{\"env\":\"test\"},\"name\":\"nginx\",\"namespace\":\"default\"},\"spec\":{\"containers\":[{\"image\":\"nginx\",\"imagePullPolicy\":\"IfNotPresent\",\"name\":\"nginx\"}],\"tolerations\":[{\"effect\":\"NoSchedule\",\"key\":\"example-key\",\"operator\":\"Exists\"}]}}\n" + }, + "managedFields": [ + { + "manager": "kubectl", + "operation": "Update", + "apiVersion": "v1", + "time": "2020-11-12T15:18:36Z", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:env": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {}, + "f:tolerations": {} + } + } + } + ] + }, + "spec": { + "volumes": [ + { + "name": "default-token-pvpz7", + "secret": { + "secretName": "default-token-pvpz7" + } + } + ], + "containers": [ + { + "name": "sleeping-sidecar", + "image": "alpine", + "command": [ + "sleep", + "1h" + ], + "resources": {}, + "volumeMounts": [ + { + "name": "default-token-pvpz7", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent" + }, + { + "name": "nginx", + "image": "nginx", + "resources": {}, + "volumeMounts": [ + { + "name": "default-token-pvpz7", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "securityContext": { + "privileged": false + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "IfNotPresent" + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "default", + "serviceAccount": "default", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "dedicated", + "operator": "Equal", + "value": "tenantA", + "effect": "NoSchedule" + } + ], + "priority": 0, + "enableServiceLinks": true, + "preemptionPolicy": "PreemptLowerPriority" + }, + "status": { + "phase": "Pending", + "qosClass": "BestEffort" + } + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1" + } + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 3763f2b1..ceb2c0b0 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -15,9 +15,10 @@ use policy_evaluator::{ }; use policy_server::{ api::admission_review::AdmissionReviewResponse, - config::{Policy, PolicyMode}, + config::{PolicyMode, PolicyOrPolicyGroup}, }; use regex::Regex; +use rstest::*; use tower::ServiceExt; use crate::common::default_test_config; @@ -55,6 +56,65 @@ async fn test_validate() { ) } +#[tokio::test] +#[rstest] +#[case::pod_with_privileged_containers( + include_str!("data/pod_with_privileged_containers.json"), + false, +)] +#[case::pod_without_privileged_containers( + include_str!("data/pod_without_privileged_containers.json"), + true, +)] +async fn test_validate_policy_group(#[case] payload: &str, #[case] expected_allowed: bool) { + let config = default_test_config(); + let app = app(config).await; + + let request = Request::builder() + .method(http::Method::POST) + .header(header::CONTENT_TYPE, "application/json") + .uri("/validate/group-policy-just-pod-privileged") + .body(Body::from(payload.to_owned())) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), 200); + + let admission_review_response: AdmissionReviewResponse = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(expected_allowed, admission_review_response.response.allowed); + + if expected_allowed { + assert_eq!(admission_review_response.response.status, None); + } else { + assert_eq!( + admission_review_response.response.status, + Some( + policy_evaluator::admission_response::AdmissionResponseStatus { + message: Some("The group policy rejected your request".to_owned()), + code: None + } + ) + ); + } + + let warning_messages = &admission_review_response + .response + .warnings + .expect("warning messages should always be filled by policy groups"); + assert_eq!(1, warning_messages.len()); + + let warning_msg = &warning_messages[0]; + if expected_allowed { + assert!(warning_msg.contains("ALLOWED")); + } else { + assert!(warning_msg.contains("DENIED")); + assert!(warning_msg.contains("Privileged container is not allowed")); + } +} + #[tokio::test] async fn test_validate_policy_not_found() { let config = default_test_config(); @@ -119,6 +179,47 @@ async fn test_validate_raw() { ); } +#[tokio::test] +async fn test_validate_policy_group_does_not_do_mutation() { + let config = default_test_config(); + let app = app(config).await; + + let request = Request::builder() + .method(http::Method::POST) + .header(header::CONTENT_TYPE, "application/json") + .uri("/validate_raw/group-policy-just-raw-mutation") + .body(Body::from(include_str!("data/raw_review.json"))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), 200); + + let admission_review_response: AdmissionReviewResponse = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert!(!admission_review_response.response.allowed); + assert_eq!( + admission_review_response.response.status, + Some( + policy_evaluator::admission_response::AdmissionResponseStatus { + message: Some("The group policy rejected your request".to_owned()), + code: None + } + ) + ); + assert!(admission_review_response.response.patch.is_none()); + + let warning_messages = &admission_review_response + .response + .warnings + .expect("warning messages should always be filled by policy groups"); + assert_eq!(1, warning_messages.len()); + let warning_msg = &warning_messages[0]; + assert!(warning_msg.contains("DENIED")); + assert!(warning_msg.contains("mutation is not allowed inside of policy group")); +} + #[tokio::test] async fn test_validate_raw_policy_not_found() { let config = default_test_config(); @@ -305,7 +406,7 @@ async fn test_verified_policy() { let mut config = default_test_config(); config.policies = HashMap::from([( "pod-privileged".to_owned(), - Policy { + PolicyOrPolicyGroup::Policy { url: "ghcr.io/kubewarden/tests/pod-privileged:v0.2.1".to_owned(), policy_mode: PolicyMode::Protect, allowed_to_mutate: None, @@ -335,7 +436,7 @@ async fn test_policy_with_invalid_settings() { let mut config = default_test_config(); config.policies.insert( "invalid_settings".to_owned(), - Policy { + PolicyOrPolicyGroup::Policy { url: "ghcr.io/kubewarden/tests/sleeping-policy:v0.1.0".to_owned(), policy_mode: PolicyMode::Protect, allowed_to_mutate: None, @@ -381,7 +482,7 @@ async fn test_policy_with_wrong_url() { let mut config = default_test_config(); config.policies.insert( "wrong_url".to_owned(), - Policy { + PolicyOrPolicyGroup::Policy { url: "ghcr.io/kubewarden/tests/not_existing:v0.1.0".to_owned(), policy_mode: PolicyMode::Protect, allowed_to_mutate: None,