Skip to content

Commit 472e23f

Browse files
authored
fix(proxy): include OPA deny reason in CONNECT 403 response (#2363)
* fix(proxy): include OPA deny reason in CONNECT 403 response When a CONNECT request was denied by OPA policy, the 403 response used a generic "not permitted by policy" message for both "endpoint not in policy" and "endpoint matched but binary didn't match." Users had no way to distinguish the two without reading supervisor logs. The OPA policy already computes a detailed deny_reason (e.g., "binary '/usr/bin/node' not allowed in policy 'X'") but the proxy was not including it in the HTTP response. Now the CONNECT deny response includes a "reason" field with the OPA deny reason when available. When the reason is empty, the field is omitted for backward compatibility. Fixes #2355 Signed-off-by: Adel Zaalouk <azaalouk@redhat.com> * docs(observability): document optional reason field in CONNECT 403 response The proxy now includes a reason field in the JSON body of denied CONNECT responses when the policy engine provides a specific denial cause. Update the Proxy Error Responses section to show the field and describe when it is present vs omitted. Signed-off-by: Adel Zaalouk <azaalouk@redhat.com> --------- Signed-off-by: Adel Zaalouk <azaalouk@redhat.com>
1 parent dae9261 commit 472e23f

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,11 +825,12 @@ async fn handle_tcp_connection(
825825
emit_activity(&activity_tx, true, "connect_policy");
826826
respond(
827827
&mut client,
828-
&build_json_error_response(
828+
&build_json_error_response_with_reason(
829829
403,
830830
"Forbidden",
831831
"policy_denied",
832832
&format!("CONNECT {host_lc}:{port} not permitted by policy"),
833+
&deny_reason,
833834
),
834835
)
835836
.await?;
@@ -4884,6 +4885,34 @@ fn build_json_error_response(status: u16, status_text: &str, error: &str, detail
48844885
.into_bytes()
48854886
}
48864887

4888+
fn build_json_error_response_with_reason(
4889+
status: u16,
4890+
status_text: &str,
4891+
error: &str,
4892+
detail: &str,
4893+
reason: &str,
4894+
) -> Vec<u8> {
4895+
let mut body = serde_json::json!({
4896+
"error": error,
4897+
"detail": detail,
4898+
});
4899+
if !reason.is_empty() {
4900+
body["reason"] = serde_json::json!(reason);
4901+
}
4902+
let body_str = body.to_string();
4903+
format!(
4904+
"HTTP/1.1 {status} {status_text}\r\n\
4905+
Content-Type: application/json\r\n\
4906+
Content-Length: {}\r\n\
4907+
Connection: close\r\n\
4908+
\r\n\
4909+
{}",
4910+
body_str.len(),
4911+
body_str,
4912+
)
4913+
.into_bytes()
4914+
}
4915+
48874916
fn build_middleware_deny_response(
48884917
policy_name: &str,
48894918
denial: &openshell_supervisor_middleware::MiddlewareDenial,
@@ -5133,6 +5162,48 @@ network_policies: {}
51335162
assert!(body.get("agent_guidance").is_none());
51345163
}
51355164

5165+
#[test]
5166+
fn policy_deny_response_includes_reason() {
5167+
let response = build_json_error_response_with_reason(
5168+
403,
5169+
"Forbidden",
5170+
"policy_denied",
5171+
"CONNECT api.example.com:443 not permitted by policy",
5172+
"binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])",
5173+
);
5174+
let response = String::from_utf8(response).expect("UTF-8 error response");
5175+
assert!(response.starts_with("HTTP/1.1 403 Forbidden"));
5176+
let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response");
5177+
let body: serde_json::Value = serde_json::from_str(body).expect("JSON response");
5178+
5179+
assert_eq!(body["error"], "policy_denied");
5180+
assert_eq!(
5181+
body["detail"],
5182+
"CONNECT api.example.com:443 not permitted by policy"
5183+
);
5184+
assert_eq!(
5185+
body["reason"],
5186+
"binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])"
5187+
);
5188+
}
5189+
5190+
#[test]
5191+
fn policy_deny_response_omits_empty_reason() {
5192+
let response = build_json_error_response_with_reason(
5193+
403,
5194+
"Forbidden",
5195+
"policy_denied",
5196+
"CONNECT api.example.com:443 not permitted by policy",
5197+
"",
5198+
);
5199+
let response = String::from_utf8(response).expect("UTF-8 error response");
5200+
let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response");
5201+
let body: serde_json::Value = serde_json::from_str(body).expect("JSON response");
5202+
5203+
assert_eq!(body["error"], "policy_denied");
5204+
assert!(body.get("reason").is_none());
5205+
}
5206+
51365207
#[test]
51375208
fn endpoint_only_opa_allows_declared_endpoint_without_process_identity() {
51385209
let policy = include_str!("../data/sandbox-policy.rego");

‎docs/observability/logging.mdx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,13 @@ A denied CONNECT returns `403 Forbidden`:
192192
```json
193193
{
194194
"error": "policy_denied",
195-
"detail": "CONNECT api.example.com:443 not permitted by policy"
195+
"detail": "CONNECT api.example.com:443 not permitted by policy",
196+
"reason": "binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])"
196197
}
197198
```
198199

200+
The `reason` field is included when the policy engine provides a specific denial reason (for example, which binary or rule caused the rejection). It is omitted when no additional detail is available.
201+
199202
An upstream that the proxy cannot reach returns `502 Bad Gateway`:
200203

201204
```json
@@ -205,7 +208,7 @@ An upstream that the proxy cannot reach returns `502 Bad Gateway`:
205208
}
206209
```
207210

208-
The `error` field is a short machine-readable code (`policy_denied`, `middleware_denied`, `middleware_failed`, `ssrf_denied`, `upstream_unreachable`). The `detail` field is a human-readable explanation suitable for display in an agent transcript.
211+
The `error` field is a short machine-readable code (`policy_denied`, `middleware_denied`, `middleware_failed`, `ssrf_denied`, `upstream_unreachable`). The `detail` field is a human-readable explanation suitable for display in an agent transcript. The optional `reason` field, when present, provides the specific denial cause from the policy engine (for example, which binary was not allowed or which rule was missing).
209212

210213
For L7 REST policy denials, the body also includes structured policy fields such as `method`, `path`, `rule_missing`, and `next_steps`. When policy advisor is enabled, it also includes `agent_guidance`, a short plain-language instruction telling the agent to read `/etc/openshell/skills/policy_advisor.md`, propose the narrowest rule through `http://policy.local/v1/proposals`, wait for `policy_reloaded: true`, and retry. A middleware denial instead identifies the policy-local config in `middleware` and can include a validated `reason_code`. A fail-closed runtime failure uses `middleware_failed` with platform-owned text. Both middleware responses omit `rule_missing`, `next_steps`, and `agent_guidance` because no policy rule is missing.
211214

0 commit comments

Comments
 (0)