Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 98 additions & 41 deletions crates/openshell-supervisor-network/src/l7/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,23 +1454,68 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
(errors, warnings)
}

/// Rules expanded from a supported L7 `access` preset for the given protocol.
fn access_preset_rules(protocol: &str, access: &str) -> Option<Vec<serde_json::Value>> {
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<String> {
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())
Expand Down Expand Up @@ -1511,45 +1556,22 @@ 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()
.unwrap()
.insert("rules".to_string(), serde_json::Value::Array(rules));
}
}

warnings
}

fn rule_json(method: &str, path: &str) -> serde_json::Value {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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!({
Expand All @@ -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();
Expand Down Expand Up @@ -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")
Expand Down
26 changes: 24 additions & 2 deletions crates/openshell-supervisor-network/src/opa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -757,7 +768,18 @@ fn preprocess_yaml_data(yaml_str: &str) -> Result<String> {
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}"))
}
Expand Down
Loading