Skip to content

Commit e13242a

Browse files
dkayclaude
andcommitted
sandbox,server: surface per-path L7 escalations as fresh draft chunks
Post-approval L7 (HTTP method/path) denials were vanishing instead of reaching a reviewer. Wire them through to a fresh, reviewable draft chunk while keeping straggler-flush noise suppressed. - sandbox: wire L7 relay denials into the denial aggregator. L7EvalContext gains a denial_tx channel; every L7 deny (request-log and forward paths) emits a DenialEvent carrying the observed method/path, feeding the same observation-driven analysis as connect-stage denials so mechanistic proposals can be path-aware. - server persistence: clear dedup_key when a chunk is decided (sqlite + postgres). New observations for the same host|port|binary then surface as a fresh pending chunk instead of folding their hit_count, through the status-blind submit upsert, into a row the reviewer already acted on. - server: make the post-approval mechanistic self-reject sweep L7-evidence-aware. A resubmit asking for nothing beyond the union of the approved grants for that endpoint still self-rejects (noise suppression); a submission carrying method/path asks OUTSIDE the approved grants stays pending for review. Path coverage uses a conservative glob matcher (* = one segment, ** trailing only, unknown shapes fall back to exact equality) so ambiguity errs toward surfacing a card. - server: gate the self-reject sweep on a live-policy probe (policy_covers_rule). Approved chunk records outlive the clauses they merged (a temporary grant expiring via RemoveBinary, or a manual --remove-rule); trusting the record alone would auto-reject every future denial for that endpoint, leaving it permanently un-reviewable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1245a3 commit e13242a

8 files changed

Lines changed: 524 additions & 14 deletions

File tree

crates/openshell-server/src/grpc/policy.rs

Lines changed: 417 additions & 12 deletions
Large diffs are not rendered by default.

crates/openshell-server/src/persistence/postgres.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,10 +736,15 @@ ORDER BY created_at_ms DESC
736736
}
737737
let payload = draft_chunk_payload_from_record(&record)?;
738738

739+
// Clear the dedup target once a chunk is decided: new observations for
740+
// the same host|port|binary must surface as a fresh pending chunk
741+
// (possibly carrying new L7 evidence) instead of silently folding
742+
// their hit_count into a row the reviewer already acted on.
739743
let result = sqlx::query(
740744
r"
741745
UPDATE objects
742-
SET status = $3, payload = $4, updated_at_ms = $5
746+
SET status = $3, payload = $4, updated_at_ms = $5,
747+
dedup_key = CASE WHEN $3 = 'pending' THEN dedup_key ELSE NULL END
743748
WHERE object_type = $1 AND id = $2
744749
",
745750
)

crates/openshell-server/src/persistence/sqlite.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,10 +758,15 @@ ORDER BY "created_at_ms" DESC
758758
}
759759
let payload = draft_chunk_payload_from_record(&record)?;
760760

761+
// Clear the dedup target once a chunk is decided: new observations for
762+
// the same host|port|binary must surface as a fresh pending chunk
763+
// (possibly carrying new L7 evidence) instead of silently folding
764+
// their hit_count into a row the reviewer already acted on.
761765
let result = sqlx::query(
762766
r#"
763767
UPDATE "objects"
764-
SET "status" = ?3, "payload" = ?4, "updated_at_ms" = ?5
768+
SET "status" = ?3, "payload" = ?4, "updated_at_ms" = ?5,
769+
"dedup_key" = CASE WHEN ?3 = 'pending' THEN "dedup_key" ELSE NULL END
765770
WHERE "object_type" = ?1 AND "id" = ?2
766771
"#,
767772
)

