Skip to content

Commit 059c35c

Browse files
committed
fix(supervisor-network): warn on unsupported L7 access presets
Signed-off-by: Yuedong Wu <dwcn22@outlook.com>
1 parent f7aa3aa commit 059c35c

2 files changed

Lines changed: 126 additions & 65 deletions

File tree

‎crates/openshell-supervisor-network/src/l7/mod.rs‎

Lines changed: 106 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,23 +1454,76 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
14541454
(errors, warnings)
14551455
}
14561456

1457+
/// Map a supported L7 `access` preset to explicit rules for `protocol`.
1458+
///
1459+
/// Returns `None` when `access` is not `read-only`, `read-write`, or `full`.
1460+
/// Graphql and websocket presets differ from REST; all other protocols use
1461+
/// REST presets. Requires prior `validate_l7_policies` so mcp/json-rpc `access`
1462+
/// is rejected before expansion runs.
1463+
fn access_preset_rules(protocol: &str, access: &str) -> Option<Vec<serde_json::Value>> {
1464+
if protocol == "graphql" {
1465+
match access {
1466+
"read-only" => Some(vec![graphql_rule_json("query")]),
1467+
"read-write" => Some(vec![
1468+
graphql_rule_json("query"),
1469+
graphql_rule_json("mutation"),
1470+
]),
1471+
"full" => Some(vec![graphql_rule_json("*")]),
1472+
_ => None,
1473+
}
1474+
} else if protocol == "websocket" {
1475+
match access {
1476+
"read-only" => Some(vec![rule_json("GET", "**")]),
1477+
"read-write" => Some(vec![
1478+
rule_json("GET", "**"),
1479+
rule_json("WEBSOCKET_TEXT", "**"),
1480+
]),
1481+
"full" => Some(vec![rule_json("*", "**")]),
1482+
_ => None,
1483+
}
1484+
} else {
1485+
match access {
1486+
"read-only" => Some(vec![
1487+
rule_json("GET", "**"),
1488+
rule_json("HEAD", "**"),
1489+
rule_json("OPTIONS", "**"),
1490+
]),
1491+
"read-write" => Some(vec![
1492+
rule_json("GET", "**"),
1493+
rule_json("HEAD", "**"),
1494+
rule_json("OPTIONS", "**"),
1495+
rule_json("POST", "**"),
1496+
rule_json("PUT", "**"),
1497+
rule_json("PATCH", "**"),
1498+
]),
1499+
"full" => Some(vec![rule_json("*", "**")]),
1500+
_ => None,
1501+
}
1502+
}
1503+
}
1504+
14571505
/// Expand `access` presets into explicit `rules` in the policy data.
14581506
///
14591507
/// This preprocesses the JSON data so Rego only needs to handle explicit rules.
1460-
pub fn expand_access_presets(data: &mut serde_json::Value) {
1508+
/// Returns warnings for unsupported `access` values that were ignored.
1509+
///
1510+
/// Unsupported preset detection lives here rather than in `validate_l7_policies`
1511+
/// so supported presets stay defined only in `access_preset_rules`.
1512+
pub fn expand_access_presets(data: &mut serde_json::Value) -> Vec<String> {
1513+
let mut warnings = Vec::new();
14611514
let Some(policies) = data
14621515
.get_mut("network_policies")
14631516
.and_then(|v| v.as_object_mut())
14641517
else {
1465-
return;
1518+
return warnings;
14661519
};
14671520

1468-
for (_name, policy) in policies.iter_mut() {
1521+
for (name, policy) in policies.iter_mut() {
14691522
let Some(endpoints) = policy.get_mut("endpoints").and_then(|v| v.as_array_mut()) else {
14701523
continue;
14711524
};
14721525

1473-
for ep in endpoints.iter_mut() {
1526+
for (i, ep) in endpoints.iter_mut().enumerate() {
14741527
let protocol = ep
14751528
.get("protocol")
14761529
.and_then(|v| v.as_str())
@@ -1511,45 +1564,22 @@ pub fn expand_access_presets(data: &mut serde_json::Value) {
15111564
continue;
15121565
}
15131566

1514-
let rules = if protocol == "graphql" {
1515-
match access.as_str() {
1516-
"read-only" => vec![graphql_rule_json("query")],
1517-
"read-write" => vec![graphql_rule_json("query"), graphql_rule_json("mutation")],
1518-
"full" => vec![graphql_rule_json("*")],
1519-
_ => continue,
1520-
}
1521-
} else if protocol == "websocket" {
1522-
match access.as_str() {
1523-
"read-only" => vec![rule_json("GET", "**")],
1524-
"read-write" => vec![rule_json("GET", "**"), rule_json("WEBSOCKET_TEXT", "**")],
1525-
"full" => vec![rule_json("*", "**")],
1526-
_ => continue,
1527-
}
1528-
} else {
1529-
match access.as_str() {
1530-
"read-only" => vec![
1531-
rule_json("GET", "**"),
1532-
rule_json("HEAD", "**"),
1533-
rule_json("OPTIONS", "**"),
1534-
],
1535-
"read-write" => vec![
1536-
rule_json("GET", "**"),
1537-
rule_json("HEAD", "**"),
1538-
rule_json("OPTIONS", "**"),
1539-
rule_json("POST", "**"),
1540-
rule_json("PUT", "**"),
1541-
rule_json("PATCH", "**"),
1542-
],
1543-
"full" => vec![rule_json("*", "**")],
1544-
_ => continue,
1545-
}
1567+
let Some(rules) = access_preset_rules(protocol, &access) else {
1568+
let loc = format!("{name}.endpoints[{i}]");
1569+
let warning = format!(
1570+
"{loc}: unsupported L7 access preset '{access}' ignored; expected one of: read-only, read-write, full"
1571+
);
1572+
warnings.push(warning);
1573+
continue;
15461574
};
15471575

15481576
ep.as_object_mut()
15491577
.unwrap()
15501578
.insert("rules".to_string(), serde_json::Value::Array(rules));
15511579
}
15521580
}
1581+
1582+
warnings
15531583
}
15541584

15551585
fn rule_json(method: &str, path: &str) -> serde_json::Value {
@@ -1840,7 +1870,8 @@ mod tests {
18401870
}
18411871
});
18421872

1843-
expand_access_presets(&mut data);
1873+
let warnings = expand_access_presets(&mut data);
1874+
assert!(warnings.is_empty(), "expected no warnings: {warnings:?}");
18441875
let rules = data["network_policies"]["test"]["endpoints"][0]["rules"]
18451876
.as_array()
18461877
.unwrap();
@@ -2700,7 +2731,8 @@ mod tests {
27002731
}
27012732
}
27022733
});
2703-
expand_access_presets(&mut data);
2734+
let warnings = expand_access_presets(&mut data);
2735+
assert!(warnings.is_empty(), "expected no warnings: {warnings:?}");
27042736
let rules = data["network_policies"]["test"]["endpoints"][0]["rules"]
27052737
.as_array()
27062738
.unwrap();
@@ -2729,7 +2761,8 @@ mod tests {
27292761
}
27302762
}
27312763
});
2732-
expand_access_presets(&mut data);
2764+
let warnings = expand_access_presets(&mut data);
2765+
assert!(warnings.is_empty(), "expected no warnings: {warnings:?}");
27332766
let rules = data["network_policies"]["test"]["endpoints"][0]["rules"]
27342767
.as_array()
27352768
.unwrap();
@@ -2738,6 +2771,36 @@ mod tests {
27382771
assert_eq!(rules[0]["allow"]["path"].as_str().unwrap(), "**");
27392772
}
27402773

