From 4c0029870a3f9cef2428d16547f63018ade3fde5 Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Wed, 8 Jul 2026 18:58:15 +0800 Subject: [PATCH] fix(supervisor-network): warn on unsupported L7 access presets Signed-off-by: Yuedong Wu --- .../src/l7/mod.rs | 139 ++++++++++++------ .../openshell-supervisor-network/src/opa.rs | 26 +++- 2 files changed, 122 insertions(+), 43 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index d10c19b8e8..bf02f3f30d 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -1454,23 +1454,68 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec, Vec< (errors, warnings) } +/// Rules expanded from a supported L7 `access` preset for the given protocol. +fn access_preset_rules(protocol: &str, access: &str) -> Option> { + if protocol == "graphql" { + match access { + "read-only" => Some(vec![graphql_rule_json("query")]), + "read-write" => Some(vec![ + graphql_rule_json("query"), + graphql_rule_json("mutation"), + ]), + "full" => Some(vec![graphql_rule_json("*")]), + _ => None, + } + } else if protocol == "websocket" { + match access { + "read-only" => Some(vec![rule_json("GET", "**")]), + "read-write" => Some(vec![ + rule_json("GET", "**"), + rule_json("WEBSOCKET_TEXT", "**"), + ]), + "full" => Some(vec![rule_json("*", "**")]), + _ => None, + } + } else { + match access { + "read-only" => Some(vec![ + rule_json("GET", "**"), + rule_json("HEAD", "**"), + rule_json("OPTIONS", "**"), + ]), + "read-write" => Some(vec![ + rule_json("GET", "**"), + rule_json("HEAD", "**"), + rule_json("OPTIONS", "**"), + rule_json("POST", "**"), + rule_json("PUT", "**"), + rule_json("PATCH", "**"), + ]), + "full" => Some(vec![rule_json("*", "**")]), + _ => None, + } + } +} + /// Expand `access` presets into explicit `rules` in the policy data. /// /// This preprocesses the JSON data so Rego only needs to handle explicit rules. -pub fn expand_access_presets(data: &mut serde_json::Value) { +/// Returns warnings for unsupported `access` values that were ignored. +pub fn expand_access_presets(data: &mut serde_json::Value) -> Vec { + let mut warnings = Vec::new(); let Some(policies) = data .get_mut("network_policies") .and_then(|v| v.as_object_mut()) else { - return; + return warnings; }; - for (_name, policy) in policies.iter_mut() { + for (name, policy) in policies.iter_mut() { let Some(endpoints) = policy.get_mut("endpoints").and_then(|v| v.as_array_mut()) else { continue; }; - for ep in endpoints.iter_mut() { + for (i, ep) in endpoints.iter_mut().enumerate() { let protocol = ep .get("protocol") .and_then(|v| v.as_str()) @@ -1511,38 +1556,13 @@ pub fn expand_access_presets(data: &mut serde_json::Value) { continue; } - let rules = if protocol == "graphql" { - match access.as_str() { - "read-only" => vec![graphql_rule_json("query")], - "read-write" => vec![graphql_rule_json("query"), graphql_rule_json("mutation")], - "full" => vec![graphql_rule_json("*")], - _ => continue, - } - } else if protocol == "websocket" { - match access.as_str() { - "read-only" => vec![rule_json("GET", "**")], - "read-write" => vec![rule_json("GET", "**"), rule_json("WEBSOCKET_TEXT", "**")], - "full" => vec![rule_json("*", "**")], - _ => continue, - } - } else { - match access.as_str() { - "read-only" => vec![ - rule_json("GET", "**"), - rule_json("HEAD", "**"), - rule_json("OPTIONS", "**"), - ], - "read-write" => vec![ - rule_json("GET", "**"), - rule_json("HEAD", "**"), - rule_json("OPTIONS", "**"), - rule_json("POST", "**"), - rule_json("PUT", "**"), - rule_json("PATCH", "**"), - ], - "full" => vec![rule_json("*", "**")], - _ => continue, - } + let Some(rules) = access_preset_rules(protocol, &access) else { + let loc = format!("{name}.endpoints[{i}]"); + let warning = format!( + "{loc}: unsupported L7 access preset '{access}' ignored; expected one of: read-only, read-write, full" + ); + warnings.push(warning); + continue; }; ep.as_object_mut() @@ -1550,6 +1570,8 @@ pub fn expand_access_presets(data: &mut serde_json::Value) { .insert("rules".to_string(), serde_json::Value::Array(rules)); } } + + warnings } fn rule_json(method: &str, path: &str) -> serde_json::Value { @@ -1840,7 +1862,8 @@ mod tests { } }); - expand_access_presets(&mut data); + let warnings = expand_access_presets(&mut data); + assert!(warnings.is_empty(), "expected no warnings: {warnings:?}"); let rules = data["network_policies"]["test"]["endpoints"][0]["rules"] .as_array() .unwrap(); @@ -2700,7 +2723,8 @@ mod tests { } } }); - expand_access_presets(&mut data); + let warnings = expand_access_presets(&mut data); + assert!(warnings.is_empty(), "expected no warnings: {warnings:?}"); let rules = data["network_policies"]["test"]["endpoints"][0]["rules"] .as_array() .unwrap(); @@ -2729,7 +2753,8 @@ mod tests { } } }); - expand_access_presets(&mut data); + let warnings = expand_access_presets(&mut data); + assert!(warnings.is_empty(), "expected no warnings: {warnings:?}"); let rules = data["network_policies"]["test"]["endpoints"][0]["rules"] .as_array() .unwrap(); @@ -2738,6 +2763,36 @@ mod tests { assert_eq!(rules[0]["allow"]["path"].as_str().unwrap(), "**"); } + #[test] + fn expand_unsupported_access_preset_warns_and_leaves_rules_unexpanded() { + let mut data = serde_json::json!({ + "network_policies": { + "allow-my-service": { + "endpoints": [{ + "host": "my-service.example.com", + "port": 80, + "protocol": "rest", + "access": "allow" + }], + "binaries": [] + } + } + }); + let warnings = expand_access_presets(&mut data); + assert!( + data["network_policies"]["allow-my-service"]["endpoints"][0] + .get("rules") + .is_none(), + "unsupported access preset must not expand rules" + ); + assert_eq!( + warnings, + vec![ + "allow-my-service.endpoints[0]: unsupported L7 access preset 'allow' ignored; expected one of: read-only, read-write, full".to_string() + ] + ); + } + #[test] fn expand_graphql_readonly_preset() { let mut data = serde_json::json!({ @@ -2753,7 +2808,8 @@ mod tests { } } }); - expand_access_presets(&mut data); + let warnings = expand_access_presets(&mut data); + assert!(warnings.is_empty(), "expected no warnings: {warnings:?}"); let rules = data["network_policies"]["test"]["endpoints"][0]["rules"] .as_array() .unwrap(); @@ -2826,7 +2882,8 @@ mod tests { } } }); - expand_access_presets(&mut data); + let warnings = expand_access_presets(&mut data); + assert!(warnings.is_empty(), "expected no warnings: {warnings:?}"); assert!( data["network_policies"]["test"]["endpoints"][0] .get("rules") diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index fbab5fedd7..18821a006a 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -222,7 +222,18 @@ impl OpaEngine { normalize_l7_policy_rule_aliases(&mut data); // Expand access presets to explicit rules after validation - crate::l7::expand_access_presets(&mut data); + let expansion_warnings = crate::l7::expand_access_presets(&mut data); + for w in &expansion_warnings { + openshell_ocsf::ocsf_emit!( + openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(openshell_ocsf::SeverityId::Medium) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Enabled, "validated") + .unmapped("warning", serde_json::json!(w.clone())) + .message(format!("L7 access preset expansion warning: {w}")) + .build() + ); + } let data_json = data.to_string(); let mut engine = regorus::Engine::new(); @@ -757,7 +768,18 @@ fn preprocess_yaml_data(yaml_str: &str) -> Result { normalize_l7_policy_rule_aliases(&mut data); // Expand access presets to explicit rules after validation - crate::l7::expand_access_presets(&mut data); + let expansion_warnings = crate::l7::expand_access_presets(&mut data); + for w in &expansion_warnings { + openshell_ocsf::ocsf_emit!( + openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(openshell_ocsf::SeverityId::Medium) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Enabled, "validated") + .unmapped("warning", serde_json::json!(w.clone())) + .message(format!("L7 access preset expansion warning: {w}")) + .build() + ); + } serde_json::to_string(&data).map_err(|e| miette::miette!("failed to serialize data: {e}")) }