Skip to content

Commit a347deb

Browse files
committed
refactor(policy): share access preset semantics
Signed-off-by: Johnny Greco <jogreco@nvidia.com>
1 parent ad953f6 commit a347deb

13 files changed

Lines changed: 202 additions & 67 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
4040
| `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary |
4141
| `crates/openshell-sandbox/` | Sandbox runtime | Container supervision, policy-enforced egress routing |
4242
| `crates/openshell-policy/` | Policy engine | Filesystem, network, and process constraints |
43+
| `crates/openshell-policy-semantics/` | Policy semantics | Dependency-light access-preset and method semantics shared by policy consumers |
4344
| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage |
4445
| `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary |
4546
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.8.0 event types, builders, shorthand/JSONL formatters, tracing layers |

‎Cargo.lock‎

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

‎architecture/security-policy.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,11 @@ results when a sound decision depends on authority or runtime context outside
349349
the model. The result records the model version and covered domains so callers
350350
can bind a successful check to those semantics.
351351

352+
Access-preset names and method expansions live in the dependency-free
353+
`openshell-policy-semantics` crate. Policy merging, runtime enforcement, and
354+
both prover models consume that shared vocabulary so changes cannot silently
355+
give the same preset different method authority in different components.
356+
352357
This containment operation is separate from the proposal-risk queries below.
353358
See the [standalone policy prover documentation](../docs/reference/policy-prover.mdx)
354359
for installation, command behavior, model limitations, evidence, and exit codes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
[package]
5+
name = "openshell-policy-semantics"
6+
description = "Dependency-light shared policy semantics for OpenShell"
7+
version.workspace = true
8+
edition.workspace = true
9+
rust-version.workspace = true
10+
license.workspace = true
11+
repository.workspace = true
12+
13+
[lints]
14+
workspace = true
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Dependency-light policy semantics shared by parsing, enforcement, and
5+
//! verification consumers.
6+
7+
/// HTTP methods allowed by the `read-only` access preset.
8+
pub const READ_ONLY_HTTP_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS"];
9+
10+
/// HTTP methods allowed by the `read-write` access preset.
11+
pub const READ_WRITE_HTTP_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"];
12+
13+
/// HTTP methods classified as writes by the policy risk model.
14+
pub const WRITE_HTTP_METHODS: &[&str] = &["POST", "PUT", "PATCH", "DELETE"];
15+
16+
/// Standard HTTP methods modeled by the policy risk prover.
17+
pub const STANDARD_HTTP_METHODS: &[&str] =
18+
&["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
19+
20+
const FULL_METHODS: &[&str] = &["*"];
21+
const WEBSOCKET_READ_ONLY_METHODS: &[&str] = &["GET"];
22+
const WEBSOCKET_READ_WRITE_METHODS: &[&str] = &["GET", "WEBSOCKET_TEXT"];
23+
24+
/// A protocol whose access presets expand into method rules.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26+
pub enum MethodProtocol {
27+
Http,
28+
WebSocket,
29+
}
30+
31+
/// A supported policy access preset.
32+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33+
pub enum AccessPreset {
34+
ReadOnly,
35+
ReadWrite,
36+
Full,
37+
}
38+
39+
impl AccessPreset {
40+
/// Parse an access preset from its policy representation.
41+
#[must_use]
42+
pub fn parse(value: &str) -> Option<Self> {
43+
match value {
44+
"read-only" => Some(Self::ReadOnly),
45+
"read-write" => Some(Self::ReadWrite),
46+
"full" => Some(Self::Full),
47+
_ => None,
48+
}
49+
}
50+
}
51+
52+
/// Expand an access preset into rules for a method-based protocol.
53+
///
54+
/// Protocols such as GraphQL that authorize native operations instead of
55+
/// methods should parse [`AccessPreset`] and map it to their own rule type.
56+
#[must_use]
57+
pub const fn method_access_preset_rules(
58+
protocol: MethodProtocol,
59+
preset: AccessPreset,
60+
) -> &'static [&'static str] {
61+
match (protocol, preset) {
62+
(_, AccessPreset::Full) => FULL_METHODS,
63+
(MethodProtocol::WebSocket, AccessPreset::ReadOnly) => WEBSOCKET_READ_ONLY_METHODS,
64+
(MethodProtocol::WebSocket, AccessPreset::ReadWrite) => WEBSOCKET_READ_WRITE_METHODS,
65+
(_, AccessPreset::ReadOnly) => READ_ONLY_HTTP_METHODS,
66+
(_, AccessPreset::ReadWrite) => READ_WRITE_HTTP_METHODS,
67+
}
68+
}
69+
70+
#[cfg(test)]
71+
mod tests {
72+
use super::*;
73+
74+
#[test]
75+
fn expands_http_presets() {
76+
assert_eq!(
77+
method_access_preset_rules(MethodProtocol::Http, AccessPreset::ReadOnly),
78+
READ_ONLY_HTTP_METHODS
79+
);
80+
assert_eq!(
81+
method_access_preset_rules(MethodProtocol::Http, AccessPreset::ReadWrite),
82+
READ_WRITE_HTTP_METHODS
83+
);
84+
assert_eq!(
85+
method_access_preset_rules(MethodProtocol::Http, AccessPreset::Full),
86+
&["*"]
87+
);
88+
}
89+
90+
#[test]
91+
fn expands_websocket_presets() {
92+
assert_eq!(
93+
method_access_preset_rules(MethodProtocol::WebSocket, AccessPreset::ReadOnly),
94+
&["GET"]
95+
);
96+
assert_eq!(
97+
method_access_preset_rules(MethodProtocol::WebSocket, AccessPreset::ReadWrite),
98+
&["GET", "WEBSOCKET_TEXT"]
99+
);
100+
}
101+
102+
#[test]
103+
fn rejects_unknown_preset() {
104+
assert_eq!(AccessPreset::parse("unknown"), None);
105+
}
106+
}

