Skip to content

Commit cfca5ba

Browse files
committed
feat(policy): route shared host l7 endpoints by path
1 parent 56a0ce1 commit cfca5ba

15 files changed

Lines changed: 631 additions & 82 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

architecture/sandbox.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,7 +1032,7 @@ flowchart LR
10321032
| `L7Protocol` | `Rest`, `Graphql`, `Sql` | Supported application protocols |
10331033
| `TlsMode` | `Auto` (default), `Skip` | TLS handling strategy — `Auto` peeks first bytes and terminates if TLS is detected; `Skip` bypasses detection entirely |
10341034
| `EnforcementMode` | `Audit`, `Enforce` | What to do on L7 deny (log-only vs block) |
1035-
| `L7EndpointConfig` | `{ protocol, tls, enforcement, allow_encoded_slash, graphql_max_body_bytes }` | Per-endpoint L7 configuration |
1035+
| `L7EndpointConfig` | `{ protocol, path, tls, enforcement, allow_encoded_slash, graphql_max_body_bytes }` | Per-endpoint L7 configuration, including optional path scoping for shared host:port APIs |
10361036
| `L7Decision` | `{ allowed, reason, matched_rule }` | Result of L7 evaluation |
10371037
| `L7RequestInfo` | `{ action, target, query_params, graphql }` | HTTP method, path, decoded query multimap, and optional GraphQL classification for policy evaluation |
10381038

