Skip to content
Closed
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ sha2 = "0.10"
rand = "0.9"
jsonwebtoken = "9"
getrandom = "0.3"
ed25519-dalek = { version = "2", features = ["rand_core", "pem", "pkcs8"] }

# Filesystem embedding
include_dir = "0.7"
Expand Down
13 changes: 13 additions & 0 deletions crates/openshell-core/src/proto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ pub mod inference {
}
}

#[allow(
clippy::all,
clippy::pedantic,
clippy::nursery,
unused_qualifications,
rust_2018_idioms
)]
pub mod policy {
pub mod v1alpha1 {
include!(concat!(env!("OUT_DIR"), "/openshell.policy.v1alpha1.rs"));
}
}

pub use datamodel::v1::*;
pub use inference::v1::*;
pub use openshell::*;
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,17 @@ uuid = { workspace = true }
hmac = "0.12"
sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
ed25519-dalek = { workspace = true }
async-trait = "0.1"
url = { workspace = true }
hex = "0.4"
russh = "0.57"
rand = { workspace = true }
# rand_core 0.6 is pinned here because ed25519-dalek v2 still consumes
# `rand_core 0.6` traits. The workspace `rand = "0.9"` ships an `OsRng`
# that implements the newer `rand_core 0.10` trait surface, so calls to
# `SigningKey::generate` need a `rand_core 0.6`-compatible RNG.
rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] }
petname = "2"
ipnet = "2"
tempfile = "3"
Expand Down
195 changes: 195 additions & 0 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ pub struct OpenShellRoot {
#[serde(default)]
pub gateway: GatewayFileSection,

/// `[openshell.policy]` table — selects the active policy-provider
/// type. `None` here is equivalent to the local default; the loader
/// validates the chosen type in [`load`].
#[serde(default)]
pub policy: Option<PolicyFileSection>,

/// `[openshell.drivers.<name>]` tables — passed verbatim to each driver
/// crate's `Deserialize` impl after the gateway-side inheritance merge.
/// Stored as raw [`toml::Value`] so each driver can evolve its schema
Expand All @@ -63,6 +69,37 @@ pub struct OpenShellRoot {
pub drivers: BTreeMap<String, toml::Value>,
}

/// `[openshell.policy]` table.
///
/// Selects the policy-provider type. Supported values: `"local"` (the
/// gateway's in-process, store-backed policy semantics) and `"attested"`
/// (out-of-process policy delivery over the
/// `openshell.policy.v1alpha1.Engine` wire — the gateway fetches signed
/// projections from a configured source).
///
/// The `type` key intentionally mirrors `openshell-providers`'
/// `ProviderPlugin`-style selector convention.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyFileSection {
/// Policy-provider type. Accepted values: `"local"` (the default if
/// the table is omitted) and `"attested"`. `type` is a Rust keyword,
/// so the field is exposed as `r#type` in code and renamed via
/// `#[serde(rename = "type")]` for the TOML surface.
#[serde(default, rename = "type")]
pub r#type: Option<String>,

/// UDS path the gateway dials to reach the policy source. Required
/// when `type = "attested"`. Ignored for `type = "local"`.
#[serde(default)]
pub source_uds_path: Option<PathBuf>,

/// Path to the gateway-side trust store JSON file. Required when
/// `type = "attested"`. Ignored for `type = "local"`.
#[serde(default)]
pub trust_store_path: Option<PathBuf>,
}

/// `[openshell.gateway]` section.
///
/// All fields are `Option<T>` so the loader can tell whether a key was set
Expand Down Expand Up @@ -182,6 +219,15 @@ pub enum ConfigFileError {
env: &'static str,
cli: &'static str,
},
#[error(
"[openshell.policy] type = '{policy_type}' is not a recognized policy type; accepted values are 'local' (default) or 'attested'"
)]
UnknownPolicyType { policy_type: String },

#[error(
"[openshell.policy] type = 'attested' requires `{field}` to be set in the config file"
)]
MissingAttestedField { field: &'static str },
}

/// Load and validate a TOML config file.
Expand Down Expand Up @@ -215,9 +261,50 @@ pub fn load(path: &Path) -> Result<ConfigFile, ConfigFileError> {
});
}

// Validate the optional policy-provider type. Unknown values are
// rejected here; required-field validation for known types runs
// immediately after.
if let Some(ref policy) = file.openshell.policy
&& let Some(ref policy_type) = policy.r#type
&& !is_known_policy_type(policy_type)
{
return Err(ConfigFileError::UnknownPolicyType {
policy_type: policy_type.clone(),
});
}

