From f5e8b1a8e0d54e82b709902c3d336eec3e01105e Mon Sep 17 00:00:00 2001 From: Flavio Castelli Date: Mon, 8 Jul 2024 14:43:54 +0200 Subject: [PATCH] feat: introduce group policies Allow multiple Kubewarden policies to be grouped together using an evaluation expression. This implents the RFC https://github.com/kubewarden/rfc/blob/main/rfc/0020-policy-group.md Signed-off-by: Flavio Castelli Co-authored-by: Fabrizio Sestito --- Cargo.lock | 86 +- Cargo.toml | 1 + README.md | 126 ++- policies.yml.example | 25 + src/api/handlers.rs | 3 +- src/api/service.rs | 118 +-- src/api/state.rs | 3 +- src/config.rs | 407 +++++---- src/evaluation.rs | 5 + src/evaluation/errors.rs | 10 +- src/evaluation/evaluation_environment.rs | 811 ++++++++++++++---- src/evaluation/policy_evaluation_settings.rs | 6 +- src/evaluation/policy_id.rs | 74 ++ src/lib.rs | 26 +- src/policy_downloader.rs | 56 +- tests/common/mod.rs | 46 +- .../data/gatekeeper_always_happy_policy.wasm | Bin 0 -> 131355 bytes .../pod_without_privileged_containers.json | 183 ++++ tests/integration_test.rs | 109 ++- 19 files changed, 1660 insertions(+), 435 deletions(-) create mode 100644 src/evaluation/policy_id.rs create mode 100644 tests/data/gatekeeper_always_happy_policy.wasm create mode 100644 tests/data/pod_without_privileged_containers.json 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 0000000000000000000000000000000000000000..05b62a8540a62ccfbbff979390d154d5e263ba56 GIT binary patch literal 131355 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 z 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 BUuggU literal 0 HcmV?d00001 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,