2774+
#[test]
2775+
fn expand_unsupported_access_preset_warns_and_leaves_rules_unexpanded() {
2776+
let mut data = serde_json::json!({
2777+
"network_policies": {
2778+
"allow-my-service": {
2779+
"endpoints": [{
2780+
"host": "my-service.example.com",
2781+
"port": 80,
2782+
"protocol": "rest",
2783+
"access": "allow"
2784+
}],
2785+
"binaries": []
2786+
}
2787+
}
2788+
});
2789+
let warnings = expand_access_presets(&mut data);
2790+
assert!(
2791+
data["network_policies"]["allow-my-service"]["endpoints"][0]
2792+
.get("rules")
2793+
.is_none(),
2794+
"unsupported access preset must not expand rules"
2795+
);
2796+
assert_eq!(
2797+
warnings,
2798+
vec![
2799+
"allow-my-service.endpoints[0]: unsupported L7 access preset 'allow' ignored; expected one of: read-only, read-write, full".to_string()
2800+
]
2801+
);
2802+
}
2803+
27412804
#[test]
27422805
fn expand_graphql_readonly_preset() {
27432806
let mut data = serde_json::json!({
@@ -2753,7 +2816,8 @@ mod tests {
27532816
}
27542817
}
27552818
});
2756-
expand_access_presets(&mut data);
2819+
let warnings = expand_access_presets(&mut data);
2820+
assert!(warnings.is_empty(), "expected no warnings: {warnings:?}");
27572821
let rules = data["network_policies"]["test"]["endpoints"][0]["rules"]
27582822
.as_array()
27592823
.unwrap();
@@ -2826,7 +2890,8 @@ mod tests {
28262890
}
28272891
}
28282892
});
2829-
expand_access_presets(&mut data);
2893+
let warnings = expand_access_presets(&mut data);
2894+
assert!(warnings.is_empty(), "expected no warnings: {warnings:?}");
28302895
assert!(
28312896
data["network_policies"]["test"]["endpoints"][0]
28322897
.get("rules")

‎crates/openshell-supervisor-network/src/opa.rs‎

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -201,17 +201,7 @@ impl OpaEngine {
201201

202202
// Validate BEFORE expanding presets
203203
let (errors, warnings) = crate::l7::validate_l7_policies(&data);
204-
for w in &warnings {
205-
openshell_ocsf::ocsf_emit!(
206-
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
207-
.severity(openshell_ocsf::SeverityId::Medium)
208-
.status(openshell_ocsf::StatusId::Success)
209-
.state(openshell_ocsf::StateId::Enabled, "validated")
210-
.unmapped("warning", serde_json::json!(w.clone()))
211-
.message(format!("L7 policy validation warning: {w}"))
212-
.build()
213-
);
214-
}
204+
emit_l7_config_warnings(&warnings, "L7 policy validation warning");
215205
if !errors.is_empty() {
216206
return Err(miette::miette!(
217207
"L7 policy validation failed:\n{}",
@@ -222,7 +212,8 @@ impl OpaEngine {
222212
normalize_l7_policy_rule_aliases(&mut data);
223213

224214
// Expand access presets to explicit rules after validation
225-
crate::l7::expand_access_presets(&mut data);
215+
let expansion_warnings = crate::l7::expand_access_presets(&mut data);
216+
emit_l7_config_warnings(&expansion_warnings, "L7 access preset expansion warning");
226217

227218
let data_json = data.to_string();
228219
let mut engine = regorus::Engine::new();
@@ -719,6 +710,20 @@ fn parse_process_policy(val: &regorus::Value) -> ProcessPolicy {
719710
}
720711
}
721712

713+
fn emit_l7_config_warnings(warnings: &[String], prefix: &str) {
714+
for w in warnings {
715+
openshell_ocsf::ocsf_emit!(
716+
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
717+
.severity(openshell_ocsf::SeverityId::Medium)
718+
.status(openshell_ocsf::StatusId::Success)
719+
.state(openshell_ocsf::StateId::Enabled, "validated")
720+
.unmapped("warning", serde_json::json!(w))
721+
.message(format!("{prefix}: {w}"))
722+
.build()
723+
);
724+
}
725+
}
726+
722727
/// Preprocess YAML policy data: parse, normalize, validate, expand access presets, return JSON.
723728
fn preprocess_yaml_data(yaml_str: &str) -> Result<String> {
724729
let mut data: serde_json::Value = serde_yml::from_str(yaml_str)
@@ -736,17 +741,7 @@ fn preprocess_yaml_data(yaml_str: &str) -> Result<String> {
736741

737742
// Validate BEFORE expanding presets (catches user errors like rules+access)
738743
let (errors, warnings) = crate::l7::validate_l7_policies(&data);
739-
for w in &warnings {
740-
openshell_ocsf::ocsf_emit!(
741-
openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx())
742-
.severity(openshell_ocsf::SeverityId::Medium)
743-
.status(openshell_ocsf::StatusId::Success)
744-
.state(openshell_ocsf::StateId::Enabled, "validated")
745-
.unmapped("warning", serde_json::json!(w.clone()))
746-
.message(format!("L7 policy validation warning: {w}"))
747-
.build()
748-
);
749-
}
744+
emit_l7_config_warnings(&warnings, "L7 policy validation warning");
750745
if !errors.is_empty() {
751746
return Err(miette::miette!(
752747
"L7 policy validation failed:\n{}",
@@ -757,7 +752,8 @@ fn preprocess_yaml_data(yaml_str: &str) -> Result<String> {
757752
normalize_l7_policy_rule_aliases(&mut data);
758753

759754
// Expand access presets to explicit rules after validation
760-
crate::l7::expand_access_presets(&mut data);
755+
let expansion_warnings = crate::l7::expand_access_presets(&mut data);
756+
emit_l7_config_warnings(&expansion_warnings, "L7 access preset expansion warning");
761757

762758
serde_json::to_string(&data).map_err(|e| miette::miette!("failed to serialize data: {e}"))
763759
}

0 commit comments

Comments
 (0)