‎crates/openshell-policy/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ repository.workspace = true
1313
[dependencies]
1414
hickory-proto = { workspace = true }
1515
openshell-core = { path = "../openshell-core", default-features = false }
16+
openshell-policy-semantics = { path = "../openshell-policy-semantics" }
1617
serde = { workspace = true }
1718
serde_json = { workspace = true }
1819
serde_yml = { workspace = true }

‎crates/openshell-policy/src/merge.rs‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,21 +2064,21 @@ fn expand_existing_access(
20642064
}
20652065

20662066
fn expand_access_preset(protocol: &str, access: &str) -> Option<Vec<L7Rule>> {
2067-
let methods = match (protocol, access) {
2068-
(_, "full") => vec!["*"],
2069-
("websocket", "read-only") => vec!["GET"],
2070-
("websocket", "read-write") => vec!["GET", "WEBSOCKET_TEXT"],
2071-
(_, "read-only") => vec!["GET", "HEAD", "OPTIONS"],
2072-
(_, "read-write") => vec!["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"],
2073-
_ => return None,
2074-
};
2067+
use openshell_policy_semantics::{AccessPreset, MethodProtocol, method_access_preset_rules};
20752068

2069+
let preset = AccessPreset::parse(access)?;
2070+
let protocol = if protocol == "websocket" {
2071+
MethodProtocol::WebSocket
2072+
} else {
2073+
MethodProtocol::Http
2074+
};
2075+
let methods = method_access_preset_rules(protocol, preset);
20762076
Some(
20772077
methods
2078-
.into_iter()
2078+
.iter()
20792079
.map(|method| L7Rule {
20802080
allow: Some(L7Allow {
2081-
method: method.to_string(),
2081+
method: (*method).to_string(),
20822082
path: "**".to_string(),
20832083
command: String::new(),
20842084
query: HashMap::default(),

‎crates/openshell-prover/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ bundled-z3 = ["z3/bundled"]
1515
prebuilt-z3 = ["z3/gh-release"]
1616

1717
[dependencies]
18+
openshell-policy-semantics = { path = "../openshell-policy-semantics" }
1819
z3 = { workspace = true }
1920
serde = { workspace = true }
2021
serde_yml = { workspace = true }

‎crates/openshell-prover/src/containment.rs‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ use serde_yml::Value;
1919
use z3::ast::{Ast, Bool, Int, Regexp, String as Z3String};
2020
use z3::{Context, Params, SatResult, Solver};
2121

22-
const READ_ONLY_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS"];
23-
const READ_WRITE_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"];
2422
const LAYER_L4: &str = "l4";
2523
const LAYER_REST: &str = "rest";
2624
const WORKDIR_SYMBOL: &str = "<OCI_WORKDIR>";
@@ -1016,11 +1014,16 @@ fn endpoint_denies(endpoint: &Endpoint, action: &SymbolicAction) -> Bool {
10161014
}
10171015

10181016
fn rest_endpoint_allows(endpoint: &Endpoint, action: &SymbolicAction) -> Bool {
1019-
match endpoint.access.as_str() {
1020-
"read-only" => methods_match(action, READ_ONLY_METHODS, "**"),
1021-
"read-write" => methods_match(action, READ_WRITE_METHODS, "**"),
1022-
"full" => any_method_matches(action, "**"),
1023-
_ => bool_or(
1017+
use openshell_policy_semantics::{AccessPreset, MethodProtocol, method_access_preset_rules};
1018+
1019+
match AccessPreset::parse(&endpoint.access) {
1020+
Some(AccessPreset::Full) => any_method_matches(action, "**"),
1021+
Some(preset) => methods_match(
1022+
action,
1023+
method_access_preset_rules(MethodProtocol::Http, preset),
1024+
"**",
1025+
),
1026+
None => bool_or(
10241027
endpoint
10251028
.rules
10261029
.iter()

‎crates/openshell-prover/src/policy.rs‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ use std::collections::{BTreeMap, HashSet};
1111
use std::path::Path;
1212

1313
use miette::{IntoDiagnostic, Result, WrapErr};
14+
use openshell_policy_semantics::{
15+
AccessPreset, READ_ONLY_HTTP_METHODS, READ_WRITE_HTTP_METHODS, STANDARD_HTTP_METHODS,
16+
};
1417
use serde::Deserialize;
1518
use serde::de::IgnoredAny;
1619

20+
pub use openshell_policy_semantics::WRITE_HTTP_METHODS as WRITE_METHODS;
21+
1722
// ---------------------------------------------------------------------------
1823
// Policy intent
1924
// ---------------------------------------------------------------------------
@@ -40,12 +45,6 @@ impl std::fmt::Display for PolicyIntent {
4045
}
4146
}
4247

43-
/// HTTP methods considered to be write operations.
44-
pub const WRITE_METHODS: &[&str] = &["POST", "PUT", "PATCH", "DELETE"];
45-
46-
/// All standard HTTP methods.
47-
const ALL_METHODS: &[&str] = &["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
48-
4948
// ---------------------------------------------------------------------------
5049
// Serde types — mirrors the YAML schema
5150
// ---------------------------------------------------------------------------
@@ -166,17 +165,17 @@ impl Endpoint {
166165
if self.protocol.is_empty() {
167166
return PolicyIntent::L4Only;
168167
}
169-
match self.access.as_str() {
170-
"read-only" => PolicyIntent::ReadOnly,
171-
"read-write" => PolicyIntent::ReadWrite,
172-
"full" => PolicyIntent::Full,
173-
_ => {
168+
match AccessPreset::parse(&self.access) {
169+
Some(AccessPreset::ReadOnly) => PolicyIntent::ReadOnly,
170+
Some(AccessPreset::ReadWrite) => PolicyIntent::ReadWrite,
171+
Some(AccessPreset::Full) => PolicyIntent::Full,
172+
None => {
174173
if self.rules.is_empty() {
175174
return PolicyIntent::Custom;
176175
}
177176
let methods: HashSet<String> =
178177
self.rules.iter().map(|r| r.method.to_uppercase()).collect();
179-
let read_only: HashSet<String> = ["GET", "HEAD", "OPTIONS"]
178+
let read_only: HashSet<String> = READ_ONLY_HTTP_METHODS
180179
.iter()
181180
.map(|s| (*s).to_owned())
182181
.collect();
@@ -207,23 +206,29 @@ impl Endpoint {
207206
if self.protocol.is_empty() {
208207
return HashSet::new(); // L4-only: all traffic passes
209208
}
210-
match self.access.as_str() {
211-
"read-only" => ["GET", "HEAD", "OPTIONS"]
209+
match AccessPreset::parse(&self.access) {
210+
Some(AccessPreset::ReadOnly) => READ_ONLY_HTTP_METHODS
211+
.iter()
212+
.map(|s| (*s).to_owned())
213+
.collect(),
214+
Some(AccessPreset::ReadWrite) => READ_WRITE_HTTP_METHODS
212215
.iter()
213216
.map(|s| (*s).to_owned())
214217
.collect(),
215-
"read-write" => ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH"]
218+
Some(AccessPreset::Full) => STANDARD_HTTP_METHODS
216219
.iter()
217220
.map(|s| (*s).to_owned())
218221
.collect(),
219-
"full" => ALL_METHODS.iter().map(|s| (*s).to_owned()).collect(),
220-
_ => {
222+
None => {
221223
if !self.rules.is_empty() {
222224
let mut methods = HashSet::new();
223225
for r in &self.rules {
224226
let m = r.method.to_uppercase();
225227
if m == "*" {
226-
return ALL_METHODS.iter().map(|s| (*s).to_owned()).collect();
228+
return STANDARD_HTTP_METHODS
229+
.iter()
230+
.map(|s| (*s).to_owned())
231+
.collect();
227232
}
228233
methods.insert(m);
229234
}

0 commit comments

Comments
 (0)