diff --git a/.gitignore b/.gitignore index 34b24bf768..342e604478 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Build output /target/ e2e/rust/target/ +target/ debug/ release/ diff --git a/Cargo.lock b/Cargo.lock index 76dedf0625..08d9f9bb8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3753,6 +3753,27 @@ dependencies = [ "zstd", ] +[[package]] +name = "openshell-gateway-interceptors" +version = "0.0.0" +dependencies = [ + "hyper-util", + "json-patch", + "metrics", + "openshell-core", + "prost", + "prost-reflect", + "prost-types", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tonic", + "tower 0.5.3", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openshell-ocsf" version = "0.0.0" @@ -3881,6 +3902,7 @@ dependencies = [ "openshell-driver-docker", "openshell-driver-kubernetes", "openshell-driver-podman", + "openshell-gateway-interceptors", "openshell-ocsf", "openshell-policy", "openshell-prover", @@ -4566,6 +4588,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "base64 0.22.1", + "prost", + "prost-types", + "serde", + "serde-value", +] + [[package]] name = "prost-types" version = "0.14.3" diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8c..184c9aa398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tonic-prost = "0.14" tonic-prost-build = "0.14" prost = "0.14" prost-types = "0.14" +prost-reflect = { version = "0.16.5", features = ["serde"] } # HTTP server axum = { version = "0.8", features = ["ws"] } diff --git a/architecture/gateway.md b/architecture/gateway.md index d873b2a105..99a8420303 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -41,6 +41,92 @@ Operators can configure a gateway-wide gRPC request rate limit. The limit is applied only to gRPC API traffic after protocol multiplexing; health, metrics, and local sandbox-service HTTP routes are not rate limited by this control. +Gateway interceptors run in one middleware layer on the `openshell.v1.OpenShell` +gRPC service after authentication and before tonic dispatches to individual +handlers. At startup the gateway calls each configured interceptor's `Describe` +RPC, validates declared bindings against the compiled OpenShell descriptor set, +and builds an immutable execution plan. Only unary OpenShell methods in the +gateway's explicit interceptable-method allowlist are decoded through the +descriptor set into protobuf JSON, evaluated through configured phases, and +re-encoded before the handler sees the request. New RPCs are non-interceptable +until deliberately added to this allowlist. Interception remains centralized: +allowlisting a unary RPC does not require method-specific gateway +instrumentation. + +Each configured interceptor selects a binding policy. `dynamic` accepts valid +manifest declarations and preserves the compatibility behavior. `allowlist` +enables only operator-configured RPCs and phases, while `exact` requires the +configured and declared sets to match. Strict policies match by RPC rather than +manifest binding ID, so renaming a binding does not change authority. Provider +profile sources remain a separate operator-controlled capability. + +The protobuf schema marks dedicated credential, token, and refresh-material +fields with a custom secret option. The middleware recursively omits those +fields from every request and post-commit response sent to an interceptor while +retaining the complete protobuf operation for handler dispatch. JSON Patch +paths and source paths cannot select an omitted field or replace a containing +object. There is no configuration that exposes annotated fields. + +`SubmitPolicyAnalysis` is interceptable because proposed chunks can eventually +change active policy through the gateway's approval workflow. An interceptor +may therefore reject policy proposals while permitting telemetry-only requests. +Gateways without a matching binding retain the standard proposal behavior. + +The descriptor codec uses protobuf's standard `oneof` semantics. If binary +input contains multiple alternatives from one group, the last member on the +wire wins. The middleware converts that selected value to ProtoJSON and +re-encodes it before dispatch, so the interceptor and handler observe the same +canonical request. ProtoJSON input that names multiple alternatives remains +invalid. + +Modification results are atomic per binding. After applying one binding's full +JSON Patch list, the middleware re-encodes the candidate as the request's +protobuf type and decodes those accepted bytes back to canonical ProtoJSON. +Invalid candidates follow that binding's failure policy: fail-open restores the +exact pre-binding operation, while fail-closed rejects the request before +handler dispatch. Later bindings only observe the same schema-valid operation +that the handler will receive; protobuf map entry ordering is not treated as a +semantic difference. + +Each interceptor evaluation selects exactly one phase payload: +`modify_operation`, `validate`, or `post_commit`. Modification and validation +payloads carry the protobuf JSON operation entering that phase. Post-commit +payloads carry the successful committed response instead of echoing the +request. Only the `validate` payload can also carry optional read-only +`current_state`; modification and post-commit evaluations never receive it. The +gateway does not yet load method-specific state, so the field remains absent; +an absent state is distinct from an explicitly empty object. Method-specific +state schemas and persistence-version binding are deferred until a concrete +consumer requires them. + +Post-commit evaluation is strictly observational. A binding that includes +`post_commit` must resolve to `fail_open`, or interceptor initialization fails. +After a handler returns success, failures never replace the committed response. +Binding failures emit the standard fail-open warning and counter; response +observation or evaluation failures outside binding policy emit warnings and the +`openshell_gateway_interceptor_post_commit_observation_failures_total` metric. +The gateway reconstructs the original response frames, including trailers and +body errors, before evaluating the observer. + +Interceptor manifests can also vend provider profile catalogs. Gateway +configuration selects the exact ordered source set from the in-tree built-in +source, the stored user source, and named profile-capable interceptors. Omitting +the setting selects `builtin + user`; selecting only an interceptor makes it +authoritative by omission. Every selected source uses the same snapshot, +semantic-validation, and duplicate-detection path. Duplicate normalized profile +IDs fail instead of creating source precedence. The gateway treats configured +interceptors as trusted sources and does not verify signature annotations in +their profile payloads. + +Each logical gateway request captures the selected sources into one validated, +immutable effective catalog before deriving provider behavior. Policy layers, +credential scope, injected environment material, dynamic token grants, and +provider-environment revisions use that same catalog. Each configured source is +therefore fetched at most once per request, and a source revision change becomes +visible on the next request instead of partway through the current request. The +capture emits debug diagnostics with the combined catalog revision, source fetch +count, and profile count; it never logs provider credentials or profile material. + Supported auth modes: | Mode | Use | @@ -143,6 +229,15 @@ populate `scope`, `version`, `status`, `dedup_key`, and `hit_count` so the gateway can efficiently fetch the latest policy, track load status, and manage advisor drafts without creating resource-specific tables. +Each sandbox policy revision stores the complete provenance annotation map +supplied with that update. The revision payload is the authoritative immutable +record; sandbox metadata receives the same annotations only as a convenience +projection and can retain keys from earlier revisions. Policy revision creation, +optional first-policy backfill, metadata projection, and superseding older +revisions commit in one database transaction. SQLite serializes this operation +with an immediate transaction, while Postgres locks the sandbox row. A failed +resource-version check or revision insert rolls back the entire operation. + SQLite is the default local store; Postgres is supported for deployments that need an external database or multi-replica coordination. Both backends expose the same `Store` API and the same logical schema. Backend differences stay @@ -220,7 +315,7 @@ modes: write. Client-facing operations that carry an `expected_resource_version` field use this mode: `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateProvider`, `UpdateProviderProfiles`, and `UpdateConfig` (policy - backfill path). + backfill and sandbox annotation updates). **Lists.** The `list_messages` and `list_messages_with_selector` helpers decode protobuf payloads from list results and hydrate `resource_version` from the diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0182e1b668..5880e21a71 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1928,6 +1928,7 @@ pub async fn sandbox_create( }), name: name.unwrap_or_default().to_string(), labels, + annotations: HashMap::new(), }; let response = match client.create_sandbox(request).await { @@ -1970,13 +1971,9 @@ pub async fn sandbox_create( match client .update_config(UpdateConfigRequest { name: sandbox_name.clone(), - policy: None, setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), setting_value: Some(setting), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await { @@ -2683,6 +2680,17 @@ pub async fn sandbox_get( } } + if let Some(metadata) = &sandbox.metadata + && !metadata.annotations.is_empty() + { + println!(" {} ", "Annotations:".dimmed()); + let mut annotations: Vec<_> = metadata.annotations.iter().collect(); + annotations.sort_by_key(|(k, _)| *k); + for (key, value) in annotations { + println!(" {key}: {value}"); + } + } + let policy_from_global = config.policy_source == PolicySource::Global as i32; println!( " {} {}", @@ -3427,10 +3435,15 @@ pub async fn sandbox_list( fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { let meta = sandbox.metadata.as_ref(); let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels)); + let annotations = meta.map_or_else( + || serde_json::json!({}), + |m| serde_json::json!(m.annotations), + ); serde_json::json!({ "id": sandbox.object_id(), "name": sandbox.object_name(), "labels": labels, + "annotations": annotations, "resource_version": meta.map_or(0, |m| m.resource_version), "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), "phase": phase_name(sandbox.phase()), @@ -3883,6 +3896,7 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), @@ -3925,6 +3939,7 @@ async fn auto_create_provider( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), @@ -4711,13 +4726,14 @@ pub async fn provider_create_with_options( }; let adc_credential_key = if from_gcloud_adc { - let profile = - openshell_providers::get_default_profile(&provider_type).ok_or_else(|| { + let profile = fetch_provider_profile(&mut client, &provider_type) + .await + .map_err(|err| { miette::miette!( - "--from-gcloud-adc requires a built-in provider profile, \ - but '{provider_type}' has none" + "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" ) })?; + let profile = ProviderTypeProfile::from_proto(&profile); let adc_cred = profile.adc_credential().ok_or_else(|| { miette::miette!( "--from-gcloud-adc is not supported for '{provider_type}' providers \ @@ -4805,6 +4821,7 @@ pub async fn provider_create_with_options( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.clone(), credentials: credential_map, @@ -5751,6 +5768,7 @@ pub async fn provider_update( created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: credential_map, @@ -6362,12 +6380,8 @@ pub async fn sandbox_policy_set_global( .update_config(UpdateConfigRequest { name: String::new(), policy: Some(policy), - setting_key: String::new(), - setting_value: None, - delete_setting: false, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6560,13 +6574,10 @@ pub async fn gateway_setting_set( let response = client .update_config(UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key.to_string(), setting_value: Some(setting_value), - delete_setting: false, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6595,13 +6606,9 @@ pub async fn sandbox_setting_set( let response = client .update_config(UpdateConfigRequest { name: name.to_string(), - policy: None, setting_key: key.to_string(), setting_value: Some(setting_value), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6630,13 +6637,10 @@ pub async fn gateway_setting_delete( let response = client .update_config(UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key.to_string(), - setting_value: None, delete_setting: true, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6665,13 +6669,9 @@ pub async fn sandbox_setting_delete( let response = client .update_config(UpdateConfigRequest { name: name.to_string(), - policy: None, setting_key: key.to_string(), - setting_value: None, delete_setting: true, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -6725,12 +6725,7 @@ pub async fn sandbox_policy_set( .update_config(UpdateConfigRequest { name: name.to_string(), policy: Some(policy), - setting_key: String::new(), - setting_value: None, - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()?; @@ -6899,13 +6894,8 @@ pub async fn sandbox_policy_update( let response = client .update_config(UpdateConfigRequest { name: name.to_string(), - policy: None, - setting_key: String::new(), - setting_value: None, - delete_setting: false, - global: false, merge_operations: plan.merge_operations, - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic()? @@ -7339,6 +7329,9 @@ fn policy_revision_to_json( if !rev.load_error.is_empty() { obj.insert("load_error".to_string(), serde_json::json!(rev.load_error)); } + if !rev.provenance.is_empty() { + obj.insert("provenance".to_string(), serde_json::json!(rev.provenance)); + } if view.includes_policy() { let policy = match rev.policy.as_ref() { Some(policy) => { @@ -7894,7 +7887,7 @@ fn format_timestamp_ms(ms: i64) -> String { #[cfg(test)] mod tests { use super::{ - ProvisioningStep, TlsOptions, build_sandbox_resource_limits, + PolicyGetView, ProvisioningStep, TlsOptions, build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, format_gateway_select_header, format_gateway_select_items, format_provider_attachment_table, gateway_add, gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_to_json, @@ -7902,7 +7895,7 @@ mod tests { inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, - plaintext_gateway_is_remote, progress_step_from_metadata, + plaintext_gateway_is_remote, policy_revision_to_json, progress_step_from_metadata, provider_profile_allows_empty_credentials, provisioning_timeout_message, ready_false_condition_message, refresh_status_header, refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan, service_expose_status_error, @@ -7927,12 +7920,41 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GpuResourceRequirements, Provider, ProviderCredentialRefresh, + GpuResourceRequirements, PolicyStatus, Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus, ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCredential, - ResourceRequirements, SandboxCondition, SandboxStatus, datamodel::v1::ObjectMeta, + ResourceRequirements, SandboxCondition, SandboxPolicyRevision, SandboxStatus, + datamodel::v1::ObjectMeta, }; + #[test] + fn policy_revision_json_includes_revision_provenance() { + let revision = SandboxPolicyRevision { + version: 2, + policy_hash: "hash".to_string(), + provenance: std::collections::HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "signed".to_string(), + )]), + ..Default::default() + }; + + let json = policy_revision_to_json( + "sandbox", + Some("example"), + Some(2), + &revision, + PolicyStatus::Pending, + PolicyGetView::Metadata, + ) + .unwrap(); + + assert_eq!( + json["provenance"]["openshell.nvidia.com/policy-signature"], + "signed" + ); + } + struct EnvVarGuard { key: &'static str, original: Option, @@ -9724,6 +9746,7 @@ mod tests { resource_version: 42, created_at_ms: 1_234_567_890_000, labels, + annotations: std::collections::HashMap::new(), }; let provider = Provider { diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7bf8612b4a..24ab5e4bb7 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -63,6 +63,7 @@ impl TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -349,6 +350,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + annotations: HashMap::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 53b178acbb..d75fe26e20 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -129,6 +129,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 1, + annotations: HashMap::new(), }), spec: None, status: None, @@ -343,21 +344,23 @@ impl OpenShell for TestOpenShell { .provider .ok_or_else(|| Status::invalid_argument("provider is required"))?; if provider.credentials.is_empty() { - let bootstrap_allowed = - if let Some(profile) = openshell_providers::get_default_profile(&provider.r#type) { - profile.allows_empty_provider_credentials() - } else { - self.state - .profiles - .lock() - .await - .get(&provider.r#type) - .cloned() - .is_some_and(|profile| { - openshell_providers::ProviderTypeProfile::from_proto(&profile) - .allows_empty_provider_credentials() - }) - }; + let bootstrap_allowed = if let Some(profile) = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == provider.r#type) + { + profile.allows_empty_provider_credentials() + } else { + self.state + .profiles + .lock() + .await + .get(&provider.r#type) + .cloned() + .is_some_and(|profile| { + openshell_providers::ProviderTypeProfile::from_proto(&profile) + .allows_empty_provider_credentials() + }) + }; if !bootstrap_allowed { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -414,7 +417,7 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - let mut profiles = openshell_providers::default_profiles() + let mut profiles = openshell_providers::builtin_profiles() .iter() .map(openshell_providers::ProviderTypeProfile::to_proto) .collect::>(); @@ -429,7 +432,10 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let id = request.into_inner().id; - let profile = if let Some(profile) = openshell_providers::get_default_profile(&id) { + let profile = if let Some(profile) = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == id) + { profile.to_proto() } else { self.state @@ -604,6 +610,7 @@ impl OpenShell for TestOpenShell { created_at_ms: existing_metadata.created_at_ms, labels: existing_metadata.labels, resource_version: 0, + annotations: HashMap::new(), }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index ec8bd53743..3dec8943f3 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -87,6 +87,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Sandbox::default() }; @@ -108,6 +109,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Sandbox::default() }; @@ -368,6 +370,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Sandbox::default() }; diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 8e799f821e..d8f8e695f2 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -79,6 +79,7 @@ impl OpenShell for TestOpenShell { created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), ..Default::default() }), diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index a0f4ec9cf5..36a2a4ec64 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -384,6 +384,12 @@ pub struct Config { /// Gateway user authentication behavior. pub auth: GatewayAuthConfig, + /// Disabled-by-default gateway interceptor service configs. + pub gateway_interceptors: Vec, + + /// Ordered provider-profile sources used to build the effective catalog. + pub provider_profile_sources: Vec, + /// mTLS user authentication configuration. When enabled, a verified TLS /// client certificate can authenticate CLI/SDK callers as a /// `Principal::User`. This is for local single-user gateways only; @@ -547,6 +553,112 @@ pub struct GatewayAuthConfig { pub allow_unauthenticated_users: bool, } +/// One configured gateway interceptor service. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct GatewayInterceptorConfig { + /// Operator-assigned instance name used in logs and config overrides. + pub name: String, + /// Interceptor gRPC endpoint. Supports `http://`, `https://`, and + /// `unix://` endpoints. + pub grpc_endpoint: String, + /// Deterministic service ordering. Lower values run first. + #[serde(default)] + pub order: i32, + /// Default failure policy for this configured service. + #[serde(default)] + pub failure_policy: Option, + /// RFC-style timeout string such as `500ms` or `2s`. + #[serde(default)] + pub timeout: Option, + /// Maximum accepted encoded `Evaluate` response size. + #[serde(default)] + pub max_response_bytes: Option, + /// Maximum JSON patches accepted from one evaluation result. + #[serde(default)] + pub max_patches: Option, + /// Controls whether manifest bindings are dynamic, allowlisted, or must + /// exactly match operator configuration. + #[serde(default)] + pub binding_policy: GatewayInterceptorBindingPolicy, + /// Binding configuration. Its validation and authorization semantics are + /// selected by `binding_policy`. + #[serde(default)] + pub bindings: Vec, +} + +/// Operator policy for authorizing interceptor manifest bindings. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GatewayInterceptorBindingPolicy { + /// Preserve manifest-controlled binding discovery. Configured bindings + /// may narrow or disable manifest declarations. + #[default] + Dynamic, + /// Enable only configured RPC selectors and phases. Extra manifest + /// declarations are ignored. + Allowlist, + /// Require configured and manifest RPC selectors and phases to match. + Exact, +} + +/// One configured source in the gateway's effective provider-profile catalog. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum GatewayProviderProfileSourceConfig { + /// Profiles bundled with the `OpenShell` build. + Builtin, + /// Profiles managed through the provider profile mutation APIs. + User, + /// Profiles vended by a configured gateway interceptor instance. + Interceptor { name: String }, +} + +/// Failure behavior when an interceptor evaluation cannot produce a valid +/// result. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GatewayInterceptorFailurePolicy { + FailClosed, + FailOpen, +} + +/// Configured binding authorization or dynamic-manifest override. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct GatewayInterceptorBindingOverride { + /// Binding id from the interceptor manifest. + #[serde(default)] + pub id: Option, + /// Full selector form: `openshell.v1.OpenShell/CreateSandbox`. + #[serde(default)] + pub rpc: Option, + /// Structured selector service, e.g. `openshell.v1.OpenShell`. + #[serde(default)] + pub service: Option, + /// Structured selector method, e.g. `CreateSandbox`. + #[serde(default)] + pub method: Option, + /// Narrowed phase set. + #[serde(default)] + pub phases: Option>, + /// Disable the selected binding. + #[serde(default)] + pub disabled: bool, + /// Binding-specific failure policy override. + #[serde(default)] + pub failure_policy: Option, +} + +/// Config file phase names. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum GatewayInterceptorPhaseConfig { + ModifyOperation, + Validate, + PostCommit, +} + const fn default_jwks_ttl_secs() -> u64 { 3600 } @@ -607,6 +719,11 @@ impl Config { tls, oidc: None, auth: GatewayAuthConfig::default(), + gateway_interceptors: Vec::new(), + provider_profile_sources: vec![ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::User, + ], mtls_auth: MtlsAuthConfig::default(), gateway_jwt: None, database_url: String::new(), @@ -697,6 +814,26 @@ impl Config { self } + /// Set configured gateway interceptors. + #[must_use] + pub fn with_gateway_interceptors(mut self, interceptors: I) -> Self + where + I: IntoIterator, + { + self.gateway_interceptors = interceptors.into_iter().collect(); + self + } + + /// Set the ordered provider-profile sources used by the gateway. + #[must_use] + pub fn with_provider_profile_sources(mut self, sources: I) -> Self + where + I: IntoIterator, + { + self.provider_profile_sources = sources.into_iter().collect(); + self + } + /// Return the effective gRPC rate limit, if fully configured and enabled. #[must_use] pub fn grpc_rate_limit(&self) -> Option<(u64, Duration)> { @@ -821,9 +958,11 @@ mod tests { #[cfg(unix)] use super::is_reachable_unix_socket; use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, detect_driver, - docker_host_unix_socket_path, is_unix_socket, normalize_compute_driver_name, - podman_socket_candidates_from_env, podman_socket_responds, + ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, + GatewayProviderProfileSourceConfig, detect_driver, docker_host_unix_socket_path, + is_unix_socket, normalize_compute_driver_name, podman_socket_candidates_from_env, + podman_socket_responds, }; #[cfg(unix)] use std::io::{Read as _, Write as _}; @@ -889,6 +1028,18 @@ mod tests { assert!(!cfg.auth.allow_unauthenticated_users); } + #[test] + fn config_defaults_to_builtin_and_user_provider_profile_sources() { + let cfg = Config::new(None); + assert_eq!( + cfg.provider_profile_sources, + vec![ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::User, + ] + ); + } + #[test] fn gateway_jwt_ttl_defaults_to_non_expiring() { let cfg: GatewayJwtConfig = serde_json::from_value(serde_json::json!({ @@ -901,6 +1052,35 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn gateway_interceptor_failure_policy_rejects_ignore() { + let err = + serde_json::from_value::(serde_json::json!("ignore")) + .unwrap_err(); + + assert!(err.to_string().contains("unknown variant `ignore`")); + } + + #[test] + fn gateway_interceptor_binding_policy_defaults_and_parses_strict_modes() { + let defaulted: GatewayInterceptorConfig = serde_json::from_value(serde_json::json!({ + "name": "governance", + "grpc_endpoint": "unix:///tmp/governance.sock" + })) + .unwrap(); + let allowlist: GatewayInterceptorBindingPolicy = + serde_json::from_value(serde_json::json!("allowlist")).unwrap(); + let exact: GatewayInterceptorBindingPolicy = + serde_json::from_value(serde_json::json!("exact")).unwrap(); + + assert_eq!( + defaulted.binding_policy, + GatewayInterceptorBindingPolicy::Dynamic + ); + assert_eq!(allowlist, GatewayInterceptorBindingPolicy::Allowlist); + assert_eq!(exact, GatewayInterceptorBindingPolicy::Exact); + } + #[test] fn grpc_rate_limit_requires_positive_pair() { assert!(Config::new(None).grpc_rate_limit().is_none()); diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 92eab00408..d5d6ed9552 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -632,12 +632,7 @@ async fn sync_policy_with_client( .update_config(UpdateConfigRequest { name: sandbox.to_string(), policy: Some(policy.clone()), - setting_key: String::new(), - setting_value: None, - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }) .await .into_diagnostic() diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 3212963691..c5e24d17a1 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -38,8 +38,10 @@ pub mod telemetry; pub mod time; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, - TlsConfig, + ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, + GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, + GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, + MtlsAuthConfig, OidcConfig, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion}; diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 08b062d2e5..96424056fd 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -16,6 +16,14 @@ pub mod openshell { include!(concat!(env!("OUT_DIR"), "/openshell.v1.rs")); } +// Cross-package references from packages nested under `openshell.*.v1` can be +// generated as `super::super::v1::*`. Keep that path available as an alias for +// the root `openshell.v1` package. +#[doc(hidden)] +pub mod v1 { + pub use super::openshell::*; +} + #[allow( clippy::all, clippy::pedantic, @@ -79,7 +87,24 @@ pub mod inference { } } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_qualifications, + rust_2018_idioms +)] +pub mod gateway_interceptor { + pub mod v1 { + include!(concat!( + env!("OUT_DIR"), + "/openshell.gateway_interceptor.v1.rs" + )); + } +} + pub use datamodel::v1::*; +pub use gateway_interceptor::v1::*; pub use inference::v1::*; pub use openshell::*; pub use sandbox::v1::*; diff --git a/crates/openshell-gateway-interceptors/Cargo.toml b/crates/openshell-gateway-interceptors/Cargo.toml new file mode 100644 index 0000000000..7bcdd6eebf --- /dev/null +++ b/crates/openshell-gateway-interceptors/Cargo.toml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-gateway-interceptors" +description = "Gateway interceptor framework for OpenShell" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +hyper-util = { workspace = true, features = ["client", "http1", "http2", "tokio"] } +json-patch = "1.4" +metrics = { workspace = true } +prost = { workspace = true } +prost-reflect = { workspace = true } +prost-types = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tower = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-gateway-interceptors/src/lib.rs b/crates/openshell-gateway-interceptors/src/lib.rs new file mode 100644 index 0000000000..32b4f3e525 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/lib.rs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway interceptor framework. +//! +//! The gateway integrates this crate once at the gRPC routing boundary. The +//! runtime uses the generated protobuf descriptor set to decode unary +//! `openshell.v1.OpenShell` request frames into protobuf-JSON-shaped values, +//! apply interceptor decisions, and re-encode the request before tonic reaches +//! the handler. Handler modules do not need per-method interceptor hooks. + +#![allow(clippy::result_large_err)] + +use openshell_core::config::GatewayInterceptorConfig; + +pub(crate) mod plan; +pub(crate) mod profile_source; +pub(crate) mod proto_json; +pub mod routes; +pub(crate) mod runtime; + +pub use plan::{FailurePolicy, Phase, RpcSelector, parse_duration}; +pub use profile_source::{GatewayInterceptorProfileSource, ProviderProfileSourceSnapshot}; +pub use proto_json::ProtoJsonCodec; +pub use runtime::{EvaluationContext, GatewayInterceptorRuntime, InterceptedRequest}; + +#[derive(Debug, thiserror::Error)] +pub enum InterceptorError { + #[error("invalid interceptor config: {0}")] + Config(String), + #[error("interceptor transport error: {0}")] + Transport(String), + #[error("invalid interceptor result: {0}")] + InvalidResult(String), + #[error("protobuf transcode error: {0}")] + Transcode(String), +} + +pub type Result = std::result::Result; + +/// Return `None` when no interceptors are configured. +pub async fn initialize( + configs: Vec, +) -> Result> { + if configs.is_empty() { + return Ok(None); + } + let runtime = GatewayInterceptorRuntime::build(configs).await?; + Ok(Some(runtime)) +} diff --git a/crates/openshell-gateway-interceptors/src/plan.rs b/crates/openshell-gateway-interceptors/src/plan.rs new file mode 100644 index 0000000000..b65d5612fd --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/plan.rs @@ -0,0 +1,1206 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptor configuration and immutable execution planning. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::time::Duration; + +use hyper_util::rt::TokioIo; +use openshell_core::config::{ + GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, + GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, +}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, + gateway_interceptor_client::GatewayInterceptorClient, +}; +use tokio::net::UnixStream; +use tonic::Request; +use tonic::codegen::http::Uri; +use tonic::transport::{Channel, Endpoint}; +use tower::service_fn; +use tracing::{info, warn}; + +use crate::profile_source::GatewayInterceptorProfileSource; +use crate::routes::OpenShellRouteIndex; +use crate::{InterceptorError, Result}; + +pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(500); +pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 1_048_576; +pub const DEFAULT_MAX_PATCHES: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Phase { + ModifyOperation, + Validate, + PostCommit, +} + +impl Phase { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ModifyOperation => "modify_operation", + Self::Validate => "validate", + Self::PostCommit => "post_commit", + } + } +} + +impl TryFrom for Phase { + type Error = InterceptorError; + + fn try_from(value: GatewayInterceptorPhase) -> Result { + match value { + GatewayInterceptorPhase::ModifyOperation => Ok(Self::ModifyOperation), + GatewayInterceptorPhase::Validate => Ok(Self::Validate), + GatewayInterceptorPhase::PostCommit => Ok(Self::PostCommit), + GatewayInterceptorPhase::Unspecified => Err(InterceptorError::Config( + "binding phase must not be unspecified".to_string(), + )), + } + } +} + +impl From for Phase { + fn from(value: GatewayInterceptorPhaseConfig) -> Self { + match value { + GatewayInterceptorPhaseConfig::ModifyOperation => Self::ModifyOperation, + GatewayInterceptorPhaseConfig::Validate => Self::Validate, + GatewayInterceptorPhaseConfig::PostCommit => Self::PostCommit, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailurePolicy { + FailClosed, + FailOpen, +} + +impl FailurePolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FailClosed => "fail_closed", + Self::FailOpen => "fail_open", + } + } +} + +impl From for FailurePolicy { + fn from(value: GatewayInterceptorFailurePolicy) -> Self { + match value { + GatewayInterceptorFailurePolicy::FailClosed => Self::FailClosed, + GatewayInterceptorFailurePolicy::FailOpen => Self::FailOpen, + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RpcSelector { + pub service: String, + pub method: String, +} + +impl RpcSelector { + #[must_use] + pub fn new(service: impl Into, method: impl Into) -> Self { + Self { + service: service.into(), + method: method.into(), + } + } + + #[must_use] + pub fn rpc(&self) -> String { + format!("{}/{}", self.service, self.method) + } + + #[must_use] + pub fn from_grpc_path(path: &str) -> Option { + let path = path.strip_prefix('/').unwrap_or(path); + let (service, method) = path.rsplit_once('/')?; + Some(Self::new(service, method)) + } +} + +#[derive(Clone)] +pub struct BindingPlan { + pub(crate) interceptor_name: String, + pub(crate) binding_id: String, + pub(crate) selector: RpcSelector, + pub(crate) phase: Phase, + pub(crate) failure_policy: FailurePolicy, + pub(crate) timeout: Duration, + pub(crate) max_response_bytes: usize, + pub(crate) max_patches: usize, + pub(crate) client: GatewayInterceptorClient, +} + +impl std::fmt::Debug for BindingPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BindingPlan") + .field("interceptor_name", &self.interceptor_name) + .field("binding_id", &self.binding_id) + .field("selector", &self.selector) + .field("phase", &self.phase) + .field("failure_policy", &self.failure_policy) + .field("timeout", &self.timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("max_patches", &self.max_patches) + .finish_non_exhaustive() + } +} + +#[derive(Debug)] +pub struct ExecutionPlan { + bindings: BTreeMap<(RpcSelector, Phase), Vec>, + profile_sources: BTreeMap, + routes: OpenShellRouteIndex, +} + +impl ExecutionPlan { + #[cfg(test)] + pub(crate) fn empty(routes: OpenShellRouteIndex) -> Self { + Self { + bindings: BTreeMap::new(), + profile_sources: BTreeMap::new(), + routes, + } + } + + pub(crate) async fn load( + mut configs: Vec, + routes: OpenShellRouteIndex, + ) -> Result { + validate_interceptor_configs(&configs)?; + configs.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.name.cmp(&b.name))); + + let mut bindings: BTreeMap<(RpcSelector, Phase), Vec> = BTreeMap::new(); + let mut profile_sources = BTreeMap::new(); + + for config in configs { + let channel = connect_endpoint(&config.grpc_endpoint).await?; + let timeout = match config.timeout.as_deref() { + Some(timeout) => parse_duration(timeout)?, + None => DEFAULT_TIMEOUT, + }; + let mut client = GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size( + config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES), + ); + let manifest = + tokio::time::timeout(timeout, client.describe(Request::new(DescribeRequest {}))) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "Describe timed out for '{}'", + config.name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "Describe failed for '{}': {status}", + config.name + )) + })? + .into_inner(); + let service_default = match config.binding_policy { + GatewayInterceptorBindingPolicy::Dynamic => { + let manifest_default = parse_optional_failure_policy(&manifest.failure_policy)?; + config + .failure_policy + .map(FailurePolicy::from) + .or(manifest_default) + .unwrap_or(FailurePolicy::FailClosed) + } + GatewayInterceptorBindingPolicy::Allowlist + | GatewayInterceptorBindingPolicy::Exact => config + .failure_policy + .map_or(FailurePolicy::FailClosed, FailurePolicy::from), + }; + let max_response_bytes = config + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + let max_patches = config.max_patches.unwrap_or(DEFAULT_MAX_PATCHES); + + let normalized_bindings = match config.binding_policy { + GatewayInterceptorBindingPolicy::Dynamic => { + warn!( + interceptor = %config.name, + "interceptor uses dynamic binding policy; valid manifest bindings are operator-authorized" + ); + normalize_dynamic_bindings( + &config.name, + &manifest.bindings, + service_default, + &config.bindings, + )? + } + GatewayInterceptorBindingPolicy::Allowlist + | GatewayInterceptorBindingPolicy::Exact => normalize_strict_bindings( + &config.name, + &manifest.bindings, + service_default, + &config.bindings, + config.binding_policy, + )?, + }; + for normalized in normalized_bindings { + if !routes + .is_interceptable(&normalized.selector.service, &normalized.selector.method) + { + return Err(InterceptorError::Config(format!( + "interceptor '{}' binding '{}' targets non-interceptable RPC '{}'", + config.name, + normalized.binding_id, + normalized.selector.rpc() + ))); + } + for phase in normalized.phases { + let plan = BindingPlan { + interceptor_name: config.name.clone(), + binding_id: normalized.binding_id.clone(), + selector: normalized.selector.clone(), + phase, + failure_policy: normalized.failure_policy, + timeout, + max_response_bytes, + max_patches, + client: GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + }; + bindings + .entry((normalized.selector.clone(), phase)) + .or_default() + .push(plan); + } + } + + if manifest.provider_profiles { + let source_id = format!("interceptor/{}", config.name); + profile_sources.insert( + config.name.clone(), + GatewayInterceptorProfileSource::new( + config.name.clone(), + source_id, + timeout, + GatewayInterceptorClient::new(channel.clone()) + .max_decoding_message_size(max_response_bytes), + ), + ); + } + } + + let count: usize = bindings.values().map(Vec::len).sum(); + info!( + bindings = count, + profile_sources = profile_sources.len(), + "gateway interceptors initialized" + ); + Ok(Self { + bindings, + profile_sources, + routes, + }) + } + + pub(crate) fn profile_source( + &self, + interceptor_name: &str, + ) -> Option { + self.profile_sources.get(interceptor_name).cloned() + } + + pub(crate) fn is_empty(&self) -> bool { + self.bindings.is_empty() && self.profile_sources.is_empty() + } + + pub(crate) fn should_intercept(&self, selector: &RpcSelector) -> bool { + self.routes + .is_interceptable(&selector.service, &selector.method) + && [Phase::ModifyOperation, Phase::Validate, Phase::PostCommit] + .iter() + .any(|phase| self.bindings.contains_key(&(selector.clone(), *phase))) + } + + pub(crate) fn input_type(&self, selector: &RpcSelector) -> Option<&str> { + self.routes.input_type(&selector.service, &selector.method) + } + + pub(crate) fn output_type(&self, selector: &RpcSelector) -> Option<&str> { + self.routes.output_type(&selector.service, &selector.method) + } + + pub(crate) fn bindings(&self, selector: &RpcSelector, phase: Phase) -> Option<&[BindingPlan]> { + self.bindings + .get(&(selector.clone(), phase)) + .map(Vec::as_slice) + } + + pub(crate) fn has_binding(&self, selector: &RpcSelector, phase: Phase) -> bool { + self.bindings.contains_key(&(selector.clone(), phase)) + } +} + +#[derive(Debug, Clone)] +struct NormalizedBinding { + binding_id: String, + selector: RpcSelector, + phases: Vec, + failure_policy: FailurePolicy, +} + +#[derive(Debug)] +struct StrictBindingConfig<'a> { + selector: RpcSelector, + phases: Vec, + source: &'a GatewayInterceptorBindingOverride, +} + +fn normalize_dynamic_bindings( + interceptor_name: &str, + manifest_bindings: &[InterceptorBinding], + service_default: FailurePolicy, + overrides: &[GatewayInterceptorBindingOverride], +) -> Result> { + let override_index = OverrideIndex::new(overrides)?; + let mut normalized = Vec::new(); + for binding in manifest_bindings { + if let Some(binding) = + normalize_binding(interceptor_name, binding, service_default, &override_index)? + { + normalized.push(binding); + } + } + Ok(normalized) +} + +fn normalize_strict_bindings( + interceptor_name: &str, + manifest_bindings: &[InterceptorBinding], + service_default: FailurePolicy, + configured_bindings: &[GatewayInterceptorBindingOverride], + policy: GatewayInterceptorBindingPolicy, +) -> Result> { + debug_assert!(matches!( + policy, + GatewayInterceptorBindingPolicy::Allowlist | GatewayInterceptorBindingPolicy::Exact + )); + + let configured = normalize_strict_config(interceptor_name, configured_bindings)?; + let configured_selectors = configured + .iter() + .map(|binding| binding.selector.clone()) + .collect::>(); + let mut normalized = Vec::with_capacity(configured.len()); + + for binding_config in configured { + let matches = manifest_bindings + .iter() + .filter(|binding| { + selector_from_proto(binding.selector.as_ref()) + .is_ok_and(|selector| selector == binding_config.selector) + }) + .collect::>(); + match matches.as_slice() { + [] => { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' did not declare configured binding '{}'", + binding_config.selector.rpc() + ))); + } + [manifest_binding] => normalized.push(normalize_strict_binding( + interceptor_name, + manifest_binding, + &binding_config, + service_default, + policy, + )?), + _ => { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared multiple bindings for configured RPC '{}'", + binding_config.selector.rpc() + ))); + } + } + } + + for manifest_binding in manifest_bindings { + let selector = match selector_from_proto(manifest_binding.selector.as_ref()) { + Ok(selector) => selector, + Err(err) if policy == GatewayInterceptorBindingPolicy::Allowlist => { + warn!( + interceptor = %interceptor_name, + binding_id = %manifest_binding.id, + error = %err, + "ignoring malformed manifest binding outside the operator allowlist" + ); + continue; + } + Err(err) => return Err(err), + }; + if configured_selectors.contains(&selector) { + continue; + } + if policy == GatewayInterceptorBindingPolicy::Exact { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared unconfigured binding '{}'", + selector.rpc() + ))); + } + warn!( + interceptor = %interceptor_name, + binding_id = %manifest_binding.id, + rpc = %selector.rpc(), + "ignoring manifest binding outside the operator allowlist" + ); + } + + Ok(normalized) +} + +fn normalize_strict_config<'a>( + interceptor_name: &str, + bindings: &'a [GatewayInterceptorBindingOverride], +) -> Result>> { + let mut selectors = BTreeSet::new(); + let mut normalized = Vec::with_capacity(bindings.len()); + for binding in bindings { + if binding.id.is_some() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' strict binding configuration must select by RPC, not id" + ))); + } + if binding.disabled { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' strict binding configuration cannot use disabled=true; omit the binding instead" + ))); + } + let selector = strict_config_selector(binding)?; + if !selectors.insert(selector.clone()) { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' has duplicate configured binding '{}'", + selector.rpc() + ))); + } + let configured_phases = binding.phases.as_ref().ok_or_else(|| { + InterceptorError::Config(format!( + "interceptor '{interceptor_name}' configured binding '{}' requires phases", + selector.rpc() + )) + })?; + if configured_phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' configured binding '{}' requires at least one phase", + selector.rpc() + ))); + } + let phases = configured_phases + .iter() + .copied() + .map(Phase::from) + .collect::>(); + if phases.iter().copied().collect::>().len() != phases.len() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' configured binding '{}' contains duplicate phases", + selector.rpc() + ))); + } + normalized.push(StrictBindingConfig { + selector, + phases, + source: binding, + }); + } + Ok(normalized) +} + +fn strict_config_selector(binding: &GatewayInterceptorBindingOverride) -> Result { + let rpc = binding + .rpc + .as_deref() + .filter(|value| !value.trim().is_empty()); + let service = binding + .service + .as_deref() + .filter(|value| !value.trim().is_empty()); + let method = binding + .method + .as_deref() + .filter(|value| !value.trim().is_empty()); + match (rpc, service, method) { + (Some(rpc), None, None) => parse_rpc_selector(rpc), + (None, Some(service), Some(method)) => { + Ok(RpcSelector::new(service.trim(), method.trim())) + } + (None, None, None) => Err(InterceptorError::Config( + "strict binding configuration requires rpc or service+method".to_string(), + )), + _ => Err(InterceptorError::Config( + "strict binding configuration requires exactly one selector form: rpc or service+method" + .to_string(), + )), + } +} + +fn normalize_strict_binding( + interceptor_name: &str, + manifest_binding: &InterceptorBinding, + config: &StrictBindingConfig<'_>, + service_default: FailurePolicy, + policy: GatewayInterceptorBindingPolicy, +) -> Result { + let binding_id = manifest_binding.id.trim(); + if binding_id.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared a binding without id" + ))); + } + let manifest_phases = manifest_binding + .phases + .iter() + .map(|phase| { + GatewayInterceptorPhase::try_from(*phase) + .map_err(|_| InterceptorError::Config("unknown binding phase".to_string())) + .and_then(Phase::try_from) + }) + .collect::>>()?; + if manifest_phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' declares no phases" + ))); + } + let configured_phases = config.phases.iter().copied().collect::>(); + let phases_match = match policy { + GatewayInterceptorBindingPolicy::Allowlist => configured_phases.is_subset(&manifest_phases), + GatewayInterceptorBindingPolicy::Exact => configured_phases == manifest_phases, + GatewayInterceptorBindingPolicy::Dynamic => unreachable!("strict policy required"), + }; + if !phases_match { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{}' phases do not satisfy {} policy", + config.selector.rpc(), + match policy { + GatewayInterceptorBindingPolicy::Allowlist => "allowlist", + GatewayInterceptorBindingPolicy::Exact => "exact", + GatewayInterceptorBindingPolicy::Dynamic => unreachable!(), + } + ))); + } + + let failure_policy = config + .source + .failure_policy + .map_or(service_default, FailurePolicy::from); + if config.phases.contains(&Phase::PostCommit) && failure_policy != FailurePolicy::FailOpen { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' uses failure_policy={} for post_commit; post_commit must use fail_open", + failure_policy.as_str() + ))); + } + + Ok(NormalizedBinding { + binding_id: binding_id.to_string(), + selector: config.selector.clone(), + phases: config.phases.clone(), + failure_policy, + }) +} + +#[derive(Debug)] +struct OverrideIndex<'a> { + by_id: HashMap<&'a str, &'a GatewayInterceptorBindingOverride>, + by_selector: HashMap, +} + +impl<'a> OverrideIndex<'a> { + fn new(overrides: &'a [GatewayInterceptorBindingOverride]) -> Result { + let mut by_id = HashMap::new(); + let mut by_selector = HashMap::new(); + for override_cfg in overrides { + if let Some(id) = override_cfg.id.as_deref() + && by_id.insert(id, override_cfg).is_some() + { + return Err(InterceptorError::Config(format!( + "duplicate interceptor binding override id '{id}'" + ))); + } + if let Some(selector) = override_selector(override_cfg)? + && by_selector.insert(selector.rpc(), override_cfg).is_some() + { + return Err(InterceptorError::Config(format!( + "duplicate interceptor binding override selector '{}'", + selector.rpc() + ))); + } + } + Ok(Self { by_id, by_selector }) + } + + fn get( + &self, + binding_id: &str, + selector: &RpcSelector, + ) -> Option<&'a GatewayInterceptorBindingOverride> { + self.by_id + .get(binding_id) + .or_else(|| self.by_selector.get(&selector.rpc())) + .copied() + } +} + +fn validate_service_config(config: &GatewayInterceptorConfig) -> Result<()> { + if config.name.trim().is_empty() { + return Err(InterceptorError::Config( + "interceptor name must not be empty".to_string(), + )); + } + if config.grpc_endpoint.trim().is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{}' grpc_endpoint must not be empty", + config.name + ))); + } + if let Some(timeout) = config.timeout.as_deref() { + parse_duration(timeout)?; + } + Ok(()) +} + +fn validate_interceptor_configs(configs: &[GatewayInterceptorConfig]) -> Result<()> { + let mut names = BTreeSet::new(); + for config in configs { + validate_service_config(config)?; + if !names.insert(config.name.clone()) { + return Err(InterceptorError::Config(format!( + "duplicate interceptor instance name '{}'", + config.name + ))); + } + } + Ok(()) +} + +fn normalize_binding( + interceptor_name: &str, + binding: &InterceptorBinding, + service_default: FailurePolicy, + overrides: &OverrideIndex<'_>, +) -> Result> { + let binding_id = binding.id.trim(); + if binding_id.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' declared a binding without id" + ))); + } + + let selector = selector_from_proto(binding.selector.as_ref())?; + let mut phases = binding + .phases + .iter() + .map(|phase| { + GatewayInterceptorPhase::try_from(*phase) + .map_err(|_| InterceptorError::Config("unknown binding phase".to_string())) + .and_then(Phase::try_from) + }) + .collect::>>()?; + phases.sort_unstable(); + phases.dedup(); + if phases.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' declares no phases" + ))); + } + + let mut failure_policy = + parse_optional_failure_policy(&binding.failure_policy)?.unwrap_or(service_default); + + if let Some(override_cfg) = overrides.get(binding_id, &selector) { + if let Some(override_selector) = override_selector(override_cfg)? + && override_selector != selector + { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot widen selector '{}' to '{}'", + selector.rpc(), + override_selector.rpc() + ))); + } + if override_cfg.disabled { + return Ok(None); + } + if let Some(override_phases) = &override_cfg.phases { + let override_set: BTreeSet = + override_phases.iter().copied().map(Phase::from).collect(); + let declared: BTreeSet = phases.iter().copied().collect(); + if !override_set.is_subset(&declared) { + return Err(InterceptorError::Config(format!( + "override for binding '{binding_id}' cannot add phases not declared by the manifest" + ))); + } + phases = override_set.into_iter().collect(); + } + if let Some(policy) = override_cfg.failure_policy { + failure_policy = policy.into(); + } + } + + if phases.contains(&Phase::PostCommit) && failure_policy != FailurePolicy::FailOpen { + return Err(InterceptorError::Config(format!( + "interceptor '{interceptor_name}' binding '{binding_id}' uses failure_policy={} for post_commit; post_commit must use fail_open", + failure_policy.as_str() + ))); + } + + Ok(Some(NormalizedBinding { + binding_id: binding_id.to_string(), + selector, + phases, + failure_policy, + })) +} + +fn selector_from_proto(selector: Option<&InterceptorSelector>) -> Result { + let selector = selector + .ok_or_else(|| InterceptorError::Config("binding selector is required".to_string()))?; + if !selector.rpc.trim().is_empty() { + return parse_rpc_selector(&selector.rpc); + } + if selector.service.trim().is_empty() || selector.method.trim().is_empty() { + return Err(InterceptorError::Config( + "binding selector requires rpc or service+method".to_string(), + )); + } + Ok(RpcSelector::new( + selector.service.trim(), + selector.method.trim(), + )) +} + +fn override_selector( + override_cfg: &GatewayInterceptorBindingOverride, +) -> Result> { + if let Some(rpc) = override_cfg.rpc.as_deref() + && !rpc.trim().is_empty() + { + return parse_rpc_selector(rpc).map(Some); + } + match ( + override_cfg + .service + .as_deref() + .filter(|v| !v.trim().is_empty()), + override_cfg + .method + .as_deref() + .filter(|v| !v.trim().is_empty()), + ) { + (Some(service), Some(method)) => Ok(Some(RpcSelector::new(service.trim(), method.trim()))), + (None, None) => Ok(None), + _ => Err(InterceptorError::Config( + "binding override selector requires both service and method".to_string(), + )), + } +} + +fn parse_rpc_selector(value: &str) -> Result { + let (service, method) = value.trim().split_once('/').ok_or_else(|| { + InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + )) + })?; + if service.is_empty() || method.is_empty() || method.contains('/') { + return Err(InterceptorError::Config(format!( + "RPC selector '{value}' must have form service/method" + ))); + } + Ok(RpcSelector::new(service, method)) +} + +fn parse_optional_failure_policy(value: &str) -> Result> { + match value.trim() { + "" => Ok(None), + "fail_closed" => Ok(Some(FailurePolicy::FailClosed)), + "fail_open" => Ok(Some(FailurePolicy::FailOpen)), + other => Err(InterceptorError::Config(format!( + "unsupported failure_policy '{other}'" + ))), + } +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(InterceptorError::Config( + "timeout must not be empty".to_string(), + )); + } + if let Some(ms) = value.strip_suffix("ms") { + let millis = ms + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_millis(millis)); + } + if let Some(seconds) = value.strip_suffix('s') { + let seconds = seconds + .parse::() + .map_err(|_| InterceptorError::Config(format!("invalid timeout '{value}'")))?; + return Ok(Duration::from_secs(seconds)); + } + Err(InterceptorError::Config(format!( + "invalid timeout '{value}'; expected suffix ms or s" + ))) +} + +async fn connect_endpoint(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + if let Some(path) = endpoint.strip_prefix("unix://") { + return connect_unix_endpoint(PathBuf::from(path)).await; + } + Endpoint::from_shared(endpoint.to_string()) + .map_err(|e| { + InterceptorError::Config(format!("invalid interceptor endpoint '{endpoint}': {e}")) + })? + .connect() + .await + .map_err(|e| InterceptorError::Transport(format!("connect {endpoint}: {e}"))) +} + +#[cfg(unix)] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + let display = path.display().to_string(); + Endpoint::from_static("http://[::]:50051") + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + async move { UnixStream::connect(path).await.map(TokioIo::new) } + })) + .await + .map_err(|e| InterceptorError::Transport(format!("connect unix://{display}: {e}"))) +} + +#[cfg(not(unix))] +async fn connect_unix_endpoint(path: PathBuf) -> Result { + Err(InterceptorError::Config(format!( + "unix interceptor endpoints are not supported on this platform: {}", + path.display() + ))) +} + +#[cfg(test)] +mod tests { + use openshell_core::config::{ + GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorPhaseConfig, + }; + use openshell_core::proto::gateway_interceptor::v1::{ + GatewayInterceptorPhase, InterceptorBinding, InterceptorSelector, + }; + + use super::*; + + fn manifest_binding( + id: &str, + rpc: &str, + phases: &[GatewayInterceptorPhase], + ) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + selector: Some(InterceptorSelector { + rpc: rpc.to_string(), + service: String::new(), + method: String::new(), + }), + phases: phases.iter().map(|phase| *phase as i32).collect(), + failure_policy: "fail_open".to_string(), + } + } + + fn strict_binding( + rpc: &str, + phases: Vec, + ) -> GatewayInterceptorBindingOverride { + GatewayInterceptorBindingOverride { + rpc: Some(rpc.to_string()), + phases: Some(phases), + ..GatewayInterceptorBindingOverride::default() + } + } + + #[test] + fn parses_timeout_suffixes() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert!(parse_duration("2").is_err()); + } + + #[test] + fn service_default_failure_policy_rejects_ignore() { + let err = parse_optional_failure_policy("ignore").unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + + #[test] + fn duplicate_interceptor_instance_names_are_invalid() { + let config = GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: "http://127.0.0.1:18081".to_string(), + ..GatewayInterceptorConfig::default() + }; + let err = validate_interceptor_configs(&[config.clone(), config]).unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: duplicate interceptor instance name 'governance'" + ); + } + + #[test] + fn interceptor_binding_policy_defaults_to_dynamic() { + assert_eq!( + GatewayInterceptorConfig::default().binding_policy, + GatewayInterceptorBindingPolicy::Dynamic + ); + } + + #[test] + fn allowlist_enables_only_configured_phases_and_ignores_manifest_policy() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let manifest = vec![ + manifest_binding( + "create", + rpc, + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + ), + manifest_binding( + "update", + "openshell.v1.OpenShell/UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + ]; + let configured = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + + let normalized = normalize_strict_bindings( + "governance", + &manifest, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap(); + + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].binding_id, "create"); + assert_eq!(normalized[0].phases, vec![Phase::Validate]); + assert_eq!(normalized[0].failure_policy, FailurePolicy::FailClosed); + } + + #[test] + fn allowlist_requires_every_configured_binding_and_phase() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let manifest = vec![manifest_binding( + "create", + rpc, + &[GatewayInterceptorPhase::Validate], + )]; + let missing_phase = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::ModifyOperation], + )]; + let missing_rpc = vec![strict_binding( + "openshell.v1.OpenShell/UpdateConfig", + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + + let phase_err = normalize_strict_bindings( + "governance", + &manifest, + FailurePolicy::FailClosed, + &missing_phase, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap_err(); + let rpc_err = normalize_strict_bindings( + "governance", + &manifest, + FailurePolicy::FailClosed, + &missing_rpc, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap_err(); + + assert!(phase_err.to_string().contains("do not satisfy allowlist")); + assert!( + rpc_err + .to_string() + .contains("did not declare configured binding") + ); + } + + #[test] + fn exact_rejects_extra_bindings_and_phases() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let configured = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + let extra_binding = vec![ + manifest_binding("create", rpc, &[GatewayInterceptorPhase::Validate]), + manifest_binding( + "update", + "openshell.v1.OpenShell/UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + ]; + let extra_phase = vec![manifest_binding( + "create", + rpc, + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + )]; + + let binding_err = normalize_strict_bindings( + "governance", + &extra_binding, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Exact, + ) + .unwrap_err(); + let phase_err = normalize_strict_bindings( + "governance", + &extra_phase, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Exact, + ) + .unwrap_err(); + + assert!(binding_err.to_string().contains("unconfigured binding")); + assert!(phase_err.to_string().contains("do not satisfy exact")); + } + + #[test] + fn strict_policies_match_by_rpc_and_reject_id_or_ambiguous_manifest_entries() { + let rpc = "openshell.v1.OpenShell/CreateSandbox"; + let mut configured_with_id = + strict_binding(rpc, vec![GatewayInterceptorPhaseConfig::Validate]); + configured_with_id.id = Some("create".to_string()); + let id_err = normalize_strict_config("governance", &[configured_with_id]).unwrap_err(); + assert!(id_err.to_string().contains("select by RPC, not id")); + + let configured = vec![strict_binding( + rpc, + vec![GatewayInterceptorPhaseConfig::Validate], + )]; + let duplicated = vec![ + manifest_binding("create-a", rpc, &[GatewayInterceptorPhase::Validate]), + manifest_binding("create-b", rpc, &[GatewayInterceptorPhase::Validate]), + ]; + let duplicate_err = normalize_strict_bindings( + "governance", + &duplicated, + FailurePolicy::FailClosed, + &configured, + GatewayInterceptorBindingPolicy::Allowlist, + ) + .unwrap_err(); + assert!(duplicate_err.to_string().contains("multiple bindings")); + } + + #[test] + fn binding_failure_policy_rejects_ignore() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "binding".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::Validate as i32], + failure_policy: "ignore".to_string(), + }; + + let err = normalize_binding("test", &binding, FailurePolicy::FailClosed, &override_index) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "invalid interceptor config: unsupported failure_policy 'ignore'" + ); + } + + #[test] + fn post_commit_binding_rejects_fail_closed() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_closed".to_string(), + }; + + let err = normalize_binding( + "audit", + &binding, + FailurePolicy::FailClosed, + &override_index, + ) + .expect_err("post_commit must not fail closed after an operation commits"); + + assert_eq!( + err.to_string(), + "invalid interceptor config: interceptor 'audit' binding 'audit-create-sandbox' uses failure_policy=fail_closed for post_commit; post_commit must use fail_open" + ); + } + + #[test] + fn post_commit_binding_accepts_fail_open() { + let overrides = Vec::new(); + let override_index = OverrideIndex::new(&overrides).unwrap(); + let binding = InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_open".to_string(), + }; + + let normalized = normalize_binding( + "audit", + &binding, + FailurePolicy::FailClosed, + &override_index, + ) + .expect("fail-open post_commit binding should be valid") + .expect("binding should be enabled"); + + assert_eq!(normalized.failure_policy, FailurePolicy::FailOpen); + } +} diff --git a/crates/openshell-gateway-interceptors/src/profile_source.rs b/crates/openshell-gateway-interceptors/src/profile_source.rs new file mode 100644 index 0000000000..74cd3b26c2 --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/profile_source.rs @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptor-vended provider profile snapshots. + +use std::time::Duration; + +use openshell_core::proto::ProviderProfile; +use openshell_core::proto::gateway_interceptor::v1::{ + ProviderProfileSnapshotRequest, gateway_interceptor_client::GatewayInterceptorClient, +}; +use prost::Message as _; +use sha2::Digest as _; +use tonic::{Request, transport::Channel}; + +use crate::{InterceptorError, Result}; + +#[derive(Debug, Clone)] +pub struct ProviderProfileSourceSnapshot { + pub revision: String, + pub profiles: Vec, +} + +#[derive(Clone)] +pub struct GatewayInterceptorProfileSource { + interceptor_name: String, + source_id: String, + timeout: Duration, + client: GatewayInterceptorClient, +} + +impl GatewayInterceptorProfileSource { + pub(crate) fn new( + interceptor_name: String, + source_id: String, + timeout: Duration, + client: GatewayInterceptorClient, + ) -> Self { + Self { + interceptor_name, + source_id, + timeout, + client, + } + } + + #[must_use] + pub fn source_id(&self) -> &str { + &self.source_id + } + + pub async fn snapshot(&self) -> Result { + let mut client = self.client.clone(); + let response = tokio::time::timeout( + self.timeout, + client.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})), + ) + .await + .map_err(|_| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles timed out for '{}'", + self.interceptor_name + )) + })? + .map_err(|status| { + InterceptorError::Transport(format!( + "SnapshotProviderProfiles failed for '{}': {status}", + self.interceptor_name + )) + })? + .into_inner(); + + let revision = if response.revision.trim().is_empty() { + provider_profile_snapshot_revision(&response.profiles) + } else { + response.revision + }; + Ok(ProviderProfileSourceSnapshot { + revision, + profiles: response.profiles, + }) + } +} + +impl std::fmt::Debug for GatewayInterceptorProfileSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewayInterceptorProfileSource") + .field("interceptor_name", &self.interceptor_name) + .field("source_id", &self.source_id) + .field("timeout", &self.timeout) + .finish_non_exhaustive() + } +} + +fn provider_profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = sha2::Sha256::new(); + hasher.update(b"openshell-provider-profile-snapshot-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs new file mode 100644 index 0000000000..caba3b94fe --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -0,0 +1,654 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Descriptor-backed protobuf and `ProtoJSON` conversion. + +use std::collections::HashSet; +use std::sync::Arc; + +use prost::Message as _; +use prost_reflect::{ + DescriptorPool, DeserializeOptions, DynamicMessage, ExtensionDescriptor, FieldDescriptor, Kind, + MessageDescriptor, ReflectMessage as _, Value as ReflectValue, +}; +use serde_json::Value; + +use crate::{InterceptorError, Result}; + +/// Descriptor-backed protobuf/JSON codec used at the interceptor seam. +/// +/// Binary input follows protobuf's standard semantics, including +/// last-member-wins handling when multiple alternatives from one `oneof` +/// appear on the wire. JSON input follows the canonical `ProtoJSON` mapping. +#[derive(Debug, Clone)] +pub struct ProtoJsonCodec { + pool: Arc, + secret_option: Option, +} + +const SECRET_OPTION: &str = "openshell.options.v1.secret"; + +impl ProtoJsonCodec { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + let pool = DescriptorPool::decode(bytes).map_err(|err| { + InterceptorError::Config(format!("decode protobuf descriptor set: {err}")) + })?; + let secret_option = pool.get_extension_by_name(SECRET_OPTION); + Ok(Self { + pool: Arc::new(pool), + secret_option, + }) + } + + pub fn openshell() -> Result { + Self::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + } + + pub fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result + where + M: prost::Message, + { + self.decode_bytes_to_json(type_name, &message.encode_to_vec()) + } + + pub fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result> { + let descriptor = self.message_descriptor(type_name)?; + let encoded_json = serde_json::to_vec(value).map_err(|err| { + InterceptorError::Transcode(format!( + "serialize {type_name} JSON before protobuf encoding: {err}" + )) + })?; + let mut deserializer = serde_json::Deserializer::from_slice(&encoded_json); + let message = DynamicMessage::deserialize_with_options( + descriptor, + &mut deserializer, + &DeserializeOptions::new(), + ) + .map_err(|err| { + InterceptorError::Transcode(format!("encode {type_name} from ProtoJSON: {err}")) + })?; + deserializer.end().map_err(|err| { + InterceptorError::Transcode(format!("encode {type_name} from ProtoJSON: {err}")) + })?; + Ok(message.encode_to_vec()) + } + + pub(crate) fn descriptor_pool(&self) -> &DescriptorPool { + &self.pool + } + + /// Decodes a protobuf message into the view exposed to gateway + /// interceptors, recursively omitting fields marked with the `OpenShell` + /// secret field option. + pub(crate) fn decode_bytes_to_interceptor_json( + &self, + type_name: &str, + bytes: &[u8], + ) -> Result { + let descriptor = self.message_descriptor(type_name)?; + let mut message = DynamicMessage::decode(descriptor, bytes).map_err(|err| { + InterceptorError::Transcode(format!("decode {type_name} protobuf message: {err}")) + })?; + self.omit_secrets(&mut message); + serde_json::to_value(message).map_err(|err| { + InterceptorError::Transcode(format!("serialize {type_name} as ProtoJSON: {err}")) + }) + } + + /// Rejects a JSON Pointer that could read, test, remove, or replace a + /// secret field. Selecting a containing message is also rejected because + /// replacing the parent could mutate an omitted descendant. + pub(crate) fn ensure_interceptor_patch_path_visible( + &self, + type_name: &str, + pointer: &str, + ) -> Result<()> { + let descriptor = self.message_descriptor(type_name)?; + let segments = parse_json_pointer(pointer)?; + if self.pointer_intersects_secret(&descriptor, &segments) { + return Err(InterceptorError::InvalidResult(format!( + "JSON patch path '{pointer}' targets an omitted secret field" + ))); + } + Ok(()) + } + + pub(crate) fn decode_bytes_to_json(&self, type_name: &str, bytes: &[u8]) -> Result { + let descriptor = self.message_descriptor(type_name)?; + let message = DynamicMessage::decode(descriptor, bytes).map_err(|err| { + InterceptorError::Transcode(format!("decode {type_name} protobuf message: {err}")) + })?; + serde_json::to_value(message).map_err(|err| { + InterceptorError::Transcode(format!("serialize {type_name} as ProtoJSON: {err}")) + }) + } + + fn message_descriptor(&self, type_name: &str) -> Result { + let normalized = type_name.strip_prefix('.').unwrap_or(type_name); + self.pool.get_message_by_name(normalized).ok_or_else(|| { + InterceptorError::Transcode(format!( + "protobuf message type '{normalized}' was not found in the descriptor set" + )) + }) + } + + fn omit_secrets(&self, message: &mut DynamicMessage) { + let fields = message.descriptor().fields().collect::>(); + for field in fields { + if self.is_secret(&field) { + message.clear_field(&field); + continue; + } + if !message.has_field(&field) { + continue; + } + omit_nested_secrets(self, message.get_field_mut(&field)); + } + } + + fn is_secret(&self, field: &FieldDescriptor) -> bool { + let Some(secret_option) = &self.secret_option else { + return false; + }; + matches!( + field.options().get_extension(secret_option).as_ref(), + ReflectValue::Bool(true) + ) + } + + fn pointer_intersects_secret( + &self, + descriptor: &MessageDescriptor, + segments: &[String], + ) -> bool { + if segments.is_empty() { + return self.message_contains_secret(descriptor, &mut HashSet::new()); + } + + let Some(field) = descriptor.get_field_by_json_name(&segments[0]) else { + return false; + }; + if self.is_secret(&field) { + return true; + } + + let remaining = &segments[1..]; + let Kind::Message(mut nested) = field.kind() else { + return false; + }; + + if field.is_map() { + let Some(value_field) = nested.get_field_by_name("value") else { + return false; + }; + let Kind::Message(value_message) = value_field.kind() else { + return false; + }; + nested = value_message; + if remaining.is_empty() { + return self.message_contains_secret(&nested, &mut HashSet::new()); + } + return self.pointer_intersects_secret(&nested, &remaining[1..]); + } + + if field.is_list() { + if remaining.is_empty() { + return self.message_contains_secret(&nested, &mut HashSet::new()); + } + return self.pointer_intersects_secret(&nested, &remaining[1..]); + } + + if remaining.is_empty() { + self.message_contains_secret(&nested, &mut HashSet::new()) + } else { + self.pointer_intersects_secret(&nested, remaining) + } + } + + fn message_contains_secret( + &self, + descriptor: &MessageDescriptor, + visiting: &mut HashSet, + ) -> bool { + if !visiting.insert(descriptor.full_name().to_string()) { + return false; + } + let contains = descriptor.fields().any(|field| { + if self.is_secret(&field) { + return true; + } + match field.kind() { + Kind::Message(message) if field.is_map() => message + .get_field_by_name("value") + .and_then(|value| match value.kind() { + Kind::Message(message) => Some(message), + _ => None, + }) + .is_some_and(|message| self.message_contains_secret(&message, visiting)), + Kind::Message(message) => self.message_contains_secret(&message, visiting), + _ => false, + } + }); + visiting.remove(descriptor.full_name()); + contains + } +} + +fn omit_nested_secrets(codec: &ProtoJsonCodec, value: &mut ReflectValue) { + match value { + ReflectValue::Message(message) => codec.omit_secrets(message), + ReflectValue::List(values) => { + for value in values { + omit_nested_secrets(codec, value); + } + } + ReflectValue::Map(values) => { + for value in values.values_mut() { + omit_nested_secrets(codec, value); + } + } + _ => {} + } +} + +fn parse_json_pointer(pointer: &str) -> Result> { + if pointer.is_empty() { + return Ok(Vec::new()); + } + let Some(pointer) = pointer.strip_prefix('/') else { + return Err(InterceptorError::InvalidResult(format!( + "invalid JSON patch path '{pointer}': JSON Pointer must start with '/'" + ))); + }; + pointer + .split('/') + .map(|segment| { + let mut decoded = String::with_capacity(segment.len()); + let mut chars = segment.chars(); + while let Some(ch) = chars.next() { + if ch != '~' { + decoded.push(ch); + continue; + } + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => { + return Err(InterceptorError::InvalidResult(format!( + "invalid JSON patch path '/{pointer}': invalid JSON Pointer escape" + ))); + } + } + } + Ok(decoded) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use openshell_core::proto::{ + CreateProviderRequest, CreateSandboxRequest, GpuResourceRequirements, Provider, + SandboxSpec, UpdateConfigRequest, + }; + use prost::Message as _; + use prost_types::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, + field_descriptor_proto::{Label, Type}, + }; + use serde_json::json; + + use super::*; + use crate::InterceptorError; + + #[test] + fn dynamic_create_sandbox_round_trip_uses_json_names() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + providers: vec!["github".to_string()], + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::from([("team".to_string(), "agent".to_string())]), + annotations: HashMap::new(), + }; + let bytes = request.encode_to_vec(); + let json = codec + .decode_bytes_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + assert_eq!(json["spec"]["providers"][0], "github"); + assert_eq!(json["labels"]["team"], "agent"); + let encoded = codec + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[test] + fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let request = CreateProviderRequest { + provider: Some(Provider { + r#type: "github".to_string(), + credentials: HashMap::from([( + "GITHUB_TOKEN".to_string(), + "secret-value".to_string(), + )]), + config: HashMap::from([("region".to_string(), "us-west".to_string())]), + ..Provider::default() + }), + }; + let encoded = request.encode_to_vec(); + + let authoritative = codec + .decode_bytes_to_json("openshell.v1.CreateProviderRequest", &encoded) + .unwrap(); + let interceptor = codec + .decode_bytes_to_interceptor_json("openshell.v1.CreateProviderRequest", &encoded) + .unwrap(); + + assert_eq!( + authoritative["provider"]["credentials"]["GITHUB_TOKEN"], + "secret-value" + ); + assert!(interceptor["provider"].get("credentials").is_none()); + assert_eq!(interceptor["provider"]["config"]["region"], "us-west"); + } + + #[test] + fn dedicated_secret_fields_are_annotated_in_the_descriptor_set() { + let codec = ProtoJsonCodec::openshell().unwrap(); + for (message_name, field_name) in [ + ("openshell.datamodel.v1.Provider", "credentials"), + ("openshell.inference.v1.ResolvedRoute", "api_key"), + ("openshell.compute.v1.DriverSandboxSpec", "sandbox_token"), + ("openshell.v1.IssueSandboxTokenResponse", "token"), + ("openshell.v1.RefreshSandboxTokenResponse", "token"), + ("openshell.v1.CreateSshSessionResponse", "token"), + ("openshell.v1.RevokeSshSessionRequest", "token"), + ("openshell.v1.TcpForwardInit", "authorization_token"), + ("openshell.v1.SshSession", "token"), + ( + "openshell.v1.StoredProviderCredentialRefreshState", + "material", + ), + ("openshell.v1.ConfigureProviderRefreshRequest", "material"), + ( + "openshell.v1.GetSandboxProviderEnvironmentResponse", + "environment", + ), + ] { + let message = codec.message_descriptor(message_name).unwrap(); + let field = message.get_field_by_name(field_name).unwrap(); + assert!( + codec.is_secret(&field), + "{message_name}.{field_name} must be marked secret" + ); + } + } + + #[test] + fn generic_sandbox_environment_remains_visible() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), + ..SandboxSpec::default() + }), + ..CreateSandboxRequest::default() + }; + + let interceptor = codec + .decode_bytes_to_interceptor_json( + "openshell.v1.CreateSandboxRequest", + &request.encode_to_vec(), + ) + .unwrap(); + + assert_eq!(interceptor["spec"]["environment"]["FEATURE_FLAG"], "on"); + } + + #[test] + fn secret_patch_paths_and_containing_objects_are_hidden() { + let codec = ProtoJsonCodec::openshell().unwrap(); + for pointer in [ + "", + "/provider", + "/provider/credentials", + "/provider/credentials/key", + ] { + let err = codec + .ensure_interceptor_patch_path_visible( + "openshell.v1.CreateProviderRequest", + pointer, + ) + .expect_err("secret path or ancestor must be rejected"); + assert!(err.to_string().contains("omitted secret field")); + } + codec + .ensure_interceptor_patch_path_visible( + "openshell.v1.CreateProviderRequest", + "/provider/config/region", + ) + .unwrap(); + codec + .ensure_interceptor_patch_path_visible( + "openshell.v1.CreateProviderRequest", + "/provider/metadata/name", + ) + .unwrap(); + } + + #[test] + fn dynamic_update_config_round_trip_preserves_annotations() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = UpdateConfigRequest { + name: "demo".to_string(), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "signed".to_string(), + )]), + ..Default::default() + }; + let bytes = request.encode_to_vec(); + let json = codec + .decode_bytes_to_json("openshell.v1.UpdateConfigRequest", &bytes) + .unwrap(); + assert_eq!( + json["annotations"]["openshell.nvidia.com/policy-signature"], + "signed" + ); + let encoded = codec + .encode_json_to_message("openshell.v1.UpdateConfigRequest", &json) + .unwrap(); + let decoded = UpdateConfigRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[test] + fn descriptor_loading_rejects_invalid_oneof_indexes() { + let descriptor_set = FileDescriptorSet { + file: vec![FileDescriptorProto { + package: Some("test".to_string()), + message_type: vec![DescriptorProto { + name: Some("Broken".to_string()), + field: vec![FieldDescriptorProto { + name: Some("value".to_string()), + number: Some(1), + label: Some(Label::Optional as i32), + r#type: Some(Type::String as i32), + oneof_index: Some(0), + ..FieldDescriptorProto::default() + }], + ..DescriptorProto::default() + }], + ..FileDescriptorProto::default() + }], + }; + + let err = ProtoJsonCodec::from_descriptor_set(&descriptor_set.encode_to_vec()) + .expect_err("out-of-range oneof index must be rejected"); + + assert!(matches!(err, InterceptorError::Config(_))); + assert!(err.to_string().contains("oneof")); + } + + #[test] + fn dynamic_codec_uses_last_policy_merge_oneof_member_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let add_then_remove = vec![0x0a, 0x00, 0x1a, 0x00]; + let remove_then_add = vec![0x1a, 0x00, 0x0a, 0x00]; + + for (bytes, selected, discarded) in [ + (add_then_remove, "removeRule", "addRule"), + (remove_then_add, "addRule", "removeRule"), + ] { + let json = codec + .decode_bytes_to_json("openshell.v1.PolicyMergeOperation", &bytes) + .unwrap(); + assert_eq!(json[selected], json!({})); + assert!(json.get(discarded).is_none()); + } + } + + #[test] + fn dynamic_codec_uses_last_interceptor_phase_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let modify_then_validate = vec![0x32, 0x00, 0x3a, 0x00]; + + let json = codec + .decode_bytes_to_json( + "openshell.gateway_interceptor.v1.InterceptorEvaluation", + &modify_then_validate, + ) + .unwrap(); + + assert_eq!(json["validate"], json!({})); + assert!(json.get("modifyOperation").is_none()); + } + + #[test] + fn dynamic_codec_uses_last_well_known_value_member_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let string_then_bool = [0x1a, 0x04, b't', b'e', b'x', b't', 0x20, 0x01]; + let bool_then_string = [0x20, 0x01, 0x1a, 0x04, b't', b'e', b'x', b't']; + + let bool_json = codec + .decode_bytes_to_json("google.protobuf.Value", &string_then_bool) + .unwrap(); + let string_json = codec + .decode_bytes_to_json("google.protobuf.Value", &bool_then_string) + .unwrap(); + + assert_eq!(bool_json, json!(true)); + assert_eq!(string_json, json!("text")); + } + + #[test] + fn dynamic_codec_uses_last_value_member_nested_in_well_known_struct() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let struct_bytes = [ + 0x0a, 0x0f, 0x0a, 0x03, b'k', b'e', b'y', 0x12, 0x08, 0x1a, 0x04, b't', b'e', b'x', + b't', 0x20, 0x01, + ]; + + let json = codec + .decode_bytes_to_json("google.protobuf.Struct", &struct_bytes) + .unwrap(); + + assert_eq!(json, json!({"key": true})); + } + + #[test] + fn dynamic_codec_rejects_ambiguous_oneof_json_before_encoding() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + let err = codec + .encode_json_to_message( + "openshell.v1.PolicyMergeOperation", + &json!({ + "addRule": {}, + "removeRule": {} + }), + ) + .expect_err("multiple JSON alternatives must be rejected"); + + assert!(err.to_string().contains("oneof 'operation'")); + } + + #[test] + fn dynamic_codec_round_trips_each_policy_merge_oneof_alternative() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + for field in [ + "addRule", + "removeEndpoint", + "removeRule", + "addDenyRules", + "addAllowRules", + "removeBinary", + ] { + let json = json!({ (field): {} }); + let encoded = codec + .encode_json_to_message("openshell.v1.PolicyMergeOperation", &json) + .unwrap(); + let decoded = codec + .decode_bytes_to_json("openshell.v1.PolicyMergeOperation", &encoded) + .unwrap(); + let decoded = decoded + .as_object() + .expect("decoded oneof must be an object"); + assert_eq!(decoded.len(), 1); + assert!(decoded.contains_key(field)); + } + } + + #[test] + fn dynamic_codec_preserves_proto3_optional_presence() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + + for request in [ + GpuResourceRequirements { count: None }, + GpuResourceRequirements { count: Some(0) }, + GpuResourceRequirements { count: Some(2) }, + ] { + let json = codec + .decode_bytes_to_json( + "openshell.v1.GpuResourceRequirements", + &request.encode_to_vec(), + ) + .unwrap(); + let encoded = codec + .encode_json_to_message("openshell.v1.GpuResourceRequirements", &json) + .unwrap(); + let decoded = GpuResourceRequirements::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + } + + #[test] + fn dynamic_codec_uses_last_nested_oneof_member_on_wire() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let update_config = [0x3a, 0x04, 0x0a, 0x00, 0x1a, 0x00]; + + let json = codec + .decode_bytes_to_json("openshell.v1.UpdateConfigRequest", &update_config) + .unwrap(); + + assert_eq!(json["mergeOperations"][0], json!({"removeRule": {}})); + } +} diff --git a/crates/openshell-gateway-interceptors/src/routes.rs b/crates/openshell-gateway-interceptors/src/routes.rs new file mode 100644 index 0000000000..c47f42e40d --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/routes.rs @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Interceptable `OpenShell` route classification. + +use std::collections::{BTreeMap, BTreeSet}; + +use prost_reflect::DescriptorPool; + +use crate::{InterceptorError, Result}; + +const SERVICE_OPEN_SHELL: &str = "openshell.v1.OpenShell"; + +/// Unary `openshell.v1.OpenShell` methods that may be targeted by gateway +/// interceptors. New methods are non-interceptable until deliberately added +/// here. +pub const INTERCEPTABLE_METHODS: &[&str] = &[ + "CreateSandbox", + "AttachSandboxProvider", + "DetachSandboxProvider", + "DeleteSandbox", + "CreateSshSession", + "ExposeService", + "DeleteService", + "RevokeSshSession", + "CreateProvider", + "ImportProviderProfiles", + "UpdateProviderProfiles", + "UpdateProvider", + "ConfigureProviderRefresh", + "RotateProviderCredential", + "DeleteProviderRefresh", + "DeleteProvider", + "DeleteProviderProfile", + "UpdateConfig", + "SubmitPolicyAnalysis", + "ApproveDraftChunk", + "RejectDraftChunk", + "ApproveAllDraftChunks", + "EditDraftChunk", + "UndoDraftChunk", + "ClearDraftChunks", +]; + +#[derive(Debug, Clone)] +pub struct OpenShellRouteIndex { + all_methods: BTreeSet, + unary_methods: BTreeSet, + input_types: BTreeMap, + output_types: BTreeMap, +} + +impl OpenShellRouteIndex { + pub fn from_descriptor_set(bytes: &[u8]) -> Result { + let pool = DescriptorPool::decode(bytes) + .map_err(|e| InterceptorError::Config(format!("decode descriptor set: {e}")))?; + Self::from_descriptor_pool(&pool) + } + + pub(crate) fn from_descriptor_pool(pool: &DescriptorPool) -> Result { + let service = pool + .get_service_by_name(SERVICE_OPEN_SHELL) + .ok_or_else(|| { + InterceptorError::Config(format!( + "descriptor set does not contain service '{SERVICE_OPEN_SHELL}'" + )) + })?; + let mut all_methods = BTreeSet::new(); + let mut unary_methods = BTreeSet::new(); + let mut input_types = BTreeMap::new(); + let mut output_types = BTreeMap::new(); + + for method in service.methods() { + let name = method.name().to_string(); + all_methods.insert(name.clone()); + if !method.is_client_streaming() && !method.is_server_streaming() { + unary_methods.insert(name.clone()); + input_types.insert(name.clone(), method.input().full_name().to_string()); + output_types.insert(name, method.output().full_name().to_string()); + } + } + + let index = Self { + all_methods, + unary_methods, + input_types, + output_types, + }; + index.validate_interceptable_list()?; + Ok(index) + } + + #[must_use] + pub fn is_interceptable(&self, service: &str, method: &str) -> bool { + service == SERVICE_OPEN_SHELL + && self.unary_methods.contains(method) + && INTERCEPTABLE_METHODS.contains(&method) + } + + #[must_use] + pub fn input_type(&self, service: &str, method: &str) -> Option<&str> { + if service == SERVICE_OPEN_SHELL && self.unary_methods.contains(method) { + self.input_types.get(method).map(String::as_str) + } else { + None + } + } + + #[must_use] + pub fn output_type(&self, service: &str, method: &str) -> Option<&str> { + if service == SERVICE_OPEN_SHELL && self.unary_methods.contains(method) { + self.output_types.get(method).map(String::as_str) + } else { + None + } + } + + fn validate_interceptable_list(&self) -> Result<()> { + let mut stale = Vec::new(); + let mut streaming = Vec::new(); + for method in INTERCEPTABLE_METHODS { + if !self.all_methods.contains(*method) { + stale.push((*method).to_string()); + } else if !self.unary_methods.contains(*method) { + streaming.push((*method).to_string()); + } + } + if !stale.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptable route list has stale methods: {stale:?}" + ))); + } + if !streaming.is_empty() { + return Err(InterceptorError::Config(format!( + "interceptable route list has streaming methods: {streaming:?}" + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interceptable_entries_match_real_unary_methods() { + OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + } + + #[test] + fn only_explicitly_allowed_write_methods_are_interceptable() { + let index = + OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + assert!(index.is_interceptable("openshell.v1.OpenShell", "CreateSandbox")); + assert!(index.is_interceptable("openshell.v1.OpenShell", "UpdateConfig")); + assert!(index.is_interceptable("openshell.v1.OpenShell", "SubmitPolicyAnalysis")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "Health")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "GetSandbox")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "WatchSandbox")); + assert!(!index.is_interceptable("openshell.v1.OpenShell", "FutureUnaryMethod")); + assert_eq!( + index.output_type("openshell.v1.OpenShell", "CreateSandbox"), + Some("openshell.v1.SandboxResponse") + ); + } +} diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs new file mode 100644 index 0000000000..e5d55d2fee --- /dev/null +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -0,0 +1,1382 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway request evaluation and interceptor execution. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Instant; + +use json_patch::{PatchOperation, patch}; +use metrics::{counter, histogram}; +use openshell_core::config::GatewayInterceptorConfig; +use openshell_core::proto::gateway_interceptor::v1::{ + InterceptorEvaluation, InterceptorResult, JsonPatch, ModifyOperationEvaluation, + PostCommitEvaluation, ValidateEvaluation, interceptor_evaluation, +}; +use prost::Message as _; +use prost_types::Struct; +use serde_json::{Map, Value}; +use tonic::{Code, Request, Status}; +use tracing::{info, warn}; + +use crate::plan::{BindingPlan, ExecutionPlan, FailurePolicy, Phase, RpcSelector}; +use crate::profile_source::GatewayInterceptorProfileSource; +use crate::{InterceptorError, ProtoJsonCodec, Result, routes}; + +const GRPC_HEADER_LEN: usize = 5; + +#[derive(Debug, Clone)] +pub struct GatewayInterceptorRuntime { + plan: Arc, + codec: ProtoJsonCodec, +} + +#[derive(Debug, Clone)] +pub struct EvaluationContext { + pub principal: BTreeMap, + pub validate_current_state: Option, +} + +#[derive(Debug, Clone)] +pub struct InterceptedRequest { + pub body: Vec, + selector: RpcSelector, +} + +#[derive(Debug, Clone, PartialEq)] +struct ValidatedOperation { + json: Value, + encoded: Vec, +} + +impl ValidatedOperation { + fn new(codec: &ProtoJsonCodec, type_name: &str, json: Value) -> Result { + let encoded = codec.encode_json_to_message(type_name, &json)?; + let json = codec.decode_bytes_to_json(type_name, &encoded)?; + Ok(Self { json, encoded }) + } + + fn apply_patches( + &self, + codec: &ProtoJsonCodec, + type_name: &str, + patches: &[JsonPatch], + ) -> Result { + let json = apply_json_patches(&self.json, patches).map_err(|err| { + InterceptorError::InvalidResult(format!( + "patched operation is not valid {type_name}: {err}" + )) + })?; + Self::new(codec, type_name, json).map_err(|err| { + InterceptorError::InvalidResult(format!( + "patched operation is not valid {type_name}: {err}" + )) + }) + } +} + +impl GatewayInterceptorRuntime { + pub(crate) async fn build(configs: Vec) -> Result { + let codec = ProtoJsonCodec::openshell()?; + let routes = routes::OpenShellRouteIndex::from_descriptor_pool(codec.descriptor_pool())?; + let plan = ExecutionPlan::load(configs, routes).await?; + Ok(Self { + plan: Arc::new(plan), + codec, + }) + } + + #[must_use] + pub fn provider_profile_source( + &self, + interceptor_name: &str, + ) -> Option { + self.plan.profile_source(interceptor_name) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.plan.is_empty() + } + + #[must_use] + pub fn should_intercept_path(&self, path: &str) -> bool { + let Some(selector) = RpcSelector::from_grpc_path(path) else { + return false; + }; + self.plan.should_intercept(&selector) + } + + pub async fn evaluate_request( + &self, + path: &str, + body: &[u8], + context: &EvaluationContext, + ) -> std::result::Result { + let selector = RpcSelector::from_grpc_path(path) + .ok_or_else(|| Status::invalid_argument("invalid gRPC method path"))?; + let input_type = self + .plan + .input_type(&selector) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))? + .to_string(); + let frame = GrpcFrame::decode(body)?; + let operation = self + .codec + .decode_bytes_to_json(&input_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + let mut operation = ValidatedOperation::new(&self.codec, &input_type, operation) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + operation = self + .evaluate_phase( + &selector, + Phase::ModifyOperation, + &input_type, + operation, + context, + ) + .await?; + operation = self + .evaluate_phase(&selector, Phase::Validate, &input_type, operation, context) + .await?; + + let encoded_operation = self + .codec + .decode_bytes_to_json(&input_type, &operation.encoded) + .map_err(|err| { + Status::internal(format!( + "validated operation encoding became invalid: {err}" + )) + })?; + if encoded_operation != operation.json { + return Err(Status::internal( + "validated operation diverged from its encoding before dispatch", + )); + } + let body = GrpcFrame { + compressed: false, + message: operation.encoded, + } + .encode() + .map_err(|err| Status::invalid_argument(err.to_string()))?; + + Ok(InterceptedRequest { body, selector }) + } + + pub async fn evaluate_post_commit( + &self, + intercepted: &InterceptedRequest, + response_body: &[u8], + context: &EvaluationContext, + ) -> std::result::Result<(), Status> { + let output_type = self + .plan + .output_type(&intercepted.selector) + .ok_or_else(|| Status::invalid_argument("unknown OpenShell method"))?; + let frame = GrpcFrame::decode(response_body)?; + let committed_response = self + .codec + .decode_bytes_to_json(output_type, &frame.message) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + let committed_response = + ValidatedOperation::new(&self.codec, output_type, committed_response) + .map_err(|err| Status::invalid_argument(err.to_string()))?; + self.evaluate_phase( + &intercepted.selector, + Phase::PostCommit, + output_type, + committed_response, + context, + ) + .await + .map(|_| ()) + } + + #[must_use] + pub fn has_post_commit(&self, intercepted: &InterceptedRequest) -> bool { + self.plan + .has_binding(&intercepted.selector, Phase::PostCommit) + } + + async fn evaluate_phase( + &self, + selector: &RpcSelector, + phase: Phase, + operation_type: &str, + operation: ValidatedOperation, + context: &EvaluationContext, + ) -> std::result::Result { + let Some(plans) = self.plan.bindings(selector, phase) else { + return Ok(operation); + }; + + let mut operation = operation; + for plan in plans { + let interceptor_view = self + .codec + .decode_bytes_to_interceptor_json(operation_type, &operation.encoded) + .map_err(|err| Status::internal(err.to_string()))?; + let result = evaluate_plan(plan, interceptor_view, context).await; + let result = match result { + Ok(result) => result, + Err(err) => { + apply_failure_policy(plan, &err)?; + continue; + } + }; + operation = + apply_evaluation_result(&self.codec, operation_type, plan, &result, operation)?; + } + Ok(operation) + } +} + +fn apply_evaluation_result( + codec: &ProtoJsonCodec, + operation_type: &str, + plan: &BindingPlan, + result: &InterceptorResult, + operation: ValidatedOperation, +) -> std::result::Result { + if let Err(err) = validate_result_contract(plan, result) { + apply_failure_policy(plan, &err)?; + return Ok(operation); + } + + if !result.allowed { + let reason = if result.reason.trim().is_empty() { + "operation denied by gateway interceptor".to_string() + } else { + result.reason.clone() + }; + emit_evaluation_metrics(plan, "deny", 0); + emit_evaluation_log(plan, result, "deny", 0); + return Err(status_from_result(result, reason)); + } + + if plan.phase == Phase::ModifyOperation && !result.patches.is_empty() { + let patch_count = result.patches.len(); + if let Err(err) = validate_patch_visibility(codec, operation_type, &result.patches) { + apply_failure_policy(plan, &err)?; + return Ok(operation); + } + match operation.apply_patches(codec, operation_type, &result.patches) { + Ok(candidate) => { + emit_evaluation_metrics(plan, "allow", patch_count); + emit_evaluation_log(plan, result, "allow", patch_count); + Ok(candidate) + } + Err(err) => { + apply_failure_policy(plan, &err)?; + Ok(operation) + } + } + } else { + emit_evaluation_metrics(plan, "allow", 0); + emit_evaluation_log(plan, result, "allow", 0); + Ok(operation) + } +} + +fn validate_patch_visibility( + codec: &ProtoJsonCodec, + operation_type: &str, + patches: &[JsonPatch], +) -> Result<()> { + for patch in patches { + codec.ensure_interceptor_patch_path_visible(operation_type, &patch.path)?; + if !patch.from.is_empty() { + codec.ensure_interceptor_patch_path_visible(operation_type, &patch.from)?; + } + } + Ok(()) +} + +async fn evaluate_plan( + plan: &BindingPlan, + operation: Value, + context: &EvaluationContext, +) -> Result { + let operation = json_to_struct(operation)?; + let current_state = if plan.phase == Phase::Validate { + context + .validate_current_state + .clone() + .map(json_to_struct) + .transpose()? + } else { + None + }; + let request = InterceptorEvaluation { + interceptor_name: plan.interceptor_name.clone(), + binding_id: plan.binding_id.clone(), + service: plan.selector.service.clone(), + method: plan.selector.method.clone(), + principal: context.principal.clone().into_iter().collect(), + phase: Some(phase_evaluation(plan.phase, operation, current_state)), + }; + + let start = Instant::now(); + let result = tokio::time::timeout( + plan.timeout, + plan.client.clone().evaluate(Request::new(request)), + ) + .await + .map_err(|_| InterceptorError::Transport("evaluation timed out".to_string()))? + .map_err(|status| InterceptorError::Transport(status.to_string()))? + .into_inner(); + let encoded_len = result.encoded_len(); + histogram!("openshell_gateway_interceptor_latency_seconds") + .record(start.elapsed().as_secs_f64()); + if encoded_len > plan.max_response_bytes { + return Err(InterceptorError::InvalidResult(format!( + "interceptor response exceeded max_response_bytes ({} > {})", + encoded_len, plan.max_response_bytes + ))); + } + Ok(result) +} + +fn phase_evaluation( + phase: Phase, + proposed_operation: Struct, + current_state: Option, +) -> interceptor_evaluation::Phase { + match phase { + Phase::ModifyOperation => { + interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation { + proposed_operation: Some(proposed_operation), + }) + } + Phase::Validate => interceptor_evaluation::Phase::Validate(ValidateEvaluation { + proposed_operation: Some(proposed_operation), + current_state, + }), + Phase::PostCommit => interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation { + committed_response: Some(proposed_operation), + }), + } +} + +fn apply_failure_policy( + plan: &BindingPlan, + err: &InterceptorError, +) -> std::result::Result<(), Status> { + match plan.failure_policy { + FailurePolicy::FailClosed => { + warn!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failed closed" + ); + counter!("openshell_gateway_interceptor_fail_closed_total").increment(1); + Err(Status::permission_denied(format!( + "gateway interceptor '{}' failed closed: {err}", + plan.interceptor_name + ))) + } + FailurePolicy::FailOpen => { + warn!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + error = %err, + "gateway interceptor failed open" + ); + counter!("openshell_gateway_interceptor_fail_open_total").increment(1); + Ok(()) + } + } +} + +fn validate_result_contract(plan: &BindingPlan, result: &InterceptorResult) -> Result<()> { + if result.patches.len() > plan.max_patches { + return Err(InterceptorError::InvalidResult(format!( + "interceptor returned too many patches ({} > {})", + result.patches.len(), + plan.max_patches + ))); + } + if plan.phase != Phase::ModifyOperation && !result.patches.is_empty() { + return Err(InterceptorError::InvalidResult(format!( + "patches are invalid during {}", + plan.phase.as_str() + ))); + } + if plan.phase == Phase::PostCommit && (!result.allowed || !result.patches.is_empty()) { + return Err(InterceptorError::InvalidResult( + "post_commit cannot deny or mutate operations".to_string(), + )); + } + Ok(()) +} + +fn status_from_result(result: &InterceptorResult, reason: String) -> Status { + let code = grpc_code_from_name(&result.status_code).unwrap_or(Code::PermissionDenied); + Status::new(code, reason) +} + +fn grpc_code_from_name(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "OK" => Some(Code::Ok), + "CANCELLED" => Some(Code::Cancelled), + "UNKNOWN" => Some(Code::Unknown), + "INVALID_ARGUMENT" => Some(Code::InvalidArgument), + "DEADLINE_EXCEEDED" => Some(Code::DeadlineExceeded), + "NOT_FOUND" => Some(Code::NotFound), + "ALREADY_EXISTS" => Some(Code::AlreadyExists), + "PERMISSION_DENIED" => Some(Code::PermissionDenied), + "RESOURCE_EXHAUSTED" => Some(Code::ResourceExhausted), + "FAILED_PRECONDITION" => Some(Code::FailedPrecondition), + "ABORTED" => Some(Code::Aborted), + "OUT_OF_RANGE" => Some(Code::OutOfRange), + "UNIMPLEMENTED" => Some(Code::Unimplemented), + "INTERNAL" => Some(Code::Internal), + "UNAVAILABLE" => Some(Code::Unavailable), + "DATA_LOSS" => Some(Code::DataLoss), + "UNAUTHENTICATED" => Some(Code::Unauthenticated), + _ => None, + } +} + +fn json_patch_operations(patches: &[JsonPatch]) -> Result> { + let mut raw = Vec::with_capacity(patches.len()); + for patch in patches { + let mut op = Map::new(); + op.insert("op".to_string(), Value::String(patch.op.clone())); + op.insert("path".to_string(), Value::String(patch.path.clone())); + if !patch.from.is_empty() { + op.insert("from".to_string(), Value::String(patch.from.clone())); + } + if let Some(value) = patch.value.as_ref() { + op.insert( + "value".to_string(), + openshell_core::proto_struct::value_to_json(value), + ); + } + raw.push(Value::Object(op)); + } + serde_json::from_value(Value::Array(raw)) + .map_err(|e| InterceptorError::InvalidResult(format!("invalid JSON patch: {e}"))) +} + +fn apply_json_patches(operation: &Value, patches: &[JsonPatch]) -> Result { + let patch_ops = json_patch_operations(patches)?; + let mut candidate = operation.clone(); + patch(&mut candidate, &patch_ops) + .map_err(|err| InterceptorError::InvalidResult(format!("invalid JSON patch: {err}")))?; + Ok(candidate) +} + +fn emit_evaluation_metrics(plan: &BindingPlan, result: &str, patch_count: usize) { + counter!( + "openshell_gateway_interceptor_evaluations_total", + "decision" => result.to_string(), + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(1); + if patch_count > 0 { + counter!( + "openshell_gateway_interceptor_patches_total", + "interceptor" => plan.interceptor_name.clone(), + "binding_id" => plan.binding_id.clone(), + ) + .increment(patch_count as u64); + } +} + +fn emit_evaluation_log( + plan: &BindingPlan, + result: &InterceptorResult, + decision: &str, + patch_count: usize, +) { + info!( + interceptor = %plan.interceptor_name, + binding_id = %plan.binding_id, + phase = plan.phase.as_str(), + service = %plan.selector.service, + method = %plan.selector.method, + decision, + patch_count, + log_annotations = ?result.log_annotations, + "gateway interceptor evaluated" + ); +} + +#[derive(Debug, Clone)] +struct GrpcFrame { + compressed: bool, + message: Vec, +} + +impl GrpcFrame { + fn decode(body: &[u8]) -> std::result::Result { + if body.len() < GRPC_HEADER_LEN { + return Err(Status::invalid_argument("gRPC frame is too short")); + } + let compressed = body[0] != 0; + if compressed { + return Err(Status::unimplemented( + "gateway interceptors do not support compressed gRPC frames", + )); + } + let len = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize; + if body.len() != GRPC_HEADER_LEN + len { + return Err(Status::invalid_argument( + "gRPC body must contain exactly one frame", + )); + } + Ok(Self { + compressed, + message: body[GRPC_HEADER_LEN..].to_vec(), + }) + } + + fn encode(&self) -> Result> { + if self.compressed { + return Err(InterceptorError::Transcode( + "compressed gRPC frames are not supported".to_string(), + )); + } + let len = u32::try_from(self.message.len()) + .map_err(|_| InterceptorError::Transcode("message exceeds u32".to_string()))?; + let mut out = Vec::with_capacity(GRPC_HEADER_LEN + self.message.len()); + out.push(0); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&self.message); + Ok(out) + } +} + +fn json_to_struct(value: Value) -> Result { + match value { + Value::Object(fields) => Ok(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + _ => Err(InterceptorError::Transcode( + "operation JSON must be an object".to_string(), + )), + } +} + +fn json_to_protobuf_value(value: Value) -> Result { + let kind = match value { + Value::Null => prost_types::value::Kind::NullValue(0), + Value::Bool(value) => prost_types::value::Kind::BoolValue(value), + Value::Number(value) => prost_types::value::Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| InterceptorError::Transcode("invalid JSON number".to_string()))?, + ), + Value::String(value) => prost_types::value::Kind::StringValue(value), + Value::Array(values) => prost_types::value::Kind::ListValue(prost_types::ListValue { + values: values + .into_iter() + .map(json_to_protobuf_value) + .collect::>()?, + }), + Value::Object(fields) => prost_types::value::Kind::StructValue(Struct { + fields: fields + .into_iter() + .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) + .collect::>()?, + }), + }; + Ok(prost_types::Value { kind: Some(kind) }) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::gateway_interceptor::v1::gateway_interceptor_client::GatewayInterceptorClient; + use openshell_core::proto::{ + CreateProviderRequest, CreateSandboxRequest, Provider, SandboxSpec, SandboxTemplate, + UpdateConfigRequest, + }; + use serde_json::json; + use std::collections::HashMap; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }; + use tonic::transport::Channel; + use tracing_subscriber::layer::SubscriberExt; + + use crate::plan::{DEFAULT_MAX_PATCHES, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_TIMEOUT}; + + #[derive(Clone)] + struct TraceBuf(Arc>>); + + impl std::io::Write for TraceBuf { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[derive(Debug)] + struct AtomicCounter(Arc); + + impl metrics::CounterFn for AtomicCounter { + fn increment(&self, value: u64) { + self.0.fetch_add(value, Ordering::Relaxed); + } + + fn absolute(&self, value: u64) { + self.0.fetch_max(value, Ordering::Relaxed); + } + } + + #[derive(Debug, Default)] + struct TestRecorder { + evaluations: Arc, + patches: Arc, + fail_open: Arc, + fail_closed: Arc, + } + + impl metrics::Recorder for TestRecorder { + fn describe_counter( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn describe_gauge( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn describe_histogram( + &self, + _key: metrics::KeyName, + _unit: Option, + _description: metrics::SharedString, + ) { + } + + fn register_counter( + &self, + key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Counter { + let counter = match key.name() { + "openshell_gateway_interceptor_evaluations_total" => &self.evaluations, + "openshell_gateway_interceptor_patches_total" => &self.patches, + "openshell_gateway_interceptor_fail_open_total" => &self.fail_open, + "openshell_gateway_interceptor_fail_closed_total" => &self.fail_closed, + _ => return metrics::Counter::noop(), + }; + metrics::Counter::from_arc(Arc::new(AtomicCounter(counter.clone()))) + } + + fn register_gauge( + &self, + _key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Gauge { + metrics::Gauge::noop() + } + + fn register_histogram( + &self, + _key: &metrics::Key, + _metadata: &metrics::Metadata<'_>, + ) -> metrics::Histogram { + metrics::Histogram::noop() + } + } + + impl TestRecorder { + fn count(counter: &AtomicU64) -> u64 { + counter.load(Ordering::Relaxed) + } + } + + fn test_modify_plan(failure_policy: FailurePolicy) -> BindingPlan { + BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "UpdateConfig".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + } + } + + fn patch(op: &str, path: &str, value: Value) -> JsonPatch { + JsonPatch { + op: op.to_string(), + path: path.to_string(), + value: Some(json_to_protobuf_value(value).unwrap()), + from: String::new(), + } + } + + fn allowed_result(patches: Vec) -> InterceptorResult { + InterceptorResult { + allowed: true, + patches, + ..InterceptorResult::default() + } + } + + fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation { + let request = CreateProviderRequest { + provider: Some(Provider { + r#type: "github".to_string(), + credentials: HashMap::from([( + "GITHUB_TOKEN".to_string(), + "secret-value".to_string(), + )]), + config: HashMap::from([("region".to_string(), "old".to_string())]), + ..Provider::default() + }), + }; + let json = codec + .decode_message_to_json("openshell.v1.CreateProviderRequest", &request) + .unwrap(); + ValidatedOperation::new(codec, "openshell.v1.CreateProviderRequest", json).unwrap() + } + + #[tokio::test] + async fn secret_patch_path_fails_closed() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let result = allowed_result(vec![patch( + "replace", + "/provider/credentials/GITHUB_TOKEN", + json!("replacement"), + )]); + + let status = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + .expect_err("secret patch must fail closed"); + + assert!(status.message().contains("omitted secret field")); + } + + #[tokio::test] + async fn secret_patch_path_fails_open_to_the_unchanged_operation() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let prior = operation.clone(); + let result = allowed_result(vec![patch( + "remove", + "/provider/credentials/GITHUB_TOKEN", + Value::Null, + )]); + + let outcome = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation, + ) + .unwrap(); + + assert_eq!(outcome, prior); + } + + #[tokio::test] + async fn secret_patch_from_path_is_rejected() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let result = allowed_result(vec![JsonPatch { + op: "copy".to_string(), + path: "/provider/config/copied".to_string(), + value: None, + from: "/provider/credentials/GITHUB_TOKEN".to_string(), + }]); + + let status = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + .expect_err("secret patch source must fail closed"); + + assert!(status.message().contains("omitted secret field")); + } + + #[tokio::test] + async fn non_secret_patch_preserves_authoritative_credentials() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let operation = create_provider_operation(&codec); + let result = allowed_result(vec![patch( + "replace", + "/provider/config/region", + json!("new"), + )]); + + let outcome = apply_evaluation_result( + &codec, + "openshell.v1.CreateProviderRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + .unwrap(); + let decoded = CreateProviderRequest::decode(outcome.encoded.as_slice()).unwrap(); + let provider = decoded.provider.unwrap(); + + assert_eq!(provider.config["region"], "new"); + assert_eq!(provider.credentials["GITHUB_TOKEN"], "secret-value"); + } + + #[test] + fn phase_evaluations_use_the_matching_oneof_variant() { + let operation = json_to_struct(json!({"name": "demo"})).unwrap(); + + let modify = phase_evaluation(Phase::ModifyOperation, operation.clone(), None); + let interceptor_evaluation::Phase::ModifyOperation(payload) = modify else { + panic!("expected modify_operation payload"); + }; + assert_eq!(payload.proposed_operation, Some(operation.clone())); + + let validate = phase_evaluation(Phase::Validate, operation.clone(), None); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.proposed_operation, Some(operation.clone())); + assert!(payload.current_state.is_none()); + + let validate = + phase_evaluation(Phase::Validate, operation.clone(), Some(Struct::default())); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.current_state, Some(Struct::default())); + + let current_state = json_to_struct(json!({"resourceVersion": "7"})).unwrap(); + let validate = phase_evaluation( + Phase::Validate, + operation.clone(), + Some(current_state.clone()), + ); + let interceptor_evaluation::Phase::Validate(payload) = validate else { + panic!("expected validate payload"); + }; + assert_eq!(payload.current_state, Some(current_state.clone())); + + let post_commit = + phase_evaluation(Phase::PostCommit, operation.clone(), Some(current_state)); + let interceptor_evaluation::Phase::PostCommit(payload) = post_commit else { + panic!("expected post_commit payload"); + }; + assert_eq!(payload.committed_response, Some(operation)); + } + + #[tokio::test] + async fn evaluation_log_emits_structured_log_annotations() { + let log_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let writer = TraceBuf(log_buf.clone()); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .without_time(); + let subscriber = tracing_subscriber::registry().with(fmt_layer); + let dispatch = tracing::Dispatch::new(subscriber); + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailClosed, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let result = InterceptorResult { + allowed: true, + log_annotations: HashMap::from([ + ( + "correlation_id".to_string(), + "governance:create-sandbox:demo".to_string(), + ), + ("policy_hash".to_string(), "abc123".to_string()), + ]), + ..InterceptorResult::default() + }; + + tracing::dispatcher::with_default(&dispatch, || { + emit_evaluation_log(&plan, &result, "allow", 2); + }); + + let output = String::from_utf8(log_buf.lock().unwrap().clone()).unwrap(); + assert!(output.contains("gateway interceptor evaluated")); + assert!(output.contains("log_annotations")); + assert!(output.contains("correlation_id")); + assert!(output.contains("governance:create-sandbox:demo")); + assert!(output.contains("policy_hash")); + } + + #[tokio::test] + async fn request_middleware_canonicalizes_ambiguous_oneof_before_dispatch() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let routes = + routes::OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + .unwrap(); + let runtime = GatewayInterceptorRuntime { + plan: Arc::new(ExecutionPlan::empty(routes)), + codec: codec.clone(), + }; + let body = GrpcFrame { + compressed: false, + message: vec![0x3a, 0x04, 0x0a, 0x00, 0x1a, 0x00], + } + .encode() + .unwrap(); + + let intercepted = runtime + .evaluate_request( + "/openshell.v1.OpenShell/UpdateConfig", + &body, + &EvaluationContext { + principal: BTreeMap::new(), + validate_current_state: None, + }, + ) + .await + .unwrap(); + let frame = GrpcFrame::decode(&intercepted.body).unwrap(); + let json = codec + .decode_bytes_to_json("openshell.v1.UpdateConfigRequest", &frame.message) + .unwrap(); + + assert_eq!(json["mergeOperations"][0], json!({"removeRule": {}})); + } + + #[tokio::test] + async fn request_middleware_accepts_semantically_equal_map_encodings() { + let codec = ProtoJsonCodec::openshell().unwrap(); + let routes = + routes::OpenShellRouteIndex::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET) + .unwrap(); + let runtime = GatewayInterceptorRuntime { + plan: Arc::new(ExecutionPlan::empty(routes)), + codec: codec.clone(), + }; + let request = UpdateConfigRequest { + name: "demo".to_string(), + expected_resource_version: u64::MAX - 1, + annotations: HashMap::from([ + ("policy-hash".to_string(), "sha256:v2:abc".to_string()), + ("policy-signature".to_string(), "signature".to_string()), + ("policy-signature-kid".to_string(), "kid".to_string()), + ("correlation-id".to_string(), "reload-1".to_string()), + ]), + ..UpdateConfigRequest::default() + }; + let body = GrpcFrame { + compressed: false, + message: request.encode_to_vec(), + } + .encode() + .unwrap(); + + let intercepted = runtime + .evaluate_request( + "/openshell.v1.OpenShell/UpdateConfig", + &body, + &EvaluationContext { + principal: BTreeMap::new(), + validate_current_state: None, + }, + ) + .await + .unwrap(); + let frame = GrpcFrame::decode(&intercepted.body).unwrap(); + let decoded = UpdateConfigRequest::decode(frame.message.as_slice()).unwrap(); + + assert_eq!(decoded, request); + } + + #[test] + fn dynamic_round_trip_uses_protobuf_json_for_struct_fields() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let request = CreateSandboxRequest { + spec: Some(SandboxSpec { + template: Some(SandboxTemplate { + resources: Some( + json_to_struct(json!({ + "limits": { + "cpu": "2", + "memory": "4Gi" + } + })) + .unwrap(), + ), + driver_config: Some( + json_to_struct(json!({ + "docker": { + "userns": "host" + } + })) + .unwrap(), + ), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }), + name: "demo".to_string(), + labels: HashMap::new(), + annotations: HashMap::new(), + }; + + let bytes = request.encode_to_vec(); + let json = codec + .decode_bytes_to_json("openshell.v1.CreateSandboxRequest", &bytes) + .unwrap(); + + assert_eq!(json["spec"]["template"]["resources"]["limits"]["cpu"], "2"); + assert_eq!( + json["spec"]["template"]["driverConfig"]["docker"]["userns"], + "host" + ); + assert!( + json["spec"]["template"]["resources"] + .get("fields") + .is_none() + ); + + let encoded = codec + .encode_json_to_message("openshell.v1.CreateSandboxRequest", &json) + .unwrap(); + let decoded = CreateSandboxRequest::decode(encoded.as_slice()).unwrap(); + assert_eq!(decoded, request); + } + + #[tokio::test] + async fn invalid_modify_patch_honors_fail_open_without_mutating_operation() { + let plan = BindingPlan { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + selector: RpcSelector { + service: "openshell.v1.OpenShell".to_string(), + method: "CreateSandbox".to_string(), + }, + phase: Phase::ModifyOperation, + failure_policy: FailurePolicy::FailOpen, + timeout: DEFAULT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_patches: DEFAULT_MAX_PATCHES, + client: GatewayInterceptorClient::new( + Channel::from_static("http://127.0.0.1:1").connect_lazy(), + ), + }; + let operation = json!({ "name": "demo" }); + let result = InterceptorResult { + allowed: true, + patches: vec![JsonPatch { + op: "replace".to_string(), + path: "/missing".to_string(), + value: Some(prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("value".to_string())), + }), + from: String::new(), + }], + ..InterceptorResult::default() + }; + + let err = apply_json_patches(&operation, &result.patches).unwrap_err(); + apply_failure_policy(&plan, &err).unwrap(); + assert_eq!(operation, json!({ "name": "demo" })); + } + + #[test] + fn validated_operation_rejects_message_repeated_and_enum_type_errors() { + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let cases = [ + ( + "openshell.v1.CreateSandboxRequest", + json!({"name": "demo", "spec": {}}), + patch("replace", "/spec", json!("not-a-message")), + ), + ( + "openshell.v1.CreateSandboxRequest", + json!({"name": "demo", "spec": {"providers": []}}), + patch("replace", "/spec/providers", json!({"provider": "github"})), + ), + ( + "openshell.v1.ReportPolicyStatusRequest", + json!({ + "sandboxId": "sandbox-id", + "version": 1, + "status": "POLICY_STATUS_LOADED" + }), + patch("replace", "/status", json!("POLICY_STATUS_UNKNOWN")), + ), + ]; + + for (type_name, json, invalid_patch) in cases { + let operation = ValidatedOperation::new(&codec, type_name, json).unwrap(); + let err = operation + .apply_patches(&codec, type_name, &[invalid_patch]) + .expect_err("wrong-type patch must not produce a validated operation"); + + assert!(matches!(err, InterceptorError::InvalidResult(_))); + assert!(err.to_string().contains("patched operation is not valid")); + } + } + + #[tokio::test] + async fn wrong_type_patch_fails_open_to_exact_prior_operation_and_metrics() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let prior = operation.clone(); + let plan = test_modify_plan(FailurePolicy::FailOpen); + let result = allowed_result(vec![patch( + "replace", + "/expectedResourceVersion", + json!("not-a-number"), + )]); + let recorder = TestRecorder::default(); + + let outcome = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &result, + operation, + ) + }) + .unwrap(); + + assert_eq!(outcome, prior); + assert_eq!(TestRecorder::count(&recorder.fail_open), 1); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); + assert_eq!(TestRecorder::count(&recorder.evaluations), 0); + assert_eq!(TestRecorder::count(&recorder.patches), 0); + } + + #[tokio::test] + async fn wrong_type_patch_fails_closed_before_dispatch_and_metrics() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let plan = test_modify_plan(FailurePolicy::FailClosed); + let result = allowed_result(vec![patch( + "replace", + "/expectedResourceVersion", + json!("not-a-number"), + )]); + let recorder = TestRecorder::default(); + + let status = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &result, + operation, + ) + }) + .expect_err("fail-closed invalid candidate must stop dispatch"); + + assert_eq!(status.code(), Code::PermissionDenied); + assert!(status.message().contains("patched operation is not valid")); + assert_eq!(TestRecorder::count(&recorder.fail_open), 0); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 1); + assert_eq!(TestRecorder::count(&recorder.evaluations), 0); + assert_eq!(TestRecorder::count(&recorder.patches), 0); + } + + #[tokio::test] + async fn invalid_patch_list_is_atomic() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let prior = operation.clone(); + let result = allowed_result(vec![ + patch("replace", "/name", json!("partially-mutated")), + patch("replace", "/expectedResourceVersion", json!("not-a-number")), + ]); + + let outcome = apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation, + ) + .unwrap(); + + assert_eq!(outcome, prior); + assert_eq!(outcome.json["name"], "demo"); + } + + #[tokio::test] + async fn later_binding_sees_pre_binding_value_after_invalid_candidate_fails_open() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let plan = test_modify_plan(FailurePolicy::FailOpen); + let invalid_first = allowed_result(vec![ + patch("replace", "/name", json!("rejected-candidate")), + patch("replace", "/expectedResourceVersion", json!("not-a-number")), + ]); + let second = allowed_result(vec![ + patch("test", "/name", json!("demo")), + patch("replace", "/name", json!("accepted-candidate")), + ]); + + let operation = apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &invalid_first, + operation, + ) + .unwrap(); + let operation = apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &plan, + &second, + operation, + ) + .unwrap(); + + assert_eq!(operation.json["name"], "accepted-candidate"); + let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); + assert_eq!(decoded.name, "accepted-candidate"); + assert_eq!(decoded.expected_resource_version, 7); + } + + #[tokio::test] + async fn ambiguous_oneof_patch_obeys_binding_failure_policy() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.PolicyMergeOperation", + json!({"addRule": {}}), + ) + .unwrap(); + let result = allowed_result(vec![patch("add", "/removeRule", json!({}))]); + + let fail_open = apply_evaluation_result( + &codec, + "openshell.v1.PolicyMergeOperation", + &test_modify_plan(FailurePolicy::FailOpen), + &result, + operation.clone(), + ) + .unwrap(); + let fail_closed = apply_evaluation_result( + &codec, + "openshell.v1.PolicyMergeOperation", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation.clone(), + ) + .expect_err("ambiguous oneof candidate must fail closed"); + + assert_eq!(fail_open, operation); + assert_eq!(fail_closed.code(), Code::PermissionDenied); + assert!(fail_closed.message().contains("oneof 'operation'")); + } + + #[tokio::test] + async fn accepted_candidate_updates_encoded_operation_and_patch_metrics() { + tokio::task::yield_now().await; + let codec = + ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let operation = ValidatedOperation::new( + &codec, + "openshell.v1.UpdateConfigRequest", + json!({"name": "demo", "expectedResourceVersion": "7"}), + ) + .unwrap(); + let result = allowed_result(vec![patch("replace", "/name", json!("accepted"))]); + let recorder = TestRecorder::default(); + + let operation = metrics::with_local_recorder(&recorder, || { + apply_evaluation_result( + &codec, + "openshell.v1.UpdateConfigRequest", + &test_modify_plan(FailurePolicy::FailClosed), + &result, + operation, + ) + }) + .unwrap(); + + let decoded = UpdateConfigRequest::decode(operation.encoded.as_slice()).unwrap(); + assert_eq!(decoded.name, "accepted"); + assert_eq!(TestRecorder::count(&recorder.evaluations), 1); + assert_eq!(TestRecorder::count(&recorder.patches), 1); + assert_eq!(TestRecorder::count(&recorder.fail_open), 0); + assert_eq!(TestRecorder::count(&recorder.fail_closed), 0); + } +} diff --git a/crates/openshell-providers/src/discovery.rs b/crates/openshell-providers/src/discovery.rs index ebe75e4349..7e212590d5 100644 --- a/crates/openshell-providers/src/discovery.rs +++ b/crates/openshell-providers/src/discovery.rs @@ -84,6 +84,7 @@ mod tests { ProviderTypeProfile { id: "custom".to_string(), resource_version: 0, + annotations: std::collections::HashMap::new(), display_name: "Custom".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other, diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index b15525e372..1d78fe3612 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -19,9 +19,8 @@ pub use context::{DiscoveryContext, RealDiscoveryContext}; pub use discovery::{discover_from_profile, discover_with_spec}; pub use profiles::{ CredentialRefreshProfile, ProfileError, ProfileValidationDiagnostic, ProviderTypeProfile, - default_profiles, get_default_profile, normalize_profile_id, parse_profile_json, - parse_profile_yaml, profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, - validate_profile_set, + builtin_profiles, normalize_profile_id, parse_profile_json, parse_profile_yaml, + profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, validate_profile_set, }; #[derive(Debug, thiserror::Error)] @@ -152,12 +151,14 @@ impl ProviderRegistry { #[must_use] pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { - get_default_profile(id) + builtin_profiles() + .iter() + .find(|profile| profile.id.eq_ignore_ascii_case(id)) } #[must_use] pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { - default_profiles().iter().collect() + builtin_profiles().iter().collect() } /// Inject provider-specific env vars via the registered plugin. diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index ddfbcaf7dc..62809138cd 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -297,6 +297,8 @@ pub struct ProviderTypeProfile { pub id: String, #[serde(default, skip_serializing_if = "is_u64_zero")] pub resource_version: u64, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub annotations: HashMap, pub display_name: String, #[serde(default)] pub description: String, @@ -328,6 +330,7 @@ impl ProviderTypeProfile { Self { id: profile.id.clone(), resource_version: profile.resource_version, + annotations: profile.annotations.clone(), display_name: profile.display_name.clone(), description: profile.description.clone(), category: ProviderProfileCategory::try_from(profile.category) @@ -418,6 +421,7 @@ impl ProviderTypeProfile { ProviderProfile { id: self.id.clone(), resource_version: self.resource_version, + annotations: self.annotations.clone(), display_name: self.display_name.clone(), description: self.description.clone(), category: self.category as i32, @@ -1696,11 +1700,11 @@ fn is_kubernetes_service_host(host: &str) -> bool { (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) } -static DEFAULT_PROFILES: OnceLock> = OnceLock::new(); +static BUILTIN_PROFILES: OnceLock> = OnceLock::new(); #[must_use] -pub fn default_profiles() -> &'static [ProviderTypeProfile] { - DEFAULT_PROFILES +pub fn builtin_profiles() -> &'static [ProviderTypeProfile] { + BUILTIN_PROFILES .get_or_init(|| { parse_profile_catalog_yamls(BUILT_IN_PROFILE_YAMLS) .expect("built-in provider profiles must be valid YAML") @@ -1708,26 +1712,28 @@ pub fn default_profiles() -> &'static [ProviderTypeProfile] { .as_slice() } -#[must_use] -pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { - default_profiles() - .iter() - .find(|profile| profile.id.eq_ignore_ascii_case(id)) -} - #[cfg(test)] mod tests { + use std::collections::HashMap; + use openshell_core::proto::ProviderProfileCategory; use super::{ - DiscoveryProfile, ProfileError, ProviderTypeProfile, default_profiles, get_default_profile, + DiscoveryProfile, ProfileError, ProviderTypeProfile, builtin_profiles, normalize_profile_id, parse_profile_catalog_yamls, parse_profile_json, parse_profile_yaml, profile_to_json, profile_to_yaml, validate_profile_set, }; + fn builtin_profile(id: &str) -> &'static ProviderTypeProfile { + builtin_profiles() + .iter() + .find(|profile| profile.id == id) + .unwrap_or_else(|| panic!("built-in profile {id} should exist")) + } + #[test] - fn default_profiles_are_sorted_by_id() { - let ids = default_profiles() + fn builtin_profiles_are_sorted_by_id() { + let ids = builtin_profiles() .iter() .map(|profile| profile.id.as_str()) .collect::>(); @@ -1738,7 +1744,7 @@ mod tests { #[test] fn github_profile_materializes_policy_metadata() { - let profile = get_default_profile("github").expect("github profile"); + let profile = builtin_profile("github"); let proto = profile.to_proto(); assert_eq!(proto.id, "github"); @@ -1768,7 +1774,7 @@ mod tests { #[test] fn credential_env_vars_are_deduplicated_in_profile_order() { - let profile = get_default_profile("claude-code").expect("claude-code profile"); + let profile = builtin_profile("claude-code"); assert_eq!( profile.credential_env_vars(), vec!["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"] @@ -1777,7 +1783,7 @@ mod tests { #[test] fn vertex_profile_declares_discovery_and_fallback_token_env_vars() { - let profile = get_default_profile("google-vertex-ai").expect("vertex profile"); + let profile = builtin_profile("google-vertex-ai"); let service_account_token = profile .credentials .iter() @@ -1875,13 +1881,13 @@ credentials: #[test] fn adc_credential_returns_oauth2_refresh_token_credential_with_adc_material() { - let profile = get_default_profile("google-cloud").expect("google-cloud profile"); + let profile = builtin_profile("google-cloud"); let adc = profile .adc_credential() .expect("google-cloud should have an ADC credential"); assert_eq!(adc.env_vars[0], "GCP_ADC_ACCESS_TOKEN"); - let profile = get_default_profile("google-vertex-ai").expect("vertex profile"); + let profile = builtin_profile("google-vertex-ai"); let adc = profile .adc_credential() .expect("vertex should have an ADC credential"); @@ -1890,10 +1896,10 @@ credentials: #[test] fn adc_credential_returns_none_for_profiles_without_adc() { - let profile = get_default_profile("github").expect("github profile"); + let profile = builtin_profile("github"); assert!(profile.adc_credential().is_none()); - let profile = get_default_profile("claude-code").expect("claude-code profile"); + let profile = builtin_profile("claude-code"); assert!(profile.adc_credential().is_none()); } @@ -2433,7 +2439,7 @@ endpoints: #[test] fn profile_json_round_trip_preserves_compact_dto_shape() { - let profile = get_default_profile("github").expect("github profile"); + let profile = builtin_profile("github"); let json = profile_to_json(profile).expect("profile should serialize"); let parsed = parse_profile_json(&json).expect("profile should parse"); @@ -2442,6 +2448,38 @@ endpoints: assert_eq!(parsed.binaries[0].path, "/usr/bin/gh"); } + #[test] + fn profile_annotations_round_trip_through_proto_and_yaml() { + let profile = parse_profile_yaml( + r" +id: signed +annotations: + openshell.nvidia.com/profile-hash: sha256:abc123 + openshell.nvidia.com/profile-signature: signed-token +display_name: Signed +description: Signed provider profile +credentials: [] +endpoints: [] +binaries: [] +", + ) + .expect("profile should parse"); + + let proto = profile.to_proto(); + assert_eq!( + proto + .annotations + .get("openshell.nvidia.com/profile-signature") + .map(String::as_str), + Some("signed-token") + ); + + let exported = profile_to_yaml(&ProviderTypeProfile::from_proto(&proto)) + .expect("profile should serialize"); + let reparsed = parse_profile_yaml(&exported).expect("exported profile should parse"); + assert_eq!(reparsed.annotations, profile.annotations); + } + #[test] fn profile_yaml_round_trip_preserves_full_network_policy_fields() { let profile = parse_profile_yaml( @@ -2593,6 +2631,7 @@ binaries: ["", /usr/bin/broken] ProviderTypeProfile { id: " alex-api ".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Space".to_string(), description: String::new(), category: ProviderProfileCategory::Other, @@ -2608,6 +2647,7 @@ binaries: ["", /usr/bin/broken] ProviderTypeProfile { id: "alex_api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Underscore".to_string(), description: String::new(), category: ProviderProfileCategory::Other, @@ -2623,6 +2663,7 @@ binaries: ["", /usr/bin/broken] ProviderTypeProfile { id: "Alex-API".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Case".to_string(), description: String::new(), category: ProviderProfileCategory::Other, diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index b5c9b34d71..c4fe7e8d0b 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -20,6 +20,7 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-driver-docker = { path = "../openshell-driver-docker" } openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } +openshell-gateway-interceptors = { path = "../openshell-gateway-interceptors" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-prover = { path = "../openshell-prover" } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 9aee2bc6d6..7260fc1fdf 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -372,8 +372,19 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result, #[serde(default)] + pub interceptors: Vec, + #[serde(default)] + pub provider_profile_sources: Option>, + #[serde(default)] pub mtls_auth: Option, #[serde(default)] pub gateway_jwt: Option, @@ -401,6 +408,30 @@ allow_unauthenticated_users = true assert!(auth.allow_unauthenticated_users); } + #[test] + fn parses_provider_profile_source_composition() { + let toml = r#" +[openshell.gateway] +provider_profile_sources = [ + { type = "builtin" }, + { type = "user" }, + { type = "interceptor", name = "provider-governance" }, +] +"#; + let tmp = write_tmp(toml); + let file = load(tmp.path()).expect("valid provider profile sources parse"); + assert_eq!( + file.openshell.gateway.provider_profile_sources, + Some(vec![ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::User, + GatewayProviderProfileSourceConfig::Interceptor { + name: "provider-governance".to_string(), + }, + ]) + ); + } + #[test] fn rejects_database_url_in_file() { let toml = r#" diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 88c771bed5..944b9b3ed7 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -200,6 +200,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::default(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c9..a71274e2d0 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -115,6 +115,8 @@ const MAX_MAP_VALUE_LEN: usize = 8192; const MAX_TEMPLATE_STRING_LEN: usize = 1024; /// Maximum number of entries in template map fields. const MAX_TEMPLATE_MAP_ENTRIES: usize = 128; +/// Maximum number of entries in metadata annotations. +const MAX_METADATA_ANNOTATIONS_ENTRIES: usize = 128; /// Maximum serialized size (bytes) for template Struct fields. const MAX_TEMPLATE_STRUCT_SIZE: usize = 65_536; /// Maximum serialized size (bytes) for the policy field. diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index c016531eb6..6fccf1eade 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -13,7 +13,10 @@ use crate::ServerState; use crate::auth::principal::Principal; use crate::persistence::{DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store}; -use crate::policy_store::PolicyStoreExt; +use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; +use crate::provider_profile_sources::EffectiveProviderProfileCatalog; +#[cfg(test)] +use crate::provider_profile_sources::ProviderProfileSources; use openshell_core::net::is_internal_ip; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; @@ -61,7 +64,7 @@ use openshell_prover::{ registry::load_embedded_binary_registry, report::finding_shorthand, }; -use openshell_providers::{get_default_profile, normalize_provider_type}; +use openshell_providers::normalize_provider_type; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -71,7 +74,7 @@ use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; use super::validation::{ - level_matches, source_matches, validate_no_reserved_provider_policy_keys, + level_matches, source_matches, validate_annotations, validate_no_reserved_provider_policy_keys, validate_policy_safety, validate_static_fields_unchanged, }; use super::{MAX_PAGE_SIZE, StoredSettingValue, StoredSettings, clamp_limit}; @@ -517,8 +520,9 @@ fn run_prover_findings( /// a `warn!` — the merged policy already excludes them at compose time, so /// silently treating them as absent here keeps the credential set consistent /// with the merged policy the prover validates against. -async fn build_credential_set_for_sandbox( +async fn build_credential_set_for_sandbox_with_catalog( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result { let mut credentials = Vec::new(); @@ -534,28 +538,16 @@ async fn build_credential_set_for_sandbox( }; let provider_type = provider.r#type.trim(); - let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { - let Some(profile) = get_default_profile(canonical_type) else { - warn!( - provider_name = %name, - provider_type, - "legacy provider type has no profile; skipping credential entry" - ); - continue; - }; - profile.clone() - } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping credential entry" - ); - continue; - }; - profile + let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); + let Some(profile) = + super::provider::get_provider_type_profile_with_catalog(catalog, profile_id) + else { + warn!( + provider_name = %name, + provider_type, + "provider type has no profile; skipping credential entry" + ); + continue; }; let target_hosts: Vec = profile @@ -987,6 +979,7 @@ async fn auto_approve_chunk( // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, sandbox: &Sandbox, sandbox_id: &str, ) -> Result { @@ -1023,8 +1016,12 @@ async fn current_effective_policy_for_sandbox( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &provider_names).await?; + let provider_layers = profile_provider_policy_layers_with_catalog( + state.store.as_ref(), + catalog, + &provider_names, + ) + .await?; if !provider_layers.is_empty() { policy = compose_effective_policy(&policy, &provider_layers); } @@ -1077,6 +1074,97 @@ fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Statu Ok(()) } +fn sandbox_metadata_annotations(sandbox: &Sandbox) -> HashMap { + sandbox + .metadata + .as_ref() + .map(|metadata| metadata.annotations.clone()) + .unwrap_or_default() +} + +fn update_config_response( + version: u32, + policy_hash: impl Into, + settings_revision: u64, + deleted: bool, + annotations: HashMap, +) -> Response { + Response::new(UpdateConfigResponse { + version, + policy_hash: policy_hash.into(), + settings_revision, + deleted, + annotations, + }) +} + +async fn persist_update_config_annotations( + state: &Arc, + sandbox_id: &str, + expected_resource_version: u64, + annotations: &HashMap, + current_annotations: &HashMap, +) -> Result, Status> { + if annotations.is_empty() { + return Ok(current_annotations.clone()); + } + if annotations + .iter() + .all(|(key, value)| current_annotations.get(key) == Some(value)) + { + return Ok(current_annotations.clone()); + } + + let annotations = annotations.clone(); + let updated = state + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.annotations.extend(annotations.clone()); + } + }) + .await + .map_err(|e| super::persistence_error_to_status(e, "store update annotations"))?; + + Ok(sandbox_metadata_annotations(&updated)) +} + +async fn persist_existing_policy_projection( + state: &Arc, + sandbox_id: &str, + expected_resource_version: u64, + annotations: &HashMap, + current_annotations: &HashMap, + backfill_policy: Option<&ProtoSandboxPolicy>, +) -> Result, Status> { + let annotations_match = annotations + .iter() + .all(|(key, value)| current_annotations.get(key) == Some(value)); + if backfill_policy.is_none() && annotations_match { + return Ok(current_annotations.clone()); + } + + let annotations = annotations.clone(); + let backfill_policy = backfill_policy.cloned(); + let updated = state + .store + .update_message_cas::(sandbox_id, expected_resource_version, |sandbox| { + if let Some(policy) = backfill_policy.as_ref() + && let Some(spec) = sandbox.spec.as_mut() + && spec.policy.is_none() + { + spec.policy = Some(policy.clone()); + } + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.annotations.extend(annotations.clone()); + } + }) + .await + .map_err(|e| super::persistence_error_to_status(e, "store policy projection"))?; + + Ok(sandbox_metadata_annotations(&updated)) +} + async fn resolve_sandbox_by_name_for_principal( store: &Store, principal: &Principal, @@ -1135,6 +1223,10 @@ pub(super) async fn handle_get_sandbox_config( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; // Try to get the latest policy from the policy history table. let latest = state @@ -1237,8 +1329,12 @@ pub(super) async fn handle_get_sandbox_config( && !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() { - let provider_layers = - profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; + let provider_layers = profile_provider_policy_layers_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &sandbox_provider_names, + ) + .await?; if !provider_layers.is_empty() { let effective_policy = compose_effective_policy(source_policy, &provider_layers); policy_hash = deterministic_policy_hash(&effective_policy); @@ -1248,8 +1344,12 @@ pub(super) async fn handle_get_sandbox_config( let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); - let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?; + let provider_env_revision = compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &sandbox_provider_names, + ) + .await?; Ok(Response::new(GetSandboxConfigResponse { policy, @@ -1263,8 +1363,20 @@ pub(super) async fn handle_get_sandbox_config( })) } -pub(super) async fn compute_provider_env_revision( +#[cfg(test)] +async fn compute_provider_env_revision( + store: &Store, + provider_names: &[String], +) -> Result { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + compute_provider_env_revision_with_catalog(store, &catalog, provider_names).await +} + +pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); @@ -1286,7 +1398,7 @@ pub(super) async fn compute_provider_env_revision( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(store, &provider.r#type, &mut hasher).await?; + hash_provider_profile_revision(catalog, &provider.r#type, &mut hasher); let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1312,44 +1424,30 @@ pub(super) async fn compute_provider_env_revision( )?)) } -async fn hash_provider_profile_revision( - store: &Store, +fn hash_provider_profile_revision( + catalog: &EffectiveProviderProfileCatalog, provider_type: &str, hasher: &mut Sha256, -) -> Result<(), Status> { - if let Some(profile) = get_default_profile(provider_type) { - hasher.update(b"builtin-profile"); - hasher.update(profile.to_proto().encode_to_vec()); - return Ok(()); - } - - hasher.update(b"custom-profile"); - match store - .get_by_name( - openshell_core::proto::StoredProviderProfile::object_type(), - provider_type, - ) - .await - .map_err(|e| { - Status::internal(format!( - "fetch provider profile '{provider_type}' failed: {e}" - )) - })? { - Some(record) => { - hasher.update(record.id.as_bytes()); - hasher.update(record.updated_at_ms.to_le_bytes()); - hasher.update(record.payload.as_slice()); - } - None => { - hasher.update(b"missing"); - } - } - Ok(()) +) { + let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); + catalog.hash_profile_revision(profile_id, hasher); } +#[cfg(test)] async fn profile_provider_policy_layers( store: &Store, provider_names: &[String], +) -> Result, Status> { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + profile_provider_policy_layers_with_catalog(store, &catalog, provider_names).await +} + +async fn profile_provider_policy_layers_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider_names: &[String], ) -> Result, Status> { let mut layers = Vec::new(); @@ -1361,28 +1459,16 @@ async fn profile_provider_policy_layers( .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; let provider_type = provider.r#type.trim(); - let profile = if let Some(canonical_type) = normalize_provider_type(provider_type) { - let Some(profile) = get_default_profile(canonical_type) else { - warn!( - provider_name = %name, - provider_type, - "legacy provider type has no profile; skipping provider policy layer" - ); - continue; - }; - profile.clone() - } else { - let Some(profile) = - super::provider::get_provider_type_profile(store, provider_type).await? - else { - warn!( - provider_name = %name, - provider_type, - "provider type has no profile; skipping provider policy layer" - ); - continue; - }; - profile + let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); + let Some(profile) = + super::provider::get_provider_type_profile_with_catalog(catalog, profile_id) + else { + warn!( + provider_name = %name, + provider_type, + "provider type has no profile; skipping provider policy layer" + ); + continue; }; let rule_name = openshell_policy::provider_rule_name(provider.object_name()); @@ -1437,11 +1523,22 @@ pub(super) async fn handle_get_sandbox_provider_environment( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let provider_names = spec.providers; - let provider_env_revision = - compute_provider_env_revision(state.store.as_ref(), &provider_names).await?; - let provider_environment = - super::provider::resolve_provider_environment(state.store.as_ref(), &provider_names) - .await?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let provider_env_revision = compute_provider_env_revision_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &provider_names, + ) + .await?; + let provider_environment = super::provider::resolve_provider_environment_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &provider_names, + ) + .await?; info!( sandbox_id = %sandbox_id, @@ -1486,6 +1583,7 @@ async fn handle_update_config_inner( sandbox_caller: bool, ) -> Result, Status> { let req = request.into_inner(); + validate_annotations(&req.annotations, "annotations")?; if sandbox_caller { validate_sandbox_caller_update(&req)?; resolve_sandbox_by_name_for_principal( @@ -1518,6 +1616,11 @@ async fn handle_update_config_inner( } if req.global { + if !req.annotations.is_empty() { + return Err(Status::invalid_argument( + "annotations are only supported for sandbox-scoped updates", + )); + } let _settings_guard = state.settings_mutex.lock().await; if has_merge_ops { @@ -1563,12 +1666,13 @@ async fn handle_update_config_inner( global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(current.version).unwrap_or(0), - policy_hash: hash, - settings_revision: global_settings.revision, - deleted: false, - })); + return Ok(update_config_response( + u32::try_from(current.version).unwrap_or(0), + hash, + global_settings.revision, + false, + HashMap::new(), + )); } let next_version = latest.map_or(1, |r| r.version + 1); @@ -1618,12 +1722,13 @@ async fn handle_update_config_inner( save_global_settings(state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(next_version).unwrap_or(0), - policy_hash: hash, - settings_revision: global_settings.revision, - deleted: false, - })); + return Ok(update_config_response( + u32::try_from(next_version).unwrap_or(0), + hash, + global_settings.revision, + false, + HashMap::new(), + )); } // Global setting mutation. @@ -1666,12 +1771,13 @@ async fn handle_update_config_inner( save_global_settings(state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateConfigResponse { - version: 0, - policy_hash: String::new(), - settings_revision: global_settings.revision, - deleted: req.delete_setting && changed, - })); + return Ok(update_config_response( + 0, + String::new(), + global_settings.revision, + req.delete_setting && changed, + HashMap::new(), + )); } if req.name.is_empty() { @@ -1688,6 +1794,7 @@ async fn handle_update_config_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + let mut response_annotations = sandbox_metadata_annotations(&sandbox); if has_setting { let _settings_guard = state.settings_mutex.lock().await; @@ -1721,12 +1828,22 @@ async fn handle_update_config_inner( .await?; } - return Ok(Response::new(UpdateConfigResponse { - version: 0, - policy_hash: String::new(), - settings_revision: sandbox_settings.revision, - deleted: removed, - })); + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; + + return Ok(update_config_response( + 0, + String::new(), + sandbox_settings.revision, + removed, + response_annotations, + )); } if globally_managed { @@ -1754,12 +1871,22 @@ async fn handle_update_config_inner( .await?; } - return Ok(Response::new(UpdateConfigResponse { - version: 0, - policy_hash: String::new(), - settings_revision: sandbox_settings.revision, - deleted: false, - })); + response_annotations = persist_update_config_annotations( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + ) + .await?; + + return Ok(update_config_response( + 0, + String::new(), + sandbox_settings.revision, + false, + response_annotations, + )); } if has_merge_ops { @@ -1776,13 +1903,32 @@ async fn handle_update_config_inner( .ok_or_else(|| Status::internal("sandbox has no spec"))?; let merge_ops = parse_merge_operations(&req.merge_operations)?; validate_merge_operations_for_server(&merge_ops)?; - let (version, hash) = apply_merge_operations_with_retry( + let atomic_context = AtomicPolicyWriteContext { + expected_resource_version: req.expected_resource_version, + provenance: &req.annotations, + annotations: &req.annotations, + }; + let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, spec.policy.as_ref(), &merge_ops, + Some(&atomic_context), ) .await?; + response_annotations = if let Some(updated_sandbox) = updated_sandbox { + sandbox_metadata_annotations(&updated_sandbox) + } else { + persist_existing_policy_projection( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + None, + ) + .await? + }; state.sandbox_watch_bus.notify(&sandbox_id); emit_gateway_policy_audit_log( @@ -1818,12 +1964,13 @@ async fn handle_update_config_inner( ); emit_config_update_policy_success(sandbox_caller); - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - })); + return Ok(update_config_response( + u32::try_from(version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )); } // Sandbox-scoped policy update. @@ -1855,68 +2002,108 @@ async fn handle_update_config_inner( validate_no_reserved_provider_policy_keys(&new_policy)?; } - if let Some(baseline_policy) = spec.policy.as_ref() { + let backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { validate_static_fields_unchanged(baseline_policy, &new_policy)?; validate_policy_safety(&new_policy)?; + None } else { - // Backfill spec.policy using CAS (first-time policy discovery) - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - let sandbox_id = sandbox.object_id().to_string(); - let new_policy_clone = new_policy.clone(); - state - .store - .update_message_cas::( - &sandbox_id, - req.expected_resource_version, - |sandbox| { - if let Some(ref mut spec) = sandbox.spec - && spec.policy.is_none() - { - spec.policy = Some(new_policy_clone.clone()); - } - }, - ) - .await - .map_err(|e| super::persistence_error_to_status(e, "backfill spec.policy"))?; - info!( - sandbox_id = %sandbox_id, - "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" - ); - } - - let latest = state - .store - .get_latest_policy(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; + Some(new_policy.clone()) + }; + let _sandbox_sync_guard = if backfill_policy.is_some() { + Some(state.compute.sandbox_sync_guard().await) + } else { + None + }; let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); + let (next_version, committed_annotations) = { + let mut committed = None; + for attempt in 1..=MERGE_RETRY_LIMIT { + let latest = state + .store + .get_latest_policy(&sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?; - if let Some(ref current) = latest - && current.policy_hash == hash - { - return Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(current.version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - })); - } + if let Some(ref current) = latest + && current.policy_hash == hash + && current.provenance == req.annotations + { + response_annotations = persist_existing_policy_projection( + state, + &sandbox_id, + req.expected_resource_version, + &req.annotations, + &response_annotations, + backfill_policy.as_ref(), + ) + .await?; + if backfill_policy.is_some() { + info!( + sandbox_id = %sandbox_id, + "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" + ); + } + return Ok(update_config_response( + u32::try_from(current.version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )); + } - let next_version = latest.map_or(1, |r| r.version + 1); - let policy_id = uuid::Uuid::new_v4().to_string(); + let next_version = latest.as_ref().map_or(1, |record| record.version + 1); + let write = AtomicPolicyRevisionWrite { + id: uuid::Uuid::new_v4().to_string(), + sandbox_id: sandbox_id.clone(), + version: next_version, + policy_payload: payload.clone(), + policy_hash: hash.clone(), + provenance: req.annotations.clone(), + expected_resource_version: req.expected_resource_version, + annotations: req.annotations.clone(), + backfill_policy: backfill_policy.clone(), + }; - state - .store - .put_policy_revision(&policy_id, &sandbox_id, next_version, &payload, &hash) - .await - .map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?; + match state.store.put_policy_revision_atomic(&write).await { + Ok(updated_sandbox) => { + committed = + Some((next_version, sandbox_metadata_annotations(&updated_sandbox))); + break; + } + Err(error) if error.is_unique_violation_on("objects_version_uq") => { + warn!( + sandbox_id = %sandbox_id, + attempt, + conflicting_version = next_version, + "UpdateConfig: policy version conflict, retrying" + ); + tokio::task::yield_now().await; + } + Err(error) => { + return Err(super::persistence_error_to_status( + error, + "persist policy revision", + )); + } + } + } + committed.ok_or_else(|| { + Status::aborted(format!( + "UpdateConfig: gave up after {MERGE_RETRY_LIMIT} policy version conflict retries" + )) + })? + }; + response_annotations = committed_annotations; - let _ = state - .store - .supersede_older_policies(&sandbox_id, next_version) - .await; + if backfill_policy.is_some() { + info!( + sandbox_id = %sandbox_id, + "UpdateConfig: backfilled spec.policy from sandbox-discovered policy" + ); + } state.sandbox_watch_bus.notify(&sandbox_id); @@ -1928,12 +2115,13 @@ async fn handle_update_config_inner( ); emit_full_policy_update_success(sandbox_caller, next_version); - Ok(Response::new(UpdateConfigResponse { - version: u32::try_from(next_version).unwrap_or(0), - policy_hash: hash, - settings_revision: 0, - deleted: false, - })) + Ok(update_config_response( + u32::try_from(next_version).unwrap_or(0), + hash, + 0, + false, + response_annotations, + )) } // --------------------------------------------------------------------------- @@ -2263,7 +2451,17 @@ pub(super) async fn handle_submit_policy_analysis( // case for the common single-chunk submission shape. If real workloads // surface a problem with batches that interact across chunks, the right // fix is to recompute baseline after each successful auto-approve. - let current_policy = current_effective_policy_for_sandbox(state, &sandbox, &sandbox_id).await?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let current_policy = current_effective_policy_for_sandbox( + state, + &provider_profile_catalog, + &sandbox, + &sandbox_id, + ) + .await?; // Auto-approval is an opt-in behavior, sourced from the settings model // (sandbox or gateway scope) so it can be flipped on a running sandbox @@ -2282,8 +2480,12 @@ pub(super) async fn handle_submit_policy_analysis( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let credential_set = - build_credential_set_for_sandbox(state.store.as_ref(), &provider_names_for_creds).await?; + let credential_set = build_credential_set_for_sandbox_with_catalog( + state.store.as_ref(), + &provider_profile_catalog, + &provider_names_for_creds, + ) + .await?; let current_version = state .store @@ -3242,6 +3444,7 @@ fn policy_record_to_revision(record: &PolicyRecord, include_policy: bool) -> San created_at_ms: record.created_at_ms, loaded_at_ms: record.loaded_at_ms.unwrap_or(0), policy, + provenance: record.provenance.clone(), } } @@ -3530,12 +3733,19 @@ fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status { } } +struct AtomicPolicyWriteContext<'a> { + expected_resource_version: u64, + provenance: &'a HashMap, + annotations: &'a HashMap, +} + async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], -) -> Result<(i64, String), Status> { + atomic_context: Option<&AtomicPolicyWriteContext<'_>>, +) -> Result<(i64, String, Option), Status> { for attempt in 1..=MERGE_RETRY_LIMIT { let latest = store .get_latest_policy(sandbox_id) @@ -3560,26 +3770,48 @@ async fn apply_merge_operations_with_retry( if let Some(ref current) = latest && current.policy_hash == hash + && atomic_context.is_none_or(|context| current.provenance == *context.provenance) { - return Ok((current.version, hash)); + return Ok((current.version, hash, None)); } if latest.is_none() && !merged.changed { - return Ok((0, hash)); + return Ok((0, hash, None)); } let payload = new_policy.encode_to_vec(); let next_version = latest.as_ref().map_or(1, |record| record.version + 1); let policy_id = uuid::Uuid::new_v4().to_string(); - match store - .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) - .await - { - Ok(()) => { - let _ = store - .supersede_older_policies(sandbox_id, next_version) - .await; + let write_result = if let Some(context) = atomic_context { + store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: policy_id, + sandbox_id: sandbox_id.to_string(), + version: next_version, + policy_payload: payload, + policy_hash: hash.clone(), + provenance: context.provenance.clone(), + expected_resource_version: context.expected_resource_version, + annotations: context.annotations.clone(), + backfill_policy: None, + }) + .await + .map(Some) + } else { + store + .put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash) + .await + .map(|()| None) + }; + + match write_result { + Ok(updated_sandbox) => { + if atomic_context.is_none() { + let _ = store + .supersede_older_policies(sandbox_id, next_version) + .await; + } if attempt > 1 { info!( @@ -3591,7 +3823,7 @@ async fn apply_merge_operations_with_retry( ); } - return Ok((next_version, hash)); + return Ok((next_version, hash, updated_sandbox)); } Err(e) => { if e.is_unique_violation_on("objects_version_uq") { @@ -3629,7 +3861,9 @@ pub(super) async fn merge_chunk_into_policy( rule, }]; validate_merge_operations_for_server(&operations)?; - apply_merge_operations_with_retry(store, sandbox_id, None, &operations).await + apply_merge_operations_with_retry(store, sandbox_id, None, &operations, None) + .await + .map(|(version, hash, _)| (version, hash)) } async fn remove_chunk_from_policy( @@ -3645,8 +3879,10 @@ async fn remove_chunk_from_policy( rule_name: chunk.rule_name.clone(), binary_path: chunk.binary.clone(), }], + None, ) .await + .map(|(version, hash, _)| (version, hash)) } // --------------------------------------------------------------------------- @@ -3940,6 +4176,7 @@ mod tests { use crate::persistence::test_store; use std::collections::HashMap; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use tonic::Code; /// Wrap a request with a user `Principal` so handler scope guards treat @@ -4091,6 +4328,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4124,6 +4362,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4156,6 +4395,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4191,6 +4431,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4278,6 +4519,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4358,6 +4600,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -4384,6 +4627,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) @@ -4427,6 +4671,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(policy), @@ -4469,26 +4714,222 @@ mod tests { } #[tokio::test] - async fn provider_policy_layers_skip_unknown_provider_types() { - let store = test_store().await; - store - .put_message(&test_provider("custom-provider", "custom")) - .await - .unwrap(); - - let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) - .await - .unwrap(); + async fn request_paths_use_one_provider_profile_snapshot() { + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, + }; - assert!(layers.is_empty()); - } + fn snapshot_profile( + id: &str, + host: &str, + credential_env: &str, + token_endpoint: &str, + ) -> ProviderProfile { + ProviderProfile { + id: id.to_string(), + display_name: id.to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: vec![credential_env.to_string()], + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: token_endpoint.to_string(), + audience: "api://snapshot-test".to_string(), + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + } + } - #[tokio::test] - async fn provider_policy_layers_skip_custom_profile_for_legacy_provider_type() { - let store = test_store().await; - store - .put_message(&test_provider("custom-provider", "generic")) - .await + let fetch_count = Arc::new(AtomicUsize::new(0)); + let state = test_server_state().await; + let mut state = Arc::into_inner(state).expect("test state should be uniquely owned"); + state.provider_profile_sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![ + ( + "revision-a".to_string(), + vec![ + snapshot_profile( + "moving-a", + "one.revision-a.example", + "TOKEN_A", + "https://auth.revision-a.example/token-a", + ), + snapshot_profile( + "moving-b", + "two.revision-a.example", + "TOKEN_B", + "https://auth.revision-a.example/token-b", + ), + ], + ), + ( + "revision-b".to_string(), + vec![ + snapshot_profile( + "moving-a", + "one.revision-b.example", + "TOKEN_A", + "https://auth.revision-b.example/token-a", + ), + snapshot_profile( + "moving-b", + "two.revision-b.example", + "TOKEN_B", + "https://auth.revision-b.example/token-b", + ), + ], + ), + ], + Arc::clone(&fetch_count), + ); + let state = Arc::new(state); + enable_providers_v2(&state).await; + + let mut provider_a = test_provider("provider-a", "moving-a"); + provider_a.credentials = HashMap::from([("TOKEN_A".to_string(), "a".to_string())]); + let mut provider_b = test_provider("provider-b", "moving-b"); + provider_b.credentials = HashMap::from([("TOKEN_B".to_string(), "b".to_string())]); + state.store.put_message(&provider_a).await.unwrap(); + state.store.put_message(&provider_b).await.unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-snapshot-consistency", + "snapshot-consistency", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["provider-a".to_string(), "provider-b".to_string()], + )) + .await + .unwrap(); + + let first = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-snapshot-consistency".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + let first_policy = first.policy.unwrap(); + assert!(first_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "one.revision-a.example") + })); + assert!(first_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "two.revision-a.example") + })); + + let second = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-snapshot-consistency".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 2); + assert_ne!(first.provider_env_revision, second.provider_env_revision); + let second_policy = second.policy.unwrap(); + assert!(second_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "one.revision-b.example") + })); + assert!(!second_policy.network_policies.values().any(|rule| { + rule.endpoints + .iter() + .any(|endpoint| endpoint.host == "one.revision-a.example") + })); + + let environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-snapshot-consistency".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 3); + assert_eq!( + environment.provider_env_revision, + second.provider_env_revision + ); + assert_eq!(environment.dynamic_credentials.len(), 2); + assert!(environment.dynamic_credentials.values().all(|credential| { + credential + .token_grant + .as_ref() + .is_some_and(|grant| grant.token_endpoint.contains("revision-b")) + })); + + handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: "snapshot-consistency".to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "snapshot_consistency_test".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "snapshot_consistency_test".to_string(), + endpoints: vec![NetworkEndpoint { + host: "proposal.example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }), + ..Default::default() + }], + ..Default::default() + })), + ) + .await + .unwrap(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 4); + } + + #[tokio::test] + async fn provider_policy_layers_skip_unknown_provider_types() { + let store = test_store().await; + store + .put_message(&test_provider("custom-provider", "custom")) + .await + .unwrap(); + + let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) + .await + .unwrap(); + + assert!(layers.is_empty()); + } + + #[tokio::test] + async fn provider_policy_layers_resolve_user_profile_for_normalized_provider_type() { + let store = test_store().await; + store + .put_message(&test_provider("custom-provider", "generic")) + .await .unwrap(); store .put_message(&openshell_core::proto::StoredProviderProfile { @@ -4498,10 +4939,12 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Generic Override".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4523,7 +4966,8 @@ mod tests { .await .unwrap(); - assert!(layers.is_empty()); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example"); } #[tokio::test] @@ -4542,10 +4986,12 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4607,10 +5053,12 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: openshell_core::proto::ProviderProfileCategory::Other as i32, @@ -4873,10 +5321,12 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom Policy".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5157,10 +5607,12 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(ProviderProfile { id: "custom-token".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom Token".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5381,6 +5833,7 @@ mod tests { profile: Some(ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -5558,6 +6011,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -5647,6 +6101,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -5751,6 +6206,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -5966,6 +6422,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -6062,6 +6519,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6177,6 +6635,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6380,6 +6839,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6477,6 +6937,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6582,6 +7043,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6675,6 +7137,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6760,6 +7223,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6847,6 +7311,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -6934,6 +7399,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7026,6 +7492,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7200,6 +7667,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7296,6 +7764,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7381,6 +7850,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7475,10 +7945,12 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), profile: Some(ProviderProfile { id: "custom-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Custom API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -7513,6 +7985,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7635,6 +8108,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -7821,6 +8295,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -7935,6 +8410,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -8039,6 +8515,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -8298,6 +8775,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -8412,6 +8890,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -8428,6 +8907,7 @@ mod tests { created_at_ms: 1_000_001, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -9031,8 +9511,8 @@ mod tests { }]; let (left, right) = tokio::join!( - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow), - apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny), + apply_merge_operations_with_retry(&store, sandbox_id, None, &add_allow, None), + apply_merge_operations_with_retry(&store, sandbox_id, None, &add_deny, None), ); let mut versions = vec![left.unwrap().0, right.unwrap().0]; @@ -10037,6 +10517,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -10071,6 +10552,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: current_version, + annotations: HashMap::new(), }), ) .await @@ -10098,6 +10580,561 @@ mod tests { ); } + #[tokio::test] + async fn update_config_policy_backfill_persists_and_returns_annotations() { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + + let state = test_server_state().await; + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-annotated-backfill".to_string(), + name: "annotated-backfill".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::from([( + "openshell.nvidia.com/existing".to_string(), + "keep".to_string(), + )]), + }), + spec: Some(SandboxSpec { + policy: None, + providers: Vec::new(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Provisioning as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let current = state + .store + .get_message_by_name::("annotated-backfill") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + let annotations = HashMap::from([ + ( + "openshell.nvidia.com/policy-signature".to_string(), + "signed-policy".to_string(), + ), + ( + "openshell.nvidia.com/policy-provenance".to_string(), + "governance-interceptor".to_string(), + ), + ]); + + let response = handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "annotated-backfill".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, + merge_operations: vec![], + expected_resource_version: current_version, + annotations: annotations.clone(), + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.version, 1); + assert_eq!( + response.annotations.get("openshell.nvidia.com/existing"), + Some(&"keep".to_string()) + ); + for (key, value) in &annotations { + assert_eq!(response.annotations.get(key), Some(value)); + } + + let stored = state + .store + .get_message_by_name::("annotated-backfill") + .await + .unwrap() + .unwrap(); + let stored_annotations = &stored.metadata.as_ref().unwrap().annotations; + assert_eq!( + stored_annotations.get("openshell.nvidia.com/existing"), + Some(&"keep".to_string()) + ); + for (key, value) in &annotations { + assert_eq!(stored_annotations.get(key), Some(value)); + } + assert!( + stored.spec.as_ref().unwrap().policy.is_some(), + "policy should still be backfilled" + ); + let revision = state + .store + .get_latest_policy("sb-annotated-backfill") + .await + .unwrap() + .unwrap(); + assert_eq!(revision.provenance, annotations); + } + + #[tokio::test] + async fn update_config_same_policy_hash_with_new_provenance_creates_revision() { + let state = test_server_state().await; + let mut policy = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut policy); + let hash = deterministic_policy_hash(&policy); + let sandbox = test_sandbox("sb-same-hash", "same-hash", policy.clone(), Vec::new()); + state.store.put_message(&sandbox).await.unwrap(); + state + .store + .put_policy_revision( + "policy-same-hash-v1", + "sb-same-hash", + 1, + &policy.encode_to_vec(), + &hash, + ) + .await + .unwrap(); + + let response = handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "same-hash".to_string(), + policy: Some(policy), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "same-hash-signature".to_string(), + )]), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.version, 2); + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("same-hash-signature") + ); + + let stored = state + .store + .get_message_by_name::("same-hash") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("same-hash-signature") + ); + let latest = state + .store + .get_latest_policy("sb-same-hash") + .await + .unwrap() + .unwrap(); + assert_eq!(latest.version, 2); + assert_eq!( + latest.provenance, + HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "same-hash-signature".to_string(), + )]) + ); + } + + #[tokio::test] + async fn update_config_same_policy_and_provenance_is_idempotent() { + let state = test_server_state().await; + let mut policy = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut policy); + state + .store + .put_message(&test_sandbox( + "sb-idempotent-provenance", + "idempotent-provenance", + policy.clone(), + Vec::new(), + )) + .await + .unwrap(); + let annotations = HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "same-signature".to_string(), + )]); + + let first = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "idempotent-provenance".to_string(), + policy: Some(policy.clone()), + annotations: annotations.clone(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + let second = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "idempotent-provenance".to_string(), + policy: Some(policy), + annotations: annotations.clone(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(first.version, 1); + assert_eq!(second.version, 1); + assert_eq!(second.annotations, annotations); + let revisions = state + .store + .list_policies("sb-idempotent-provenance", 10, 0) + .await + .unwrap(); + assert_eq!(revisions.len(), 1); + assert_eq!(revisions[0].provenance, annotations); + } + + #[tokio::test] + async fn update_config_full_policy_empty_annotations_preserves_existing_annotations() { + let state = test_server_state().await; + let mut baseline = test_policy_with_rule("sandbox_only", "old.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut baseline); + let mut sandbox = test_sandbox( + "sb-preserve-full", + "preserve-full", + baseline.clone(), + Vec::new(), + ); + sandbox.metadata.as_mut().unwrap().annotations.insert( + "openshell.nvidia.com/policy-signature".to_string(), + "keep".to_string(), + ); + state.store.put_message(&sandbox).await.unwrap(); + state + .store + .put_policy_revision( + "policy-preserve-full-v1", + "sb-preserve-full", + 1, + &baseline.encode_to_vec(), + &deterministic_policy_hash(&baseline), + ) + .await + .unwrap(); + + let mut updated = test_policy_with_rule("sandbox_only", "new.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut updated); + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "preserve-full".to_string(), + policy: Some(updated), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + let stored = state + .store + .get_message_by_name::("preserve-full") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + } + + #[tokio::test] + async fn update_config_merge_empty_annotations_preserves_existing_annotations() { + let state = test_server_state().await; + let mut baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut baseline); + let mut sandbox = test_sandbox("sb-preserve-merge", "preserve-merge", baseline, Vec::new()); + sandbox.metadata.as_mut().unwrap().annotations.insert( + "openshell.nvidia.com/policy-provenance".to_string(), + "keep".to_string(), + ); + state.store.put_message(&sandbox).await.unwrap(); + + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "preserve-merge".to_string(), + merge_operations: vec![PolicyMergeOperation { + operation: Some(policy_merge_operation::Operation::AddRule( + openshell_core::proto::AddNetworkRule { + rule_name: "allow_api_example".to_string(), + rule: Some(NetworkPolicyRule { + name: "allow_api_example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }, + )), + }], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-provenance") + .map(String::as_str), + Some("keep") + ); + let stored = state + .store + .get_message_by_name::("preserve-merge") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-provenance") + .map(String::as_str), + Some("keep") + ); + } + + #[tokio::test] + async fn update_config_merge_stores_revision_provenance_atomically() { + let state = test_server_state().await; + let mut baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com"); + openshell_policy::ensure_sandbox_process_identity(&mut baseline); + state + .store + .put_message(&test_sandbox( + "sb-merge-provenance", + "merge-provenance", + baseline, + Vec::new(), + )) + .await + .unwrap(); + let provenance = HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "merge-signature".to_string(), + )]); + + let response = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "merge-provenance".to_string(), + merge_operations: vec![PolicyMergeOperation { + operation: Some(policy_merge_operation::Operation::AddRule( + openshell_core::proto::AddNetworkRule { + rule_name: "allow_api_example".to_string(), + rule: Some(NetworkPolicyRule { + name: "allow_api_example".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }, + )), + }], + annotations: provenance.clone(), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.version, 1); + assert_eq!(response.annotations, provenance); + let revision = state + .store + .get_latest_policy("sb-merge-provenance") + .await + .unwrap() + .unwrap(); + assert_eq!(revision.provenance, provenance); + } + + #[tokio::test] + async fn update_config_backfill_empty_annotations_preserves_existing_annotations() { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + + let state = test_server_state().await; + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-preserve-backfill".to_string(), + name: "preserve-backfill".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "keep".to_string(), + )]), + }), + spec: Some(SandboxSpec { + policy: None, + providers: Vec::new(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Provisioning as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let current = state + .store + .get_message_by_name::("preserve-backfill") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + + let response = handle_update_config( + &state, + Request::new(UpdateConfigRequest { + name: "preserve-backfill".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + expected_resource_version: current_version, + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + response + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + let stored = state + .store + .get_message_by_name::("preserve-backfill") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .metadata + .as_ref() + .unwrap() + .annotations + .get("openshell.nvidia.com/policy-signature") + .map(String::as_str), + Some("keep") + ); + assert!( + stored.spec.as_ref().unwrap().policy.is_some(), + "policy should still be backfilled" + ); + } + + #[tokio::test] + async fn update_config_global_rejects_annotations() { + let state = test_server_state().await; + let err = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + global: true, + setting_key: settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::StringValue("auto".to_string())), + }), + annotations: HashMap::from([( + "openshell.nvidia.com/policy-signature".to_string(), + "global".to_string(), + )]), + ..Default::default() + })), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("sandbox-scoped")); + } + + #[tokio::test] + async fn update_config_rejects_invalid_annotations() { + let state = test_server_state().await; + state + .store + .put_message(&test_sandbox( + "sb-invalid-annotation", + "invalid-annotation", + ProtoSandboxPolicy::default(), + Vec::new(), + )) + .await + .unwrap(); + + let err = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "invalid-annotation".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + annotations: HashMap::from([("bad key".to_string(), "value".to_string())]), + ..Default::default() + })), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("label key")); + } + #[tokio::test] async fn update_config_user_policy_rejects_reserved_provider_key() { let state = test_server_state().await; @@ -10143,6 +11180,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10246,6 +11284,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10280,6 +11319,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: 99, // stale version + annotations: HashMap::new(), }), ) .await @@ -10310,6 +11350,15 @@ mod tests { unchanged.spec.as_ref().unwrap().policy.is_none(), "policy should still be None after failed backfill" ); + assert!( + state + .store + .get_latest_policy("sb-1") + .await + .unwrap() + .is_none(), + "failed backfill must not leave an orphan revision" + ); } #[tokio::test] @@ -10327,6 +11376,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { policy: None, @@ -10365,6 +11415,7 @@ mod tests { global: false, merge_operations: vec![], expected_resource_version: initial_version, + annotations: HashMap::new(), }), ) .await @@ -10409,5 +11460,15 @@ mod tests { final_sandbox.spec.as_ref().unwrap().policy.is_some(), "policy should be backfilled after one success" ); + assert_eq!( + state + .store + .list_policies("sb-1", 10, 0) + .await + .unwrap() + .len(), + 1, + "concurrent backfills must create exactly one revision" + ); } } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c909..aa4b3ab195 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -8,6 +8,10 @@ use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; +use crate::provider_profile_sources::{ + EffectiveProviderProfileCatalog, ProviderProfileSources, profile_response_payload, + profile_storage_payload, stored_profile_resource_version, stored_provider_profile, +}; use openshell_core::proto::{ Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCredential, Sandbox, @@ -63,8 +67,20 @@ impl ProviderEnvironment { } } +#[cfg(test)] pub(super) async fn create_provider_record( store: &Store, + provider: Provider, +) -> Result { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + create_provider_record_with_catalog(store, &catalog, provider).await +} + +pub(super) async fn create_provider_record_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, mut provider: Provider, ) -> Result { use crate::persistence::{ObjectName, current_time_ms}; @@ -78,6 +94,7 @@ pub(super) async fn create_provider_record( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }); } @@ -98,7 +115,7 @@ pub(super) async fn create_provider_record( return Err(Status::invalid_argument("provider.type is required")); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(store, &provider.r#type).await? + && !provider_type_allows_empty_credentials(catalog, &provider.r#type) { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -173,9 +190,21 @@ pub(super) async fn list_provider_records( .collect()) } +#[cfg(test)] pub(super) async fn update_provider_record( store: &Store, provider: Provider, +) -> Result { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + update_provider_record_with_catalog(store, &catalog, provider).await +} + +pub(super) async fn update_provider_record_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider: Provider, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -230,7 +259,8 @@ pub(super) async fn update_provider_record( // #1347. super::validation::validate_object_metadata(candidate.metadata.as_ref(), "provider")?; validate_provider_mutable_fields(&candidate)?; - validate_provider_update_against_attached_sandboxes(store, &candidate).await?; + validate_provider_update_against_attached_sandboxes_with_catalog(store, catalog, &candidate) + .await?; // Serialize labels for storage let labels_map = candidate.object_labels(); @@ -431,9 +461,21 @@ fn merge_i64_map( /// collects credential key-value pairs. Returns a map of environment variables /// to inject into the sandbox. Credential keys must be unique across attached /// providers so one provider cannot silently overwrite another provider's token. +#[cfg(test)] pub(super) async fn resolve_provider_environment( store: &Store, provider_names: &[String], +) -> Result { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + resolve_provider_environment_with_catalog(store, &catalog, provider_names).await +} + +pub(super) async fn resolve_provider_environment_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider_names: &[String], ) -> Result { if provider_names.is_empty() { return Ok(ProviderEnvironment::default()); @@ -442,7 +484,8 @@ pub(super) async fn resolve_provider_environment( let mut env = std::collections::HashMap::new(); let mut expires = std::collections::HashMap::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at(store, provider_names, None, now_ms).await?; + validate_provider_environment_keys_unique_at(store, catalog, provider_names, None, now_ms) + .await?; let registry = openshell_providers::ProviderRegistry::new(); for name in provider_names { @@ -495,7 +538,12 @@ pub(super) async fn resolve_provider_environment( Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials(store, provider_names).await?, + dynamic_credentials: resolve_dynamic_credentials_with_catalog( + store, + catalog, + provider_names, + ) + .await?, }) } @@ -504,8 +552,9 @@ pub(super) async fn resolve_provider_environment( /// Returns a map of endpoint-bound keys to credential metadata for credentials /// that have `token_grant` configuration. Keys are internal supervisor metadata: /// host, port, endpoint path, and provider credential identity. -pub(super) async fn resolve_dynamic_credentials( +pub(super) async fn resolve_dynamic_credentials_with_catalog( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], ) -> Result, Status> { if provider_names.is_empty() { @@ -527,7 +576,7 @@ pub(super) async fn resolve_dynamic_credentials( let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { + let Some(profile) = get_provider_type_profile_with_catalog(catalog, profile_id) else { continue; }; @@ -817,9 +866,21 @@ fn endpoint_path_matches(pattern: &str, path: &str) -> bool { pub async fn validate_provider_environment_keys_unique( store: &Store, provider_names: &[String], +) -> Result<(), Status> { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + validate_provider_environment_keys_unique_with_catalog(store, &catalog, provider_names).await +} + +pub async fn validate_provider_environment_keys_unique_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider_names: &[String], ) -> Result<(), Status> { validate_provider_environment_keys_unique_at( store, + catalog, provider_names, None, crate::persistence::current_time_ms(), @@ -827,8 +888,9 @@ pub async fn validate_provider_environment_keys_unique( .await } -pub async fn validate_provider_credential_key_available_for_attached_sandboxes( +pub async fn validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, credential_key: &str, ) -> Result<(), Status> { @@ -838,12 +900,25 @@ pub async fn validate_provider_credential_key_available_for_attached_sandboxes( .entry(credential_key.to_string()) .or_insert_with(|| "pending".to_string()); candidate.credential_expires_at_ms.remove(credential_key); - validate_provider_update_against_attached_sandboxes(store, &candidate).await + validate_provider_update_against_attached_sandboxes_with_catalog(store, catalog, &candidate) + .await } pub async fn validate_provider_update_against_attached_sandboxes( store: &Store, provider: &Provider, +) -> Result<(), Status> { + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(store) + .await?; + validate_provider_update_against_attached_sandboxes_with_catalog(store, &catalog, provider) + .await +} + +pub async fn validate_provider_update_against_attached_sandboxes_with_catalog( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, ) -> Result<(), Status> { let provider_name = provider.object_name().to_string(); for sandbox in sandboxes_using_provider_records(store, &provider_name).await? { @@ -853,6 +928,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( }; validate_provider_environment_keys_unique_at( store, + catalog, &spec.providers, Some(provider), crate::persistence::current_time_ms(), @@ -870,6 +946,7 @@ pub async fn validate_provider_update_against_attached_sandboxes( async fn validate_provider_environment_keys_unique_at( store: &Store, + catalog: &EffectiveProviderProfileCatalog, provider_names: &[String], candidate_provider: Option<&Provider>, now_ms: i64, @@ -899,7 +976,9 @@ async fn validate_provider_environment_keys_unique_at( seen.insert(key, provider_name.clone()); } } - dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, &provider, + )); } validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; Ok(()) @@ -915,19 +994,16 @@ struct DynamicTokenGrantBinding { score: u32, } -async fn dynamic_token_grant_bindings_for_provider( - store: &Store, +fn dynamic_token_grant_bindings_for_provider_with_catalog( + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, -) -> Result, Status> { +) -> Vec { let provider_name = provider.object_name().to_string(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = get_provider_type_profile(store, profile_id).await? else { - return Ok(Vec::new()); + let Some(profile) = get_provider_type_profile_with_catalog(catalog, profile_id) else { + return Vec::new(); }; - Ok(dynamic_token_grant_bindings_for_profile( - &provider_name, - &profile.to_proto(), - )) + dynamic_token_grant_bindings_for_profile(&provider_name, &profile.to_proto()) } fn dynamic_token_grant_bindings_for_profile( @@ -1141,8 +1217,8 @@ use openshell_core::proto::{ UpdateProviderProfilesResponse, UpdateProviderRequest, }; use openshell_providers::{ - CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, default_profiles, - get_default_profile, normalize_profile_id, normalize_provider_type, validate_profile_set, + CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, + normalize_profile_id, normalize_provider_type, validate_profile_set, }; use std::sync::Arc; use tonic::{Request, Response}; @@ -1161,7 +1237,12 @@ pub(super) async fn handle_create_provider( return Err(Status::invalid_argument("provider is required")); }; let provider_type = provider.r#type.clone(); - let result = create_provider_record(state.store.as_ref(), provider).await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let result = + create_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -1207,12 +1288,6 @@ pub(super) async fn handle_list_providers( Ok(Response::new(ListProvidersResponse { providers })) } -impl ObjectType for StoredProviderProfile { - fn object_type() -> &'static str { - "provider_profile" - } -} - pub(super) async fn handle_list_provider_profiles( state: &Arc, request: Request, @@ -1220,13 +1295,15 @@ pub(super) async fn handle_list_provider_profiles( let request = request.into_inner(); let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; - let mut profiles = merged_provider_profiles(state.store.as_ref()).await?; - profiles.sort_by(|left, right| left.id.cmp(&right.id)); - let profiles = profiles + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let profiles = catalog + .list_profiles() .into_iter() .skip(offset) .take(limit) - .map(|profile| profile.to_proto()) .collect(); Ok(Response::new(ListProviderProfilesResponse { profiles })) @@ -1238,10 +1315,13 @@ pub(super) async fn handle_get_provider_profile( ) -> Result, Status> { let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; - let profile = get_provider_type_profile(state.store.as_ref(), &id) - .await? - .ok_or_else(|| Status::not_found("provider profile not found"))? - .to_proto(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let profile = catalog + .get_profile(&id) + .ok_or_else(|| Status::not_found("provider profile not found"))?; Ok(Response::new(ProviderProfileResponse { profile: Some(profile), @@ -1256,11 +1336,22 @@ pub(super) async fn handle_import_provider_profiles( let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "import").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &catalog, + &profiles, + "import", + ) + .await?, ); } @@ -1313,8 +1404,14 @@ pub(super) async fn handle_update_provider_profiles( let (profiles, mut diagnostics) = profiles_from_import_items(&items); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); let target_id = normalize_profile_id_request(&request.id)?; + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; diagnostics.extend( - profile_update_target_diagnostics(state.store.as_ref(), &profiles, &target_id).await?, + profile_update_target_diagnostics(state.store.as_ref(), &catalog, &profiles, &target_id) + .await?, ); diagnostics.extend(validate_profile_set(&profiles)); let expected_resource_version = if request.expected_resource_version != 0 { @@ -1335,14 +1432,15 @@ pub(super) async fn handle_update_provider_profiles( severity: "error".to_string(), }); } - let _sandbox_sync_guard = if has_errors(&diagnostics) { - None - } else { - Some(state.compute.sandbox_sync_guard().await) - }; if !has_errors(&diagnostics) { diagnostics.extend( - profile_attached_sandbox_diagnostics(state.store.as_ref(), &profiles, "update").await?, + profile_attached_sandbox_diagnostics( + state.store.as_ref(), + &catalog, + &profiles, + "update", + ) + .await?, ); } @@ -1416,7 +1514,12 @@ pub(super) async fn handle_lint_provider_profiles( let request = request.into_inner(); let (profiles, mut diagnostics) = profiles_from_import_items(&request.profiles); add_empty_profile_set_diagnostic(&profiles, &mut diagnostics); - diagnostics.extend(profile_conflict_diagnostics(state.store.as_ref(), &profiles).await?); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + diagnostics + .extend(profile_conflict_diagnostics(state.store.as_ref(), &catalog, &profiles).await?); diagnostics.extend(validate_profile_set(&profiles)); let valid = !has_errors(&diagnostics); @@ -1432,13 +1535,17 @@ pub(super) async fn handle_delete_provider_profile( ) -> Result, Status> { let id = request.into_inner().id; let id = normalize_profile_id_request(&id)?; - if get_default_profile(&id).is_some() { - return Err(Status::failed_precondition( - "built-in provider profiles cannot be deleted", - )); + let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + if let Some(source_id) = catalog.static_source_for_profile(&id) { + return Err(Status::failed_precondition(format!( + "provider profile '{id}' is managed by source '{source_id}' and cannot be deleted" + ))); } - let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let existing = state .store .get_message_by_name::(&id) @@ -1465,41 +1572,20 @@ pub(super) async fn handle_delete_provider_profile( Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) async fn get_provider_type_profile( - store: &Store, +pub(super) fn get_provider_type_profile_with_catalog( + catalog: &EffectiveProviderProfileCatalog, id: &str, -) -> Result, Status> { - let Some(id) = normalize_profile_id(id) else { - return Ok(None); - }; - if let Some(profile) = get_default_profile(&id) { - return Ok(Some(profile.clone())); - } - let profile = store - .get_message_by_name::(&id) - .await - .map_err(|e| Status::internal(format!("fetch provider profile failed: {e}")))? - .and_then(|stored| { - let resource_version = stored_profile_resource_version(&stored); - stored.profile.map(|profile| { - ProviderTypeProfile::from_proto(&profile_response_payload( - profile, - resource_version, - )) - }) - }); - Ok(profile) +) -> Option { + catalog.get_type_profile(id) } -async fn provider_refresh_defaults( - store: &Store, +fn provider_refresh_defaults( + catalog: &EffectiveProviderProfileCatalog, provider: &Provider, credential_key: &str, -) -> Result, Status> { - let Some(profile) = get_provider_type_profile(store, &provider.r#type).await? else { - return Ok(None); - }; - Ok(profile +) -> Option { + let profile = get_provider_type_profile_with_catalog(catalog, &provider.r#type)?; + profile .credentials .iter() .find(|credential| { @@ -1509,7 +1595,7 @@ async fn provider_refresh_defaults( .iter() .any(|env_var| env_var == credential_key) }) - .and_then(|credential| credential.refresh.clone())) + .and_then(|credential| credential.refresh.clone()) } fn validate_refresh_material( @@ -1537,41 +1623,14 @@ fn validate_refresh_material( Ok(()) } -async fn provider_type_allows_empty_credentials( - store: &Store, +fn provider_type_allows_empty_credentials( + catalog: &EffectiveProviderProfileCatalog, provider_type: &str, -) -> Result { - let Some(profile) = get_provider_type_profile(store, provider_type).await? else { - return Ok(false); +) -> bool { + let Some(profile) = get_provider_type_profile_with_catalog(catalog, provider_type) else { + return false; }; - Ok(profile.allows_empty_provider_credentials()) -} - -async fn merged_provider_profiles(store: &Store) -> Result, Status> { - let mut profiles = default_profiles().to_vec(); - profiles.extend( - custom_provider_profiles(store) - .await? - .into_iter() - .filter_map(|stored| { - let resource_version = stored_profile_resource_version(&stored); - stored.profile.map(|profile| { - ProviderTypeProfile::from_proto(&profile_response_payload( - profile, - resource_version, - )) - }) - }), - ); - Ok(profiles) -} - -async fn custom_provider_profiles(store: &Store) -> Result, Status> { - let profiles: Vec = store - .list_messages(10_000, 0) - .await - .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; - Ok(profiles) + profile.allows_empty_provider_credentials() } fn normalize_profile_id_request(id: &str) -> Result { @@ -1625,6 +1684,7 @@ fn add_empty_profile_set_diagnostic( async fn profile_conflict_diagnostics( store: &Store, + catalog: &EffectiveProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], ) -> Result, Status> { let mut diagnostics = Vec::new(); @@ -1632,12 +1692,14 @@ async fn profile_conflict_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if get_default_profile(&id).is_some() { + if let Some(source_id) = catalog.static_source_for_profile(&id) { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), field: "id".to_string(), - message: format!("provider profile '{id}' is built-in and cannot be overwritten"), + message: format!( + "provider profile '{id}' is managed by source '{source_id}' and cannot be overwritten" + ), severity: "error".to_string(), }); continue; @@ -1662,6 +1724,7 @@ async fn profile_conflict_diagnostics( async fn profile_update_target_diagnostics( store: &Store, + catalog: &EffectiveProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], target_id: &str, ) -> Result, Status> { @@ -1682,12 +1745,14 @@ async fn profile_update_target_diagnostics( }); } } - if get_default_profile(target_id).is_some() { + if let Some(source_id) = catalog.static_source_for_profile(target_id) { diagnostics.push(ProfileValidationDiagnostic { source: target_id.to_string(), profile_id: target_id.to_string(), field: "id".to_string(), - message: format!("provider profile '{target_id}' is built-in and cannot be updated"), + message: format!( + "provider profile '{target_id}' is managed by source '{source_id}' and cannot be updated" + ), severity: "error".to_string(), }); return Ok(diagnostics); @@ -1710,12 +1775,14 @@ async fn profile_update_target_diagnostics( let Some(id) = normalize_profile_id(&profile.id) else { continue; }; - if get_default_profile(&id).is_some() { + if let Some(source_id) = catalog.static_source_for_profile(&id) { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: id.clone(), field: "id".to_string(), - message: format!("provider profile '{id}' is built-in and cannot be updated"), + message: format!( + "provider profile '{id}' is managed by source '{source_id}' and cannot be updated" + ), severity: "error".to_string(), }); } @@ -1725,6 +1792,7 @@ async fn profile_update_target_diagnostics( async fn profile_attached_sandbox_diagnostics( store: &Store, + catalog: &EffectiveProviderProfileCatalog, profiles: &[(String, ProviderTypeProfile)], operation: &str, ) -> Result, Status> { @@ -1775,7 +1843,9 @@ async fn profile_attached_sandbox_diagnostics( imported_profiles_used.push(used); } } else { - bindings.extend(dynamic_token_grant_bindings_for_provider(store, &provider).await?); + bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, &provider, + )); } } @@ -1801,42 +1871,6 @@ async fn profile_attached_sandbox_diagnostics( Ok(diagnostics) } -fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { - use crate::persistence::current_time_ms; - let now_ms = current_time_ms(); - let profile = profile_storage_payload(profile); - StoredProviderProfile { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: uuid::Uuid::new_v4().to_string(), - name: profile.id.clone(), - created_at_ms: now_ms, - labels: std::collections::HashMap::new(), - resource_version: 0, - }), - profile: Some(profile), - } -} - -fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { - profile.resource_version = 0; - profile -} - -fn profile_response_payload( - mut profile: ProviderProfile, - resource_version: u64, -) -> ProviderProfile { - profile.resource_version = resource_version; - profile -} - -fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { - stored - .metadata - .as_ref() - .map_or(0, |metadata| metadata.resource_version) -} - fn proto_diagnostic(diagnostic: ProfileValidationDiagnostic) -> ProviderProfileDiagnostic { ProviderProfileDiagnostic { source: diagnostic.source, @@ -1904,7 +1938,12 @@ pub(super) async fn handle_update_provider( provider .credential_expires_at_ms .extend(req.credential_expires_at_ms); - let result = update_provider_record(state.store.as_ref(), provider).await; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + let result = + update_provider_record_with_catalog(state.store.as_ref(), &catalog, provider).await; match result { Ok(provider) => { emit_provider_lifecycle( @@ -2058,14 +2097,18 @@ pub(super) async fn handle_configure_provider_refresh( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - validate_provider_credential_key_available_for_attached_sandboxes( + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; + validate_provider_credential_key_available_for_attached_sandboxes_with_catalog( state.store.as_ref(), + &catalog, &provider, credential_key, ) .await?; - let refresh_defaults = - provider_refresh_defaults(state.store.as_ref(), &provider, credential_key).await?; + let refresh_defaults = provider_refresh_defaults(&catalog, &provider, credential_key); validate_refresh_material(&request.material, refresh_defaults.as_ref())?; let material_scopes = crate::provider_refresh::material_scopes(&request.material); let token_url = refresh_defaults @@ -2146,6 +2189,7 @@ pub(super) async fn handle_configure_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2155,7 +2199,7 @@ pub(super) async fn handle_configure_provider_refresh( expires_at_ms, )]), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; } Ok(Response::new(ConfigureProviderRefreshResponse { @@ -2234,6 +2278,10 @@ pub(super) async fn handle_delete_provider_refresh( .is_some_and(|expires_at_ms| *expires_at_ms == refresh_state.expires_at_ms) }); if refresh_owned_expiry { + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await?; let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2241,6 +2289,7 @@ pub(super) async fn handle_delete_provider_refresh( created_at_ms: 0, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: String::new(), credentials: std::collections::HashMap::new(), @@ -2250,7 +2299,7 @@ pub(super) async fn handle_delete_provider_refresh( 0, )]), }; - update_provider_record(state.store.as_ref(), updated).await?; + update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated).await?; } Ok(Response::new(DeleteProviderRefreshResponse { @@ -2453,6 +2502,7 @@ mod tests { let profile = ProviderProfile { id: "keycloak-sso".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Keycloak SSO".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -2526,6 +2576,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), @@ -2603,6 +2654,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2766,7 +2818,7 @@ mod tests { assert!(built_in.diagnostics.iter().any(|diagnostic| { diagnostic .message - .contains("built-in and cannot be updated") + .contains("managed by source 'builtin' and cannot be updated") })); let missing = handle_update_provider_profiles( @@ -2918,6 +2970,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec![ @@ -2980,6 +3033,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: [ @@ -3002,6 +3056,7 @@ mod tests { ProviderProfile { id: id.to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: format!("{id} Profile"), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -3269,7 +3324,7 @@ mod tests { response .diagnostics .iter() - .any(|diagnostic| diagnostic.message.contains("built-in")) + .any(|diagnostic| diagnostic.message.contains("managed by source 'builtin'")) ); } @@ -3441,6 +3496,7 @@ mod tests { profile: Some(ProviderProfile { id: "advanced-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Advanced API".to_string(), description: String::new(), category: ProviderProfileCategory::Other as i32, @@ -3589,6 +3645,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["custom-provider".to_string()], @@ -3624,6 +3681,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3724,6 +3782,139 @@ mod tests { ); } + async fn state_with_authoritative_profiles_over_default_grants() -> Arc { + let state = test_server_state().await; + let store = state.store.as_ref(); + import_token_grant_profile(&state, "grant-a", "api.example.com", 443, "/v1/**").await; + import_token_grant_profile(&state, "grant-b", "api.example.com", 443, "/v1/**").await; + create_empty_token_grant_provider(store, "provider-a", "grant-a").await; + create_empty_token_grant_provider(store, "provider-b", "grant-b").await; + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-authoritative-profiles-id".to_string(), + name: "sandbox-authoritative-profiles".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + }), + spec: Some(SandboxSpec { + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let mut state = Arc::into_inner(state).expect("test server state should be uniquely owned"); + state.provider_profile_sources = ProviderProfileSources::from_test_profiles(vec![ + custom_profile("grant-a"), + custom_profile("grant-b"), + ]); + Arc::new(state) + } + + #[tokio::test] + async fn configure_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { + let state = state_with_authoritative_profiles_over_default_grants().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + + handle_configure_provider_refresh( + &state, + Request::new(ConfigureProviderRefreshRequest { + provider: "provider-a".to_string(), + credential_key: "REFRESH_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms: Some(expires_at_ms), + }), + ) + .await + .expect("authoritative profiles should govern the provider expiry update"); + + let provider = state + .store + .get_message_by_name::("provider-a") + .await + .unwrap() + .expect("provider-a"); + assert_eq!( + provider.credential_expires_at_ms.get("REFRESH_TOKEN"), + Some(&expires_at_ms) + ); + } + + #[tokio::test] + async fn delete_provider_refresh_uses_authoritative_profile_sources_for_expiry_update() { + let state = state_with_authoritative_profiles_over_default_grants().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + let updated = Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "provider-a".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + }), + r#type: String::new(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), + }; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref()) + .await + .unwrap(); + let provider = update_provider_record_with_catalog(state.store.as_ref(), &catalog, updated) + .await + .unwrap(); + let refresh_state = crate::provider_refresh::new_refresh_state( + &provider, + "REFRESH_TOKEN", + crate::provider_refresh::NewRefreshStateConfig { + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials, + material: HashMap::new(), + secret_material_keys: Vec::new(), + expires_at_ms, + token_url: String::new(), + scopes: Vec::new(), + refresh_before_seconds: 0, + max_lifetime_seconds: 0, + }, + ) + .unwrap(); + crate::provider_refresh::put_refresh_state(state.store.as_ref(), &refresh_state) + .await + .unwrap(); + + handle_delete_provider_refresh( + &state, + Request::new(DeleteProviderRefreshRequest { + provider: "provider-a".to_string(), + credential_key: "REFRESH_TOKEN".to_string(), + }), + ) + .await + .expect("authoritative profiles should govern the provider expiry update"); + + let provider = state + .store + .get_message_by_name::("provider-a") + .await + .unwrap() + .expect("provider-a"); + assert!( + !provider + .credential_expires_at_ms + .contains_key("REFRESH_TOKEN") + ); + } + #[tokio::test] async fn configure_provider_refresh_accepts_vertex_service_account_token_key() { let state = test_server_state().await; @@ -3736,6 +3927,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -3799,6 +3991,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3842,6 +4035,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -3894,6 +4088,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -3916,6 +4111,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) @@ -3935,6 +4131,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -3986,6 +4183,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), @@ -4005,6 +4203,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -4071,6 +4270,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -4138,6 +4338,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -4289,6 +4490,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -4411,6 +4613,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -4455,6 +4658,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4484,6 +4688,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(( @@ -4518,6 +4723,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4538,6 +4744,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: HashMap::new(), @@ -4556,6 +4763,7 @@ mod tests { profile: Some(ProviderProfile { id: "delegated-refresh-api".to_string(), resource_version: 0, + annotations: HashMap::new(), display_name: "Delegated Refresh API".to_string(), description: String::new(), category: ProviderProfileCategory::Messaging as i32, @@ -4612,6 +4820,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), @@ -4648,6 +4857,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), @@ -4684,6 +4894,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), @@ -4704,6 +4915,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), @@ -4730,6 +4942,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4758,6 +4971,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4805,6 +5019,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), @@ -4856,6 +5071,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: HashMap::new(), @@ -4885,6 +5101,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: HashMap::new(), @@ -4916,6 +5133,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), @@ -4944,6 +5162,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), @@ -4961,6 +5180,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) @@ -4992,6 +5212,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: [ @@ -5046,6 +5267,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5095,6 +5317,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "test".to_string(), credentials: [ @@ -5129,6 +5352,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5151,6 +5375,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) @@ -5184,6 +5409,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) @@ -5203,6 +5429,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -5241,6 +5468,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5315,6 +5543,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5359,6 +5588,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -5407,6 +5637,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -5454,6 +5685,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -5492,6 +5724,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -5514,6 +5747,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -5534,6 +5768,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -5552,6 +5787,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), credentials: std::iter::once(( @@ -5586,6 +5822,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -5607,6 +5844,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -5643,6 +5881,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec::default()), status: None, @@ -5690,6 +5929,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), @@ -6037,6 +6277,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), @@ -6139,6 +6380,7 @@ mod tests { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: "github".to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 542fee97c0..8e2f6d25c5 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -133,6 +133,7 @@ async fn handle_create_sandbox_inner( crate::grpc::validation::validate_label_key(key)?; crate::grpc::validation::validate_label_value(value)?; } + crate::grpc::validation::validate_annotations(&request.annotations, "annotations")?; let _sandbox_sync_guard = if spec.providers.is_empty() { None @@ -182,6 +183,7 @@ async fn handle_create_sandbox_inner( created_at_ms: now_ms, labels: request.labels.clone(), resource_version: 0, + annotations: request.annotations.clone(), }), spec: Some(spec), status: None, @@ -1361,6 +1363,7 @@ pub(super) async fn handle_create_ssh_session( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), @@ -2306,6 +2309,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) @@ -2323,6 +2327,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(openshell_core::proto::SandboxSpec { log_level: "debug".to_string(), @@ -2693,6 +2698,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2725,6 +2731,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await @@ -2735,6 +2742,73 @@ mod tests { assert!(err.message().contains("reserved '_provider_' prefix")); } + #[tokio::test] + async fn create_sandbox_persists_long_metadata_annotations() { + let state = test_server_state().await; + let annotation_key = "openshell.nvidia.com/policy-signature".to_string(); + let annotation_value = "x".repeat(512); + + let response = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "annotated".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: HashMap::new(), + annotations: HashMap::from([(annotation_key.clone(), annotation_value.clone())]), + }), + ) + .await + .expect("long annotations should be accepted") + .into_inner(); + + let created = response.sandbox.expect("created sandbox"); + assert_eq!( + created + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(&annotation_key)), + Some(&annotation_value) + ); + + let fetched = handle_get_sandbox( + &state, + Request::new(GetSandboxRequest { + name: "annotated".to_string(), + }), + ) + .await + .expect("created sandbox should be fetchable") + .into_inner() + .sandbox + .expect("fetched sandbox"); + assert_eq!( + fetched + .metadata + .as_ref() + .and_then(|metadata| metadata.annotations.get(&annotation_key)), + Some(&annotation_value) + ); + } + + #[tokio::test] + async fn create_sandbox_still_rejects_long_label_values() { + let state = test_server_state().await; + let err = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "bad-label".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: HashMap::from([("team".to_string(), "x".repeat(512))]), + annotations: HashMap::new(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("label value exceeds")); + } + #[tokio::test] async fn create_sandbox_with_providers_waits_for_sandbox_sync_guard() { let state = test_server_state().await; @@ -2756,6 +2830,7 @@ mod tests { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }), ) .await diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 246d639bef..01d8dbfe8e 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -87,6 +87,7 @@ pub(super) async fn handle_expose_service( created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -286,6 +287,7 @@ mod tests { created_at_ms: 1_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index d8c02e7500..8f4242af17 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -15,10 +15,10 @@ use prost::Message; use tonic::Status; use super::{ - MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_NAME_LEN, - MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, MAX_PROVIDER_CREDENTIALS_ENTRIES, - MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, - MAX_TEMPLATE_STRUCT_SIZE, + MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, + MAX_METADATA_ANNOTATIONS_ENTRIES, MAX_NAME_LEN, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES, + MAX_PROVIDER_CREDENTIALS_ENTRIES, MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS, + MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, MAX_TEMPLATE_STRUCT_SIZE, }; // --------------------------------------------------------------------------- @@ -263,6 +263,28 @@ pub(super) fn validate_string_map( Ok(()) } +/// Validate object annotations. +/// +/// Annotation keys use the same qualified-key shape as labels. Annotation +/// values are opaque metadata and use the normal string-map size limits rather +/// than Kubernetes label value limits. +pub(super) fn validate_annotations( + annotations: &std::collections::HashMap, + field_name: &str, +) -> Result<(), Status> { + validate_string_map( + annotations, + MAX_METADATA_ANNOTATIONS_ENTRIES, + MAX_MAP_KEY_LEN, + MAX_MAP_VALUE_LEN, + field_name, + )?; + for key in annotations.keys() { + validate_label_key(key)?; + } + Ok(()) +} + /// OPENSHELL_* keys that are allowed in exec environment. The Python SDK's /// `exec_python()` sends a serialized callable via this key. const EXEC_ALLOWED_OPENSHELL_KEYS: &[&str] = &["OPENSHELL_PYFUNC_B64"]; @@ -622,6 +644,11 @@ pub(super) fn validate_object_metadata( validate_label_value(value)?; } + validate_annotations( + &metadata.annotations, + &format!("{resource_type}.metadata.annotations"), + )?; + Ok(()) } @@ -1153,6 +1180,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials, diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 43416c35d1..2b58bd15cd 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -211,6 +211,7 @@ async fn upsert_cluster_inference_route( created_at_ms: now_ms, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }); (new_id, new_metadata, 1, WriteCondition::MustCreate) }; @@ -1030,6 +1031,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), config: Some(ClusterInferenceConfig { provider_name: provider_name.to_string(), @@ -1048,6 +1050,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), @@ -1145,6 +1148,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), // Placeholder credential — the router ignores it because @@ -1221,6 +1225,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), credentials: std::iter::once(( @@ -1269,6 +1274,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "aws-bedrock".to_string(), credentials: std::collections::HashMap::new(), @@ -1494,6 +1500,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) @@ -1517,6 +1524,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), config: Some(ClusterInferenceConfig { provider_name: "openai-dev".to_string(), @@ -1628,6 +1636,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -1965,6 +1974,7 @@ mod tests { created_at_ms: 1_000_000, labels: std::collections::HashMap::new(), resource_version: 1, + annotations: std::collections::HashMap::new(), }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6462ccbbf3..d40364523c 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -35,6 +35,7 @@ mod inference; mod multiplex; mod persistence; pub(crate) mod policy_store; +mod provider_profile_sources; mod provider_refresh; mod readiness; mod sandbox_index; @@ -147,6 +148,14 @@ pub struct ServerState { /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + + /// Immutable gateway interceptor execution plan. `None` when disabled. + pub(crate) gateway_interceptors: + Option, + + /// Gateway-local provider profile sources. User-imported profiles are read + /// on demand when the user source is configured. + pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -197,6 +206,9 @@ impl ServerState { sandbox_jwt_authenticator: None, k8s_sa_authenticator: None, grpc_rate_limiter, + gateway_interceptors: None, + provider_profile_sources: + provider_profile_sources::ProviderProfileSources::with_default_sources(), } } } @@ -263,6 +275,25 @@ pub(crate) async fn run_server( supervisor_sessions.clone(), ) .await?; + let gateway_interceptors = + openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()) + .await + .map_err(|e| { + Error::config(format!("gateway interceptor initialization failed: {e}")) + })?; + let provider_profile_sources = provider_profile_sources::ProviderProfileSources::from_config( + &config.provider_profile_sources, + gateway_interceptors.as_ref(), + ) + .map_err(|err| { + Error::config(format!( + "provider profile source configuration failed: {err}" + )) + })?; + info!( + sources = ?provider_profile_sources.source_ids(), + "provider profile sources configured" + ); let mut state = ServerState::new( config.clone(), store.clone(), @@ -273,6 +304,8 @@ pub(crate) async fn run_server( supervisor_sessions, oidc_cache, ); + state.gateway_interceptors = gateway_interceptors; + state.provider_profile_sources = provider_profile_sources; // Load the gateway-minted sandbox JWT signing key when configured. // Optional so single-driver dev deployments without certgen continue diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 9e70c64721..5c1f9dad4f 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -6,10 +6,10 @@ //! This module implements connection-level multiplexing that routes requests //! to either the gRPC service or HTTP endpoints based on the request headers. -use bytes::Bytes; -use http::{HeaderValue, Request, Response}; +use bytes::{Bytes, BytesMut}; +use http::{Extensions, HeaderValue, Request, Response}; use http_body::Body; -use http_body_util::BodyExt; +use http_body_util::{BodyExt, Full, LengthLimitError, Limited, StreamBody}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo, TokioTimer}, @@ -21,6 +21,9 @@ use openshell_core::Config; use openshell_core::proto::{ inference_server::InferenceServer, open_shell_server::OpenShellServer, }; +use openshell_gateway_interceptors::{EvaluationContext, GatewayInterceptorRuntime}; +use std::collections::BTreeMap; +use std::convert::Infallible; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; @@ -29,7 +32,7 @@ use std::time::{Duration, Instant}; use tokio::io::{AsyncRead, AsyncWrite}; use tower::ServiceExt; use tower_http::request_id::{MakeRequestId, RequestId}; -use tracing::Span; +use tracing::{Span, warn}; use crate::{ OpenShellService, ServerState, @@ -120,6 +123,7 @@ macro_rules! request_id_middleware { /// bound memory allocation from a single request. Sandbox creation is /// the largest payload and well within this cap under normal use. const MAX_GRPC_DECODE_SIZE: usize = 1_048_576; +const MAX_INTERCEPTED_GRPC_BODY_SIZE: usize = MAX_GRPC_DECODE_SIZE + 5; /// Multiplexed gRPC/HTTP service. #[derive(Clone)] @@ -154,6 +158,8 @@ impl MultiplexService { { let openshell = OpenShellServer::new(OpenShellService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); + let openshell = + GatewayInterceptorGrpcService::new(openshell, self.state.gateway_interceptors.clone()); let inference = InferenceServer::new(InferenceService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { @@ -223,6 +229,268 @@ impl MultiplexService { } } +/// `OpenShell` gRPC wrapper that applies configured gateway interceptors before +/// tonic dispatches to a specific RPC handler. +#[derive(Clone)] +struct GatewayInterceptorGrpcService { + inner: S, + interceptors: Option, +} + +impl GatewayInterceptorGrpcService { + fn new(inner: S, interceptors: Option) -> Self { + Self { + inner, + interceptors, + } + } +} + +impl tower::Service> for GatewayInterceptorGrpcService +where + S: tower::Service, Response = Response> + + Clone + + Send + + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let interceptors = self.interceptors.clone(); + let mut inner = self.inner.clone(); + + Box::pin(async move { + let Some(interceptors) = interceptors else { + return inner.ready().await?.call(req).await; + }; + + let path = req.uri().path().to_string(); + if !interceptors.should_intercept_path(&path) { + return inner.ready().await?.call(req).await; + } + + let context = gateway_interceptor_context(req.extensions()); + let (parts, body) = req.into_parts(); + let body = match collect_intercepted_grpc_body(body).await { + Ok(body) => body, + Err(status) => return Ok(status.into_http()), + }; + + let intercepted = match interceptors.evaluate_request(&path, &body, &context).await { + Ok(intercepted) => intercepted, + Err(status) => return Ok(status.into_http()), + }; + + let req = Request::from_parts( + parts, + boxed_body_from_bytes(Bytes::from(intercepted.body.clone())), + ); + let response = inner.ready().await?.call(req).await?; + + if grpc_status_from_response(&response) != "0" + || !interceptors.has_post_commit(&intercepted) + { + return Ok(response); + } + + let (response, observation) = observe_intercepted_grpc_response(response).await; + let (response_body, trailers) = match observation { + Ok(observation) => observation, + Err(error) => { + warn!( + error = %error, + "gateway post-commit response observation failed; preserving committed response" + ); + counter!( + "openshell_gateway_interceptor_post_commit_observation_failures_total", + "stage" => "response_body" + ) + .increment(1); + return Ok(response); + } + }; + if grpc_status_from_response_and_trailers(&response, trailers.as_ref()) == "0" + && let Err(status) = interceptors + .evaluate_post_commit(&intercepted, &response_body, &context) + .await + { + warn!( + error = %status, + "gateway post-commit evaluation failed; preserving committed response" + ); + counter!( + "openshell_gateway_interceptor_post_commit_observation_failures_total", + "stage" => "evaluation" + ) + .increment(1); + } + + Ok(response) + }) + } +} + +async fn collect_intercepted_grpc_body(body: BoxBody) -> Result { + Limited::new(body, MAX_INTERCEPTED_GRPC_BODY_SIZE) + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .map_err(|err| { + if err.downcast_ref::().is_some() { + tonic::Status::resource_exhausted(format!( + "gRPC request body exceeds interceptor evaluation limit of {MAX_INTERCEPTED_GRPC_BODY_SIZE} bytes" + )) + } else { + tonic::Status::internal(format!( + "failed to read gRPC request body for interceptor evaluation: {err}" + )) + } + }) +} + +fn boxed_body_from_bytes(bytes: Bytes) -> BoxBody { + let body = Full::new(bytes) + .map_err(|never: Infallible| -> Box { match never {} }) + .boxed_unsync(); + BoxBody(body) +} + +async fn observe_intercepted_grpc_response( + response: Response, +) -> ( + Response, + Result<(Bytes, Option), String>, +) { + let (parts, mut body) = response.into_parts(); + let mut frames = Vec::new(); + let mut bytes = BytesMut::new(); + let mut trailers = None; + + while let Some(frame) = body.frame().await { + match frame { + Ok(frame) => { + if let Some(data) = frame.data_ref() { + bytes.extend_from_slice(data); + } + if let Some(frame_trailers) = frame.trailers_ref() { + trailers = Some(frame_trailers.clone()); + } + frames.push(Ok(frame)); + } + Err(status) => { + let error = + format!("failed to read gRPC response for post-commit evaluation: {status}"); + frames.push(Err(status)); + return ( + Response::from_parts(parts, tonic_body_from_frames(frames)), + Err(error), + ); + } + } + } + + ( + Response::from_parts(parts, tonic_body_from_frames(frames)), + Ok((bytes.freeze(), trailers)), + ) +} + +#[cfg(test)] +fn tonic_body_from_bytes_and_trailers( + bytes: Bytes, + trailers: Option, +) -> tonic::body::Body { + let mut frames: Vec, tonic::Status>> = Vec::with_capacity(2); + if !bytes.is_empty() { + frames.push(Ok(http_body::Frame::data(bytes))); + } + if let Some(trailers) = trailers { + frames.push(Ok(http_body::Frame::trailers(trailers))); + } + tonic_body_from_frames(frames) +} + +fn tonic_body_from_frames( + frames: Vec, tonic::Status>>, +) -> tonic::body::Body { + tonic::body::Body::new(StreamBody::new(futures::stream::iter(frames))) +} + +fn gateway_interceptor_context(extensions: &Extensions) -> EvaluationContext { + EvaluationContext { + principal: extensions + .get::() + .map_or_else(unknown_gateway_principal, gateway_principal_fields), + validate_current_state: None, + } +} + +fn gateway_principal_fields(principal: &Principal) -> BTreeMap { + use crate::auth::principal::SandboxIdentitySource; + + let mut fields = BTreeMap::new(); + match principal { + Principal::User(user) => { + fields.insert("kind".to_string(), "user".to_string()); + fields.insert("subject".to_string(), user.identity.subject.clone()); + if let Some(display_name) = &user.identity.display_name { + fields.insert("display_name".to_string(), display_name.clone()); + } + fields.insert( + "provider".to_string(), + identity_provider_name(user.identity.provider).to_string(), + ); + if !user.identity.roles.is_empty() { + fields.insert("roles".to_string(), user.identity.roles.join(",")); + } + if !user.identity.scopes.is_empty() { + fields.insert("scopes".to_string(), user.identity.scopes.join(",")); + } + } + Principal::Sandbox(sandbox) => { + fields.insert("kind".to_string(), "sandbox".to_string()); + fields.insert("sandbox_id".to_string(), sandbox.sandbox_id.clone()); + fields.insert( + "source".to_string(), + match &sandbox.source { + SandboxIdentitySource::BootstrapJwt { .. } => "bootstrap_jwt", + SandboxIdentitySource::BootstrapCert { .. } => "bootstrap_cert", + SandboxIdentitySource::K8sServiceAccount { .. } => "k8s_service_account", + } + .to_string(), + ); + if let Some(trust_domain) = &sandbox.trust_domain { + fields.insert("trust_domain".to_string(), trust_domain.clone()); + } + } + Principal::Anonymous => { + fields.insert("kind".to_string(), "anonymous".to_string()); + } + } + fields +} + +fn unknown_gateway_principal() -> BTreeMap { + BTreeMap::from([("kind".to_string(), "unknown".to_string())]) +} + +fn identity_provider_name(provider: crate::auth::identity::IdentityProvider) -> &'static str { + match provider { + crate::auth::identity::IdentityProvider::Oidc => "oidc", + crate::auth::identity::IdentityProvider::Mtls => "mtls", + crate::auth::identity::IdentityProvider::CloudflareAccess => "cloudflare_access", + crate::auth::identity::IdentityProvider::LocalDev => "local_dev", + } +} + #[derive(Clone, Debug)] pub struct GrpcRateLimiter { requests: u64, @@ -731,6 +999,17 @@ fn grpc_status_from_response(res: &Response) -> String { .map_or_else(|| "0".to_string(), ToString::to_string) } +fn grpc_status_from_response_and_trailers( + res: &Response, + trailers: Option<&http::HeaderMap>, +) -> String { + trailers + .and_then(|trailers| trailers.get("grpc-status")) + .or_else(|| res.headers().get("grpc-status")) + .and_then(|value| value.to_str().ok()) + .map_or_else(|| "0".to_string(), ToString::to_string) +} + fn normalize_http_path(path: &str) -> &'static str { match path { p if p.starts_with("/_ws_tunnel") => "/_ws_tunnel", @@ -806,10 +1085,95 @@ mod tests { use super::*; use bytes::Bytes; use http_body_util::Empty; + use openshell_core::GatewayInterceptorConfig; + use openshell_core::proto::CreateSandboxRequest; + use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorManifest, InterceptorResult, InterceptorSelector, ProviderProfileSnapshot, + ProviderProfileSnapshotRequest, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, + }; + use prost::Message as _; + use std::convert::Infallible; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio_stream::wrappers::TcpListenerStream; use tower::Service; + #[derive(Clone)] + struct PostCommitTestInterceptor; + + #[tonic::async_trait] + impl GatewayInterceptor for PostCommitTestInterceptor { + async fn describe( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(InterceptorManifest { + name: "post-commit-test".to_string(), + failure_policy: "fail_open".to_string(), + bindings: vec![InterceptorBinding { + id: "audit-create-sandbox".to_string(), + selector: Some(InterceptorSelector { + rpc: "openshell.v1.OpenShell/CreateSandbox".to_string(), + service: String::new(), + method: String::new(), + }), + phases: vec![GatewayInterceptorPhase::PostCommit as i32], + failure_policy: "fail_open".to_string(), + }], + provider_profiles: false, + })) + } + + async fn evaluate( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(InterceptorResult { + allowed: true, + ..InterceptorResult::default() + })) + } + + async fn snapshot_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("not a profile source")) + } + } + + fn grpc_frame(message: &[u8]) -> Bytes { + let mut frame = Vec::with_capacity(5 + message.len()); + frame.push(0); + frame.extend_from_slice(&u32::try_from(message.len()).unwrap().to_be_bytes()); + frame.extend_from_slice(message); + Bytes::from(frame) + } + + async fn post_commit_test_runtime() -> (GatewayInterceptorRuntime, tokio::task::JoinHandle<()>) + { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(GatewayInterceptorServer::new(PostCommitTestInterceptor)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let runtime = openshell_gateway_interceptors::initialize(vec![GatewayInterceptorConfig { + name: "post-commit-test".to_string(), + grpc_endpoint: format!("http://{address}"), + ..GatewayInterceptorConfig::default() + }]) + .await + .unwrap() + .unwrap(); + (runtime, task) + } + #[test] fn uuid_request_id_generates_valid_uuid() { let mut maker = UuidRequestId; @@ -886,6 +1250,113 @@ mod tests { sender.send_request(req).await.unwrap() } + #[tokio::test] + async fn intercepted_grpc_body_collection_rejects_oversized_body() { + let oversized = Bytes::from(vec![0_u8; MAX_INTERCEPTED_GRPC_BODY_SIZE + 1]); + let status = collect_intercepted_grpc_body(boxed_body_from_bytes(oversized)) + .await + .expect_err("oversized body should be rejected"); + + assert_eq!(status.code(), tonic::Code::ResourceExhausted); + } + + #[tokio::test] + async fn intercepted_grpc_response_preserves_body_and_trailers() { + let bytes = Bytes::from_static(b"committed-response"); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", HeaderValue::from_static("0")); + let response = Response::new(tonic_body_from_bytes_and_trailers( + bytes.clone(), + Some(trailers.clone()), + )); + + let (response, observation) = observe_intercepted_grpc_response(response).await; + let (observed, observed_trailers) = observation.unwrap(); + + assert_eq!(observed, bytes); + assert_eq!(observed_trailers.as_ref(), Some(&trailers)); + assert_eq!( + grpc_status_from_response_and_trailers(&response, observed_trailers.as_ref()), + "0" + ); + + let collected = response.into_body().collect().await.unwrap(); + assert_eq!(collected.trailers(), Some(&trailers)); + assert_eq!(collected.to_bytes(), bytes); + } + + #[tokio::test] + async fn intercepted_grpc_response_preserves_body_error() { + let bytes = Bytes::from_static(b"committed-response-prefix"); + let response = Response::new(tonic_body_from_frames(vec![ + Ok(http_body::Frame::data(bytes.clone())), + Err(tonic::Status::unavailable("response stream failed")), + ])); + + let (response, observation) = observe_intercepted_grpc_response(response).await; + + let observation_error = observation.unwrap_err(); + assert!(observation_error.contains("failed to read gRPC response")); + assert!(observation_error.contains("response stream failed")); + let mut body = response.into_body(); + let data = body.frame().await.unwrap().unwrap(); + assert_eq!(data.data_ref(), Some(&bytes)); + let status = body.frame().await.unwrap().unwrap_err(); + assert_eq!(status.code(), tonic::Code::Unavailable); + assert_eq!(status.message(), "response stream failed"); + assert!(body.frame().await.is_none()); + } + + #[tokio::test] + async fn post_commit_decode_failure_preserves_committed_response() { + let (runtime, interceptor_task) = post_commit_test_runtime().await; + let committed = Arc::new(AtomicUsize::new(0)); + let committed_for_service = committed.clone(); + let committed_body = Bytes::from_static(b"malformed committed gRPC response"); + let committed_body_for_service = committed_body.clone(); + let inner = tower::service_fn(move |_request: Request| { + let committed = committed_for_service.clone(); + let body = committed_body_for_service.clone(); + async move { + committed.fetch_add(1, Ordering::SeqCst); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", HeaderValue::from_static("0")); + Ok::<_, Infallible>(Response::new(tonic_body_from_bytes_and_trailers( + body, + Some(trailers), + ))) + } + }); + let mut service = GatewayInterceptorGrpcService::new(inner, Some(runtime)); + let request_body = grpc_frame(&CreateSandboxRequest::default().encode_to_vec()); + let request = Request::builder() + .uri("/openshell.v1.OpenShell/CreateSandbox") + .body(boxed_body_from_bytes(request_body)) + .unwrap(); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + + assert_eq!(committed.load(Ordering::SeqCst), 1); + let collected = response.into_body().collect().await.unwrap(); + assert_eq!(collected.to_bytes(), committed_body); + interceptor_task.abort(); + } + + #[test] + fn grpc_trailer_status_takes_precedence_over_headers() { + let response = Response::builder() + .header("grpc-status", "0") + .body(()) + .unwrap(); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", HeaderValue::from_static("7")); + + assert_eq!( + grpc_status_from_response_and_trailers(&response, Some(&trailers)), + "7" + ); + } + #[tokio::test] async fn http_response_includes_request_id() { let addr = start_http_server_with_middleware().await; @@ -963,7 +1434,7 @@ mod tests { impl Service> for CountingGrpcService { type Response = Response; - type Error = std::convert::Infallible; + type Error = Infallible; type Future = std::future::Ready>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { @@ -995,7 +1466,7 @@ mod tests { impl Service> for PendingInnerService { type Response = Response; - type Error = std::convert::Infallible; + type Error = Infallible; type Future = std::future::Ready>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { @@ -1376,7 +1847,7 @@ mod tests { impl Service> for PrincipalRecorder { type Response = Response; - type Error = std::convert::Infallible; + type Error = Infallible; type Future = Pin> + Send>>; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index bb32a8efd6..e3f9e5a7b3 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -6,9 +6,13 @@ use super::{ WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ - draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record, - policy_record_from_parts, + AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, + policy_payload_from_record, policy_record_for_atomic_write, policy_record_from_parts, + project_policy_revision_onto_sandbox, }; +use openshell_core::SetResourceVersion; +use openshell_core::proto::Sandbox; +use prost::Message; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool, Row}; @@ -447,6 +451,7 @@ LIMIT $3 OFFSET $4 load_error: None, created_at_ms: now_ms, loaded_at_ms: None, + provenance: std::collections::HashMap::default(), }; let wrapped_payload = policy_payload_from_record(&record)?; @@ -471,6 +476,104 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) Ok(()) } + pub async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult { + let now_ms = current_time_ms(); + let record = policy_record_for_atomic_write(write, now_ms); + let wrapped_payload = policy_payload_from_record(&record)?; + let mut tx = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + + let row = sqlx::query( + r" +SELECT payload, resource_version +FROM objects +WHERE object_type = 'sandbox' AND id = $1 +FOR UPDATE +", + ) + .bind(&write.sandbox_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| map_db_error(&e))? + .ok_or_else(|| { + PersistenceError::Database(format!("sandbox object {} not found", write.sandbox_id)) + })?; + + let sandbox_payload: Vec = row.get("payload"); + let current_version: i64 = row.try_get("resource_version").unwrap_or(1); + let current_version = current_version.max(1).cast_unsigned(); + let (mut sandbox, sandbox_changed) = + project_policy_revision_onto_sandbox(write, &sandbox_payload, current_version)?; + + let resulting_version = if sandbox_changed { + let result = sqlx::query( + r" +UPDATE objects +SET payload = $2, updated_at_ms = $3, resource_version = resource_version + 1 +WHERE object_type = 'sandbox' AND id = $1 AND resource_version = $4 +", + ) + .bind(&write.sandbox_id) + .bind(sandbox.encode_to_vec()) + .bind(now_ms) + .bind(i64::try_from(current_version).unwrap_or(i64::MAX)) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + if result.rows_affected() != 1 { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_version), + }); + } + current_version.saturating_add(1) + } else { + current_version + }; + + sqlx::query( + r" +INSERT INTO objects ( + object_type, id, scope, version, status, payload, created_at_ms, updated_at_ms +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $7) +", + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.id) + .bind(&write.sandbox_id) + .bind(write.version) + .bind("pending") + .bind(wrapped_payload) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + sqlx::query( + r" +UPDATE objects +SET status = 'superseded', updated_at_ms = $4 +WHERE object_type = $1 + AND scope = $2 + AND version < $3 + AND status IN ('pending', 'loaded') +", + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.sandbox_id) + .bind(write.version) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + sandbox.set_resource_version(resulting_version); + Ok(sandbox) + } + pub async fn get_latest_policy( &self, sandbox_id: &str, diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index efb6a9bf45..0b1f50df6a 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -6,10 +6,14 @@ use super::{ WriteCondition, WriteResult, current_time_ms, map_db_error, map_migrate_error, }; use crate::policy_store::{ - draft_chunk_payload_from_record, draft_chunk_record_from_parts, policy_payload_from_record, - policy_record_from_parts, + AtomicPolicyRevisionWrite, draft_chunk_payload_from_record, draft_chunk_record_from_parts, + policy_payload_from_record, policy_record_for_atomic_write, policy_record_from_parts, + project_policy_revision_onto_sandbox, }; +use openshell_core::SetResourceVersion; use openshell_core::paths::set_file_owner_only; +use openshell_core::proto::Sandbox; +use prost::Message; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Connection, Row, SqlitePool}; use std::path::{Path, PathBuf}; @@ -468,6 +472,7 @@ LIMIT ?3 OFFSET ?4 load_error: None, created_at_ms: now_ms, loaded_at_ms: None, + provenance: std::collections::HashMap::default(), }; let wrapped_payload = policy_payload_from_record(&record)?; @@ -492,6 +497,107 @@ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) Ok(()) } + pub async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult { + let now_ms = current_time_ms(); + let record = policy_record_for_atomic_write(write, now_ms); + let wrapped_payload = policy_payload_from_record(&record)?; + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|e| map_db_error(&e))?; + + let row = sqlx::query( + r#" +SELECT "payload", "resource_version" +FROM "objects" +WHERE "object_type" = 'sandbox' AND "id" = ?1 +"#, + ) + .bind(&write.sandbox_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| map_db_error(&e))? + .ok_or_else(|| { + PersistenceError::Database(format!("sandbox object {} not found", write.sandbox_id)) + })?; + + let sandbox_payload: Vec = row.get("payload"); + let current_version: i64 = row.try_get("resource_version").unwrap_or(1); + let current_version = current_version.max(1).cast_unsigned(); + let (mut sandbox, sandbox_changed) = + project_policy_revision_onto_sandbox(write, &sandbox_payload, current_version)?; + + let resulting_version = if sandbox_changed { + let result = sqlx::query( + r#" +UPDATE "objects" +SET "payload" = ?2, "updated_at_ms" = ?3, "resource_version" = "resource_version" + 1 +WHERE "object_type" = 'sandbox' AND "id" = ?1 AND "resource_version" = ?4 +"#, + ) + .bind(&write.sandbox_id) + .bind(sandbox.encode_to_vec()) + .bind(now_ms) + .bind(i64::try_from(current_version).unwrap_or(i64::MAX)) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + if result.rows_affected() != 1 { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_version), + }); + } + current_version.saturating_add(1) + } else { + current_version + }; + + sqlx::query( + r#" +INSERT INTO "objects" ( + "object_type", "id", "scope", "version", "status", "payload", "created_at_ms", "updated_at_ms" +) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) +"#, + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.id) + .bind(&write.sandbox_id) + .bind(write.version) + .bind("pending") + .bind(wrapped_payload) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + sqlx::query( + r#" +UPDATE "objects" +SET "status" = 'superseded', "updated_at_ms" = ?4 +WHERE "object_type" = ?1 + AND "scope" = ?2 + AND "version" < ?3 + AND "status" IN ('pending', 'loaded') +"#, + ) + .bind(POLICY_OBJECT_TYPE) + .bind(&write.sandbox_id) + .bind(write.version) + .bind(now_ms) + .execute(&mut *tx) + .await + .map_err(|e| map_db_error(&e))?; + + tx.commit().await.map_err(|e| map_db_error(&e))?; + sandbox.set_resource_version(resulting_version); + Ok(sandbox) + } + pub async fn get_latest_policy( &self, sandbox_id: &str, diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index d092b68de0..c92f63827a 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use super::{ObjectType, PersistenceError, Store, generate_name, test_store}; -use crate::policy_store::PolicyStoreExt; -use openshell_core::proto::{ObjectForTest, SandboxPolicy}; +use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; +use openshell_core::proto::datamodel::v1::ObjectMeta as ProtoObjectMeta; +use openshell_core::proto::{ObjectForTest, Sandbox, SandboxPolicy, SandboxSpec}; use prost::Message; +use std::collections::HashMap as StdHashMap; #[tokio::test] async fn sqlite_put_get_round_trip() { @@ -607,6 +609,134 @@ async fn empty_labels_not_matched_by_selector() { // Policy revision tests // --------------------------------------------------------------------------- +fn policy_test_sandbox(id: &str, name: &str) -> Sandbox { + Sandbox { + metadata: Some(ProtoObjectMeta { + id: id.to_string(), + name: name.to_string(), + created_at_ms: 1, + ..Default::default() + }), + spec: Some(SandboxSpec::default()), + ..Default::default() + } +} + +#[tokio::test] +async fn policy_atomic_write_commits_revision_provenance_and_sandbox_projection() { + let store = test_store().await; + store + .put_message(&policy_test_sandbox("sandbox-atomic", "atomic")) + .await + .unwrap(); + let current = store + .get_message::("sandbox-atomic") + .await + .unwrap() + .unwrap(); + let current_version = current.metadata.as_ref().unwrap().resource_version; + let policy = SandboxPolicy { + version: 1, + ..Default::default() + }; + let provenance = StdHashMap::from([("signature".to_string(), "signed".to_string())]); + + let updated = store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: "policy-atomic-1".to_string(), + sandbox_id: "sandbox-atomic".to_string(), + version: 1, + policy_payload: policy.encode_to_vec(), + policy_hash: "hash-atomic-1".to_string(), + provenance: provenance.clone(), + expected_resource_version: current_version, + annotations: provenance.clone(), + backfill_policy: Some(policy.clone()), + }) + .await + .unwrap(); + + assert_eq!( + updated.metadata.as_ref().unwrap().resource_version, + current_version + 1 + ); + assert_eq!(updated.metadata.as_ref().unwrap().annotations, provenance); + assert_eq!( + updated.spec.as_ref().unwrap().policy.as_ref(), + Some(&policy) + ); + let revision = store + .get_latest_policy("sandbox-atomic") + .await + .unwrap() + .unwrap(); + assert_eq!(revision.provenance, provenance); + store + .update_policy_status("sandbox-atomic", 1, "loaded", None, Some(100)) + .await + .unwrap(); + let loaded = store + .get_latest_policy("sandbox-atomic") + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.provenance, provenance); +} + +#[tokio::test] +async fn policy_atomic_write_rolls_back_sandbox_when_revision_insert_conflicts() { + let store = test_store().await; + store + .put_message(&policy_test_sandbox("sandbox-rollback", "rollback")) + .await + .unwrap(); + let policy = SandboxPolicy::default(); + store + .put_policy_revision( + "existing-policy", + "sandbox-rollback", + 1, + &policy.encode_to_vec(), + "existing-hash", + ) + .await + .unwrap(); + let before = store + .get_message::("sandbox-rollback") + .await + .unwrap() + .unwrap(); + let before_version = before.metadata.as_ref().unwrap().resource_version; + + let error = store + .put_policy_revision_atomic(&AtomicPolicyRevisionWrite { + id: "conflicting-policy".to_string(), + sandbox_id: "sandbox-rollback".to_string(), + version: 1, + policy_payload: policy.encode_to_vec(), + policy_hash: "conflicting-hash".to_string(), + provenance: StdHashMap::from([("signature".to_string(), "new".to_string())]), + expected_resource_version: before_version, + annotations: StdHashMap::from([("signature".to_string(), "new".to_string())]), + backfill_policy: Some(policy), + }) + .await + .unwrap_err(); + assert!(error.is_unique_violation_on("objects_version_uq")); + + let after = store + .get_message::("sandbox-rollback") + .await + .unwrap() + .unwrap(); + assert_eq!( + after.metadata.as_ref().unwrap().resource_version, + before_version + ); + assert!(after.metadata.as_ref().unwrap().annotations.is_empty()); + assert!(after.spec.as_ref().unwrap().policy.is_none()); +} + #[tokio::test] async fn policy_put_and_get_latest() { let store = test_store().await; @@ -1243,6 +1373,7 @@ async fn cas_update_message_cas_succeeds() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status: None, @@ -1282,6 +1413,7 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { created_at_ms: 1000, labels: std::collections::HashMap::new(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), spec: None, status: None, diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 5a647704c2..7e3b7856a4 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -1,12 +1,98 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::persistence::{DraftChunkRecord, PersistenceResult, PolicyRecord, Store}; +use crate::persistence::{ + DraftChunkRecord, PersistenceError, PersistenceResult, PolicyRecord, SetResourceVersion, Store, +}; use openshell_core::proto::{ - DraftChunkPayload, NetworkPolicyRule, PolicyRevisionPayload, + DraftChunkPayload, NetworkPolicyRule, PolicyRevisionPayload, Sandbox, SandboxPolicy as ProtoSandboxPolicy, }; use prost::Message; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct AtomicPolicyRevisionWrite { + pub id: String, + pub sandbox_id: String, + pub version: i64, + pub policy_payload: Vec, + pub policy_hash: String, + pub provenance: HashMap, + pub expected_resource_version: u64, + pub annotations: HashMap, + pub backfill_policy: Option, +} + +pub fn policy_record_for_atomic_write( + write: &AtomicPolicyRevisionWrite, + created_at_ms: i64, +) -> PolicyRecord { + PolicyRecord { + id: write.id.clone(), + sandbox_id: write.sandbox_id.clone(), + version: write.version, + policy_payload: write.policy_payload.clone(), + policy_hash: write.policy_hash.clone(), + status: "pending".to_string(), + load_error: None, + created_at_ms, + loaded_at_ms: None, + provenance: write.provenance.clone(), + } +} + +pub fn project_policy_revision_onto_sandbox( + write: &AtomicPolicyRevisionWrite, + payload: &[u8], + current_resource_version: u64, +) -> PersistenceResult<(Sandbox, bool)> { + if write.expected_resource_version != 0 + && write.expected_resource_version != current_resource_version + { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_resource_version), + }); + } + + let mut sandbox = Sandbox::decode(payload) + .map_err(|e| PersistenceError::Decode(format!("decode sandbox payload failed: {e}")))?; + sandbox.set_resource_version(current_resource_version); + + let mut changed = false; + if let Some(backfill_policy) = write.backfill_policy.as_ref() { + let spec = sandbox + .spec + .as_mut() + .ok_or_else(|| PersistenceError::Decode("sandbox payload missing spec".to_string()))?; + match spec.policy.as_ref() { + None => { + spec.policy = Some(backfill_policy.clone()); + changed = true; + } + Some(current) if current == backfill_policy => {} + Some(_) => { + return Err(PersistenceError::Conflict { + current_resource_version: Some(current_resource_version), + }); + } + } + } + + if !write.annotations.is_empty() { + let metadata = sandbox.metadata.as_mut().ok_or_else(|| { + PersistenceError::Decode("sandbox payload missing metadata".to_string()) + })?; + for (key, value) in &write.annotations { + if metadata.annotations.get(key) != Some(value) { + metadata.annotations.insert(key.clone(), value.clone()); + changed = true; + } + } + } + + Ok((sandbox, changed)) +} pub trait PolicyStoreExt { async fn put_policy_revision( @@ -18,6 +104,13 @@ pub trait PolicyStoreExt { hash: &str, ) -> PersistenceResult<()>; + /// Atomically create a sandbox policy revision, project its annotations + /// onto sandbox metadata, and optionally backfill `spec.policy`. + async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult; + async fn get_latest_policy(&self, sandbox_id: &str) -> PersistenceResult>; #[allow(dead_code)] @@ -135,6 +228,16 @@ impl PolicyStoreExt for Store { } } + async fn put_policy_revision_atomic( + &self, + write: &AtomicPolicyRevisionWrite, + ) -> PersistenceResult { + match self { + Self::Postgres(store) => store.put_policy_revision_atomic(write).await, + Self::Sqlite(store) => store.put_policy_revision_atomic(write).await, + } + } + async fn get_latest_policy(&self, sandbox_id: &str) -> PersistenceResult> { match self { Self::Postgres(store) => store.get_latest_policy(sandbox_id).await, @@ -313,14 +416,14 @@ impl PolicyStoreExt for Store { } pub fn policy_payload_from_record(record: &PolicyRecord) -> PersistenceResult> { - let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!("decode policy payload failed: {e}")) - })?; + let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) + .map_err(|e| PersistenceError::Decode(format!("decode policy payload failed: {e}")))?; Ok(PolicyRevisionPayload { policy: Some(policy), hash: record.policy_hash.clone(), load_error: record.load_error.clone().unwrap_or_default(), loaded_at_ms: record.loaded_at_ms.unwrap_or(0), + provenance: record.provenance.clone(), } .encode_to_vec()) } @@ -333,12 +436,11 @@ pub fn policy_record_from_parts( payload: &[u8], created_at_ms: i64, ) -> PersistenceResult { - let wrapper = PolicyRevisionPayload::decode(payload).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!("decode policy wrapper failed: {e}")) - })?; - let policy = wrapper.policy.ok_or_else(|| { - crate::persistence::PersistenceError::Decode("policy wrapper missing policy".to_string()) - })?; + let wrapper = PolicyRevisionPayload::decode(payload) + .map_err(|e| PersistenceError::Decode(format!("decode policy wrapper failed: {e}")))?; + let policy = wrapper + .policy + .ok_or_else(|| PersistenceError::Decode("policy wrapper missing policy".to_string()))?; Ok(PolicyRecord { id, sandbox_id, @@ -353,6 +455,7 @@ pub fn policy_record_from_parts( }, created_at_ms, loaded_at_ms: (wrapper.loaded_at_ms > 0).then_some(wrapper.loaded_at_ms), + provenance: wrapper.provenance, }) } @@ -371,11 +474,8 @@ pub fn draft_chunk_payload_from_record(chunk: &DraftChunkRecord) -> PersistenceR None } else { Some( - NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!( - "decode draft rule failed: {e}" - )) - })?, + NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()) + .map_err(|e| PersistenceError::Decode(format!("decode draft rule failed: {e}")))?, ) }; Ok(DraftChunkPayload { @@ -405,11 +505,8 @@ pub fn draft_chunk_record_from_parts( created_at_ms: i64, updated_at_ms: i64, ) -> PersistenceResult { - let wrapper = DraftChunkPayload::decode(payload).map_err(|e| { - crate::persistence::PersistenceError::Decode(format!( - "decode draft chunk wrapper failed: {e}" - )) - })?; + let wrapper = DraftChunkPayload::decode(payload) + .map_err(|e| PersistenceError::Decode(format!("decode draft chunk wrapper failed: {e}")))?; let proposed_rule = wrapper .proposed_rule .map(|rule| rule.encode_to_vec()) diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs new file mode 100644 index 0000000000..f6882172b3 --- /dev/null +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -0,0 +1,1079 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway-local provider profile sources. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use openshell_core::GatewayProviderProfileSourceConfig; +use openshell_core::proto::{ProviderProfile, StoredProviderProfile}; +use openshell_gateway_interceptors::{ + GatewayInterceptorProfileSource, GatewayInterceptorRuntime, + ProviderProfileSourceSnapshot as InterceptorProfileSnapshot, +}; +use openshell_providers::{ + ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, + validate_profile_set, +}; +use prost::Message as _; +use sha2::{Digest, Sha256}; +use tonic::Status; +use tracing::debug; + +use crate::persistence::{ObjectType, Store}; + +const BUILTIN_SOURCE_ID: &str = "builtin"; +const USER_SOURCE_ID: &str = "user"; + +impl ObjectType for StoredProviderProfile { + fn object_type() -> &'static str { + "provider_profile" + } +} + +#[derive(Debug, Clone)] +pub struct ProviderProfileSnapshot { + revision: String, + profiles: Vec, +} + +#[async_trait] +pub trait ProviderProfileSource: Send + Sync + std::fmt::Debug { + fn source_id(&self) -> &str; + fn user_managed(&self) -> bool; + fn allow_empty(&self) -> bool; + async fn snapshot(&self, store: &Store) -> Result; +} + +#[derive(Debug, Clone, Default)] +struct BuiltinProviderProfileSource; + +#[async_trait] +impl ProviderProfileSource for BuiltinProviderProfileSource { + fn source_id(&self) -> &str { + BUILTIN_SOURCE_ID + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + let profiles = builtin_profiles() + .iter() + .map(ProviderTypeProfile::to_proto) + .collect::>(); + Ok(ProviderProfileSnapshot { + revision: profile_snapshot_revision(&profiles), + profiles, + }) + } +} + +#[derive(Debug, Clone, Default)] +struct UserProviderProfileSource; + +#[async_trait] +impl ProviderProfileSource for UserProviderProfileSource { + fn source_id(&self) -> &str { + USER_SOURCE_ID + } + + fn user_managed(&self) -> bool { + true + } + + fn allow_empty(&self) -> bool { + true + } + + async fn snapshot(&self, store: &Store) -> Result { + let stored = user_provider_profiles(store).await?; + let mut profiles = Vec::new(); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-user-provider-profile-source-v1"); + for stored in stored { + let resource_version = stored_profile_resource_version(&stored); + hasher.update(resource_version.to_le_bytes()); + if let Some(profile) = stored.profile { + let profile = profile_response_payload(profile, resource_version); + hasher.update(profile.encode_to_vec()); + profiles.push(profile); + } + } + Ok(ProviderProfileSnapshot { + revision: format!("sha256:{:x}", hasher.finalize()), + profiles, + }) + } +} + +#[async_trait] +impl ProviderProfileSource for GatewayInterceptorProfileSource { + fn source_id(&self) -> &str { + Self::source_id(self) + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + let InterceptorProfileSnapshot { revision, profiles } = + Self::snapshot(self).await.map_err(|err| { + Status::unavailable(format!( + "provider profile source '{}' snapshot failed: {err}", + self.source_id() + )) + })?; + Ok(ProviderProfileSnapshot { revision, profiles }) + } +} + +#[derive(Debug, Clone)] +pub struct ProviderProfileSources { + sources: Vec>, +} + +#[derive(Debug, Clone)] +struct CollectedProviderProfileSnapshot { + source_id: String, + revision: String, + profiles: Vec, + user_managed: bool, + allow_empty: bool, +} + +#[derive(Debug, Clone)] +struct EffectiveProfileEntry { + source_id: String, + source_revision: String, + user_managed: bool, + profile: ProviderTypeProfile, + response: ProviderProfile, +} + +#[derive(Debug, Clone)] +pub struct EffectiveProviderProfileCatalog { + profiles: BTreeMap, + revision: String, + source_count: usize, +} + +impl ProviderProfileSources { + pub fn with_default_sources() -> Self { + Self { + sources: vec![ + Arc::new(BuiltinProviderProfileSource), + Arc::new(UserProviderProfileSource), + ], + } + } + + pub fn from_config( + configured: &[GatewayProviderProfileSourceConfig], + runtime: Option<&GatewayInterceptorRuntime>, + ) -> Result { + if configured.is_empty() { + return Err("provider_profile_sources must contain at least one source".to_string()); + } + + let mut source_ids = BTreeSet::new(); + let mut sources: Vec> = Vec::with_capacity(configured.len()); + for source in configured { + let source: Arc = match source { + GatewayProviderProfileSourceConfig::Builtin => { + Arc::new(BuiltinProviderProfileSource) + } + GatewayProviderProfileSourceConfig::User => Arc::new(UserProviderProfileSource), + GatewayProviderProfileSourceConfig::Interceptor { name } => { + if name.trim().is_empty() { + return Err("provider profile interceptor source name must not be empty" + .to_string()); + } + let source = runtime + .and_then(|runtime| runtime.provider_profile_source(name)) + .ok_or_else(|| { + format!( + "provider profile source interceptor '{name}' is not configured or does not advertise provider_profiles" + ) + })?; + Arc::new(source) + } + }; + let source_id = source.source_id().to_string(); + if !source_ids.insert(source_id.clone()) { + return Err(format!( + "duplicate provider profile source '{source_id}' in provider_profile_sources" + )); + } + sources.push(source); + } + Ok(Self { sources }) + } + + pub fn source_ids(&self) -> Vec<&str> { + self.sources + .iter() + .map(|source| source.source_id()) + .collect() + } + + #[cfg(test)] + pub(crate) fn from_test_profiles(profiles: Vec) -> Self { + Self { + sources: vec![Arc::new(StaticProviderProfileSource { + snapshot: ProviderProfileSnapshot { + revision: profile_snapshot_revision(&profiles), + profiles, + }, + })], + } + } + + #[cfg(test)] + pub(crate) fn from_test_snapshot_sequence( + snapshots: Vec<(String, Vec)>, + fetch_count: Arc, + ) -> Self { + assert!( + !snapshots.is_empty(), + "test snapshot sequence must not be empty" + ); + Self { + sources: vec![Arc::new(SequencedProviderProfileSource { + snapshots: snapshots + .into_iter() + .map(|(revision, profiles)| ProviderProfileSnapshot { revision, profiles }) + .collect(), + fetch_count, + })], + } + } + + pub(crate) async fn snapshot_catalog( + &self, + store: &Store, + ) -> Result { + let snapshots = self.snapshots(store).await?; + let catalog = build_effective_profiles(snapshots)?; + debug!( + catalog_revision = %catalog.revision(), + source_fetch_count = catalog.source_count(), + profile_count = catalog.profiles.len(), + "captured provider profile catalog snapshot" + ); + Ok(catalog) + } + + async fn snapshots( + &self, + store: &Store, + ) -> Result, Status> { + let mut snapshots = Vec::with_capacity(self.sources.len()); + for source in &self.sources { + let snapshot = source.snapshot(store).await?; + snapshots.push(CollectedProviderProfileSnapshot { + source_id: source.source_id().to_string(), + revision: snapshot.revision, + profiles: snapshot.profiles, + user_managed: source.user_managed(), + allow_empty: source.allow_empty(), + }); + } + Ok(snapshots) + } +} + +impl EffectiveProviderProfileCatalog { + pub(crate) fn revision(&self) -> &str { + &self.revision + } + + pub(crate) fn source_count(&self) -> usize { + self.source_count + } + + pub(crate) fn list_profiles(&self) -> Vec { + self.profiles + .values() + .map(|entry| entry.response.clone()) + .collect() + } + + pub(crate) fn get_profile(&self, id: &str) -> Option { + let id = normalize_profile_id(id)?; + self.profiles.get(&id).map(|entry| entry.response.clone()) + } + + pub(crate) fn get_type_profile(&self, id: &str) -> Option { + let id = normalize_profile_id(id)?; + self.profiles.get(&id).map(|entry| entry.profile.clone()) + } + + pub(crate) fn static_source_for_profile(&self, id: &str) -> Option { + let id = normalize_profile_id(id)?; + self.profiles + .get(&id) + .filter(|entry| !entry.user_managed) + .map(|entry| entry.source_id.clone()) + } + + pub(crate) fn hash_profile_revision(&self, profile_id: &str, hasher: &mut Sha256) { + let Some(profile_id) = normalize_profile_id(profile_id) else { + hasher.update(b"invalid-profile-id"); + return; + }; + + let Some(entry) = self.profiles.get(&profile_id) else { + hasher.update(b"missing"); + return; + }; + + hasher.update(b"provider-profile-source-entry"); + hasher.update(entry.source_id.as_bytes()); + hasher.update(entry.source_revision.as_bytes()); + let ownership_tag: &[u8] = if entry.user_managed { + b"user-managed" + } else { + b"source-managed" + }; + hasher.update(ownership_tag); + hasher.update(entry.response.encode_to_vec()); + } +} + +fn build_effective_profiles( + snapshots: Vec, +) -> Result { + let mut source_ids = BTreeSet::new(); + let mut profiles: BTreeMap = BTreeMap::new(); + let source_count = snapshots.len(); + let mut catalog_hasher = Sha256::new(); + catalog_hasher.update(b"openshell-effective-provider-profile-catalog-v1"); + + for snapshot in snapshots { + let source_id = snapshot.source_id.trim(); + if source_id.is_empty() { + return Err(Status::failed_precondition( + "provider profile source id must not be empty", + )); + } + if !source_ids.insert(source_id.to_string()) { + return Err(Status::failed_precondition(format!( + "duplicate provider profile source id '{source_id}'" + ))); + } + let source_revision = snapshot.revision.trim(); + if source_revision.is_empty() { + return Err(Status::failed_precondition(format!( + "provider profile source '{source_id}' returned an empty revision" + ))); + } + if snapshot.profiles.is_empty() && !snapshot.allow_empty { + return Err(Status::failed_precondition(format!( + "provider profile source '{source_id}' returned no profiles" + ))); + } + + catalog_hasher.update((source_id.len() as u64).to_le_bytes()); + catalog_hasher.update(source_id.as_bytes()); + catalog_hasher.update((source_revision.len() as u64).to_le_bytes()); + catalog_hasher.update(source_revision.as_bytes()); + + let source_profiles = snapshot + .profiles + .iter() + .map(|profile| { + ( + source_id.to_string(), + ProviderTypeProfile::from_proto(profile), + ) + }) + .collect::>(); + validate_source_profiles(source_id, &source_profiles)?; + + for profile in snapshot.profiles { + let id = normalize_profile_id(&profile.id).ok_or_else(|| { + Status::failed_precondition(format!( + "provider profile '{}' in source '{}' has invalid id", + profile.id, source_id + )) + })?; + if let Some(existing) = profiles.get(&id) { + let location = if existing.source_id == source_id { + format!("within source '{source_id}'") + } else { + format!( + "across configured sources '{}' and '{source_id}'", + existing.source_id + ) + }; + return Err(Status::failed_precondition(format!( + "duplicate provider profile id '{id}' {location}" + ))); + } + profiles.insert( + id, + EffectiveProfileEntry { + source_id: source_id.to_string(), + source_revision: source_revision.to_string(), + user_managed: snapshot.user_managed, + profile: ProviderTypeProfile::from_proto(&profile), + response: profile, + }, + ); + } + } + + Ok(EffectiveProviderProfileCatalog { + profiles, + revision: format!("sha256:{:x}", catalog_hasher.finalize()), + source_count, + }) +} + +fn validate_source_profiles( + source_id: &str, + profiles: &[(String, ProviderTypeProfile)], +) -> Result<(), Status> { + let diagnostics = validate_profile_set(profiles); + if let Some(diagnostic) = diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == "error") + { + return Err(Status::failed_precondition(format!( + "provider profile source '{source_id}' is invalid: {}", + format_diagnostic(diagnostic) + ))); + } + Ok(()) +} + +fn format_diagnostic(diagnostic: ProfileValidationDiagnostic) -> String { + if diagnostic.profile_id.is_empty() { + format!("{}: {}", diagnostic.field, diagnostic.message) + } else { + format!( + "provider profile '{}' {}: {}", + diagnostic.profile_id, diagnostic.field, diagnostic.message + ) + } +} + +fn profile_snapshot_revision(profiles: &[ProviderProfile]) -> String { + let mut profiles = profiles.to_vec(); + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let mut hasher = Sha256::new(); + hasher.update(b"openshell-provider-profile-snapshot-v1"); + for profile in profiles { + hasher.update(profile.encode_to_vec()); + } + format!("sha256:{:x}", hasher.finalize()) +} + +pub async fn user_provider_profiles(store: &Store) -> Result, Status> { + let profiles: Vec = store + .list_messages(10_000, 0) + .await + .map_err(|e| Status::internal(format!("list provider profiles failed: {e}")))?; + Ok(profiles) +} + +pub fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfile { + use crate::persistence::current_time_ms; + let now_ms = current_time_ms(); + let profile = profile_storage_payload(profile); + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: profile.id.clone(), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: std::collections::HashMap::new(), + }), + profile: Some(profile), + } +} + +pub fn profile_storage_payload(mut profile: ProviderProfile) -> ProviderProfile { + profile.resource_version = 0; + profile +} + +pub fn profile_response_payload( + mut profile: ProviderProfile, + resource_version: u64, +) -> ProviderProfile { + profile.resource_version = resource_version; + profile +} + +pub fn stored_profile_resource_version(stored: &StoredProviderProfile) -> u64 { + stored + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version) +} + +#[cfg(test)] +#[derive(Debug, Clone)] +struct StaticProviderProfileSource { + snapshot: ProviderProfileSnapshot, +} + +#[cfg(test)] +#[async_trait] +impl ProviderProfileSource for StaticProviderProfileSource { + fn source_id(&self) -> &'static str { + "test" + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + Ok(self.snapshot.clone()) + } +} + +#[cfg(test)] +#[derive(Debug)] +struct SequencedProviderProfileSource { + snapshots: Vec, + fetch_count: Arc, +} + +#[cfg(test)] +#[async_trait] +impl ProviderProfileSource for SequencedProviderProfileSource { + fn source_id(&self) -> &'static str { + "sequenced" + } + + fn user_managed(&self) -> bool { + false + } + + fn allow_empty(&self) -> bool { + false + } + + async fn snapshot(&self, _store: &Store) -> Result { + let index = self + .fetch_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(self.snapshots[index.min(self.snapshots.len() - 1)].clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::GatewayInterceptorConfig; + use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, InterceptorEvaluation, InterceptorManifest, InterceptorResult, + ProviderProfileSnapshot as ProtoProviderProfileSnapshot, ProviderProfileSnapshotRequest, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::{Request, Response}; + + #[derive(Clone)] + struct MockProfileInterceptor { + advertises_profiles: bool, + snapshot: ProtoProviderProfileSnapshot, + } + + #[tonic::async_trait] + impl GatewayInterceptor for MockProfileInterceptor { + async fn describe( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(InterceptorManifest { + name: "mock-profile-source".to_string(), + provider_profiles: self.advertises_profiles, + ..InterceptorManifest::default() + })) + } + + async fn evaluate( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(InterceptorResult { + allowed: true, + ..InterceptorResult::default() + })) + } + + async fn snapshot_provider_profiles( + &self, + _request: Request, + ) -> Result, Status> { + if self.snapshot.revision == "test:unavailable" { + return Err(Status::unavailable("mock profile source unavailable")); + } + Ok(Response::new(self.snapshot.clone())) + } + } + + async fn interceptor_runtime( + snapshot: ProtoProviderProfileSnapshot, + advertises_profiles: bool, + ) -> (GatewayInterceptorRuntime, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(GatewayInterceptorServer::new(MockProfileInterceptor { + advertises_profiles, + snapshot, + })) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let runtime = openshell_gateway_interceptors::initialize(vec![GatewayInterceptorConfig { + name: "governance".to_string(), + grpc_endpoint: format!("http://{address}"), + ..GatewayInterceptorConfig::default() + }]) + .await + .unwrap() + .unwrap(); + (runtime, task) + } + + fn profile(id: &str) -> ProviderProfile { + let mut profile = builtin_profiles() + .iter() + .find(|profile| profile.id == "github") + .expect("github built-in profile") + .clone(); + profile.id = id.to_string(); + profile.display_name = id.to_string(); + profile.to_proto() + } + + #[tokio::test] + async fn captured_catalog_is_immutable_and_each_source_is_fetched_once() { + let mut revision_a = profile("moving-profile"); + revision_a.display_name = "revision-a".to_string(); + let mut revision_b = revision_a.clone(); + revision_b.display_name = "revision-b".to_string(); + let fetch_count = Arc::new(AtomicUsize::new(0)); + let sources = ProviderProfileSources::from_test_snapshot_sequence( + vec![ + ("revision-a".to_string(), vec![revision_a]), + ("revision-b".to_string(), vec![revision_b]), + ], + Arc::clone(&fetch_count), + ); + let store = crate::persistence::test_store().await; + + let first = sources.snapshot_catalog(&store).await.unwrap(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + assert_eq!(first.source_count(), 1); + assert_eq!( + first.get_profile("moving-profile").unwrap().display_name, + "revision-a" + ); + assert!(first.get_type_profile("moving-profile").is_some()); + let mut first_profile_hash = Sha256::new(); + first.hash_profile_revision("moving-profile", &mut first_profile_hash); + let first_profile_hash = first_profile_hash.finalize(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + + let second = sources.snapshot_catalog(&store).await.unwrap(); + assert_eq!(fetch_count.load(Ordering::SeqCst), 2); + assert_eq!( + second.get_profile("moving-profile").unwrap().display_name, + "revision-b" + ); + let mut second_profile_hash = Sha256::new(); + second.hash_profile_revision("moving-profile", &mut second_profile_hash); + assert_ne!(first_profile_hash, second_profile_hash.finalize()); + assert_ne!(first.revision(), second.revision()); + } + + #[test] + fn empty_source_revision_is_invalid() { + let err = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "source-a".to_string(), + revision: " ".to_string(), + profiles: vec![profile("github")], + user_managed: false, + allow_empty: false, + }]) + .unwrap_err(); + + assert!(err.message().contains("returned an empty revision")); + } + + #[test] + fn duplicate_profile_ids_across_sources_are_invalid() { + let err = build_effective_profiles(vec![ + CollectedProviderProfileSnapshot { + source_id: "source-a".to_string(), + revision: "a".to_string(), + profiles: vec![profile("github")], + user_managed: false, + allow_empty: false, + }, + CollectedProviderProfileSnapshot { + source_id: "source-b".to_string(), + revision: "b".to_string(), + profiles: vec![profile("github")], + user_managed: false, + allow_empty: false, + }, + ]) + .unwrap_err(); + + assert!(err.message().contains("duplicate provider profile id")); + } + + #[test] + fn configured_local_sources_preserve_order() { + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::User, + GatewayProviderProfileSourceConfig::Builtin, + ], + None, + ) + .unwrap(); + + assert_eq!(sources.source_ids(), vec!["user", "builtin"]); + } + + #[test] + fn configured_sources_must_not_be_empty() { + let err = ProviderProfileSources::from_config(&[], None).unwrap_err(); + assert!(err.contains("at least one source")); + } + + #[test] + fn configured_sources_must_be_unique() { + let err = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Builtin, + ], + None, + ) + .unwrap_err(); + assert!(err.contains("duplicate provider profile source 'builtin'")); + } + + #[test] + fn configured_interceptor_must_advertise_profile_capability() { + let err = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + None, + ) + .unwrap_err(); + assert!(err.contains("not configured or does not advertise provider_profiles")); + } + + #[test] + fn source_that_disallows_empty_snapshots_fails_closed() { + let err = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "interceptor/test".to_string(), + revision: "empty".to_string(), + profiles: Vec::new(), + user_managed: false, + allow_empty: false, + }]) + .unwrap_err(); + + assert!(err.message().contains("returned no profiles")); + } + + #[test] + fn user_source_may_return_an_empty_snapshot() { + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "empty".to_string(), + profiles: Vec::new(), + user_managed: true, + allow_empty: true, + }]) + .unwrap(); + + assert!(catalog.profiles.is_empty()); + } + + #[test] + fn invalid_profile_semantics_fail_closed() { + let err = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "interceptor/test".to_string(), + revision: "invalid".to_string(), + profiles: vec![profile("GitHub")], + user_managed: false, + allow_empty: false, + }]) + .unwrap_err(); + + assert!( + err.message() + .contains("provider profile source 'interceptor/test' is invalid") + ); + } + + #[tokio::test] + async fn interceptor_snapshot_passes_through_adapter_and_validation_boundary() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: String::new(), + profiles: vec![profile("github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let profiles = sources + .snapshot_catalog(&store) + .await + .unwrap() + .list_profiles(); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].id, "github"); + let snapshot = runtime + .provider_profile_source("governance") + .unwrap() + .snapshot() + .await + .unwrap(); + assert!(snapshot.revision.starts_with("sha256:")); + task.abort(); + } + + #[tokio::test] + async fn empty_interceptor_snapshot_received_over_adapter_fails_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "empty".to_string(), + profiles: Vec::new(), + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.snapshot_catalog(&store).await.unwrap_err(); + assert!(err.message().contains("returned no profiles")); + task.abort(); + } + + #[tokio::test] + async fn invalid_interceptor_snapshot_received_over_adapter_fails_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "invalid".to_string(), + profiles: vec![profile("GitHub")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.snapshot_catalog(&store).await.unwrap_err(); + assert!(err.message().contains("is invalid")); + task.abort(); + } + + #[tokio::test] + async fn distinct_local_and_interceptor_profiles_compose() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "external".to_string(), + profiles: vec![profile("governed-github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }, + ], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let profiles = sources + .snapshot_catalog(&store) + .await + .unwrap() + .list_profiles(); + assert!(profiles.iter().any(|profile| profile.id == "github")); + assert!( + profiles + .iter() + .any(|profile| profile.id == "governed-github") + ); + task.abort(); + } + + #[tokio::test] + async fn duplicate_profile_ids_across_local_and_interceptor_sources_fail_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "external".to_string(), + profiles: vec![profile("github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }, + ], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.snapshot_catalog(&store).await.unwrap_err(); + assert!( + err.message() + .contains("duplicate provider profile id 'github'") + ); + task.abort(); + } + + #[tokio::test] + async fn duplicate_profiles_within_interceptor_snapshot_fail_closed() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "duplicates".to_string(), + profiles: vec![profile("github"), profile("github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.snapshot_catalog(&store).await.unwrap_err(); + assert!(err.message().contains("duplicate provider profile id")); + task.abort(); + } + + #[tokio::test] + async fn unavailable_selected_interceptor_does_not_fall_back() { + let (runtime, task) = interceptor_runtime( + ProtoProviderProfileSnapshot { + revision: "test:unavailable".to_string(), + profiles: vec![profile("governed-github")], + }, + true, + ) + .await; + let sources = ProviderProfileSources::from_config( + &[ + GatewayProviderProfileSourceConfig::Builtin, + GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }, + ], + Some(&runtime), + ) + .unwrap(); + let store = crate::persistence::test_store().await; + + let err = sources.snapshot_catalog(&store).await.unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unavailable); + task.abort(); + } + + #[tokio::test] + async fn interceptor_without_profile_capability_cannot_be_selected() { + let (runtime, task) = + interceptor_runtime(ProtoProviderProfileSnapshot::default(), false).await; + let err = ProviderProfileSources::from_config( + &[GatewayProviderProfileSourceConfig::Interceptor { + name: "governance".to_string(), + }], + Some(&runtime), + ) + .unwrap_err(); + + assert!(err.contains("does not advertise provider_profiles")); + task.abort(); + } + + #[test] + fn source_managed_profiles_report_static_source() { + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "interceptor/test".to_string(), + revision: "test".to_string(), + profiles: vec![profile("slack")], + user_managed: false, + allow_empty: false, + }]) + .unwrap(); + + let entry = catalog.profiles.get("slack").unwrap(); + assert_eq!(entry.source_id, "interceptor/test"); + assert!(!entry.user_managed); + } +} diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c4..b604a3ef33 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -200,6 +200,7 @@ pub fn new_refresh_state( created_at_ms: now_ms, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), provider_id, provider_name, @@ -924,6 +925,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -1172,6 +1174,7 @@ mod tests { created_at_ms: 1, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: provider_type.to_string(), credentials: HashMap::new(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 7ebd6dba93..1dabb77442 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -804,6 +804,7 @@ mod tests { created_at_ms: 1_700_000_000_000, labels: std::collections::HashMap::default(), resource_version: 0, + annotations: std::collections::HashMap::new(), }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 752fee1c08..4c6589b9f7 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -86,6 +86,7 @@ mod tests { created_at_ms: 1000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 4adf9e8b6f..16ddbc0d47 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -841,6 +841,7 @@ mod tests { created_at_ms: 1_000_000, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), ..Default::default() } diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4538b207d6..0d59c58589 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -575,6 +575,8 @@ pub struct App { pub sandbox_notes: Vec, /// Formatted labels for each sandbox (e.g., "env=prod,team=platform" or empty string). pub sandbox_labels: Vec, + /// Formatted annotations for each sandbox (e.g., "policy-signature=abc" or empty string). + pub sandbox_annotations: Vec, pub sandbox_policy_versions: Vec, pub sandbox_selected: usize, pub sandbox_count: usize, @@ -689,6 +691,11 @@ pub fn format_labels(labels: &HashMap) -> String { .join(",") } +/// Format object annotations as a comma-separated key=value string. +pub fn format_annotations(annotations: &HashMap) -> String { + format_labels(annotations) +} + pub fn provider_name(provider: &openshell_core::proto::Provider) -> &str { provider .metadata @@ -903,6 +910,7 @@ impl App { sandbox_images: Vec::new(), sandbox_notes: Vec::new(), sandbox_labels: Vec::new(), + sandbox_annotations: Vec::new(), sandbox_policy_versions: Vec::new(), sandbox_selected: 0, sandbox_count: 0, @@ -2765,6 +2773,7 @@ impl App { self.sandbox_images.clear(); self.sandbox_notes.clear(); self.sandbox_labels.clear(); + self.sandbox_annotations.clear(); self.sandbox_policy_versions.clear(); self.sandbox_selected = 0; self.sandbox_count = 0; diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index b6a8dafaf2..bdca8932b8 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1379,6 +1379,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { ..Default::default() }), labels: HashMap::new(), + annotations: HashMap::new(), }; let sandbox_name = @@ -1643,6 +1644,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: ptype.clone(), credentials: credentials.clone(), @@ -1735,6 +1737,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { created_at_ms: 0, labels: HashMap::new(), resource_version: 0, + annotations: HashMap::new(), }), r#type: ptype, credentials, @@ -2104,13 +2107,10 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - delete_setting: false, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2140,13 +2140,10 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name: String::new(), - policy: None, setting_key: key, - setting_value: None, delete_setting: true, global: true, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2210,13 +2207,9 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name, - policy: None, setting_key: key, setting_value: Some(SettingValue { value: Some(value) }), - delete_setting: false, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2250,13 +2243,9 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let req = UpdateConfigRequest { name, - policy: None, setting_key: key, - setting_value: None, delete_setting: true, - global: false, - merge_operations: vec![], - expected_resource_version: 0, + ..Default::default() }; let result = tokio::time::timeout(Duration::from_secs(5), client.update_config(req)).await; @@ -2375,6 +2364,16 @@ async fn refresh_sandboxes(app: &mut App) { }) .collect(); + app.sandbox_annotations = sandboxes + .iter() + .map(|s| { + s.metadata + .as_ref() + .map(|metadata| app::format_annotations(&metadata.annotations)) + .unwrap_or_default() + }) + .collect(); + if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { app.sandbox_selected = app.sandbox_count - 1; } diff --git a/crates/openshell-tui/src/ui/sandbox_detail.rs b/crates/openshell-tui/src/ui/sandbox_detail.rs index 7cdbec8bd0..f212f21b06 100644 --- a/crates/openshell-tui/src/ui/sandbox_detail.rs +++ b/crates/openshell-tui/src/ui/sandbox_detail.rs @@ -84,29 +84,40 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(labels_str, t.text), ]); - // Row 4: Providers + // Row 4: Annotations + let annotations_str = app + .sandbox_annotations + .get(idx) + .filter(|s| !s.is_empty()) + .map_or("none", String::as_str); + let row4 = Line::from(vec![ + Span::styled(" Annotations: ", t.muted), + Span::styled(annotations_str, t.text), + ]); + + // Row 5: Providers let providers_str = if app.sandbox_providers_list.is_empty() { "none".to_string() } else { app.sandbox_providers_list.join(", ") }; - let row4 = Line::from(vec![ + let row5 = Line::from(vec![ Span::styled(" Providers: ", t.muted), Span::styled(providers_str, t.text), ]); - // Row 5: Forwarded Ports + // Row 6: Forwarded Ports let forwards_str = app .sandbox_notes .get(idx) .filter(|s| !s.is_empty()) .map_or("none", String::as_str); - let row5 = Line::from(vec![ + let row6 = Line::from(vec![ Span::styled(" Forwards: ", t.muted), Span::styled(forwards_str, t.text), ]); - let mut lines = vec![Line::from(""), row1, row2, row3, row4, row5]; + let mut lines = vec![row1, row2, row3, row4, row5, row6]; // Show global policy indicator when the sandbox's policy is managed at // gateway scope. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 247774a6c0..6ed2bae858 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -104,6 +104,13 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" grpc_rate_limit_requests = 120 grpc_rate_limit_window_seconds = 60 +# Optional exact provider-profile source composition. When omitted, the +# gateway uses builtin + user. +provider_profile_sources = [ + { type = "builtin" }, + { type = "user" }, +] + # Gateway listener TLS (distinct from the per-driver guest_tls_*). [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" @@ -133,6 +140,25 @@ roles_claim = "realm_access.roles" admin_role = "openshell-admin" user_role = "openshell-user" scopes_claim = "" + +[[openshell.gateway.interceptors]] +name = "quota" +grpc_endpoint = "unix:///run/openshell/interceptors/quota.sock" +order = 10 +failure_policy = "fail_closed" +binding_policy = "allowlist" +timeout = "500ms" +max_response_bytes = 1048576 +max_patches = 32 + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateSandbox" +phases = ["modify_operation", "validate"] +failure_policy = "fail_closed" + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateConfig" +phases = ["validate"] ``` Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth] enabled = true` to authenticate CLI callers from verified client certificates. Kubernetes deployments must leave this unset and use OIDC or a trusted access proxy; the Helm chart does not render this table. @@ -141,6 +167,33 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +`[[openshell.gateway.interceptors]]` configures gateway-side interceptor services. The gateway calls each service's `Describe` RPC at startup, validates its declared OpenShell RPC bindings against the compiled service descriptor, and applies matching phases from a central gRPC middleware path. Interceptors can target only methods in the gateway's built-in allowlist of unary mutation RPCs. New RPCs are non-interceptable until they are deliberately added to that allowlist; adding one does not require handler-specific interceptor code. Request bodies are exposed as protobuf JSON objects. Fields marked secret in the protobuf schema are recursively omitted from requests and post-commit responses. Interceptors cannot patch an omitted field or a containing object. + +`binding_policy` controls how the manifest and operator binding configuration combine: + +- `dynamic` enables valid manifest bindings and treats configured entries as optional narrowing overrides. This is the compatibility default. The gateway logs a startup warning because the interceptor controls its non-secret RPC authority. +- `allowlist` enables only configured RPC selectors and phases. The gateway ignores and logs extra manifest declarations, but fails startup when the manifest omits a configured RPC or phase. +- `exact` requires the configured and manifest RPC selectors and phases to match exactly. + +Bindings under `allowlist` and `exact` require `rpc` or `service` plus `method`, and a nonempty `phases` list. They match by RPC rather than manifest binding ID. Duplicate selectors, `id`, and `disabled` are invalid in these modes; omit a binding to disable it. Binding failure policy comes from the binding, then the configured service, then defaults to `fail_closed`. Manifest failure policies do not override strict-mode operator configuration. + +Bindings that include `post_commit` must resolve to `failure_policy = "fail_open"`; the gateway rejects fail-closed post-commit configuration at startup. Post-commit evaluation is observational. If response observation or interceptor evaluation fails after a handler succeeds, the gateway logs and counts the failure but returns the committed response unchanged. + +`provider_profile_sources` selects the exact ordered provider-profile source set. Omit it to use the built-in and user-managed sources. Use `{ type = "builtin" }`, `{ type = "user" }`, and `{ type = "interceptor", name = "" }` entries to compose a catalog. An interceptor entry must reference a configured service whose manifest advertises `provider_profiles = true`. Selecting only an interceptor makes that catalog authoritative; include local sources explicitly to compose them. Empty or duplicate source lists, unknown interceptor names, and duplicate normalized profile IDs fail closed. Source order controls collection and diagnostics, not override precedence. + +For an authoritative interceptor catalog, select only that interceptor: + +```toml +[openshell.gateway] +provider_profile_sources = [ + { type = "interceptor", name = "provider-governance" }, +] +``` + +The gateway validates snapshot structure and provider-profile semantics. It treats a configured interceptor as the source trust boundary and does not verify signature, hash, or key annotations in profile payloads. + +`failure_policy` accepts `fail_closed` or `fail_open`. `timeout` accepts `ms` and `s` suffixes. In `dynamic` mode, binding overrides may select a manifest binding by `id`, `rpc`, or `service` plus `method`; they can disable a binding, narrow its phases, or override its failure policy. + `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. ## Driver References diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 8ccb7859e9..c3c84b8383 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -51,7 +51,8 @@ The feature flag controls provider-derived policy layers. OpenShell still suppor Providers v2 currently includes these user-facing features: -- Built-in provider profiles stored in the `providers/` directory of the GitHub repository. +- Built-in provider profiles loaded by the gateway by default. +- Gateway configuration can compose built-in, user-managed, and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. - `openshell provider list-profiles` with table, YAML, and JSON output. - `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from built-in or imported profile IDs with `openshell provider create --type `. @@ -89,6 +90,13 @@ List available profiles: openshell provider list-profiles ``` +By default the gateway lists built-in profiles plus custom profiles imported +through the profile APIs. When a configured gateway interceptor vends an +authoritative provider profile catalog, that catalog becomes the visible source +of truth: list, export, provider creation, policy composition, and sandbox +provider environment resolution use the interceptor-vended profiles instead of +built-in or user-imported profiles. + Built-in Providers v2 profiles currently include: | Profile ID | Category | Credential environment variables | @@ -138,7 +146,7 @@ openshell provider profile update github-profile -f github-profile.yaml Exported custom profiles include `resource_version`. OpenShell requires that version during update so stale files cannot silently overwrite newer profile definitions. The target ID in the command must match the profile ID in the file. Update accepts one file at a time. If an update would make dynamic token grants ambiguous for an attached sandbox, OpenShell rejects it before changing the profile. -Custom profile IDs must use lowercase kebab-case with `a-z`, `0-9`, and `-`. Built-in profile IDs and legacy provider aliases are reserved. Built-in profiles are read-only, and OpenShell rejects updating or deleting a built-in profile. OpenShell also rejects deleting a custom profile while a sandbox-attached provider uses it. +Custom profile IDs must use lowercase kebab-case with `a-z`, `0-9`, and `-`. Built-in profile IDs and legacy provider aliases are reserved. Built-in and interceptor-managed profiles are read-only through the profile APIs. OpenShell also rejects deleting a custom profile while a sandbox-attached provider uses it. ### Category Enum @@ -157,11 +165,14 @@ The `category` field controls how `openshell provider list-profiles` groups prof ### Profile Schema Provider profile YAML and JSON use this shape. Treat this as a field map, not a profile to import verbatim. The endpoint and rule fields mirror the network policy schema used under `network_policies`. Refer to [Policy Schema Reference](/reference/policy-schema) for field semantics. +Use `annotations` only for non-secret metadata such as source, signature, or governance markers. OpenShell preserves annotations through profile import, export, and interceptor-managed profile snapshots. ```yaml wordWrap showLineNumbers={false} id: custom-api # Present on exported custom profiles; preserve it when updating. resource_version: 1 +annotations: + example.com/source: platform display_name: Custom API description: Custom API access for sandbox agents category: data diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock new file mode 100644 index 0000000000..a3df3b79f4 --- /dev/null +++ b/examples/governance-interceptor/Cargo.lock @@ -0,0 +1,2148 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "base64", + "ipnet", + "miette", + "prost", + "prost-types", + "protobuf-src", + "serde", + "serde_json", + "thiserror", + "tokio", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + +[[package]] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +dependencies = [ + "jsonwebtoken", + "openshell-core", + "openshell-policy", + "openshell-providers", + "prost", + "prost-reflect", + "prost-types", + "rcgen", + "serde", + "serde_json", + "serde_yml", + "sha2", + "tokio", + "tonic", +] + +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-core", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "openshell-providers" +version = "0.0.0" +dependencies = [ + "glob", + "openshell-core", + "serde", + "serde_json", + "serde_yml", + "thiserror", + "url", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "base64", + "prost", + "prost-types", + "serde", + "serde-value", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf-src" +version = "1.1.0+21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7ac8852baeb3cc6fb83b93646fb93c0ffe5d14bf138c945ceb4b9948ee0e3c1" +dependencies = [ + "autotools", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/governance-interceptor/Cargo.toml b/examples/governance-interceptor/Cargo.toml new file mode 100644 index 0000000000..c05115ecc1 --- /dev/null +++ b/examples/governance-interceptor/Cargo.toml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-governance-interceptor-example" +version = "0.0.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" + +[dependencies] +jsonwebtoken = "9" +openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-policy = { path = "../../crates/openshell-policy" } +openshell-providers = { path = "../../crates/openshell-providers" } +prost = "0.14" +prost-reflect = { version = "0.16.5", features = ["serde"] } +prost-types = "0.14" +rcgen = { version = "0.13", features = ["crypto", "pem"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = "0.0.12" +sha2 = "0.10" +tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "fs", "signal"] } +tonic = { version = "0.14", features = ["transport"] } + +[[bin]] +name = "governance-interceptor" +path = "src/main.rs" + +[[bin]] +name = "governance-smoke-client" +path = "src/smoke_client.rs" diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md new file mode 100644 index 0000000000..ad1e5d486b --- /dev/null +++ b/examples/governance-interceptor/README.md @@ -0,0 +1,156 @@ +# Governance Interceptor Example + +This standalone example implements the +`openshell.gateway_interceptor.v1.GatewayInterceptor` service. It demonstrates +how an interceptor can vend provider profiles and make them the gateway's +authoritative profile source. + +- provider profile YAML lives in `profiles/*.yaml` +- `provider list-profiles` shows only the profiles vended by this interceptor +- providers can only be created with a `type` that matches one of those vended + profile IDs +- every vended provider profile gets governance annotations for its hash, + signature, and signing key ID +- every new sandbox receives `policy.yaml` during `CreateSandbox` +- requested sandbox providers must match one of the vended profile IDs +- every new sandbox gets an `openshell.nvidia.com/policy-signature` metadata + annotation that is used to verify the policy +- sandbox creation evaluations add a `correlation_id` log annotation for gateway + audit logs, plus non-secret policy hash/signing key metadata +- sandbox policy synchronization must carry the current signed governance + policy; unsigned, stale, or modified policies are denied for every caller +- sandbox policy analysis may report telemetry, but sandbox-authored policy + proposals are denied before they reach the gateway handler +- `proposal_approval_mode=auto` is blocked at both sandbox and global scope +- users cannot import or update provider profiles outside the vended set +- provider profile deletion is blocked by the interceptor + +Run the interceptor: + +```shell +cargo run -- \ + --listen 127.0.0.1:18081 \ + --policy policy.yaml \ + --profiles profiles \ + --gateway-endpoint http://127.0.0.1:8080 +``` + +At startup the example parses `policy.yaml`, converts it to the protobuf JSON +shape used by sandbox creation, computes a canonical SHA-256 digest, and signs +that digest as an EdDSA JWT. The interceptor adds that JWT to each governed +sandbox under `metadata.annotations["openshell.nvidia.com/policy-signature"]` +and verifies the JWT against the sandbox policy during the `CreateSandbox` +validate phase. The signing key is generated in memory on each interceptor +start. This keeps the example self-contained. Production governance services +should load managed signing keys, publish verifier keys, and define a rotation +process. + +The example owns this digest contract independently of the gateway. It uses a +local reflected protobuf codec, recursively sorts ProtoJSON object keys, and +preserves repeated-field order. Policy and profile hashes use the +`sha256:v2:` format, and their JWTs require +`hash_algorithm=openshell-governance-protojson-sha256-v2`. The gateway's policy +hash is a separate operational revision identifier and is not expected to +match the signed governance hash. + +The interceptor polls the policy file every second by default. When `policy.yaml` +changes and parses successfully, the interceptor re-signs it immediately. New +sandboxes receive the updated signed policy through `CreateSandbox`. If +`--gateway-endpoint` is set, the example also lists running sandboxes and calls +`UpdateConfig` for ready or provisioning sandboxes so dynamic policy changes +propagate through the normal sandbox config polling path. Static baseline +changes that the gateway rejects for existing sandboxes are logged and still +apply to newly created sandboxes. + +The example also validates `SubmitPolicyAnalysis`. Requests without proposed +policy chunks remain available for denial and network-activity telemetry. +Requests containing proposed chunks are denied, so a sandbox cannot use the +gateway's optional auto-approval path to widen its governed policy. This rule +belongs to the example: gateways without this binding retain the standard +proposal workflow. + +Provider profile YAML files are loaded by the interceptor from `--profiles` +(default: this example's `profiles/` directory). The interceptor names each +profile from its filename without the extension: `profiles/github.yaml` becomes +profile ID `github`, and `profiles/slack.yaml` becomes profile ID `slack`. The +YAML files do not need an `id` field; if one is present, the filename still wins. + +The interceptor advertises `provider_profiles = true` in its manifest and vends +the current profile set through `SnapshotProviderProfiles`. The gateway config +selects the interceptor as its only provider profile source, so +`provider list-profiles` shows only `github` and `slack`; built-in and user +sources are omitted. The example signs each profile's canonical protobuf payload +and exposes the JWT under +`annotations["openshell.nvidia.com/profile-signature"]`; the signed hash and key +ID are exposed beside it. These annotations demonstrate logic an interceptor +can own; the gateway treats them as opaque metadata and does not verify them. +Valid edits to files under `profiles/` change the profile signature and snapshot +revision, so running sandboxes that use the edited provider profile reload their +effective provider-derived policy through the normal gateway config polling +path. Invalid edits keep the last valid snapshot active. + +Gateway TOML snippet: + +```toml +[openshell.gateway] +provider_profile_sources = [ + { type = "interceptor", name = "provider-governance" }, +] + +[[openshell.gateway.interceptors]] +name = "provider-governance" +grpc_endpoint = "http://127.0.0.1:18081" +order = 10 +failure_policy = "fail_closed" +binding_policy = "allowlist" +timeout = "500ms" +max_response_bytes = 1048576 +max_patches = 32 + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateSandbox" +phases = ["modify_operation", "validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/CreateProvider" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateConfig" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/SubmitPolicyAnalysis" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/ImportProviderProfiles" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/UpdateProviderProfiles" +phases = ["validate"] + +[[openshell.gateway.interceptors.bindings]] +rpc = "openshell.v1.OpenShell/DeleteProviderProfile" +phases = ["validate"] +``` + +Run the launcher script to start a local gateway with the interceptor attached. +The script prints the gateway endpoint and log paths, then keeps the gateway and +interceptor running until you press Ctrl-C: + +```shell +./smoke.sh +``` + +To run the governance smoke test suite and stop the gateway when it completes: + +```shell +./smoke.sh --test-suite +``` + +The suite uses a gateway-signed JWT for the created sandbox identity to attempt +an unsigned policy widening and a policy proposal. It verifies that both are +denied, telemetry is accepted, and the active policy version and hash remain +unchanged. diff --git a/examples/governance-interceptor/policy.yaml b/examples/governance-interceptor/policy.yaml new file mode 100644 index 0000000000..021e635db2 --- /dev/null +++ b/examples/governance-interceptor/policy.yaml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + my_api: + name: my-api + endpoints: + - host: api-1.example.com + port: 443 + protocol: rest + enforcement: enforce + access: full + binaries: + - path: /usr/bin/curl diff --git a/examples/governance-interceptor/profiles/github.yaml b/examples/governance-interceptor/profiles/github.yaml new file mode 100644 index 0000000000..4b90aa7c3b --- /dev/null +++ b/examples/governance-interceptor/profiles/github.yaml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +display_name: GitHub +description: GitHub API and Git operations +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN, GH_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api-1.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: api.github.com + port: 443 + path: /graphql + protocol: graphql + access: read-only + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] diff --git a/examples/governance-interceptor/profiles/slack.yaml b/examples/governance-interceptor/profiles/slack.yaml new file mode 100644 index 0000000000..1296919548 --- /dev/null +++ b/examples/governance-interceptor/profiles/slack.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +display_name: Slack +description: Read-only Slack Web API access for governed sandbox agents +category: messaging +credentials: + - name: api_token + description: Slack bot or user token + env_vars: [SLACK_BOT_TOKEN, SLACK_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: slack.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /api/team.info + - allow: + method: GET + path: /api/users.info + - allow: + method: GET + path: /api/conversations.info + - allow: + method: GET + path: /api/conversations.history +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh new file mode 100755 index 0000000000..351d73fea5 --- /dev/null +++ b/examples/governance-interceptor/smoke.sh @@ -0,0 +1,673 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE_DIR="$ROOT/examples/governance-interceptor" +RUN_TEST_SUITE=0 + +usage() { + cat <&2 + usage >&2 + exit 2 + ;; + esac +done + +TMPDIR="$(mktemp -d)" +LOG_DIR="$TMPDIR/logs" +JWT_DIR="$TMPDIR/jwt" +GATEWAY_CONFIG="$TMPDIR/gateway.toml" +POLICY_FILE="$TMPDIR/policy.yaml" +PROFILE_DIR="$TMPDIR/profiles" +SETUP_LOG="$LOG_DIR/setup.log" +GATEWAY_LOG="$LOG_DIR/gateway.log" +INTERCEPTOR_LOG="$LOG_DIR/interceptor.log" +if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then + RUN_ID="governance-smoke-$$-$RANDOM" +else + RUN_ID="governance-interactive-$$-$RANDOM" +fi +SANDBOX_NAME="$RUN_ID-sandbox" + +mkdir -p "$LOG_DIR" "$PROFILE_DIR" +cp "$EXAMPLE_DIR"/profiles/*.yaml "$PROFILE_DIR"/ + +cleanup() { + local status=$? + trap - EXIT + + if [[ -n "${INTERCEPTOR_PID:-}" ]]; then + kill "$INTERCEPTOR_PID" 2>/dev/null || true + wait "$INTERCEPTOR_PID" 2>/dev/null || true + fi + + if [[ -n "${GATEWAY_PID:-}" ]]; then + kill "$GATEWAY_PID" 2>/dev/null || true + wait "$GATEWAY_PID" 2>/dev/null || true + fi + + if [[ "$status" -eq 0 ]]; then + rm -rf "$TMPDIR" + else + echo "logs retained in $LOG_DIR" >&2 + fi + + exit "$status" +} +trap cleanup EXIT + +port_is_free() { + local port="$1" + + if command -v lsof >/dev/null 2>&1; then + ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + return + fi + + if command -v nc >/dev/null 2>&1; then + ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + return + fi + + return 0 +} + +choose_port_block() { + local count="$1" + local start offset ok + + for _ in {1..200}; do + start=$((20000 + RANDOM % 20000)) + ok=1 + + for ((offset = 0; offset < count; offset++)); do + if ! port_is_free "$((start + offset))"; then + ok=0 + break + fi + done + + if [[ "$ok" == "1" ]]; then + printf '%s\n' "$start" + return + fi + done + + echo "failed to find free local ports for governance interceptor launcher" >&2 + exit 1 +} + +PORT_BASE="$(choose_port_block 3)" +INTERCEPTOR_ADDR="127.0.0.1:$PORT_BASE" +GATEWAY_PORT="$((PORT_BASE + 1))" +HEALTH_PORT="$((PORT_BASE + 2))" +GATEWAY_ADDR="127.0.0.1:$GATEWAY_PORT" +HEALTH_ADDR="127.0.0.1:$HEALTH_PORT" +GATEWAY_ENDPOINT="http://$GATEWAY_ADDR" + +dump_log_file() { + local label="$1" + local path="$2" + + printf '\n--- %s: %s ---\n' "$label" "$path" >&2 + if [[ -f "$path" ]]; then + cat "$path" >&2 + else + printf '(missing)\n' >&2 + fi +} + +dump_logs() { + dump_log_file "setup log" "$SETUP_LOG" + dump_log_file "gateway log" "$GATEWAY_LOG" + dump_log_file "interceptor log" "$INTERCEPTOR_LOG" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + dump_logs + exit 1 +} + +log_command() { + local label="$1" + shift + + { + printf '\n== %s ==\n' "$label" + printf '+' + printf ' %q' "$@" + printf '\n' + } >>"$SETUP_LOG" +} + +run_setup_step() { + local label="$1" + shift + + printf 'INFO %s\n' "$label" + log_command "$label" "$@" + if ! "$@" >>"$SETUP_LOG" 2>&1; then + fail "$label" + fi +} + +run_step() { + local label="$1" + shift + + log_command "$label" "$@" + if "$@" >>"$SETUP_LOG" 2>&1; then + pass "$label" + else + fail "$label" + fi +} + +expect_failure() { + local label="$1" + shift + + log_command "$label" "$@" + if "$@" >>"$SETUP_LOG" 2>&1; then + fail "$label" + else + pass "$label" + fi +} + +expect_output_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + + log_command "$label" "$@" + if "$@" >"$output_file" 2>>"$SETUP_LOG" && grep -Fq -- "$needle" "$output_file"; then + pass "$label" + else + cat "$output_file" >>"$SETUP_LOG" 2>/dev/null || true + fail "$label" + fi +} + +expect_output_not_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + + log_command "$label" "$@" + if "$@" >"$output_file" 2>>"$SETUP_LOG" && ! grep -Fq -- "$needle" "$output_file"; then + pass "$label" + else + cat "$output_file" >>"$SETUP_LOG" 2>/dev/null || true + fail "$label" + fi +} + +expect_log_contains() { + local label="$1" + local needle="$2" + local path="$3" + + if grep -Fq -- "$needle" "$path"; then + pass "$label" + else + fail "$label" + fi +} + +wait_for_output_contains() { + local label="$1" + local needle="$2" + shift 2 + local output_file="$LOG_DIR/${label//[^A-Za-z0-9_]/_}.out" + + log_command "$label" "$@" + for _ in {1..60}; do + if "$@" >"$output_file" 2>>"$SETUP_LOG" && grep -Fq -- "$needle" "$output_file"; then + pass "$label" + return + fi + sleep 1 + done + + cat "$output_file" >>"$SETUP_LOG" 2>/dev/null || true + fail "$label" +} + +policy_hash_for_sandbox() { + local sandbox_name="$1" + + "${CLI[@]}" policy get "$sandbox_name" --full -o json \ + | awk -F'"' '/"hash":/ { print $4; exit }' +} + +policy_signature_for_sandbox() { + local sandbox_name="$1" + + "${CLI[@]}" sandbox get "$sandbox_name" \ + | awk -F': ' '/openshell.nvidia.com\/policy-signature:/ { print $2; exit }' +} + +profile_signature_for_profile() { + local profile_id="$1" + + "${CLI[@]}" provider profile export "$profile_id" -o json \ + | awk -F'"' '/"openshell.nvidia.com\/profile-signature":/ { print $4; exit }' +} + +wait_for_profile() { + local profile_id="$1" + local label="loading $profile_id provider profile" + + { + printf '\n== %s ==\n' "$label" + printf '+ wait for provider profile %q\n' "$profile_id" + } >>"$SETUP_LOG" + + for _ in {1..60}; do + if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then + printf 'INFO %s\n' "$label" + return + fi + sleep 1 + done + + fail "$label" +} + +generate_gateway_jwt_bundle() { + if ! command -v openssl >/dev/null 2>&1; then + echo "openssl is required to generate local smoke-test gateway JWT keys" >&2 + exit 1 + fi + + mkdir -p "$JWT_DIR" + openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 + openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 + printf '%s\n' "$RUN_ID" >"$JWT_DIR/kid" +} + +write_gateway_config() { + cat >"$GATEWAY_CONFIG" <"$INTERCEPTOR_LOG" 2>&1 & + INTERCEPTOR_PID=$! +} + +start_gateway() { + printf 'INFO starting gateway\n' + env -u OPENSHELL_DRIVERS "$ROOT/target/debug/openshell-gateway" \ + --config "$GATEWAY_CONFIG" \ + --bind-address 127.0.0.1 \ + --port "$GATEWAY_PORT" \ + --health-port "$HEALTH_PORT" \ + --metrics-port 0 \ + --log-level info \ + --disable-tls \ + --db-url "sqlite://$TMPDIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID=$! +} + +wait_for_gateway() { + local label="gateway starts with interceptor" + + for _ in {1..60}; do + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "$label" + fi + + if curl -fsS "http://$HEALTH_ADDR/healthz" >/dev/null 2>&1; then + printf 'INFO %s\n' "$label" + return + fi + + sleep 1 + done + + fail "$label" +} + +configure_gateway() { + CLI=( + env + -u OPENSHELL_SANDBOX_POLICY + "$ROOT/target/debug/openshell" + --gateway-endpoint "$GATEWAY_ENDPOINT" + ) + + run_setup_step "enabling provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes + wait_for_profile "github" + wait_for_profile "slack" +} + +run_suite() { + expect_output_contains "lists github profile" "github" "${CLI[@]}" provider list-profiles + expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles + expect_output_not_contains "hides codex profile" "codex" "${CLI[@]}" provider list-profiles + expect_output_not_contains "hides google cloud profile" "google-cloud" "${CLI[@]}" provider list-profiles + expect_output_contains "github profile has governance profile signature" "openshell.nvidia.com/profile-signature" "${CLI[@]}" provider profile export github -o json + expect_output_contains "github profile has governance profile hash" "openshell.nvidia.com/profile-hash" "${CLI[@]}" provider profile export github -o json + + cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' +id: custom-slack +display_name: Custom Slack +description: Profile outside the managed github/slack set used to verify interceptor import denial +category: messaging +credentials: [] +endpoints: [] +binaries: [] +EOF + + expect_failure "denies provider profile delete" "${CLI[@]}" provider profile delete slack + expect_failure "denies disallowed provider profile import" "${CLI[@]}" provider profile import -f "$TMPDIR/disallowed-profile.yaml" + + run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy + run_step "allows slack provider create" "${CLI[@]}" provider create --name slack --type slack --credential SLACK_BOT_TOKEN=dummy + + expect_failure "denies disallowed provider create" "${CLI[@]}" provider create --name bitbucket --type bitbucket --credential BITBUCKET_TOKEN=dummy + expect_failure "denies automatic proposal approval" "${CLI[@]}" settings set --global --key proposal_approval_mode --value auto --yes + + run_step "creates sandbox with selected github provider" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --provider github --no-auto-providers --keep --no-tty -- /bin/sh -lc true + expect_log_contains "gateway logs interceptor log annotations" "log_annotations" "$GATEWAY_LOG" + expect_log_contains "gateway logs governance correlation id" "governance:create-sandbox:$SANDBOX_NAME" "$GATEWAY_LOG" + expect_output_contains "sandbox has github provider" "github" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + expect_output_not_contains "sandbox does not auto-add slack provider" "slack" "${CLI[@]}" sandbox provider list "$SANDBOX_NAME" + expect_output_contains "effective policy has github provider layer" "_provider_github" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + expect_output_not_contains "effective policy omits unselected slack layer" "_provider_slack" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + + local sandbox_id + sandbox_id="$("${CLI[@]}" sandbox get "$SANDBOX_NAME" | awk '/Id:/ && !found { print $2; found=1 }')" + if [[ -z "$sandbox_id" ]]; then + fail "reads governed sandbox id" + fi + pass "reads governed sandbox id" + + run_step \ + "denies authenticated governance bypasses without changing policy" \ + "$EXAMPLE_DIR/target/debug/governance-smoke-client" \ + "$GATEWAY_ENDPOINT" "$SANDBOX_NAME" "$sandbox_id" \ + "$JWT_DIR/signing.pem" "$RUN_ID" "$RUN_ID" + + local initial_policy_signature + initial_policy_signature="$(policy_signature_for_sandbox "$SANDBOX_NAME")" + if [[ -z "$initial_policy_signature" ]]; then + fail "reads initial governance policy signature" + fi + pass "reads initial governance policy signature" + + local initial_github_profile_signature + initial_github_profile_signature="$(profile_signature_for_profile github)" + if [[ -z "$initial_github_profile_signature" ]]; then + fail "reads initial governance profile signature" + fi + pass "reads initial governance profile signature" + + cat >"$POLICY_FILE" <<'EOF' +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + example_api: + name: example-api + endpoints: + - host: example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only +EOF + wait_for_output_contains "gateway sees policy.yaml reload" "example_api" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + local policy_reload_hash + policy_reload_hash="$(policy_hash_for_sandbox "$SANDBOX_NAME")" + if [[ -z "$policy_reload_hash" ]]; then + fail "reads reloaded policy.yaml hash" + fi + wait_for_output_contains "running sandbox logs policy.yaml reload" "$policy_reload_hash" "${CLI[@]}" logs "$SANDBOX_NAME" --source sandbox --since 90s + + local reloaded_policy_signature="" + { + printf '\n== policy.yaml reload updates sandbox policy signature ==\n' + printf '+ wait for sandbox annotation %q to change\n' "openshell.nvidia.com/policy-signature" + } >>"$SETUP_LOG" + for _ in {1..60}; do + reloaded_policy_signature="$(policy_signature_for_sandbox "$SANDBOX_NAME")" + if [[ -n "$reloaded_policy_signature" && "$reloaded_policy_signature" != "$initial_policy_signature" ]]; then + break + fi + sleep 1 + done + if [[ -z "$reloaded_policy_signature" || "$reloaded_policy_signature" == "$initial_policy_signature" ]]; then + fail "policy.yaml reload updates sandbox policy signature" + fi + pass "policy.yaml reload updates sandbox policy signature" + + cat >"$PROFILE_DIR/github.yaml" <<'EOF' +display_name: GitHub +description: GitHub API and Git operations +category: source_control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN, GH_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: api.github.com + port: 443 + path: /graphql + protocol: graphql + access: read-only + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: profile-reload.example + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] +EOF + wait_for_output_contains "gateway sees github profile reload" "profile-reload.example" "${CLI[@]}" provider profile export github -o yaml + wait_for_output_contains "effective policy has reloaded github profile" "profile-reload.example" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json + local reloaded_github_profile_signature="" + { + printf '\n== github profile reload updates profile signature ==\n' + printf '+ wait for provider profile annotation %q to change\n' "openshell.nvidia.com/profile-signature" + } >>"$SETUP_LOG" + for _ in {1..60}; do + reloaded_github_profile_signature="$(profile_signature_for_profile github)" + if [[ -n "$reloaded_github_profile_signature" && "$reloaded_github_profile_signature" != "$initial_github_profile_signature" ]]; then + break + fi + sleep 1 + done + if [[ -z "$reloaded_github_profile_signature" || "$reloaded_github_profile_signature" == "$initial_github_profile_signature" ]]; then + fail "github profile reload updates profile signature" + fi + pass "github profile reload updates profile signature" + local profile_reload_hash + profile_reload_hash="$(policy_hash_for_sandbox "$SANDBOX_NAME")" + if [[ -z "$profile_reload_hash" ]]; then + fail "reads reloaded profile policy hash" + fi + wait_for_output_contains "running sandbox logs github profile reload" "$profile_reload_hash" "${CLI[@]}" logs "$SANDBOX_NAME" --source sandbox --since 90s + + expect_failure "denies policy replacement" "${CLI[@]}" policy set "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" + + run_step "deletes governed sandbox" "${CLI[@]}" sandbox delete "$SANDBOX_NAME" +} + +print_ready() { + cat </dev/null; then + fail "gateway process exited" + fi + + if ! kill -0 "$INTERCEPTOR_PID" 2>/dev/null; then + fail "governance interceptor process exited" + fi + + sleep 1 + done +} + +cd "$ROOT" + +run_setup_step "building gateway" cargo build --quiet -p openshell-server --bin openshell-gateway +run_setup_step "building governance interceptor" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" +run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell + +generate_gateway_jwt_bundle +cp "$EXAMPLE_DIR/policy.yaml" "$POLICY_FILE" +write_gateway_config +start_interceptor +start_gateway +wait_for_gateway +configure_gateway + +if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then + run_suite + echo "ALL PASS governance interceptor smoke" +else + print_ready + wait_until_stopped +fi diff --git a/examples/governance-interceptor/src/main.rs b/examples/governance-interceptor/src/main.rs new file mode 100644 index 0000000000..54b01bdd4d --- /dev/null +++ b/examples/governance-interceptor/src/main.rs @@ -0,0 +1,1420 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod policy_hash; +mod proto_json; + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use jsonwebtoken::{ + Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, decode_header, encode, +}; +use openshell_core::proto::gateway_interceptor::v1::{ + DescribeRequest, GatewayInterceptorPhase, InterceptorBinding, InterceptorEvaluation, + InterceptorManifest, InterceptorResult, InterceptorSelector, JsonPatch, + ProviderProfileSnapshot, ProviderProfileSnapshotRequest, + gateway_interceptor_server::{GatewayInterceptor, GatewayInterceptorServer}, + interceptor_evaluation, +}; +use openshell_core::proto::{ + ListSandboxesRequest, ProviderProfile, Sandbox, SandboxPhase, SandboxPolicy, + UpdateConfigRequest, open_shell_client::OpenShellClient, +}; +use openshell_policy::parse_sandbox_policy; +use openshell_providers::{ProviderTypeProfile, normalize_profile_id}; +use policy_hash::{ + HASH_ALGORITHM, canonical_policy_hash, canonical_profile_hash, + canonical_profile_snapshot_revision, is_v2_digest, +}; +use prost::Message as _; +use prost_types::ListValue; +use prost_types::{Struct, Value as ProtoValue, value::Kind}; +use proto_json::{decode_message_to_json, encode_json_to_message}; +use rcgen::{KeyPair, PKCS_ED25519}; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value, json}; +use sha2::{Digest, Sha256}; +use tonic::Code; +use tonic::transport::{Channel, Server}; +use tonic::{Request, Response, Status}; + +const POLICY_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/policy-signature"; +const POLICY_HASH_ANNOTATION: &str = "openshell.nvidia.com/policy-hash"; +const POLICY_SIGNATURE_KID_ANNOTATION: &str = "openshell.nvidia.com/policy-signature-kid"; +const POLICY_RELOAD_CORRELATION_ANNOTATION: &str = + "openshell.nvidia.com/policy-reload-correlation-id"; +const PROFILE_SIGNATURE_ANNOTATION: &str = "openshell.nvidia.com/profile-signature"; +const PROFILE_HASH_ANNOTATION: &str = "openshell.nvidia.com/profile-hash"; +const PROFILE_SIGNATURE_KID_ANNOTATION: &str = "openshell.nvidia.com/profile-signature-kid"; +const POLICY_JWT_ISSUER: &str = "openshell-governance-interceptor"; +const POLICY_JWT_AUDIENCE: &str = "openshell-governance-policy"; +const POLICY_JWT_SUBJECT: &str = "policy.yaml"; +const PROFILE_JWT_AUDIENCE: &str = "openshell-governance-profile"; +const PROFILE_JWT_SUBJECT_PREFIX: &str = "provider-profile:"; +const CREATE_SANDBOX_CORRELATION_PREFIX: &str = "governance:create-sandbox"; +const RELOAD_CORRELATION_PREFIX: &str = "governance:reload-policy"; +const SERVICE: &str = "openshell.v1.OpenShell"; +const SANDBOX_POLICY_TYPE: &str = "openshell.sandbox.v1.SandboxPolicy"; +const DEFAULT_POLICY_WATCH_INTERVAL_MS: u64 = 1_000; + +#[derive(Clone)] +struct PolicySigner { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + kid: String, +} + +impl std::fmt::Debug for PolicySigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PolicySigner") + .field("kid", &self.kid) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct PolicySignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + hash_algorithm: String, + policy_sha256: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileSignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + hash_algorithm: String, + profile_id: String, + profile_sha256: String, +} + +impl PolicySigner { + fn generate() -> Result { + let keypair = KeyPair::generate_for(&PKCS_ED25519) + .map_err(|err| format!("failed to generate policy signing key: {err}"))?; + let signing_key_pem = keypair.serialize_pem(); + let public_key_pem = keypair.public_key_pem(); + let encoding_key = EncodingKey::from_ed_pem(signing_key_pem.as_bytes()) + .map_err(|err| format!("failed to parse policy signing key: {err}"))?; + let decoding_key = DecodingKey::from_ed_pem(public_key_pem.as_bytes()) + .map_err(|err| format!("failed to parse policy verification key: {err}"))?; + let kid = kid_from_public_key_der(&keypair.public_key_der()); + Ok(Self { + encoding_key, + decoding_key, + kid, + }) + } + + fn kid(&self) -> &str { + &self.kid + } + + fn sign_policy(&self, policy_hash: &str) -> Result { + let claims = PolicySignatureClaims { + sub: POLICY_JWT_SUBJECT.to_string(), + iss: POLICY_JWT_ISSUER.to_string(), + aud: POLICY_JWT_AUDIENCE.to_string(), + iat: now_secs(), + exp: 0, + hash_algorithm: HASH_ALGORITHM.to_string(), + policy_sha256: policy_hash.to_string(), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + encode(&header, &claims, &self.encoding_key) + .map_err(|err| format!("failed to sign policy JWT: {err}")) + } + + fn sign_profile(&self, profile_id: &str, profile_hash: &str) -> Result { + let claims = ProfileSignatureClaims { + sub: format!("{PROFILE_JWT_SUBJECT_PREFIX}{profile_id}"), + iss: POLICY_JWT_ISSUER.to_string(), + aud: PROFILE_JWT_AUDIENCE.to_string(), + iat: 0, + exp: 0, + hash_algorithm: HASH_ALGORITHM.to_string(), + profile_id: profile_id.to_string(), + profile_sha256: profile_hash.to_string(), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + encode(&header, &claims, &self.encoding_key) + .map_err(|err| format!("failed to sign provider profile JWT: {err}")) + } + + fn verify_policy_signature(&self, token: &str, policy_hash: &str) -> Result<(), String> { + let header = decode_header(token) + .map_err(|err| format!("failed to decode policy JWT header: {err}"))?; + if header.kid.as_deref() != Some(self.kid.as_str()) { + return Err("unexpected policy signing key id".to_string()); + } + if header.alg != Algorithm::EdDSA { + return Err("unexpected policy signing algorithm".to_string()); + } + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.algorithms = vec![Algorithm::EdDSA]; + validation.set_issuer(&[POLICY_JWT_ISSUER]); + validation.set_audience(&[POLICY_JWT_AUDIENCE]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); + validation.validate_exp = false; + + let data = decode::(token, &self.decoding_key, &validation) + .map_err(|err| format!("failed to verify policy JWT: {err}"))?; + if data.claims.sub != POLICY_JWT_SUBJECT { + return Err("unexpected policy JWT subject".to_string()); + } + if data.claims.hash_algorithm != HASH_ALGORITHM { + return Err("unexpected policy hash algorithm".to_string()); + } + if !is_v2_digest(&data.claims.policy_sha256) { + return Err("unexpected policy hash format".to_string()); + } + if data.claims.policy_sha256 != policy_hash { + return Err("signed policy hash does not match sandbox policy".to_string()); + } + Ok(()) + } + + #[cfg(test)] + fn verify_profile_signature( + &self, + token: &str, + profile_id: &str, + profile_hash: &str, + ) -> Result<(), String> { + let header = decode_header(token) + .map_err(|err| format!("failed to decode provider profile JWT header: {err}"))?; + if header.kid.as_deref() != Some(self.kid.as_str()) { + return Err("unexpected provider profile signing key id".to_string()); + } + if header.alg != Algorithm::EdDSA { + return Err("unexpected provider profile signing algorithm".to_string()); + } + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.algorithms = vec![Algorithm::EdDSA]; + validation.set_issuer(&[POLICY_JWT_ISSUER]); + validation.set_audience(&[PROFILE_JWT_AUDIENCE]); + validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); + validation.validate_exp = false; + + let data = decode::(token, &self.decoding_key, &validation) + .map_err(|err| format!("failed to verify provider profile JWT: {err}"))?; + if data.claims.sub != format!("{PROFILE_JWT_SUBJECT_PREFIX}{profile_id}") { + return Err("unexpected provider profile JWT subject".to_string()); + } + if data.claims.profile_id != profile_id { + return Err("unexpected provider profile id".to_string()); + } + if data.claims.hash_algorithm != HASH_ALGORITHM { + return Err("unexpected provider profile hash algorithm".to_string()); + } + if !is_v2_digest(&data.claims.profile_sha256) { + return Err("unexpected provider profile hash format".to_string()); + } + if data.claims.profile_sha256 != profile_hash { + return Err("signed provider profile hash does not match profile".to_string()); + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct GovernanceInterceptorService { + policy_signer: PolicySigner, + policy_state: Arc>, + profiles_path: Option, + profile_state: Arc>, +} + +#[derive(Clone, Debug)] +struct PolicyState { + policy: Value, + policy_proto: SandboxPolicy, + policy_hash: String, + policy_signature: String, + policy_signature_kid: String, +} + +#[derive(Clone, Debug)] +struct ProviderProfileState { + ids: Vec, + profiles: Vec, + revision: String, +} + +#[derive(Clone, Debug)] +struct LoadedProviderProfile { + profile: ProviderProfile, +} + +impl GovernanceInterceptorService { + #[cfg(test)] + fn from_profiles(profiles: Vec) -> Result { + Self::from_yaml(include_str!("../policy.yaml"), profiles, None) + } + + fn from_policy_and_profiles_path(policy_yaml: &str, path: PathBuf) -> Result { + let profiles = load_provider_profiles(&path)?; + Self::from_yaml(policy_yaml, profiles, Some(path)) + } + + fn from_yaml( + policy_yaml: &str, + profiles: Vec, + profiles_path: Option, + ) -> Result { + if profiles.is_empty() { + return Err("at least one provider profile must be loaded".to_string()); + } + let policy_signer = PolicySigner::generate()?; + let profile_state = profile_state_from_loaded(profiles, &policy_signer)?; + let policy_state = load_policy_state(policy_yaml, &policy_signer)?; + Ok(Self { + policy_signer, + policy_state: Arc::new(RwLock::new(policy_state)), + profiles_path, + profile_state: Arc::new(RwLock::new(profile_state)), + }) + } + + fn manifest(&self) -> InterceptorManifest { + InterceptorManifest { + name: "provider-governance".to_string(), + failure_policy: "fail_closed".to_string(), + provider_profiles: true, + bindings: vec![ + binding( + "govern-create-sandbox", + "CreateSandbox", + &[ + GatewayInterceptorPhase::ModifyOperation, + GatewayInterceptorPhase::Validate, + ], + ), + binding( + "govern-create-provider", + "CreateProvider", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-config", + "UpdateConfig", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-submit-policy-analysis", + "SubmitPolicyAnalysis", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-import-provider-profiles", + "ImportProviderProfiles", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-update-provider-profiles", + "UpdateProviderProfiles", + &[GatewayInterceptorPhase::Validate], + ), + binding( + "govern-delete-provider-profile", + "DeleteProviderProfile", + &[GatewayInterceptorPhase::Validate], + ), + ], + } + } + + fn evaluate_inner( + &self, + evaluation: &InterceptorEvaluation, + ) -> Result { + let profile_state = self.current_profile_state(); + let policy_state = self.current_policy_state(); + let phase = evaluation + .phase + .as_ref() + .ok_or_else(|| Status::invalid_argument("interceptor phase is required"))?; + let proposed_operation = match phase { + interceptor_evaluation::Phase::ModifyOperation(payload) => { + payload.proposed_operation.as_ref() + } + interceptor_evaluation::Phase::Validate(payload) => payload.proposed_operation.as_ref(), + interceptor_evaluation::Phase::PostCommit(payload) => { + payload.committed_response.as_ref() + } + } + .ok_or_else(|| Status::invalid_argument("phase payload is required"))?; + let operation = struct_to_json(proposed_operation); + + match (evaluation.method.as_str(), phase) { + ("CreateSandbox", interceptor_evaluation::Phase::ModifyOperation(_)) => { + Self::patch_create_sandbox(&operation, &policy_state) + } + ("CreateSandbox", interceptor_evaluation::Phase::Validate(_)) => { + Ok(validate_create_sandbox( + &operation, + &profile_state.ids, + &policy_state, + &self.policy_signer, + )) + } + ("CreateProvider", interceptor_evaluation::Phase::Validate(_)) => { + Ok(self.validate_create_provider(&operation, &profile_state.ids)) + } + ("UpdateConfig", interceptor_evaluation::Phase::Validate(_)) => Ok( + validate_update_config(&operation, &policy_state, &self.policy_signer), + ), + ("SubmitPolicyAnalysis", interceptor_evaluation::Phase::Validate(_)) => Ok( + validate_submit_policy_analysis(&operation, &evaluation.principal), + ), + ("ImportProviderProfiles", interceptor_evaluation::Phase::Validate(_)) => { + Ok(self.validate_import_provider_profiles(&operation, &profile_state.ids)) + } + ("UpdateProviderProfiles", interceptor_evaluation::Phase::Validate(_)) => { + Ok(self.validate_update_provider_profiles(&operation, &profile_state.ids)) + } + ("DeleteProviderProfile", interceptor_evaluation::Phase::Validate(_)) => { + Ok(validate_delete_provider_profile()) + } + _ => Ok(allow()), + } + } + + fn patch_create_sandbox( + operation: &Value, + policy_state: &PolicyState, + ) -> Result { + let mut patches = Vec::new(); + if operation.get("spec").is_some_and(Value::is_object) { + patches.push(json_patch( + "add", + "/spec/policy", + policy_state.policy.clone(), + )?); + } else { + patches.push(json_patch( + "add", + "/spec", + json!({ + "policy": policy_state.policy.clone(), + }), + )?); + } + + add_policy_signature_patches(operation, &mut patches, &policy_state.policy_signature)?; + + let mut result = allow(); + result.patches = patches; + result.log_annotations.insert( + "correlation_id".to_string(), + create_sandbox_correlation_id(operation), + ); + result + .log_annotations + .insert("policy_hash".to_string(), policy_state.policy_hash.clone()); + result.log_annotations.insert( + "policy_signature_kid".to_string(), + policy_state.policy_signature_kid.clone(), + ); + Ok(result) + } + + fn current_profile_state(&self) -> ProviderProfileState { + if let Some(path) = &self.profiles_path { + match load_provider_profiles(path) + .and_then(|profiles| profile_state_from_loaded(profiles, &self.policy_signer)) + { + Ok(state) => { + if let Ok(mut guard) = self.profile_state.write() { + *guard = state.clone(); + } + return state; + } + Err(err) => { + eprintln!( + "failed to reload provider profiles; keeping last valid snapshot: {err}" + ); + } + } + } + self.profile_state + .read() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + + fn current_policy_state(&self) -> PolicyState { + self.policy_state + .read() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + + fn reload_policy_from_yaml(&self, policy_yaml: &str) -> Result, String> { + let next = load_policy_state(policy_yaml, &self.policy_signer)?; + let mut guard = self + .policy_state + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.policy_hash == next.policy_hash { + return Ok(None); + } + *guard = next.clone(); + Ok(Some(next)) + } + + fn validate_create_provider( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { + let provider_type = provider_type(operation); + if !is_managed_profile_id(managed_profile_ids, provider_type) { + return deny(&format!( + "providers may only use vended provider profiles: {}", + format_id_list(managed_profile_ids) + )); + } + allow() + } + + fn validate_import_provider_profiles( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { + let Some(profiles) = operation.get("profiles").and_then(Value::as_array) else { + return deny("provider profile imports must include governed profile payloads"); + }; + if profiles.is_empty() { + return deny("provider profile imports must include governed profile payloads"); + } + for item in profiles { + let id = profile_id_from_import_item(item); + if !is_managed_profile_id(managed_profile_ids, id) { + return deny(&format!( + "only managed provider profiles may be imported: {}", + format_id_list(managed_profile_ids) + )); + } + } + allow() + } + + fn validate_update_provider_profiles( + &self, + operation: &Value, + managed_profile_ids: &[String], + ) -> InterceptorResult { + let target_id = operation + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(); + if !is_managed_profile_id(managed_profile_ids, target_id) { + return deny(&format!( + "only managed provider profiles may be updated: {}", + format_id_list(managed_profile_ids) + )); + } + let payload_id = operation + .get("profile") + .map(profile_id_from_import_item) + .unwrap_or_default(); + if payload_id != target_id { + return deny( + "provider profile update target must match the governed profile payload id", + ); + } + allow() + } +} + +#[tonic::async_trait] +impl GatewayInterceptor for GovernanceInterceptorService { + async fn describe( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(self.manifest())) + } + + async fn evaluate( + &self, + request: Request, + ) -> Result, Status> { + self.evaluate_inner(request.get_ref()).map(Response::new) + } + + async fn snapshot_provider_profiles( + &self, + _request: Request, + ) -> Result, Status> { + let state = self.current_profile_state(); + Ok(Response::new(ProviderProfileSnapshot { + revision: state.revision, + profiles: state.profiles, + })) + } +} + +fn binding(id: &str, method: &str, phases: &[GatewayInterceptorPhase]) -> InterceptorBinding { + InterceptorBinding { + id: id.to_string(), + selector: Some(InterceptorSelector { + rpc: format!("{SERVICE}/{method}"), + service: String::new(), + method: String::new(), + }), + phases: phases.iter().map(|phase| *phase as i32).collect(), + failure_policy: "fail_closed".to_string(), + } +} + +fn create_sandbox_correlation_id(operation: &Value) -> String { + let sandbox_name = operation + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("unnamed"); + format!("{CREATE_SANDBOX_CORRELATION_PREFIX}:{sandbox_name}") +} + +fn allow() -> InterceptorResult { + InterceptorResult { + allowed: true, + reason: String::new(), + status_code: String::new(), + patches: Vec::new(), + log_annotations: HashMap::new(), + } +} + +fn deny(reason: &str) -> InterceptorResult { + InterceptorResult { + allowed: false, + reason: reason.to_string(), + status_code: "PERMISSION_DENIED".to_string(), + patches: Vec::new(), + log_annotations: HashMap::new(), + } +} + +fn validate_create_sandbox( + operation: &Value, + managed_profile_ids: &[String], + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> InterceptorResult { + let Some(policy) = operation.pointer("/spec/policy") else { + return deny("sandbox policy must match the provider governance baseline"); + }; + let Some(signature) = operation + .pointer(&format!( + "/annotations/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + )) + .and_then(Value::as_str) + else { + return deny("sandbox is missing the governance policy signature"); + }; + let signature_validation = + validate_signed_policy_payload(policy, signature, policy_state, policy_signer); + if let Err(reason) = signature_validation { + return deny(&reason); + } + if !providers_are_managed(operation.pointer("/spec/providers"), managed_profile_ids) { + return deny(&format!( + "sandbox providers may only use vended provider profiles: {}", + format_id_list(managed_profile_ids) + )); + } + allow() +} + +fn validate_signed_policy_payload( + policy: &Value, + signature: &str, + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> Result<(), String> { + let sandbox_policy = sandbox_policy_from_interceptor_json(policy)?; + let sandbox_policy_hash = canonical_policy_hash(&sandbox_policy)?; + policy_signer + .verify_policy_signature(signature, &sandbox_policy_hash) + .map_err(|err| format!("sandbox policy signature is invalid: {err}"))?; + if sandbox_policy_hash != policy_state.policy_hash + || sandbox_policy != policy_state.policy_proto + { + return Err("sandbox policy must match the provider governance baseline".to_string()); + } + Ok(()) +} + +fn validate_update_config( + operation: &Value, + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> InterceptorResult { + if requests_auto_proposal_approval(operation) { + return deny( + "automatic policy proposal approval is blocked by provider profile governance", + ); + } + let is_global = operation + .get("global") + .and_then(Value::as_bool) + .unwrap_or(false); + let has_policy = operation + .get("policy") + .is_some_and(|value| !value.is_null()); + let has_merge_operations = operation + .get("mergeOperations") + .or_else(|| operation.get("merge_operations")) + .and_then(Value::as_array) + .is_some_and(|operations| !operations.is_empty()); + if !is_global && has_policy { + return validate_update_config_policy(operation, policy_state, policy_signer); + } + if !is_global && has_merge_operations { + deny("sandbox policy updates are blocked by provider profile governance") + } else { + allow() + } +} + +fn requests_auto_proposal_approval(operation: &Value) -> bool { + let setting_key = operation + .get("settingKey") + .or_else(|| operation.get("setting_key")) + .and_then(Value::as_str); + if setting_key != Some("proposal_approval_mode") { + return false; + } + + operation + .get("settingValue") + .or_else(|| operation.get("setting_value")) + .and_then(|value| { + value + .get("stringValue") + .or_else(|| value.get("string_value")) + }) + .and_then(Value::as_str) + == Some("auto") +} + +fn validate_submit_policy_analysis( + operation: &Value, + principal: &HashMap, +) -> InterceptorResult { + if principal.get("kind").map(String::as_str) != Some("sandbox") { + return deny("policy analysis requires an authenticated sandbox principal"); + } + + match operation + .get("proposedChunks") + .or_else(|| operation.get("proposed_chunks")) + { + Some(Value::Array(chunks)) if !chunks.is_empty() => { + deny("sandbox-authored policy proposals are blocked by provider profile governance") + } + Some(Value::Array(_)) | None => allow(), + Some(_) => deny("policy analysis proposed chunks must be an array"), + } +} + +fn validate_update_config_policy( + operation: &Value, + policy_state: &PolicyState, + policy_signer: &PolicySigner, +) -> InterceptorResult { + let Some(policy) = operation.get("policy") else { + return deny("sandbox policy updates must include a policy payload"); + }; + let Some(annotations) = operation.get("annotations").and_then(Value::as_object) else { + return deny("sandbox policy updates must include governance annotations"); + }; + let Some(signature) = annotations + .get(POLICY_SIGNATURE_ANNOTATION) + .and_then(Value::as_str) + else { + return deny("sandbox policy update is missing the governance policy signature"); + }; + let Some(policy_hash) = annotations + .get(POLICY_HASH_ANNOTATION) + .and_then(Value::as_str) + else { + return deny("sandbox policy update is missing the governance policy hash"); + }; + let Some(policy_signature_kid) = annotations + .get(POLICY_SIGNATURE_KID_ANNOTATION) + .and_then(Value::as_str) + else { + return deny("sandbox policy update is missing the governance policy signing key id"); + }; + if policy_hash != policy_state.policy_hash + || policy_signature_kid != policy_state.policy_signature_kid + { + return deny("sandbox policy update governance annotations are stale"); + } + match validate_signed_policy_payload(policy, signature, policy_state, policy_signer) { + Ok(()) => allow(), + Err(reason) => deny(&reason), + } +} + +fn validate_delete_provider_profile() -> InterceptorResult { + deny("provider profile deletes are blocked by provider governance") +} + +fn load_policy_state( + policy_yaml: &str, + policy_signer: &PolicySigner, +) -> Result { + let policy_proto = parse_sandbox_policy(policy_yaml) + .map_err(|err| format!("failed to parse policy YAML: {err}"))?; + let policy = sandbox_policy_to_proto_json(&policy_proto)?; + let policy = normalize_for_struct(policy)?; + let policy_hash = canonical_policy_hash(&policy_proto)?; + let policy_signature = policy_signer.sign_policy(&policy_hash)?; + Ok(PolicyState { + policy, + policy_proto, + policy_hash, + policy_signature, + policy_signature_kid: policy_signer.kid().to_string(), + }) +} + +fn provider_type(operation: &Value) -> &str { + operation + .pointer("/provider/type") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn profile_id_from_import_item(item: &Value) -> &str { + item.pointer("/profile/id") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn load_provider_profiles(path: &Path) -> Result, String> { + if path.is_dir() { + let mut entries = std::fs::read_dir(path) + .map_err(|err| format!("failed to read provider profiles dir: {err}"))? + .collect::, _>>() + .map_err(|err| format!("failed to read provider profiles dir entry: {err}"))?; + entries.sort_by_key(|entry| entry.path()); + let mut profiles = Vec::new(); + for entry in entries { + let path = entry.path(); + if !profile_path_supported(&path) { + continue; + } + profiles.push(load_provider_profile_file(&path)?); + } + validate_loaded_profiles(&profiles)?; + return Ok(profiles); + } + if path.is_file() { + let profiles = vec![load_provider_profile_file(path)?]; + validate_loaded_profiles(&profiles)?; + return Ok(profiles); + } + Err(format!( + "provider profiles path not found: {}", + path.display() + )) +} + +fn load_provider_profile_file(path: &Path) -> Result { + let profile_id = profile_id_from_file_name(path)?; + let input = std::fs::read_to_string(path) + .map_err(|err| format!("failed to read provider profile {}: {err}", path.display()))?; + let source = path.display().to_string(); + load_provider_profile_source(&source, &input, &profile_id) +} + +fn load_provider_profile_source( + source: &str, + input: &str, + profile_id: &str, +) -> Result { + let mut value = serde_yml::from_str::(input) + .map_err(|err| format!("failed to parse provider profile {source}: {err}"))?; + let mapping = value + .as_mapping_mut() + .ok_or_else(|| format!("provider profile {source} must be a YAML mapping"))?; + mapping.insert( + serde_yml::Value::String("id".to_string()), + serde_yml::Value::String(profile_id.to_string()), + ); + let profile = serde_yml::from_value::(value) + .map_err(|err| format!("failed to decode provider profile {source}: {err}"))? + .to_proto(); + Ok(LoadedProviderProfile { profile }) +} + +fn profile_id_from_file_name(path: &Path) -> Result { + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| { + format!( + "provider profile path has no UTF-8 file stem: {}", + path.display() + ) + })?; + let Some(normalized) = normalize_profile_id(stem) else { + return Err(format!( + "provider profile filename stem must be lowercase kebab-case: {}", + path.display() + )); + }; + if normalized != stem { + return Err(format!( + "provider profile filename stem must already be normalized: {}", + path.display() + )); + } + Ok(normalized) +} + +fn profile_path_supported(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("yaml" | "yml") + ) +} + +fn validate_loaded_profiles(profiles: &[LoadedProviderProfile]) -> Result<(), String> { + if profiles.is_empty() { + return Err("provider profiles path did not contain any YAML files".to_string()); + } + let mut ids = profiles + .iter() + .map(|profile| profile.profile.id.as_str()) + .collect::>(); + ids.sort_unstable(); + for pair in ids.windows(2) { + if pair[0] == pair[1] { + return Err(format!( + "duplicate provider profile filename stem: {}", + pair[0] + )); + } + } + Ok(()) +} + +fn loaded_profile_ids(profiles: &[LoadedProviderProfile]) -> Vec { + profiles + .iter() + .map(|profile| profile.profile.id.clone()) + .collect() +} + +fn profile_state_from_loaded( + profiles: Vec, + policy_signer: &PolicySigner, +) -> Result { + let ids = loaded_profile_ids(&profiles); + let profiles = profiles + .into_iter() + .map(|loaded| sign_provider_profile(loaded.profile, policy_signer)) + .collect::, _>>()?; + Ok(ProviderProfileState { + revision: canonical_profile_snapshot_revision(&profiles)?, + ids, + profiles, + }) +} + +fn sign_provider_profile( + mut profile: ProviderProfile, + policy_signer: &PolicySigner, +) -> Result { + profile.annotations.remove(PROFILE_SIGNATURE_ANNOTATION); + profile.annotations.remove(PROFILE_HASH_ANNOTATION); + profile.annotations.remove(PROFILE_SIGNATURE_KID_ANNOTATION); + + let profile_hash = deterministic_profile_hash(&profile)?; + let profile_signature = policy_signer.sign_profile(&profile.id, &profile_hash)?; + profile + .annotations + .insert(PROFILE_HASH_ANNOTATION.to_string(), profile_hash); + profile.annotations.insert( + PROFILE_SIGNATURE_KID_ANNOTATION.to_string(), + policy_signer.kid().to_string(), + ); + profile + .annotations + .insert(PROFILE_SIGNATURE_ANNOTATION.to_string(), profile_signature); + Ok(profile) +} + +fn deterministic_profile_hash(profile: &ProviderProfile) -> Result { + let mut profile = profile.clone(); + profile.annotations.remove(PROFILE_SIGNATURE_ANNOTATION); + profile.annotations.remove(PROFILE_HASH_ANNOTATION); + profile.annotations.remove(PROFILE_SIGNATURE_KID_ANNOTATION); + canonical_profile_hash(&profile) +} + +fn is_managed_profile_id(managed_profile_ids: &[String], id: &str) -> bool { + managed_profile_ids.iter().any(|managed| managed == id) +} + +fn format_id_list(ids: &[String]) -> String { + ids.join(", ") +} + +fn providers_are_managed(value: Option<&Value>, managed_profile_ids: &[String]) -> bool { + let Some(value) = value else { + return true; + }; + let Value::Array(providers) = value else { + return false; + }; + providers.iter().all(|provider| { + provider + .as_str() + .is_some_and(|provider| is_managed_profile_id(managed_profile_ids, provider)) + }) +} + +fn json_patch(op: &str, path: &str, value: Value) -> Result { + Ok(JsonPatch { + op: op.to_string(), + path: path.to_string(), + value: Some(json_to_proto_value(&value).map_err(Status::internal)?), + from: String::new(), + }) +} + +fn add_policy_signature_patches( + operation: &Value, + patches: &mut Vec, + policy_signature: &str, +) -> Result<(), Status> { + let signature = Value::String(policy_signature.to_string()); + if operation + .get("annotations") + .is_none_or(|value| !value.is_object()) + { + patches.push(json_patch( + "add", + "/annotations", + json!({ + POLICY_SIGNATURE_ANNOTATION: policy_signature, + }), + )?); + } else { + patches.push(json_patch( + "add", + &format!( + "/annotations/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + ), + signature, + )?); + } + Ok(()) +} + +fn json_pointer_escape(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +fn normalize_for_struct(value: Value) -> Result { + json_to_proto_value(&value).map(|value| proto_value_to_json(&value)) +} + +fn kid_from_public_key_der(public_key_der: &[u8]) -> String { + let digest = Sha256::digest(public_key_der); + hex_encode_prefix(&digest, 16) +} + +fn hex_encode_prefix(bytes: &[u8], n: usize) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(n * 2); + for byte in bytes.iter().take(n) { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn now_secs() -> i64 { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + ) + .unwrap_or(i64::MAX) +} + +fn sandbox_policy_to_proto_json(policy: &SandboxPolicy) -> Result { + decode_message_to_json(SANDBOX_POLICY_TYPE, policy) + .map_err(|err| format!("failed to render policy protobuf JSON: {err}")) +} + +fn sandbox_policy_from_interceptor_json(policy: &Value) -> Result { + let bytes = encode_json_to_message(SANDBOX_POLICY_TYPE, policy) + .map_err(|err| format!("sandbox policy cannot be decoded as protobuf JSON: {err}"))?; + SandboxPolicy::decode(bytes.as_slice()) + .map_err(|err| format!("sandbox policy protobuf payload is invalid: {err}")) +} + +fn struct_to_json(value: &Struct) -> Value { + Value::Object( + value + .fields + .iter() + .map(|(key, value)| (key.clone(), proto_value_to_json(value))) + .collect(), + ) +} + +#[cfg(test)] +fn json_to_struct(value: &Value) -> Result { + let Value::Object(fields) = value else { + return Err("JSON value must be an object".to_string()); + }; + Ok(Struct { + fields: fields + .iter() + .map(|(key, value)| json_to_proto_value(value).map(|value| (key.clone(), value))) + .collect::>()?, + }) +} + +fn json_to_proto_value(value: &Value) -> Result { + let kind = match value { + Value::Null => Kind::NullValue(0), + Value::Bool(value) => Kind::BoolValue(*value), + Value::Number(value) => Kind::NumberValue( + value + .as_f64() + .ok_or_else(|| "invalid JSON number".to_string())?, + ), + Value::String(value) => Kind::StringValue(value.clone()), + Value::Array(values) => Kind::ListValue(ListValue { + values: values + .iter() + .map(json_to_proto_value) + .collect::>()?, + }), + Value::Object(fields) => Kind::StructValue(Struct { + fields: fields + .iter() + .map(|(key, value)| json_to_proto_value(value).map(|value| (key.clone(), value))) + .collect::>()?, + }), + }; + Ok(ProtoValue { kind: Some(kind) }) +} + +fn proto_value_to_json(value: &ProtoValue) -> Value { + match value.kind.as_ref() { + Some(Kind::NullValue(_)) | None => Value::Null, + Some(Kind::NumberValue(value)) => { + Number::from_f64(*value).map_or(Value::Null, Value::Number) + } + Some(Kind::StringValue(value)) => Value::String(value.clone()), + Some(Kind::BoolValue(value)) => Value::Bool(*value), + Some(Kind::StructValue(value)) => struct_to_json(value), + Some(Kind::ListValue(value)) => { + Value::Array(value.values.iter().map(proto_value_to_json).collect()) + } + } +} + +fn spawn_policy_watch_worker( + service: GovernanceInterceptorService, + policy_path: PathBuf, + gateway_endpoint: Option, + interval: Duration, +) { + tokio::spawn(async move { + let mut last_seen = policy_file_fingerprint(&policy_path).await.ok(); + loop { + tokio::time::sleep(interval).await; + let fingerprint = match policy_file_fingerprint(&policy_path).await { + Ok(fingerprint) => fingerprint, + Err(err) => { + eprintln!("failed to stat governance policy file: {err}"); + continue; + } + }; + if last_seen.as_ref() == Some(&fingerprint) { + continue; + } + last_seen = Some(fingerprint); + + let policy_yaml = match tokio::fs::read_to_string(&policy_path).await { + Ok(policy_yaml) => policy_yaml, + Err(err) => { + eprintln!( + "failed to read governance policy file {}: {err}", + policy_path.display() + ); + continue; + } + }; + + let policy_state = match service.reload_policy_from_yaml(&policy_yaml) { + Ok(Some(policy_state)) => policy_state, + Ok(None) => continue, + Err(err) => { + eprintln!( + "failed to reload governance policy file {}; keeping previous policy: {err}", + policy_path.display() + ); + continue; + } + }; + + println!("reloaded governance policy {}", policy_state.policy_hash); + if let Some(endpoint) = gateway_endpoint.as_deref() { + if let Err(err) = + propagate_policy_to_running_sandboxes(endpoint, &policy_state).await + { + eprintln!("failed to propagate governance policy reload: {err}"); + } + } else { + println!( + "gateway endpoint not configured; policy reload applies to future sandbox creation only" + ); + } + } + }); +} + +async fn policy_file_fingerprint(path: &Path) -> Result<(SystemTime, u64), String> { + let metadata = tokio::fs::metadata(path) + .await + .map_err(|err| format!("{}: {err}", path.display()))?; + let modified = metadata.modified().unwrap_or(UNIX_EPOCH); + Ok((modified, metadata.len())) +} + +async fn propagate_policy_to_running_sandboxes( + gateway_endpoint: &str, + policy_state: &PolicyState, +) -> Result<(), String> { + let channel = Channel::from_shared(gateway_endpoint.to_string()) + .map_err(|err| format!("invalid gateway endpoint {gateway_endpoint}: {err}"))? + .connect() + .await + .map_err(|err| format!("connect to gateway {gateway_endpoint} failed: {err}"))?; + let mut client = OpenShellClient::new(channel); + let mut offset = 0_u32; + let limit = 100_u32; + let correlation_id = format!("{}:{}", RELOAD_CORRELATION_PREFIX, now_secs()); + loop { + let response = client + .list_sandboxes(ListSandboxesRequest { + limit, + offset, + label_selector: String::new(), + }) + .await + .map_err(|status| format!("list sandboxes failed: {status}"))? + .into_inner(); + let count = response.sandboxes.len(); + for sandbox in response.sandboxes { + if !sandbox_accepts_policy_reload(&sandbox) { + continue; + } + let Some(name) = sandbox_name(&sandbox).filter(|name| !name.is_empty()) else { + continue; + }; + let resource_version = sandbox + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + let result = client + .update_config(UpdateConfigRequest { + name: name.clone(), + policy: Some(policy_state.policy_proto.clone()), + annotations: policy_update_annotations(policy_state, &correlation_id), + expected_resource_version: resource_version, + ..Default::default() + }) + .await; + match result { + Ok(response) => { + println!( + "propagated governance policy reload to sandbox {} version {}", + name, + response.into_inner().version + ); + } + Err(status) if status.code() == Code::InvalidArgument => { + eprintln!( + "governance policy reload rejected for sandbox {name}: {}", + status.message() + ); + } + Err(status) => { + eprintln!("failed to update sandbox {name}: {status}"); + } + } + } + if count < usize::try_from(limit).unwrap_or(usize::MAX) { + break; + } + offset = offset.saturating_add(limit); + } + Ok(()) +} + +fn sandbox_accepts_policy_reload(sandbox: &Sandbox) -> bool { + let phase = sandbox + .status + .as_ref() + .and_then(|status| SandboxPhase::try_from(status.phase).ok()); + matches!( + phase, + Some(SandboxPhase::Ready | SandboxPhase::Provisioning) + ) +} + +fn sandbox_name(sandbox: &Sandbox) -> Option { + sandbox + .metadata + .as_ref() + .map(|metadata| metadata.name.clone()) +} + +fn policy_update_annotations( + policy_state: &PolicyState, + correlation_id: &str, +) -> HashMap { + HashMap::from([ + ( + POLICY_SIGNATURE_ANNOTATION.to_string(), + policy_state.policy_signature.clone(), + ), + ( + POLICY_HASH_ANNOTATION.to_string(), + policy_state.policy_hash.clone(), + ), + ( + POLICY_SIGNATURE_KID_ANNOTATION.to_string(), + policy_state.policy_signature_kid.clone(), + ), + ( + POLICY_RELOAD_CORRELATION_ANNOTATION.to_string(), + correlation_id.to_string(), + ), + ]) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut listen: SocketAddr = "127.0.0.1:18081".parse()?; + let mut policy_path: Option = None; + let mut profiles_path: Option = None; + let mut gateway_endpoint: Option = None; + let mut policy_watch_interval = Duration::from_millis(DEFAULT_POLICY_WATCH_INTERVAL_MS); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--listen" => { + let value = args.next().ok_or("--listen requires an address")?; + listen = value.parse()?; + } + "--policy" => { + let value = args.next().ok_or("--policy requires a path")?; + policy_path = Some(PathBuf::from(value)); + } + "--profiles" => { + let value = args.next().ok_or("--profiles requires a path")?; + profiles_path = Some(PathBuf::from(value)); + } + "--gateway-endpoint" => { + let value = args.next().ok_or("--gateway-endpoint requires a URL")?; + gateway_endpoint = Some(value); + } + "--policy-watch-interval-ms" => { + let value = args + .next() + .ok_or("--policy-watch-interval-ms requires a duration")?; + let millis = value.parse::()?; + if millis == 0 { + return Err("--policy-watch-interval-ms must be greater than zero".into()); + } + policy_watch_interval = Duration::from_millis(millis); + } + "-h" | "--help" => { + println!( + "usage: governance-interceptor [--listen ADDR] [--policy FILE] [--profiles FILE_OR_DIR] [--gateway-endpoint URL] [--policy-watch-interval-ms MS]" + ); + return Ok(()); + } + _ => return Err(format!("unknown argument: {arg}").into()), + } + } + + let policy_path = policy_path.unwrap_or_else(default_policy_path); + let policy_yaml = tokio::fs::read_to_string(&policy_path).await?; + let profiles_path = profiles_path.unwrap_or_else(default_profiles_path); + let service = + GovernanceInterceptorService::from_policy_and_profiles_path(&policy_yaml, profiles_path)?; + + if let Some(endpoint) = &gateway_endpoint { + println!("policy reload propagation enabled through gateway endpoint {endpoint}"); + } else { + println!("policy reload propagation disabled; --gateway-endpoint was not provided"); + } + let profile_state = service.current_profile_state(); + println!("loaded provider profiles: {}", profile_state.ids.join(", ")); + println!( + "loaded governance policy {} from {}", + service.current_policy_state().policy_hash, + policy_path.display() + ); + spawn_policy_watch_worker( + service.clone(), + policy_path, + gateway_endpoint, + policy_watch_interval, + ); + + println!("governance interceptor listening on {listen}"); + Server::builder() + .add_service(GatewayInterceptorServer::new(service)) + .serve(listen) + .await?; + Ok(()) +} + +fn default_profiles_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("profiles") +} + +fn default_policy_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("policy.yaml") +} + +#[cfg(test)] +mod tests; diff --git a/examples/governance-interceptor/src/policy_hash.rs b/examples/governance-interceptor/src/policy_hash.rs new file mode 100644 index 0000000000..7344d5209e --- /dev/null +++ b/examples/governance-interceptor/src/policy_hash.rs @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_core::proto::{ProviderProfile, SandboxPolicy}; +use prost::Message; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::proto_json::decode_message_to_json; + +pub(crate) const HASH_ALGORITHM: &str = "openshell-governance-protojson-sha256-v2"; +const HASH_PREFIX: &str = "sha256:v2:"; +const SANDBOX_POLICY_TYPE: &str = "openshell.sandbox.v1.SandboxPolicy"; +const PROVIDER_PROFILE_TYPE: &str = "openshell.v1.ProviderProfile"; +const POLICY_DOMAIN: &str = "openshell-governance-policy"; +const PROFILE_DOMAIN: &str = "openshell-governance-provider-profile"; +const PROFILE_SNAPSHOT_DOMAIN: &str = "openshell-governance-provider-profile-snapshot"; + +pub(crate) fn canonical_policy_hash(policy: &SandboxPolicy) -> Result { + canonical_message_hash(SANDBOX_POLICY_TYPE, policy, POLICY_DOMAIN) +} + +pub(crate) fn canonical_profile_hash(profile: &ProviderProfile) -> Result { + canonical_message_hash(PROVIDER_PROFILE_TYPE, profile, PROFILE_DOMAIN) +} + +pub(crate) fn canonical_profile_snapshot_revision( + profiles: &[ProviderProfile], +) -> Result { + let mut canonical_profiles = profiles + .iter() + .map(|profile| { + Ok(( + profile.id.as_bytes(), + canonical_message_bytes(PROVIDER_PROFILE_TYPE, profile)?, + )) + }) + .collect::, String>>()?; + canonical_profiles.sort_by(|left, right| left.0.cmp(right.0)); + + let mut hasher = Sha256::new(); + hasher.update(HASH_ALGORITHM.as_bytes()); + hash_framed(&mut hasher, PROFILE_SNAPSHOT_DOMAIN.as_bytes())?; + hash_framed(&mut hasher, PROVIDER_PROFILE_TYPE.as_bytes())?; + hash_framed( + &mut hasher, + &u64::try_from(canonical_profiles.len()) + .map_err(|_| "provider profile count exceeds hash framing limit")? + .to_be_bytes(), + )?; + for (id, canonical) in canonical_profiles { + hash_framed(&mut hasher, id)?; + hash_framed(&mut hasher, &canonical)?; + } + Ok(format!("{HASH_PREFIX}{}", hex_encode(&hasher.finalize()))) +} + +pub(crate) fn is_v2_digest(value: &str) -> bool { + value.strip_prefix(HASH_PREFIX).is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn canonical_message_hash(type_name: &str, message: &M, domain: &str) -> Result +where + M: Message, +{ + let canonical = canonical_message_bytes(type_name, message)?; + let mut hasher = Sha256::new(); + hasher.update(HASH_ALGORITHM.as_bytes()); + hash_framed(&mut hasher, domain.as_bytes())?; + hash_framed(&mut hasher, type_name.as_bytes())?; + hash_framed(&mut hasher, &canonical)?; + Ok(format!("{HASH_PREFIX}{}", hex_encode(&hasher.finalize()))) +} + +fn canonical_message_bytes(type_name: &str, message: &M) -> Result, String> +where + M: Message, +{ + let value = decode_message_to_json(type_name, message)?; + let mut bytes = Vec::new(); + write_canonical_json(&value, &mut bytes)?; + Ok(bytes) +} + +fn write_canonical_json(value: &Value, output: &mut Vec) -> Result<(), String> { + match value { + Value::Object(object) => { + output.push(b'{'); + let mut keys = object.keys().collect::>(); + keys.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + for (index, key) in keys.into_iter().enumerate() { + if index != 0 { + output.push(b','); + } + serde_json::to_writer(&mut *output, key) + .map_err(|err| format!("serialize canonical JSON object key: {err}"))?; + output.push(b':'); + write_canonical_json(&object[key], output)?; + } + output.push(b'}'); + } + Value::Array(values) => { + output.push(b'['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + output.push(b','); + } + write_canonical_json(value, output)?; + } + output.push(b']'); + } + _ => serde_json::to_writer(output, value) + .map_err(|err| format!("serialize canonical JSON scalar: {err}"))?, + } + Ok(()) +} + +fn hash_framed(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), String> { + let length = u64::try_from(bytes.len()).map_err(|_| "hash input exceeds framing limit")?; + hasher.update(length.to_be_bytes()); + hasher.update(bytes); + Ok(()) +} + +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use openshell_core::proto::{ + GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkEndpoint, + NetworkPolicyRule, + }; + + use super::*; + + #[test] + fn policy_hash_is_recursive_and_preserves_repeated_order() { + let left = policy_with_nested_maps(false); + let right = policy_with_nested_maps(true); + assert_eq!( + canonical_policy_hash(&left).unwrap(), + canonical_policy_hash(&right).unwrap() + ); + + let mut reordered = right; + reordered.network_policies.get_mut("api").unwrap().endpoints[0].rules[0] + .allow + .as_mut() + .unwrap() + .fields + .reverse(); + assert_ne!( + canonical_policy_hash(&left).unwrap(), + canonical_policy_hash(&reordered).unwrap() + ); + } + + #[test] + fn profile_hash_and_snapshot_revision_ignore_map_and_profile_order() { + let left_profile = ProviderProfile { + id: "alpha".to_string(), + annotations: map(false, [("zeta", "last"), ("alpha", "first")]), + endpoints: vec![ + policy_with_nested_maps(false) + .network_policies + .remove("api") + .unwrap() + .endpoints + .remove(0), + ], + ..ProviderProfile::default() + }; + let right_profile = ProviderProfile { + id: "alpha".to_string(), + annotations: map(true, [("zeta", "last"), ("alpha", "first")]), + endpoints: vec![ + policy_with_nested_maps(true) + .network_policies + .remove("api") + .unwrap() + .endpoints + .remove(0), + ], + ..ProviderProfile::default() + }; + assert_eq!( + canonical_profile_hash(&left_profile).unwrap(), + canonical_profile_hash(&right_profile).unwrap() + ); + + let beta = ProviderProfile { + id: "beta".to_string(), + ..ProviderProfile::default() + }; + assert_eq!( + canonical_profile_snapshot_revision(&[left_profile, beta.clone()]).unwrap(), + canonical_profile_snapshot_revision(&[beta, right_profile]).unwrap() + ); + } + + #[test] + fn digest_format_is_explicitly_v2() { + let digest = canonical_policy_hash(&SandboxPolicy::default()).unwrap(); + assert!(is_v2_digest(&digest)); + assert!(!is_v2_digest("sha256:deadbeef")); + assert!(!is_v2_digest(&format!("{HASH_PREFIX}{}", "A".repeat(64)))); + } + + fn policy_with_nested_maps(reverse: bool) -> SandboxPolicy { + let allow = L7Allow { + query: map(reverse, [("state", "open"), ("label", "bug")]), + params: map(reverse, [("name", "search"), ("kind", "tool")]), + fields: vec!["repository".to_string(), "issues".to_string()], + ..L7Allow::default() + }; + let deny = L7DenyRule { + query: map(reverse, [("private", "true"), ("admin", "true")]), + params: map(reverse, [("name", "delete"), ("kind", "tool")]), + ..L7DenyRule::default() + }; + let endpoint = NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + rules: vec![L7Rule { allow: Some(allow) }], + deny_rules: vec![deny], + graphql_persisted_queries: map( + reverse, + [("query-b", "LookupB"), ("query-a", "LookupA")], + ), + ..NetworkEndpoint::default() + }; + let rules = if reverse { + [("unused", "unused.example.com"), ("api", "api.example.com")] + } else { + [("api", "api.example.com"), ("unused", "unused.example.com")] + }; + SandboxPolicy { + version: 1, + network_policies: rules + .into_iter() + .map(|(name, host)| { + let endpoints = if name == "api" { + vec![endpoint.clone()] + } else { + vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..NetworkEndpoint::default() + }] + }; + ( + name.to_string(), + NetworkPolicyRule { + name: name.to_string(), + endpoints, + ..NetworkPolicyRule::default() + }, + ) + }) + .collect(), + ..SandboxPolicy::default() + } + } + + trait MapValue: Sized { + fn from_test_value(value: &str) -> Self; + } + + impl MapValue for String { + fn from_test_value(value: &str) -> Self { + value.to_string() + } + } + + impl MapValue for L7QueryMatcher { + fn from_test_value(value: &str) -> Self { + Self { + glob: value.to_string(), + ..Self::default() + } + } + } + + impl MapValue for GraphqlOperation { + fn from_test_value(value: &str) -> Self { + Self { + operation_type: "query".to_string(), + operation_name: value.to_string(), + fields: vec![value.to_string()], + } + } + } + + fn map(reverse: bool, entries: [(&str, &str); N]) -> HashMap + where + V: MapValue, + { + let mut entries = entries; + if reverse { + entries.reverse(); + } + entries + .into_iter() + .map(|(key, value)| (key.to_string(), V::from_test_value(value))) + .collect() + } +} diff --git a/examples/governance-interceptor/src/proto_json.rs b/examples/governance-interceptor/src/proto_json.rs new file mode 100644 index 0000000000..5dcf31087a --- /dev/null +++ b/examples/governance-interceptor/src/proto_json.rs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Example-local descriptor-backed protobuf/ProtoJSON conversion. + +use std::sync::{Arc, OnceLock}; + +use prost::Message as _; +use prost_reflect::{DescriptorPool, DeserializeOptions, DynamicMessage, MessageDescriptor}; +use serde_json::Value; + +#[derive(Debug, Clone)] +struct ProtoJsonCodec { + pool: Arc, +} + +impl ProtoJsonCodec { + fn openshell() -> Result { + let pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .map_err(|err| format!("decode OpenShell protobuf descriptor set: {err}"))?; + Ok(Self { + pool: Arc::new(pool), + }) + } + + fn decode_message_to_json(&self, type_name: &str, message: &M) -> Result + where + M: prost::Message, + { + let descriptor = self.message_descriptor(type_name)?; + let message = DynamicMessage::decode(descriptor, message.encode_to_vec().as_slice()) + .map_err(|err| format!("decode {type_name} protobuf message: {err}"))?; + serde_json::to_value(message) + .map_err(|err| format!("serialize {type_name} as ProtoJSON: {err}")) + } + + fn encode_json_to_message(&self, type_name: &str, value: &Value) -> Result, String> { + let descriptor = self.message_descriptor(type_name)?; + let encoded_json = serde_json::to_vec(value) + .map_err(|err| format!("serialize {type_name} JSON before protobuf encoding: {err}"))?; + let mut deserializer = serde_json::Deserializer::from_slice(&encoded_json); + let message = DynamicMessage::deserialize_with_options( + descriptor, + &mut deserializer, + &DeserializeOptions::new(), + ) + .map_err(|err| format!("encode {type_name} from ProtoJSON: {err}"))?; + deserializer + .end() + .map_err(|err| format!("encode {type_name} from ProtoJSON: {err}"))?; + Ok(message.encode_to_vec()) + } + + fn message_descriptor(&self, type_name: &str) -> Result { + let normalized = type_name.strip_prefix('.').unwrap_or(type_name); + self.pool.get_message_by_name(normalized).ok_or_else(|| { + format!("protobuf message type '{normalized}' was not found in the descriptor set") + }) + } +} + +fn openshell_codec() -> Result<&'static ProtoJsonCodec, String> { + static CODEC: OnceLock = OnceLock::new(); + + if let Some(codec) = CODEC.get() { + return Ok(codec); + } + + let codec = ProtoJsonCodec::openshell()?; + let _ = CODEC.set(codec); + CODEC + .get() + .ok_or_else(|| "initialize OpenShell protobuf codec".to_string()) +} + +pub(crate) fn decode_message_to_json(type_name: &str, message: &M) -> Result +where + M: prost::Message, +{ + openshell_codec()?.decode_message_to_json(type_name, message) +} + +pub(crate) fn encode_json_to_message(type_name: &str, value: &Value) -> Result, String> { + openshell_codec()?.encode_json_to_message(type_name, value) +} diff --git a/examples/governance-interceptor/src/smoke_client.rs b/examples/governance-interceptor/src/smoke_client.rs new file mode 100644 index 0000000000..e1dfde4038 --- /dev/null +++ b/examples/governance-interceptor/src/smoke_client.rs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authenticated negative-path client for the governance example smoke suite. + +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use openshell_core::proto::{ + GetSandboxConfigRequest, NetworkActivitySummary, PolicyChunk, SubmitPolicyAnalysisRequest, + UpdateConfigRequest, open_shell_client::OpenShellClient, +}; +use serde::Serialize; +use tonic::Code; +use tonic::metadata::AsciiMetadataValue; +use tonic::service::interceptor::InterceptedService; +use tonic::transport::Channel; + +#[derive(Serialize)] +struct SandboxJwtClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + sandbox_id: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let usage = "usage: governance-smoke-client \ + "; + let endpoint = args.next().ok_or(usage)?; + let sandbox_name = args.next().ok_or(usage)?; + let sandbox_id = args.next().ok_or(usage)?; + let signing_key_path = args.next().ok_or(usage)?; + let gateway_kid = args.next().ok_or(usage)?; + let gateway_id = args.next().ok_or(usage)?; + if args.next().is_some() { + return Err(usage.into()); + } + + let token = mint_sandbox_token( + &std::fs::read(signing_key_path)?, + &gateway_kid, + &gateway_id, + &sandbox_id, + )?; + let channel = Channel::from_shared(endpoint)?.connect().await?; + let bearer = AsciiMetadataValue::try_from(format!("Bearer {token}"))?; + let interceptor = move |mut request: tonic::Request<()>| { + request + .metadata_mut() + .insert("authorization", bearer.clone()); + Ok(request) + }; + let mut client = OpenShellClient::new(InterceptedService::new(channel, interceptor)); + + let before = client + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_id: sandbox_id.clone(), + }) + .await? + .into_inner(); + let mut widened_policy = before + .policy + .clone() + .ok_or("governed sandbox did not have an active policy")?; + let mut added_rule = widened_policy + .network_policies + .values() + .next() + .cloned() + .ok_or("governed policy did not contain a network rule to clone")?; + added_rule.name = "sandbox-added".to_string(); + let endpoint_to_change = added_rule + .endpoints + .first_mut() + .ok_or("governed policy network rule did not contain an endpoint")?; + endpoint_to_change.host = "sandbox-added.example".to_string(); + widened_policy + .network_policies + .insert("sandbox_added".to_string(), added_rule.clone()); + + let policy_result = client + .update_config(UpdateConfigRequest { + name: sandbox_name.clone(), + policy: Some(widened_policy), + ..Default::default() + }) + .await; + expect_permission_denied(policy_result, "unsigned sandbox policy widening")?; + + let proposal_result = client + .submit_policy_analysis(SubmitPolicyAnalysisRequest { + name: sandbox_name.clone(), + proposed_chunks: vec![PolicyChunk { + rule_name: "sandbox_added".to_string(), + proposed_rule: Some(added_rule), + rationale: "authenticated governance bypass regression".to_string(), + ..Default::default() + }], + analysis_mode: "agent_authored".to_string(), + ..Default::default() + }) + .await; + expect_permission_denied(proposal_result, "sandbox-authored policy proposal")?; + + client + .submit_policy_analysis(SubmitPolicyAnalysisRequest { + name: sandbox_name, + network_activity_summaries: vec![NetworkActivitySummary { + network_activity_count: 1, + ..Default::default() + }], + analysis_mode: "activity".to_string(), + ..Default::default() + }) + .await + .map_err(|status| format!("telemetry-only policy analysis was denied: {status}"))?; + + let after = client + .get_sandbox_config(GetSandboxConfigRequest { sandbox_id }) + .await? + .into_inner(); + if after.version != before.version || after.policy_hash != before.policy_hash { + return Err(format!( + "denied sandbox requests changed active policy: before version/hash={}/{}, after={}/{}", + before.version, before.policy_hash, after.version, after.policy_hash + ) + .into()); + } + + println!("authenticated governance bypasses denied; telemetry accepted; policy unchanged"); + Ok(()) +} + +fn mint_sandbox_token( + signing_key_pem: &[u8], + kid: &str, + gateway_id: &str, + sandbox_id: &str, +) -> Result> { + let identity = format!("openshell-gateway:{gateway_id}"); + let claims = SandboxJwtClaims { + sub: format!("spiffe://openshell/sandbox/{sandbox_id}"), + iss: identity.clone(), + aud: identity, + iat: 0, + exp: 0, + sandbox_id: sandbox_id.to_string(), + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(kid.to_string()); + let key = EncodingKey::from_ed_pem(signing_key_pem)?; + Ok(encode(&header, &claims, &key)?) +} + +fn expect_permission_denied( + result: Result, tonic::Status>, + operation: &str, +) -> Result<(), Box> { + match result { + Err(status) if status.code() == Code::PermissionDenied => Ok(()), + Err(status) => Err(format!( + "{operation} failed with {}, expected permission denied: {}", + status.code(), + status.message() + ) + .into()), + Ok(_) => Err(format!("{operation} unexpectedly succeeded").into()), + } +} diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs new file mode 100644 index 0000000000..4575fe2ac8 --- /dev/null +++ b/examples/governance-interceptor/src/tests.rs @@ -0,0 +1,969 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use openshell_core::proto::gateway_interceptor::v1::{ + ModifyOperationEvaluation, PostCommitEvaluation, ValidateEvaluation, +}; +use serde_json::json; + +fn service() -> GovernanceInterceptorService { + let profiles = load_provider_profiles(&default_profiles_path()).unwrap(); + GovernanceInterceptorService::from_profiles(profiles).unwrap() +} + +fn evaluation( + method: &str, + phase: GatewayInterceptorPhase, + operation: Value, +) -> InterceptorEvaluation { + let proposed_operation = Some(json_to_struct(&operation).unwrap()); + let phase = match phase { + GatewayInterceptorPhase::ModifyOperation => { + interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation { + proposed_operation, + }) + } + GatewayInterceptorPhase::Validate => { + interceptor_evaluation::Phase::Validate(ValidateEvaluation { + proposed_operation, + current_state: None, + }) + } + GatewayInterceptorPhase::PostCommit => { + interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation { + committed_response: proposed_operation, + }) + } + GatewayInterceptorPhase::Unspecified => panic!("test evaluation phase must be specified"), + }; + InterceptorEvaluation { + interceptor_name: "test".to_string(), + binding_id: "binding".to_string(), + service: SERVICE.to_string(), + method: method.to_string(), + principal: HashMap::new(), + phase: Some(phase), + } +} + +fn sandbox_evaluation( + method: &str, + phase: GatewayInterceptorPhase, + operation: Value, +) -> InterceptorEvaluation { + let mut evaluation = evaluation(method, phase, operation); + evaluation + .principal + .insert("kind".to_string(), "sandbox".to_string()); + evaluation + .principal + .insert("sandbox_id".to_string(), "demo-id".to_string()); + evaluation +} + +fn managed_profile_ids(service: &GovernanceInterceptorService) -> Vec { + service.current_profile_state().ids +} + +fn policy_state(service: &GovernanceInterceptorService) -> PolicyState { + service.current_policy_state() +} + +fn jwt_header(service: &GovernanceInterceptorService) -> Header { + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(service.policy_signer.kid().to_string()); + header +} + +#[test] +fn evaluation_requires_a_phase_payload() { + let service = service(); + let mut request = evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({}), + ); + request.phase = None; + + let error = service.evaluate_inner(&request).unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.message(), "interceptor phase is required"); +} + +#[test] +fn evaluation_requires_a_proposed_operation() { + let service = service(); + let mut request = evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({}), + ); + let Some(interceptor_evaluation::Phase::Validate(payload)) = request.phase.as_mut() else { + panic!("expected validate payload"); + }; + payload.proposed_operation = None; + + let error = service.evaluate_inner(&request).unwrap_err(); + assert_eq!(error.code(), Code::InvalidArgument); + assert_eq!(error.message(), "phase payload is required"); +} + +fn assert_signed_profile(service: &GovernanceInterceptorService, profile: &ProviderProfile) { + let profile_hash = profile + .annotations + .get(PROFILE_HASH_ANNOTATION) + .expect("profile hash annotation"); + assert_eq!( + profile_hash, + &deterministic_profile_hash(profile).expect("profile hash") + ); + assert_eq!( + profile + .annotations + .get(PROFILE_SIGNATURE_KID_ANNOTATION) + .map(String::as_str), + Some(service.policy_signer.kid()) + ); + let profile_signature = profile + .annotations + .get(PROFILE_SIGNATURE_ANNOTATION) + .expect("profile signature annotation"); + service + .policy_signer + .verify_profile_signature(profile_signature, &profile.id, profile_hash) + .expect("profile signature verifies"); +} + +fn governed_create_operation( + service: &GovernanceInterceptorService, + policy: Value, + signature: String, +) -> Value { + governed_create_operation_with_providers(policy, signature, managed_profile_ids(service)) +} + +fn governed_create_operation_with_providers( + policy: Value, + signature: String, + providers: Vec, +) -> Value { + let mut operation = json!({ + "spec": { + "policy": policy, + "providers": providers, + }, + "annotations": {}, + }); + operation + .pointer_mut("/annotations") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + POLICY_SIGNATURE_ANNOTATION.to_string(), + Value::String(signature), + ); + operation +} + +fn signature_patch_token(result: &InterceptorResult) -> String { + result + .patches + .iter() + .find(|patch| { + patch.path == "/annotations/openshell.nvidia.com~1policy-signature" + || patch.path == "/annotations" + }) + .and_then(|patch| patch.value.as_ref()) + .map(proto_value_to_json) + .and_then(|value| { + value.as_str().map(ToString::to_string).or_else(|| { + value + .pointer(&format!( + "/{}", + json_pointer_escape(POLICY_SIGNATURE_ANNOTATION) + )) + .and_then(Value::as_str) + .map(ToString::to_string) + }) + }) + .expect("signature patch value") +} + +fn policy_yaml_with_dynamic_rule() -> String { + let policy = include_str!("../policy.yaml"); + let changed = policy + .replace("api-1.example.com", "api-2.example.com") + .replace("api.example.com", "api.changed.example.com"); + if changed != policy { + return changed; + } + + policy.replace( + "network_policies: {}", + r#"network_policies: + example_api: +name: example-api +endpoints: +- host: example.com + port: 443 + protocol: rest + enforcement: enforce + access: read-only"#, + ) +} + +#[test] +fn manifest_declares_governance_bindings() { + let service = service(); + let manifest = service.manifest(); + let ids: Vec<_> = manifest + .bindings + .iter() + .map(|binding| binding.id.as_str()) + .collect(); + assert!(ids.contains(&"govern-import-provider-profiles")); + assert!(ids.contains(&"govern-update-provider-profiles")); + assert!(ids.contains(&"govern-delete-provider-profile")); + assert!(ids.contains(&"govern-update-config")); + assert!(ids.contains(&"govern-submit-policy-analysis")); + assert!(ids.contains(&"govern-create-sandbox")); + assert!(!ids.contains(&"govern-attach-provider")); + assert!(!ids.contains(&"govern-detach-provider")); + assert!(!ids.contains(&"govern-update-provider")); + assert!(!ids.contains(&"govern-delete-provider")); + assert_eq!(manifest.failure_policy, "fail_closed"); + assert!(manifest.provider_profiles); +} + +#[tokio::test] +async fn snapshot_provider_profiles_returns_current_profiles() { + let service = service(); + let snapshot = service + .snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {})) + .await + .unwrap() + .into_inner(); + assert!(!snapshot.revision.is_empty()); + let profile_ids = snapshot + .profiles + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(); + assert_eq!(profile_ids, vec!["github", "slack"]); + for profile in &snapshot.profiles { + assert_signed_profile(&service, profile); + } +} + +#[test] +fn profile_loader_uses_file_name_as_profile_id() { + let loaded = load_provider_profile_source( + "profiles/example-api.yaml", + r#" +id: ignored +display_name: Example API +description: Example profile +credentials: [] +endpoints: [] +binaries: [] +"#, + "example-api", + ) + .unwrap(); + assert_eq!(loaded.profile.id, "example-api"); + + let loaded = load_provider_profile_source( + "profiles/no-id.yaml", + r#" +display_name: No ID +description: Filename supplies the profile id +credentials: [] +endpoints: [] +binaries: [] +"#, + "no-id", + ) + .unwrap(); + assert_eq!(loaded.profile.id, "no-id"); +} + +#[test] +fn create_sandbox_modify_adds_policy_and_signature_without_replacing_providers() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::ModifyOperation, + json!({ + "name": "demo", + "spec": {"providers": ["github"]}, + "labels": {"team": "platform"}, + }), + )) + .unwrap(); + + assert!(result.allowed); + let paths: Vec<_> = result + .patches + .iter() + .map(|patch| patch.path.as_str()) + .collect(); + assert!(paths.contains(&"/spec/policy")); + assert!(!paths.contains(&"/spec/providers")); + assert!( + paths.contains(&"/annotations") + || paths.contains(&"/annotations/openshell.nvidia.com~1policy-signature") + ); + let token = signature_patch_token(&result); + assert_eq!(token.split('.').count(), 3); + assert_eq!( + result + .log_annotations + .get("correlation_id") + .map(String::as_str), + Some("governance:create-sandbox:demo") + ); + assert!(result.log_annotations.contains_key("policy_hash")); + assert!(result.log_annotations.contains_key("policy_signature_kid")); + assert!(!result.log_annotations.contains_key("policy_signature")); +} + +#[test] +fn create_sandbox_validate_accepts_selected_provider_subset() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation_with_providers( + state.policy.clone(), + state.policy_signature.clone(), + vec!["github".to_string()], + ), + )) + .unwrap(); + assert!(result.allowed); +} + +#[test] +fn create_sandbox_validate_accepts_missing_provider_list() { + let service = service(); + let state = policy_state(&service); + let mut operation = json!({ + "spec": { + "policy": state.policy.clone(), + }, + "annotations": {}, + }); + operation + .pointer_mut("/annotations") + .and_then(Value::as_object_mut) + .unwrap() + .insert( + POLICY_SIGNATURE_ANNOTATION.to_string(), + Value::String(state.policy_signature.clone()), + ); + + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(result.allowed); +} + +#[test] +fn create_sandbox_validate_denies_unmanaged_provider() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation_with_providers( + state.policy.clone(), + state.policy_signature.clone(), + vec!["github".to_string(), "teams".to_string()], + ), + )) + .unwrap(); + assert!(!result.allowed); + assert!( + result + .reason + .contains("sandbox providers may only use vended provider profiles") + ); +} + +#[test] +fn create_sandbox_validate_denies_missing_signature() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + json!({ + "spec": { + "policy": state.policy, + "providers": managed_profile_ids(&service), + }, + }), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("missing")); +} + +#[test] +fn create_sandbox_validate_denies_malformed_signature() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(&service, state.policy.clone(), "not-a-jwt".to_string()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); +} + +#[test] +fn create_sandbox_validate_denies_signature_from_other_key() { + let governance = service(); + let other = service(); + let governance_state = policy_state(&governance); + let other_state = policy_state(&other); + let result = governance + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation( + &governance, + governance_state.policy.clone(), + other_state.policy_signature, + ), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); +} + +#[test] +fn create_sandbox_validate_denies_signed_policy_mismatch() { + let service = service(); + let state = policy_state(&service); + let mut tampered_policy = state.policy.clone(); + tampered_policy + .as_object_mut() + .unwrap() + .insert("version".to_string(), json!(999)); + let result = service + .evaluate_inner(&evaluation( + "CreateSandbox", + GatewayInterceptorPhase::Validate, + governed_create_operation(&service, tampered_policy, state.policy_signature.clone()), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("signature")); +} + +#[test] +fn policy_signature_rejects_legacy_hash_algorithm() { + let service = service(); + let state = policy_state(&service); + let claims = PolicySignatureClaims { + sub: POLICY_JWT_SUBJECT.to_string(), + iss: POLICY_JWT_ISSUER.to_string(), + aud: POLICY_JWT_AUDIENCE.to_string(), + iat: now_secs(), + exp: 0, + hash_algorithm: "openshell-governance-protobuf-sha256-v1".to_string(), + policy_sha256: state.policy_hash.clone(), + }; + let token = encode( + &jwt_header(&service), + &claims, + &service.policy_signer.encoding_key, + ) + .unwrap(); + + let error = service + .policy_signer + .verify_policy_signature(&token, &state.policy_hash) + .unwrap_err(); + assert_eq!(error, "unexpected policy hash algorithm"); +} + +#[test] +fn profile_signature_rejects_missing_hash_algorithm() { + #[derive(serde::Serialize)] + struct LegacyProfileSignatureClaims { + sub: String, + iss: String, + aud: String, + iat: i64, + exp: i64, + profile_id: String, + profile_sha256: String, + } + + let service = service(); + let profile = &service.current_profile_state().profiles[0]; + let profile_hash = profile.annotations.get(PROFILE_HASH_ANNOTATION).unwrap(); + let claims = LegacyProfileSignatureClaims { + sub: format!("{PROFILE_JWT_SUBJECT_PREFIX}{}", profile.id), + iss: POLICY_JWT_ISSUER.to_string(), + aud: PROFILE_JWT_AUDIENCE.to_string(), + iat: 0, + exp: 0, + profile_id: profile.id.clone(), + profile_sha256: profile_hash.clone(), + }; + let token = encode( + &jwt_header(&service), + &claims, + &service.policy_signer.encoding_key, + ) + .unwrap(); + + let error = service + .policy_signer + .verify_profile_signature(&token, &profile.id, profile_hash) + .unwrap_err(); + assert!(error.contains("missing field `hash_algorithm`")); +} + +#[test] +fn policy_patch_uses_protobuf_json_names() { + let service = service(); + let state = policy_state(&service); + assert!(state.policy.get("filesystem").is_some()); + assert!(state.policy.get("networkPolicies").is_some()); + assert!(state.policy.get("filesystem_policy").is_none()); + assert!(state.policy.get("network_policies").is_none()); +} + +#[test] +fn policy_reload_updates_hash_and_preserves_last_valid_state_on_error() { + let service = service(); + let before = policy_state(&service); + let changed = service + .reload_policy_from_yaml(&policy_yaml_with_dynamic_rule()) + .unwrap() + .expect("policy hash should change"); + assert_ne!(before.policy_hash, changed.policy_hash); + assert_eq!(policy_state(&service).policy_hash, changed.policy_hash); + + let err = service + .reload_policy_from_yaml("version: not-a-number") + .expect_err("invalid policy should be rejected"); + assert!(err.contains("failed to parse policy YAML")); + assert_eq!(policy_state(&service).policy_hash, changed.policy_hash); +} + +#[test] +fn signed_governance_policy_update_is_allowed() { + let service = service(); + let state = policy_state(&service); + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": state.policy.clone(), + "annotations": policy_update_annotations(&state, "governance:reload-policy:test"), + }), + )) + .unwrap(); + assert!(result.allowed); +} + +#[test] +fn sandbox_policy_sync_requires_current_signed_governance_policy() { + let service = service(); + let state = policy_state(&service); + + let unsigned = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"name": "demo", "policy": state.policy.clone()}), + )) + .unwrap(); + assert!(!unsigned.allowed); + assert!(unsigned.reason.contains("governance annotations")); + + let signed = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": state.policy.clone(), + "annotations": policy_update_annotations( + &state, + "governance:sandbox-sync:test", + ), + }), + )) + .unwrap(); + assert!(signed.allowed); + + let mut widened = state.policy.clone(); + widened["networkPolicies"]["sandbox_added"] = json!({ + "name": "sandbox-added", + "endpoints": [{"host": "sandbox-added.example", "port": 443}], + }); + let copied_annotations = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": widened, + "annotations": policy_update_annotations( + &state, + "governance:sandbox-sync:copied", + ), + }), + )) + .unwrap(); + assert!(!copied_annotations.allowed); + assert!(copied_annotations.reason.contains("invalid")); +} + +#[test] +fn stale_governance_policy_update_is_denied_after_reload() { + let service = service(); + let stale = policy_state(&service); + let changed = service + .reload_policy_from_yaml(&policy_yaml_with_dynamic_rule()) + .unwrap() + .expect("policy hash should change"); + assert_ne!(stale.policy_hash, changed.policy_hash); + + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "policy": stale.policy.clone(), + "annotations": policy_update_annotations(&stale, "governance:reload-policy:stale"), + }), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("stale")); +} + +#[test] +fn provider_creation_is_limited_to_vended_profiles() { + let service = service(); + let github = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "work-github"}, "type": "github"}}), + )) + .unwrap(); + assert!(github.allowed); + + let slack = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "team-chat"}, "type": "slack"}}), + )) + .unwrap(); + assert!(slack.allowed); + + let teams = service + .evaluate_inner(&evaluation( + "CreateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "teams"}, "type": "teams"}}), + )) + .unwrap(); + assert!(!teams.allowed); + assert!( + teams + .reason + .contains("providers may only use vended provider profiles") + ); +} + +#[test] +fn provider_profile_import_is_limited_to_governed_profiles() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "ImportProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "profiles": [ + {"profile": {"id": "github"}}, + {"profile": {"id": "slack"}} + ] + }), + )) + .unwrap(); + assert!(result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "ImportProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({"profiles": [{"profile": {"id": "custom-slack"}}]}), + )) + .unwrap(); + assert!(!result.allowed); +} + +#[test] +fn provider_profile_update_is_limited_to_matching_governed_profiles() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "slack", + "profile": {"profile": {"id": "slack"}} + }), + )) + .unwrap(); + assert!(result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "slack", + "profile": {"profile": {"id": "github"}} + }), + )) + .unwrap(); + assert!(!result.allowed); + + let result = service + .evaluate_inner(&evaluation( + "UpdateProviderProfiles", + GatewayInterceptorPhase::Validate, + json!({ + "id": "custom-slack", + "profile": {"profile": {"id": "custom-slack"}} + }), + )) + .unwrap(); + assert!(!result.allowed); +} + +#[test] +fn provider_profile_delete_is_denied() { + let service = service(); + let result = service + .evaluate_inner(&evaluation( + "DeleteProviderProfile", + GatewayInterceptorPhase::Validate, + json!({"id": "github"}), + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("deletes are blocked")); +} + +#[test] +fn provider_update_and_delete_are_not_governed() { + let service = service(); + let update = service + .evaluate_inner(&evaluation( + "UpdateProvider", + GatewayInterceptorPhase::Validate, + json!({"provider": {"metadata": {"name": "slack"}}}), + )) + .unwrap(); + assert!(update.allowed); + + let delete = service + .evaluate_inner(&evaluation( + "DeleteProvider", + GatewayInterceptorPhase::Validate, + json!({"name": "github"}), + )) + .unwrap(); + assert!(delete.allowed); +} + +#[test] +fn policy_update_and_merge_are_denied() { + let service = service(); + for operation in [ + json!({"name": "demo", "policy": {"version": 1}}), + json!({"name": "demo", "mergeOperations": [{"op": "add"}]}), + json!({"name": "demo", "merge_operations": [{"op": "add"}]}), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(!result.allowed); + } + + let settings_update = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"global": true, "settingKey": "providers_v2_enabled"}), + )) + .unwrap(); + assert!(settings_update.allowed); + + let global_policy_update = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"global": true, "policy": {"version": 1}}), + )) + .unwrap(); + assert!(global_policy_update.allowed); + + let sandbox_policy_sync = service + .evaluate_inner(&sandbox_evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + json!({"name": "demo", "policy": {"version": 1}}), + )) + .unwrap(); + assert!(!sandbox_policy_sync.allowed); +} + +#[test] +fn automatic_proposal_approval_settings_are_denied() { + let service = service(); + + for operation in [ + json!({ + "global": true, + "settingKey": "proposal_approval_mode", + "settingValue": {"stringValue": "auto"}, + }), + json!({ + "name": "demo", + "settingKey": "proposal_approval_mode", + "settingValue": {"stringValue": "auto"}, + }), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(!result.allowed); + assert!(result.reason.contains("automatic policy proposal approval")); + } + + for operation in [ + json!({ + "global": true, + "settingKey": "proposal_approval_mode", + "settingValue": {"stringValue": "manual"}, + }), + json!({ + "global": true, + "settingKey": "proposal_approval_mode", + "deleteSetting": true, + }), + ] { + let result = service + .evaluate_inner(&evaluation( + "UpdateConfig", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(result.allowed); + } +} + +#[test] +fn sandbox_policy_analysis_allows_telemetry_but_denies_proposals() { + let service = service(); + + for operation in [ + json!({ + "name": "demo", + "networkActivitySummaries": [{"networkActivityCount": 1}], + }), + json!({ + "name": "demo", + "summaries": [{"host": "denied.example", "port": 443}], + }), + json!({"name": "demo", "proposedChunks": []}), + ] { + let result = service + .evaluate_inner(&sandbox_evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + operation, + )) + .unwrap(); + assert!(result.allowed); + } + + let proposal = service + .evaluate_inner(&sandbox_evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + json!({ + "name": "demo", + "proposedChunks": [{"ruleName": "sandbox-added"}], + }), + )) + .unwrap(); + assert!(!proposal.allowed); + assert!( + proposal + .reason + .contains("sandbox-authored policy proposals") + ); +} + +#[test] +fn policy_analysis_fails_closed_without_a_sandbox_principal() { + let service = service(); + + let missing = service + .evaluate_inner(&evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + json!({"name": "demo"}), + )) + .unwrap(); + assert!(!missing.allowed); + + let mut user = evaluation( + "SubmitPolicyAnalysis", + GatewayInterceptorPhase::Validate, + json!({"name": "demo"}), + ); + user.principal + .insert("kind".to_string(), "user".to_string()); + let user = service.evaluate_inner(&user).unwrap(); + assert!(!user.allowed); +} diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f471f575a0..3ef95afbbd 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.compute.v1; import "google/protobuf/struct.proto"; +import "options.proto"; // Internal compute-driver contract used by the gateway. // @@ -94,7 +95,7 @@ message DriverSandboxSpec { // the Kubernetes driver ignores this field and relies on its projected // ServiceAccount token bootstrap instead). Never echoed to the public // Sandbox proto. - string sandbox_token = 11; + string sandbox_token = 11 [(openshell.options.v1.secret) = true]; } message ResourceRequirements { diff --git a/proto/datamodel.proto b/proto/datamodel.proto index f92d7b7a36..ada4716b11 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -5,10 +5,13 @@ syntax = "proto3"; package openshell.datamodel.v1; +import "options.proto"; + // Kubernetes-style metadata shared by all top-level OpenShell domain objects. // -// This structure provides consistent metadata (identity, labels, timestamps, -// resource versioning) across Sandbox, Provider, SshSession, and other resources. +// This structure provides consistent metadata (identity, labels, annotations, +// timestamps, resource versioning) across Sandbox, Provider, SshSession, and +// other resources. message ObjectMeta { // Stable object ID generated by the gateway. string id = 1; @@ -26,6 +29,10 @@ message ObjectMeta { // Optimistic concurrency control version. // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. uint64 resource_version = 5; + + // Opaque key-value metadata that is not used for selectors. + // Annotation keys use the same qualified-key shape as labels, but values may be longer. + map annotations = 6; } // Provider model stored by OpenShell. @@ -35,7 +42,7 @@ message Provider { // Canonical provider type slug (for example: "claude", "gitlab"). string type = 2; // Secret values used for authentication. - map credentials = 3; + map credentials = 3 [(openshell.options.v1.secret) = true]; // Non-secret provider configuration. map config = 4; // Expiration timestamps for credential values, keyed by credential/env var diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto new file mode 100644 index 0000000000..f6b4a19210 --- /dev/null +++ b/proto/gateway_interceptor.proto @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.gateway_interceptor.v1; + +import "google/protobuf/struct.proto"; +import "openshell.proto"; + +// GatewayInterceptor lets an external governance service evaluate gateway +// control-plane operations after OpenShell admission and before or after the +// gateway applies the operation. +service GatewayInterceptor { + // Describe returns the interceptor manifest and declared bindings. + rpc Describe(DescribeRequest) returns (InterceptorManifest); + + // SnapshotProviderProfiles returns the interceptor's current provider + // profile snapshot when the manifest advertises provider_profiles = true. + rpc SnapshotProviderProfiles(ProviderProfileSnapshotRequest) + returns (ProviderProfileSnapshot); + + // Evaluate returns an allow, deny, or mutation decision for one operation + // phase. + rpc Evaluate(InterceptorEvaluation) returns (InterceptorResult); +} + +message DescribeRequest {} + +message ProviderProfileSnapshotRequest {} + +enum GatewayInterceptorPhase { + GATEWAY_INTERCEPTOR_PHASE_UNSPECIFIED = 0; + GATEWAY_INTERCEPTOR_PHASE_MODIFY_OPERATION = 2; + GATEWAY_INTERCEPTOR_PHASE_VALIDATE = 3; + GATEWAY_INTERCEPTOR_PHASE_POST_COMMIT = 4; +} + +message InterceptorEvaluation { + // Configured interceptor instance name. + string interceptor_name = 1; + // Manifest binding id selected for this evaluation. + string binding_id = 2; + // Public gRPC service name, e.g. "openshell.v1.OpenShell". + string service = 3; + // Public gRPC method name, e.g. "CreateSandbox". + string method = 4; + // Caller identity summary. Values are intentionally non-secret. + map principal = 5; + + // Exactly one phase payload is set for each evaluation. + oneof phase { + // Evaluation that may modify the prepared gateway operation. + ModifyOperationEvaluation modify_operation = 6; + // Evaluation that may reject, but not mutate, the prepared operation. + ValidateEvaluation validate = 7; + // Evaluation after the gateway operation has completed successfully. + PostCommitEvaluation post_commit = 8; + } +} + +message ModifyOperationEvaluation { + // Gateway-prepared operation the gateway proposes to execute. + google.protobuf.Struct proposed_operation = 1; +} + +message ValidateEvaluation { + // Gateway-prepared operation after earlier interceptor modifications. + google.protobuf.Struct proposed_operation = 1; + // Optional read-only gateway state loaded before applying the operation. + google.protobuf.Struct current_state = 2; +} + +message PostCommitEvaluation { + // Protobuf JSON-shaped response returned by the committed gateway operation. + google.protobuf.Struct committed_response = 1; +} + +message InterceptorResult { + // False denies the operation before side effects for modify_operation and + // validate. Post-commit denial is invalid. + bool allowed = 1; + // Human-readable reason for logs and denied gRPC status messages. + string reason = 2; + // Optional gRPC status code name for denials, e.g. "PERMISSION_DENIED". + string status_code = 3; + // RFC 6902 JSON patches. Only valid during modify_operation. + repeated JsonPatch patches = 4; + // Non-secret annotations included in gateway logs. + map log_annotations = 5; +} + +message InterceptorManifest { + // Human-readable interceptor name declared by the service. + string name = 1; + // Bindings declared by the interceptor service. + repeated InterceptorBinding bindings = 2; + // Optional default failure policy for bindings without their own policy. + // Supported values are "fail_closed" and "fail_open". + string failure_policy = 3; + // True when this interceptor implements SnapshotProviderProfiles. + bool provider_profiles = 4; +} + +message ProviderProfileSnapshot { + // Opaque source revision used for cache freshness and sandbox reload checks. + string revision = 1; + // Complete profile snapshot vended by this source. + repeated openshell.v1.ProviderProfile profiles = 2; +} + +message InterceptorBinding { + // Stable binding id used for config overrides and audit logs. + string id = 1; + // RPC selector. Selectors are intentionally tied to the public API shape. + InterceptorSelector selector = 2; + // Phases this binding wants to evaluate. + repeated GatewayInterceptorPhase phases = 3; + // Optional binding-specific failure policy. + // Supported values are "fail_closed" and "fail_open". + // Bindings that include post_commit must resolve to "fail_open" because the + // gateway operation has already committed before that phase runs. + string failure_policy = 4; +} + +message InterceptorSelector { + // Full selector form: "openshell.v1.OpenShell/CreateSandbox". + string rpc = 1; + // Structured service/method form. If rpc is set, it takes precedence. + string service = 2; + string method = 3; +} + +message JsonPatch { + string op = 1; + string path = 2; + google.protobuf.Value value = 3; + string from = 4; +} diff --git a/proto/inference.proto b/proto/inference.proto index b0bc581e81..e982a2700e 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.inference.v1; import "datamodel.proto"; +import "options.proto"; // Inference service provides cluster inference configuration and bundle delivery. service Inference { @@ -104,7 +105,7 @@ message ResolvedRoute { string name = 1; string base_url = 2; repeated string protocols = 3; - string api_key = 4; + string api_key = 4 [(openshell.options.v1.secret) = true]; string model_id = 5; string provider_type = 6; // Per-route request timeout in seconds. 0 means use default (60s). diff --git a/proto/openshell.proto b/proto/openshell.proto index d2d884f2e8..776ae66ca6 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -7,6 +7,7 @@ package openshell.v1; import "datamodel.proto"; import "google/protobuf/struct.proto"; +import "options.proto"; import "sandbox.proto"; // OpenShell service provides sandbox, provider, and runtime management capabilities. @@ -256,7 +257,7 @@ message IssueSandboxTokenRequest {} // gateway RPC. message IssueSandboxTokenResponse { // Gateway-minted JWT bound to the calling sandbox's UUID. - string token = 1; + string token = 1 [(openshell.options.v1.secret) = true]; // Absolute expiry of the issued token, milliseconds since the epoch. 0 means // the token is non-expiring. int64 expires_at_ms = 2; @@ -271,7 +272,7 @@ message RefreshSandboxTokenRequest {} // in-memory bearer credential. message RefreshSandboxTokenResponse { // Fresh gateway-minted JWT bound to the same sandbox UUID. - string token = 1; + string token = 1 [(openshell.options.v1.secret) = true]; // Absolute expiry of the new token, milliseconds since the epoch. 0 means // the token is non-expiring. int64 expires_at_ms = 2; @@ -447,6 +448,8 @@ message CreateSandboxRequest { string name = 2; // Optional labels for the sandbox (key-value metadata). map labels = 3; + // Optional annotations for the sandbox (non-selector metadata). + map annotations = 4; } // Get sandbox request. @@ -555,7 +558,7 @@ message CreateSshSessionResponse { // Session token for the gateway tunnel. URL-safe ASCII // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or // whitespace. - string token = 2; + string token = 2 [(openshell.options.v1.secret) = true]; // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus @@ -649,7 +652,7 @@ message ServiceEndpointResponse { // Revoke SSH session request. message RevokeSshSessionRequest { // Session token to revoke. - string token = 1; + string token = 1 [(openshell.options.v1.secret) = true]; } // Revoke SSH session response. @@ -725,7 +728,7 @@ message TcpForwardInit { } // Optional target-specific authorization token. SSH targets use this as the // short-lived SSH session token issued by CreateSshSession. - string authorization_token = 7; + string authorization_token = 7 [(openshell.options.v1.secret) = true]; } // A single frame on the CLI-to-gateway TCP forward stream. @@ -764,7 +767,7 @@ message SshSession { string sandbox_id = 2; // Session token. - string token = 3; + string token = 3 [(openshell.options.v1.secret) = true]; // Expiry timestamp in milliseconds since epoch. 0 means no expiry // (backward-compatible default for sessions created before this field existed). @@ -1016,7 +1019,7 @@ message StoredProviderCredentialRefreshState { string provider_name = 3; string credential_key = 4; ProviderCredentialRefreshStrategy strategy = 5; - map material = 6; + map material = 6 [(openshell.options.v1.secret) = true]; repeated string secret_material_keys = 7; int64 expires_at_ms = 8; int64 next_refresh_at_ms = 9; @@ -1042,7 +1045,7 @@ message ConfigureProviderRefreshRequest { string provider = 1; string credential_key = 2; ProviderCredentialRefreshStrategy strategy = 3; - map material = 4; + map material = 4 [(openshell.options.v1.secret) = true]; repeated string secret_material_keys = 5; optional int64 expires_at_ms = 6; } @@ -1096,6 +1099,8 @@ message ProviderProfile { // profile files use 0. Gateway responses set this for stored custom profiles. // Update calls use this for optimistic concurrency. uint64 resource_version = 10; + // Optional non-secret annotations attached by profile sources or importers. + map annotations = 11; } // Stored custom provider profile object. @@ -1180,7 +1185,7 @@ message GetSandboxProviderEnvironmentRequest { // Get sandbox provider environment response. message GetSandboxProviderEnvironmentResponse { // Provider credential environment variables. - map environment = 1; + map environment = 1 [(openshell.options.v1.secret) = true]; // Fingerprint for the provider credential inputs that produced environment. uint64 provider_env_revision = 2; // Expiration timestamps for returned environment variables. @@ -1226,6 +1231,13 @@ message UpdateConfigRequest { // matches this value before applying the mutation, returning ABORTED on mismatch. // Ignored for global-scoped updates. uint64 expected_resource_version = 8; + // Caller-provided annotations associated with a sandbox-scoped update. Values + // must not contain secrets; the gateway treats them as opaque metadata and does + // not interpret or verify their semantics. For policy updates, the gateway + // stores the annotations immutably with the revision and merges them into + // sandbox metadata as a convenience projection. For setting-only updates, it + // only merges them into sandbox metadata. + map annotations = 9; } message PolicyMergeOperation { @@ -1281,6 +1293,8 @@ message UpdateConfigResponse { uint64 settings_revision = 3; // True when a setting delete operation removed an existing key. bool deleted = 4; + // Sandbox metadata annotations after the update. Empty for global updates. + map annotations = 5; } // Get sandbox policy status request. @@ -1347,6 +1361,8 @@ message SandboxPolicyRevision { int64 loaded_at_ms = 6; // The full policy (only populated when explicitly requested). openshell.sandbox.v1.SandboxPolicy policy = 7; + // Immutable provenance supplied with this policy revision. + map provenance = 8; } // Policy load status. @@ -1828,6 +1844,8 @@ message PolicyRevisionPayload { string load_error = 3; // When the policy version was reported as loaded (ms since epoch). 0 if unset. int64 loaded_at_ms = 4; + // Immutable provenance supplied when this revision was created. + map provenance = 5; } // Stored payload for a draft policy chunk row in the generic objects table. @@ -1871,6 +1889,7 @@ message StoredPolicyRevision { optional string load_error = 7; int64 created_at_ms = 8; optional int64 loaded_at_ms = 9; + map provenance = 10; } // Internal stored draft chunk row materialized from the generic objects table. diff --git a/proto/options.proto b/proto/options.proto new file mode 100644 index 0000000000..ca0764cc3f --- /dev/null +++ b/proto/options.proto @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.options.v1; + +import "google/protobuf/descriptor.proto"; + +// Marks a protobuf field whose value must not cross generic observation or +// extension boundaries such as gateway interceptors. +extend google.protobuf.FieldOptions { + bool secret = 50001; +} diff --git a/tasks/python.toml b/tasks/python.toml index b95d966714..62b8b4f787 100644 --- a/tasks/python.toml +++ b/tasks/python.toml @@ -230,6 +230,7 @@ uv run python -m grpc_tools.protoc \ proto/inference.proto \ proto/openshell.proto \ proto/datamodel.proto \ + proto/options.proto \ proto/sandbox.proto # Fix absolute imports in generated stubs to use package-relative imports uv run python - <<'PY' @@ -239,6 +240,7 @@ import re line_rewrites = { "python/openshell/_proto/inference_pb2.py": [ (r"^import datamodel_pb2 as datamodel__pb2$", "from . import datamodel_pb2 as datamodel__pb2"), + (r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2"), ], "python/openshell/_proto/inference_pb2_grpc.py": [ (r"^import inference_pb2 as inference__pb2$", "from . import inference_pb2 as inference__pb2"), @@ -249,9 +251,11 @@ line_rewrites = { ], "python/openshell/_proto/openshell_pb2.py": [ (r"^import datamodel_pb2 as datamodel__pb2$", "from . import datamodel_pb2 as datamodel__pb2"), + (r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2"), (r"^import sandbox_pb2 as sandbox__pb2$", "from . import sandbox_pb2 as sandbox__pb2"), ], "python/openshell/_proto/datamodel_pb2.py": [ + (r"^import options_pb2 as options__pb2$", "from . import options_pb2 as options__pb2"), (r"^import sandbox_pb2 as sandbox__pb2$", "from . import sandbox_pb2 as sandbox__pb2"), ], "python/openshell/_proto/datamodel_pb2_grpc.py": [