architecture/security-policy.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ Each endpoint defines a network destination and, optionally, L7 inspection behav
469469
| `host` | `string` | _(required)_ | Hostname or glob pattern to match (case-insensitive). Supports wildcards (`*.example.com`). Optional when `allowed_ips` is set (see [Hostless Endpoints](#hostless-endpoints-allowed_ips-without-host)). See [Host Wildcards](#host-wildcards). |
470470
| `port` | `integer` | _(required)_ | TCP port to match. Mutually exclusive with `ports` — if both are set, `ports` takes precedence. See [Multi-Port Endpoints](#multi-port-endpoints). |
471471
| `ports` | `integer[]`| `[]` | Multiple TCP ports to match. When non-empty, the endpoint covers all listed ports. Backwards compatible with `port`. See [Multi-Port Endpoints](#multi-port-endpoints). |
472+
| `path` | `string` | `""` | Optional HTTP path glob for L7 endpoint selection when multiple protocols share a host:port, such as `/repos/**` and `/graphql`. Empty matches all paths. |
472473
| `protocol` | `string` | `""` | Application protocol for L7 inspection. See [Behavioral Trigger: L7 Inspection](#behavioral-trigger-l7-inspection). |
473474
| `tls` | `string` | `""` (auto) | TLS handling mode. Absent or empty: auto-detect and terminate TLS if detected. `"skip"`: bypass TLS detection entirely. `"terminate"` and `"passthrough"` are deprecated (treated as auto). See [Behavioral Trigger: TLS Handling](#behavioral-trigger-tls-handling). |
474475
| `enforcement` | `string` | `"audit"` | L7 enforcement mode: `"enforce"` or `"audit"` |
@@ -739,7 +740,7 @@ flowchart LR
739740

740741
This is the single most important behavioral trigger in the policy language. An endpoint with no `protocol` field passes traffic opaquely after the L4 (CONNECT) check. Adding `protocol: rest` or `protocol: graphql` activates per-request HTTP parsing and policy evaluation inside the proxy.
741742

742-
**Implementation path**: After L4 CONNECT is allowed, the proxy calls `query_l7_route_snapshot()` which evaluates the Rego rule `data.openshell.sandbox.matched_endpoint_config` and records the policy generation. If an endpoint `protocol` config is returned, the proxy enters `relay_with_inspection()` instead of `copy_bidirectional()`. See `crates/openshell-sandbox/src/proxy.rs` -- `handle_tcp_connection()`.
743+
**Implementation path**: After L4 CONNECT is allowed, the proxy calls `query_l7_route_snapshot()` which evaluates the Rego rule `data.openshell.sandbox._matching_endpoint_configs` and records the policy generation. If one or more endpoint `protocol` configs are returned, the proxy enters path-aware L7 route selection instead of `copy_bidirectional()`. See `crates/openshell-sandbox/src/proxy.rs` -- `handle_tcp_connection()`.
743744

744745
For L7-inspected CONNECT tunnels, the proxy binds endpoint config and the per-tunnel policy engine clone to the policy generation observed at tunnel setup. If a live policy reload advances the generation, the relay closes the existing keep-alive tunnel before forwarding another request. HTTP passthrough tunnels without endpoint `protocol` use the same generation guard for parsed requests even though they do not evaluate L7 OPA rules. Clients should reconnect so the next request is evaluated under the current policy.
745746

crates/openshell-policy/src/lib.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ struct NetworkPolicyRuleDef {
8989
struct NetworkEndpointDef {
9090
#[serde(default, skip_serializing_if = "String::is_empty")]
9191
host: String,
92+
#[serde(default, skip_serializing_if = "String::is_empty")]
93+
path: String,
9294
/// Single port (backwards compat). Mutually exclusive with `ports`.
9395
/// Uses `u16` to reject invalid values >65535 at parse time.
9496
#[serde(default, skip_serializing_if = "is_zero")]
@@ -245,6 +247,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy {
245247
};
246248
NetworkEndpoint {
247249
host: e.host,
250+
path: e.path,
248251
port: normalized_ports.first().copied().unwrap_or(0),
249252
ports: normalized_ports,
250253
protocol: e.protocol,
@@ -409,6 +412,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
409412
};
410413
NetworkEndpointDef {
411414
host: e.host.clone(),
415+
path: e.path.clone(),
412416
port,
413417
ports,
414418
protocol: e.protocol.clone(),
@@ -1392,6 +1396,34 @@ network_policies:
13921396
assert_eq!(ep.port, 443);
13931397
}
13941398

1399+
#[test]
1400+
fn round_trip_preserves_endpoint_path() {
1401+
let yaml = r#"
1402+
version: 1
1403+
network_policies:
1404+
test:
1405+
name: test
1406+
endpoints:
1407+
- host: api.example.com
1408+
port: 443
1409+
path: "/graphql"
1410+
protocol: graphql
1411+
rules:
1412+
- allow:
1413+
operation_type: query
1414+
binaries:
1415+
- { path: /usr/bin/curl }
1416+
"#;
1417+
let proto1 = parse_sandbox_policy(yaml).expect("parse failed");
1418+
let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed");
1419+
let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed");
1420+
1421+
let ep1 = &proto1.network_policies["test"].endpoints[0];
1422+
let ep2 = &proto2.network_policies["test"].endpoints[0];
1423+
assert_eq!(ep1.path, "/graphql");
1424+
assert_eq!(ep1.path, ep2.path);
1425+
}
1426+
13951427
#[test]
13961428
fn round_trip_preserves_multi_port() {
13971429
let yaml = r"

crates/openshell-policy/src/merge.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,9 @@ fn merge_endpoint(
389389
if existing.host.is_empty() {
390390
existing.host.clone_from(&incoming.host);
391391
}
392+
if existing.path.is_empty() {
393+
existing.path.clone_from(&incoming.path);
394+
}
392395

393396
merge_endpoint_ports(existing, incoming);
394397
let existing_protocol = existing.protocol.clone();
@@ -508,6 +511,9 @@ fn endpoints_overlap(left: &NetworkEndpoint, right: &NetworkEndpoint) -> bool {
508511
if !left.host.eq_ignore_ascii_case(&right.host) {
509512
return false;
510513
}
514+
if left.path != right.path {
515+
return false;
516+
}
511517

512518
let left_ports = canonical_ports(left);
513519
let right_ports = canonical_ports(right);

crates/openshell-sandbox/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ serde = { workspace = true }
6767
serde_json = { workspace = true }
6868
serde_yml = { workspace = true }
6969
apollo-parser = { workspace = true }
70+
glob = { workspace = true }
7071

7172
# Logging
7273
tracing = { workspace = true }

crates/openshell-sandbox/data/sandbox-policy.rego

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ default allow_request = false
179179
_policy_allows_l7(policy) if {
180180
some ep
181181
ep := policy.endpoints[_]
182-
endpoint_matches_request(ep, input.network)
182+
endpoint_matches_l7_request(ep, input.network, input.request)
183183
request_allowed_for_endpoint(input.request, ep)
184184
}
185185

@@ -207,7 +207,7 @@ default deny_request = false
207207
_policy_denies_l7(policy) if {
208208
some ep
209209
ep := policy.endpoints[_]
210-
endpoint_matches_request(ep, input.network)
210+
endpoint_matches_l7_request(ep, input.network, input.request)
211211
request_denied_for_endpoint(input.request, ep)
212212
}
213213

@@ -250,6 +250,16 @@ request_denied_for_endpoint(request, endpoint) if {
250250
graphql_deny_rule_matches_operation(op, deny_rule, endpoint)
251251
}
252252

253+
# A GraphQL endpoint path is authoritative once it matches. If the parsed
254+
# GraphQL request is malformed, hash-only without a trusted registry entry, or
255+
# contains an operation outside the GraphQL allow rules, a broader REST rule on
256+
# the same host:port must not allow it through.
257+
request_denied_for_endpoint(request, endpoint) if {
258+
endpoint.protocol == "graphql"
259+
is_object(request.graphql)
260+
not graphql_request_allowed(request, endpoint)
261+
}
262+
253263
# Deny query matching: fail-closed semantics.
254264
# If no query rules on the deny rule, match unconditionally (any query params).
255265
# If query rules present, trigger the deny if ANY value for a configured key
@@ -314,7 +324,7 @@ request_deny_reason := reason if {
314324
input.request
315325
deny_request
316326
graphql_request_has_operations(input.request)
317-
reason := "GraphQL operation blocked by deny rule"
327+
reason := "GraphQL operation blocked by endpoint policy"
318328
}
319329

320330
request_deny_reason := reason if {
@@ -632,6 +642,21 @@ endpoint_matches_request(ep, network) if {
632642
ep.ports[_] == network.port
633643
}
634644

645+
endpoint_matches_l7_request(ep, network, request) if {
646+
endpoint_matches_request(ep, network)
647+
endpoint_path_matches_request(ep, request)
648+
}
649+
650+
endpoint_path_matches_request(ep, request) if {
651+
object.get(ep, "path", "") == ""
652+
}
653+
654+
endpoint_path_matches_request(ep, request) if {
655+
path := object.get(ep, "path", "")
656+
path != ""
657+
path_matches(request.path, path)
658+
}
659+
635660
# An endpoint has extended config if it specifies L7 protocol, allowed_ips,
636661
# or an explicit tls mode (e.g. tls: skip).
637662
endpoint_has_extended_config(ep) if {

crates/openshell-sandbox/src/l7/mod.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ pub enum EnforcementMode {
6161
#[derive(Debug, Clone)]
6262
pub struct L7EndpointConfig {
6363
pub protocol: L7Protocol,
64+
/// Optional endpoint-level HTTP path glob used to select between L7
65+
/// protocols that share the same host:port.
66+
pub path: String,
6467
pub tls: TlsMode,
6568
pub enforcement: EnforcementMode,
6669
/// Maximum GraphQL request body bytes to buffer for inspection.
@@ -142,13 +145,41 @@ pub fn parse_l7_config(val: &regorus::Value) -> Option<L7EndpointConfig> {
142145

143146
Some(L7EndpointConfig {
144147
protocol,
148+
path: get_object_str(val, "path").unwrap_or_default(),
145149
tls,
146150
enforcement,
147151
graphql_max_body_bytes,
148152
allow_encoded_slash,
149153
})
150154
}
151155

156+
impl L7EndpointConfig {
157+
pub fn matches_path(&self, path: &str) -> bool {
158+
endpoint_path_matches(&self.path, path)
159+
}
160+
161+
pub fn path_specificity(&self) -> usize {
162+
if self.path.is_empty() {
163+
0
164+
} else {
165+
self.path.chars().filter(|c| *c != '*').count()
166+
}
167+
}
168+
}
169+
170+
pub fn endpoint_path_matches(pattern: &str, path: &str) -> bool {
171+
if pattern.is_empty() || pattern == "**" || pattern == "/**" {
172+
return true;
173+
}
174+
if pattern == path {
175+
return true;
176+
}
177+
if let Some(prefix) = pattern.strip_suffix("/**") {
178+
return path == prefix || path.starts_with(&format!("{prefix}/"));
179+
}
180+
glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path))
181+
}
182+
152183
/// Parse the `tls` field from an endpoint config, independent of L7 protocol.
153184
///
154185
/// Used to check for `tls: skip` even on L4-only endpoints (no `protocol`
@@ -352,6 +383,7 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
352383
.and_then(|v| v.as_array())
353384
.is_some_and(|a| !a.is_empty());
354385
let host = ep.get("host").and_then(|v| v.as_str()).unwrap_or("");
386+
let endpoint_path = ep.get("path").and_then(|v| v.as_str()).unwrap_or("");
355387

356388
// Read ports from either "ports" array or scalar "port".
357389
let ports: Vec<u64> = ep.get("ports").and_then(|v| v.as_array()).map_or_else(
@@ -366,6 +398,17 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
366398
);
367399
let loc = format!("{name}.endpoints[{i}]");
368400

401+
if !endpoint_path.is_empty() {
402+
if !endpoint_path.starts_with('/') && endpoint_path != "**" {
403+
errors.push(format!(
404+
"{loc}: endpoint path must start with '/' or be '**', got '{endpoint_path}'"
405+
));
406+
}
407+
if let Some(warning) = check_glob_syntax(endpoint_path) {
408+
warnings.push(format!("{loc}.path: {warning}"));
409+
}
410+
}
411+
369412
// Validate host wildcard patterns.
370413
if host.contains('*') {
371414
if host == "*" || host == "**" {

0 commit comments

Comments
 (0)