crates/openshell-supervisor-network/src/l7/graphql.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,7 @@ network_policies:
804804
activity_tx: None,
805805
dynamic_credentials: None,
806806
token_grant_resolver: None,
807+
denial_tx: None,
807808
};
808809
let request_info = crate::l7::L7RequestInfo {
809810
action: req.action,

crates/openshell-supervisor-network/src/l7/relay.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::l7::{EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo};
1313
use crate::opa::{PolicyGenerationGuard, TunnelPolicyEngine};
1414
use miette::{IntoDiagnostic, Result, miette};
1515
use openshell_core::activity::{ActivitySender, try_record_activity};
16+
use openshell_core::denial::DenialEvent;
1617
use openshell_core::secrets::{self, SecretResolver};
1718
use openshell_ocsf::{
1819
ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest,
@@ -51,6 +52,10 @@ pub struct L7EvalContext {
5152
/// Dynamic token grant resolver for endpoint-bound credentials.
5253
pub(crate) token_grant_resolver:
5354
Option<Arc<dyn crate::l7::token_grant_injection::TokenGrantResolver>>,
55+
/// Denial aggregator channel. L7 request denials feed the same
56+
/// observation-driven policy analysis as connect-stage denials, carrying
57+
/// the observed method/path so proposals can be path-aware.
58+
pub(crate) denial_tx: Option<tokio::sync::mpsc::UnboundedSender<DenialEvent>>,
5459
}
5560

5661
#[derive(Default)]
@@ -464,6 +469,28 @@ fn emit_l7_request_log(
464469
.build();
465470
ocsf_emit!(event);
466471
emit_activity(ctx, decision_str == "deny", "l7_policy");
472+
if decision_str == "deny" {
473+
emit_l7_denial(ctx, request_info, redacted_target, reason);
474+
}
475+
}
476+
477+
/// Feed an L7 request denial to the denial aggregator (if configured) so the
478+
/// observation-driven analysis can propose path-aware rules. The target is
479+
/// already redacted (no query string / credentials), matching what the OCSF
480+
/// log records.
481+
fn emit_l7_denial(ctx: &L7EvalContext, request_info: &L7RequestInfo, path: &str, reason: &str) {
482+
if let Some(tx) = &ctx.denial_tx {
483+
let _ = tx.send(DenialEvent {
484+
host: ctx.host.clone(),
485+
port: ctx.port,
486+
binary: ctx.binary_path.clone(),
487+
ancestors: ctx.ancestors.clone(),
488+
deny_reason: reason.to_string(),
489+
denial_stage: "l7".to_string(),
490+
l7_method: Some(request_info.action.clone()),
491+
l7_path: Some(path.to_string()),
492+
});
493+
}
467494
}
468495

469496
fn emit_activity(ctx: &L7EvalContext, denied: bool, deny_group: &'static str) {
@@ -774,6 +801,9 @@ where
774801
))
775802
.build();
776803
ocsf_emit!(event);
804+
if decision_str == "deny" {
805+
emit_l7_denial(ctx, &request_info, &redacted_target, &reason);
806+
}
777807
}
778808

779809
// Store the resolved target for the deny response redaction
@@ -1424,6 +1454,7 @@ network_policies:
14241454
activity_tx: None,
14251455
dynamic_credentials: Some(fixture.dynamic_credentials()),
14261456
token_grant_resolver: Some(fixture.resolver()),
1457+
denial_tx: None,
14271458
};
14281459

14291460
(config, tunnel_engine, ctx, fixture)
@@ -1467,6 +1498,7 @@ network_policies:
14671498
activity_tx: None,
14681499
dynamic_credentials: Some(fixture.dynamic_credentials()),
14691500
token_grant_resolver: Some(fixture.resolver()),
1501+
denial_tx: None,
14701502
};
14711503

14721504
(generation_guard, ctx, fixture)
@@ -1482,6 +1514,53 @@ network_policies:
14821514
.count()
14831515
}
14841516

1517+
/// An L7 deny must feed the denial aggregator with the observed
1518+
/// method/path so observation-driven analysis can propose path-aware
1519+
/// rules; allows must not.
1520+
#[test]
1521+
fn l7_deny_emits_denial_event_with_method_and_path() {
1522+
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1523+
let ctx = L7EvalContext {
1524+
host: "api.example.test".into(),
1525+
port: 443,
1526+
policy_name: "rest_api".into(),
1527+
binary_path: "/usr/bin/gh".into(),
1528+
ancestors: vec!["/bin/bash".into()],
1529+
cmdline_paths: vec![],
1530+
secret_resolver: None,
1531+
activity_tx: None,
1532+
dynamic_credentials: None,
1533+
token_grant_resolver: None,
1534+
denial_tx: Some(tx),
1535+
};
1536+
let request = L7RequestInfo {
1537+
action: "GET".into(),
1538+
target: "/user".into(),
1539+
query_params: std::collections::HashMap::new(),
1540+
graphql: None,
1541+
};
1542+
1543+
emit_l7_request_log(
1544+
&ctx,
1545+
&request,
1546+
"/user",
1547+
"deny",
1548+
"l7",
1549+
"GET /user not permitted by policy",
1550+
None,
1551+
);
1552+
let event = rx.try_recv().expect("deny must emit a denial event");
1553+
assert_eq!(event.host, "api.example.test");
1554+
assert_eq!(event.port, 443);
1555+
assert_eq!(event.binary, "/usr/bin/gh");
1556+
assert_eq!(event.denial_stage, "l7");
1557+
assert_eq!(event.l7_method.as_deref(), Some("GET"));
1558+
assert_eq!(event.l7_path.as_deref(), Some("/user"));
1559+
1560+
emit_l7_request_log(&ctx, &request, "/user", "allow", "l7", "", None);
1561+
assert!(rx.try_recv().is_err(), "allow must not emit a denial event");
1562+
}
1563+
14851564
#[test]
14861565
fn parse_rejection_detail_adds_l7_hint_for_encoded_slash() {
14871566
let detail = parse_rejection_detail(
@@ -1786,6 +1865,7 @@ network_policies:
17861865
activity_tx: None,
17871866
dynamic_credentials: None,
17881867
token_grant_resolver: None,
1868+
denial_tx: None,
17891869
};
17901870
let request = L7RequestInfo {
17911871
action: "WEBSOCKET_TEXT".into(),
@@ -1844,6 +1924,7 @@ network_policies:
18441924
activity_tx: None,
18451925
dynamic_credentials: None,
18461926
token_grant_resolver: None,
1927+
denial_tx: None,
18471928
};
18481929

18491930
let (mut app, mut relay_client) = tokio::io::duplex(8192);
@@ -1951,6 +2032,7 @@ network_policies:
19512032
activity_tx: None,
19522033
dynamic_credentials: None,
19532034
token_grant_resolver: None,
2035+
denial_tx: None,
19542036
};
19552037

