Skip to content
Open
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
113 changes: 93 additions & 20 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,26 +71,33 @@ pub struct OpenShellRoot {

/// `[openshell.policy]` table.
///
/// Selects the policy-provider type. Today the only fully-supported value
/// is `"local"`, which keeps the gateway's historical in-process,
/// store-backed policy semantics. `"attested"` is reserved for the
/// Attested Policy Projection provider (forthcoming session); declaring it
/// today is parsed successfully but rejected at gateway startup with a
/// clear "policy type not yet available" error.
/// 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 rather than the APF/RFC
/// "driver" vocabulary.
/// `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"` (declared but not yet
/// implemented). `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.
/// 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.
Expand Down Expand Up @@ -213,9 +220,14 @@ pub enum ConfigFileError {
cli: &'static str,
},
#[error(
"[openshell.policy] type = '{policy_type}' is not a recognized policy type; accepted values are 'local' (default) or 'attested' (not yet available)"
"[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 @@ -249,10 +261,9 @@ pub fn load(path: &Path) -> Result<ConfigFile, ConfigFileError> {
});
}

// Validate the optional policy-provider type. The "attested" value is
// accepted at parse time because the config file may be written ahead
// of the provider landing; startup is responsible for turning that
// into a clear "policy type not yet available" error.
// 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)
Expand All @@ -262,6 +273,24 @@ pub fn load(path: &Path) -> Result<ConfigFile, ConfigFileError> {
});
}

// `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)
}

Expand Down Expand Up @@ -503,17 +532,61 @@ type = "local"

#[test]
fn parses_policy_type_attested() {
// "attested" is accepted at parse time; the gateway startup turns
// this into a clear "policy type not yet available" error so
// deployments can stage the value ahead of the provider landing.
// "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]
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-server/src/grpc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,12 @@ fn policy_error_to_status(error: PolicyError) -> Status {
PolicyError::Persistence(err) => {
super::persistence_error_to_status(err, "policy provider")
}
// Source-side failures (engine unreachable, decode error, etc.)
// surface as `unavailable` so callers retry — the gateway itself
// is healthy.
PolicyError::SourceError(err) => {
Status::unavailable(format!("policy source failure: {err}"))
}
}
}

Expand Down
95 changes: 60 additions & 35 deletions crates/openshell-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,8 @@ pub async fn run_server(
);

// Override the default `local` policy provider when the config file
// selects a different policy type. The `attested` type is reserved for
// the forthcoming Attested Policy Projection work; declaring it today
// returns a startup error rather than a generic "unknown policy type"
// message so deployments staging the value get a clear signal.
if let Some(provider) = resolve_policy_provider(config_file.as_ref(), store.clone())? {
// selects a different policy type.
if let Some(provider) = resolve_policy_provider(config_file.as_ref(), store.clone()).await? {
state.policy_provider = provider;
}

Expand Down Expand Up @@ -487,25 +484,10 @@ pub async fn run_server(
Ok(())
}

/// Build the policy-provider registry for this gateway process. Currently
/// holds only `local`; the next session adds the `attested` policy type
/// here.
fn build_policy_provider_registry(store: Arc<Store>) -> policy_provider::PolicyProviderRegistry {
let mut registry = policy_provider::PolicyProviderRegistry::new();
registry.register(policy_provider::LocalPolicyProvider::new(store));
registry
}

/// Resolve the configured policy provider, if the config file selects one.
/// Returns `Ok(None)` when no override is needed (the default `local`
/// provider from `ServerState::new` already covers that case).
///
/// `"local"` returns a fresh provider via the registry. `"attested"` is
/// parsed at config-file load time but the registry does not yet contain
/// the provider — startup returns a clear "policy type not yet available"
/// error so deployments staging the value get a clear signal rather than
/// silently falling back to local.
fn resolve_policy_provider(
async fn resolve_policy_provider(
config_file: Option<&config_file::ConfigFile>,
store: Arc<Store>,
) -> Result<Option<Arc<dyn policy_provider::PolicyProvider>>> {
Expand All @@ -518,21 +500,64 @@ fn resolve_policy_provider(
let Some(policy_type) = policy.r#type.as_deref() else {
return Ok(None);
};
let registry = build_policy_provider_registry(store);
if let Some(provider) = registry.get(policy_type) {
return Ok(Some(provider));
}
if policy_type == policy_provider::ATTESTED_POLICY_TYPE_ID {
return Err(Error::config(
"[openshell.policy] type = 'attested' is not yet available in this build; \
use 'local' or omit the [openshell.policy] table",
));

match policy_type {
policy_provider::LOCAL_POLICY_TYPE_ID => Ok(Some(Arc::new(
policy_provider::LocalPolicyProvider::new(store),
))),
policy_provider::ATTESTED_POLICY_TYPE_ID => {
Ok(Some(build_attested_policy_provider(policy).await?))
}
// Unreachable in practice — `config_file::load` already rejects
// unknown policy type names. Defensive for any straggler.
other => Err(Error::config(format!(
"unknown policy provider type '{other}'"
))),
}
// Unreachable in practice — `config_file::load` already rejects
// unknown policy type names. Treat any straggler defensively.
Err(Error::config(format!(
"unknown policy provider type '{policy_type}'"
)))
}

/// Construct an `AttestedPolicyProvider` from the parsed `[openshell.policy]`
/// table. `config_file::load` has already validated the required fields
/// are present.
async fn build_attested_policy_provider(
policy: &config_file::PolicyFileSection,
) -> Result<Arc<dyn policy_provider::PolicyProvider>> {
let source_uds_path = policy
.source_uds_path
.as_ref()
.expect("source_uds_path must be present (validated at config load)");
let trust_store_path = policy
.trust_store_path
.as_ref()
.expect("trust_store_path must be present (validated at config load)");

let trust_store = policy_provider::TrustStore::load(trust_store_path).map_err(|e| {
Error::config(format!(
"failed to load policy trust store from '{}': {e}",
trust_store_path.display()
))
})?;

let source = policy_provider::GrpcPolicySource::connect(source_uds_path)
.await
.map_err(|e| {
Error::config(format!(
"failed to connect to policy source at '{}': {e}",
source_uds_path.display()
))
})?;

let provider = policy_provider::AttestedPolicyProvider::new(Arc::new(source), trust_store)
.await
.map_err(|e| Error::config(format!("attested policy provider startup failed: {e}")))?;

info!(
source_uds_path = %source_uds_path.display(),
trust_store_path = %trust_store_path.display(),
"attested policy provider initialized"
);

Ok(Arc::new(provider))
}

fn gateway_listener_addresses(
Expand Down
Loading