// `attested` requires both file paths. They are optional in the
// struct so `type = "local"` does not trip a deserialize error; the
// explicit check here surfaces a friendly message at load time.
if let Some(ref policy) = file.openshell.policy
&& policy.r#type.as_deref() == Some("attested")
{
if policy.source_uds_path.is_none() {
return Err(ConfigFileError::MissingAttestedField {
field: "source_uds_path",
});
}
if policy.trust_store_path.is_none() {
return Err(ConfigFileError::MissingAttestedField {
field: "trust_store_path",
});
}
}

Ok(file)
}

/// Policy-type strings recognized by the policy-provider config validator.
/// `"local"` is the historical (and v0) default; `"attested"` is reserved
/// for the forthcoming Attested Policy Projection provider and is parsed
/// successfully so deployments can stage the value ahead of the provider
/// landing.
pub const KNOWN_POLICY_TYPES: &[&str] = &["local", "attested"];

fn is_known_policy_type(policy_type: &str) -> bool {
KNOWN_POLICY_TYPES.contains(&policy_type)
}

/// Build the merged TOML table for `driver` by overlaying inheritable
/// `[openshell.gateway]` defaults onto `[openshell.drivers.<name>]`.
///
Expand Down Expand Up @@ -431,6 +518,114 @@ ssh_gateway_port = 8080
assert!(matches!(err, ConfigFileError::Parse { .. }));
}

#[test]
fn parses_policy_type_local() {
let toml = r#"
[openshell.policy]
type = "local"
"#;
let tmp = write_tmp(toml);
let file = load(tmp.path()).expect("local policy type parses");
let policy = file.openshell.policy.expect("policy table present");
assert_eq!(policy.r#type.as_deref(), Some("local"));
}

#[test]
fn parses_policy_type_attested() {
// "attested" requires both `source_uds_path` and
// `trust_store_path`; the loader rejects the table if either is
// missing. With both present, the policy section round-trips.
let toml = r#"
[openshell.policy]
type = "attested"
source_uds_path = "/run/openshell/policy.sock"
trust_store_path = "/etc/openshell/trust.json"
"#;
let tmp = write_tmp(toml);
let file = load(tmp.path()).expect("attested policy type parses");
let policy = file.openshell.policy.expect("policy table present");
assert_eq!(policy.r#type.as_deref(), Some("attested"));
assert_eq!(
policy.source_uds_path.as_deref(),
Some(Path::new("/run/openshell/policy.sock"))
);
assert_eq!(
policy.trust_store_path.as_deref(),
Some(Path::new("/etc/openshell/trust.json"))
);
}

#[test]
fn rejects_attested_without_source_uds_path() {
let toml = r#"
[openshell.policy]
type = "attested"
trust_store_path = "/etc/openshell/trust.json"
"#;
let tmp = write_tmp(toml);
let err = load(tmp.path()).expect_err("missing source_uds_path must error");
assert!(matches!(
err,
ConfigFileError::MissingAttestedField {
field: "source_uds_path"
}
));
}

#[test]
fn rejects_attested_without_trust_store_path() {
let toml = r#"
[openshell.policy]
type = "attested"
source_uds_path = "/run/openshell/policy.sock"
"#;
let tmp = write_tmp(toml);
let err = load(tmp.path()).expect_err("missing trust_store_path must error");
assert!(matches!(
err,
ConfigFileError::MissingAttestedField {
field: "trust_store_path"
}
));
}

#[test]
fn rejects_unknown_policy_type() {
let toml = r#"
[openshell.policy]
type = "nonsense"
"#;
let tmp = write_tmp(toml);
let err = load(tmp.path()).expect_err("unknown policy type must be rejected");
assert!(matches!(
err,
ConfigFileError::UnknownPolicyType { ref policy_type } if policy_type == "nonsense"
));
}

#[test]
fn missing_policy_table_is_ok() {
let toml = r#"
[openshell.gateway]
log_level = "info"
"#;
let tmp = write_tmp(toml);
let file = load(tmp.path()).expect("absent policy table is allowed");
assert!(file.openshell.policy.is_none());
}

#[test]
fn rejects_unknown_policy_field() {
let toml = r#"
[openshell.policy]
type = "local"
nonsense = true
"#;
let tmp = write_tmp(toml);
let err = load(tmp.path()).expect_err("unknown policy field must be rejected");
assert!(matches!(err, ConfigFileError::Parse { .. }));
}

#[test]
fn rejects_unsupported_version() {
let toml = r"
Expand Down
Loading
Loading