19562038
let (mut app, mut relay_client) = tokio::io::duplex(8192);
@@ -2071,6 +2153,7 @@ network_policies:
20712153
activity_tx: None,
20722154
dynamic_credentials: None,
20732155
token_grant_resolver: None,
2156+
denial_tx: None,
20742157
};
20752158

20762159
let (mut app, mut relay_client) = tokio::io::duplex(8192);
@@ -2244,6 +2327,7 @@ network_policies:
22442327
activity_tx: None,
22452328
dynamic_credentials: None,
22462329
token_grant_resolver: None,
2330+
denial_tx: None,
22472331
};
22482332

22492333
let (mut app, mut relay_client) = tokio::io::duplex(8192);
@@ -2334,6 +2418,7 @@ network_policies:
23342418
activity_tx: None,
23352419
dynamic_credentials: None,
23362420
token_grant_resolver: None,
2421+
denial_tx: None,
23372422
};
23382423

23392424
let (mut app, mut relay_client) = tokio::io::duplex(8192);

crates/openshell-supervisor-network/src/l7/token_grant_injection.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,7 @@ mod tests {
735735
activity_tx: None,
736736
dynamic_credentials: Some(fixture.dynamic_credentials()),
737737
token_grant_resolver: Some(fixture.resolver()),
738+
denial_tx: None,
738739
};
739740
let req = L7Request {
740741
action: "GET".to_string(),
@@ -772,6 +773,7 @@ mod tests {
772773
activity_tx: None,
773774
dynamic_credentials: Some(fixture.dynamic_credentials()),
774775
token_grant_resolver: Some(fixture.resolver()),
776+
denial_tx: None,
775777
};
776778
let req = L7Request {
777779
action: "GET".to_string(),

crates/openshell-supervisor-network/src/l7/websocket.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,7 @@ network_policies:
12731273
activity_tx: None,
12741274
dynamic_credentials: None,
12751275
token_grant_resolver: None,
1276+
denial_tx: None,
12761277
};
12771278
let (mut client_write, mut relay_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024);
12781279
let (mut relay_write, mut upstream_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,7 @@ async fn handle_tcp_connection(
970970
token_grant_resolver: dynamic_credentials
971971
.as_ref()
972972
.map(|_| crate::l7::token_grant_injection::default_resolver()),
973+
denial_tx: denial_tx.clone(),
973974
};
974975

975976
if effective_tls_skip {
@@ -3215,6 +3216,7 @@ async fn handle_forward_proxy(
32153216
token_grant_resolver: dynamic_credentials
32163217
.as_ref()
32173218
.map(|_| crate::l7::token_grant_injection::default_resolver()),
3219+
denial_tx: denial_tx.cloned(),
32183220
};
32193221
let mut l7_activity_pending = false;
32203222

@@ -4293,6 +4295,7 @@ mod tests {
42934295
activity_tx: None,
42944296
dynamic_credentials: Some(fixture.dynamic_credentials()),
42954297
token_grant_resolver: Some(fixture.resolver()),
4298+
denial_tx: None,
42964299
};
42974300

42984301
(ctx, fixture)
@@ -4351,6 +4354,7 @@ mod tests {
43514354
activity_tx: None,
43524355
dynamic_credentials: None,
43534356
token_grant_resolver: None,
4357+
denial_tx: None,
43544358
};
43554359
(config, tunnel_engine, ctx)
43564360
}
@@ -4519,6 +4523,7 @@ mod tests {
45194523
activity_tx: None,
45204524
dynamic_credentials: None,
45214525
token_grant_resolver: None,
4526+
denial_tx: None,
45224527
};
45234528
let query_params = std::collections::HashMap::new();
45244529

@@ -4562,6 +4567,7 @@ mod tests {
45624567
activity_tx: None,
45634568
dynamic_credentials: None,
45644569
token_grant_resolver: None,
4570+
denial_tx: None,
45654571
};
45664572
let query_params = std::collections::HashMap::new();
45674573
let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false);

0 commit comments

Comments
 (0)