mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-21 06:11:31 +08:00
* feat(providers): serve sandbox config files on demand Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(providers): defer managed file documentation Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(providers): mark managed file api experimental Signed-off-by: Drew Newberry <anewberry@nvidia.com> * style(go): format provider profile fields Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(providers): preserve legacy environment with managed files Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
22856 lines
836 KiB
Rust
22856 lines
836 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Policy updates, status, draft chunks, config/settings layer, and sandbox logs.
|
|
|
|
#![allow(clippy::result_large_err)] // gRPC handlers return Result<Response<_>, Status>
|
|
#![allow(clippy::cast_possible_truncation)] // Intentional u128->i64 etc. for timestamp math
|
|
#![allow(clippy::cast_sign_loss)] // Intentional i32->u32 conversions from proto types
|
|
#![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat
|
|
#![allow(clippy::cast_precision_loss)] // f64->f32 for confidence scores
|
|
#![allow(clippy::items_after_statements)] // DB_PORTS const inside function
|
|
|
|
mod endpoint_status;
|
|
mod provisioning_clock;
|
|
pub use provisioning_clock::configuration_change;
|
|
|
|
pub(super) use endpoint_status::{
|
|
handle_peer_report_endpoint_status, handle_report_endpoint_status,
|
|
};
|
|
pub use endpoint_status::{
|
|
invalidate_endpoint_status_on_startup, reset_endpoint_status_for_supervisor_session,
|
|
retry_endpoint_status_after_supervisor_disconnect,
|
|
};
|
|
|
|
use crate::ServerState;
|
|
use crate::auth::principal::Principal;
|
|
use crate::auth::workspace_authz::{MinWorkspaceRole, require_platform_admin};
|
|
use crate::pagination::Pagination;
|
|
use crate::persistence::{
|
|
DraftChunkRecord, ObjectId, ObjectListQuery, ObjectName, ObjectType, ObjectWorkspace,
|
|
PolicyRecord, Store,
|
|
};
|
|
use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt};
|
|
use crate::provider_profile_sources::EffectiveProviderProfileCatalog;
|
|
#[cfg(test)]
|
|
use crate::provider_profile_sources::ProviderProfileSources;
|
|
use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState;
|
|
#[cfg(test)]
|
|
use crate::storage_proto::StoredProviderProfile;
|
|
use openshell_core::net::{is_always_blocked_ip, is_internal_ip};
|
|
use openshell_core::policy_identity::{canonical_rule_bytes, deterministic_policy_hash};
|
|
use openshell_core::proto::policy_merge_operation;
|
|
use openshell_core::proto::setting_value;
|
|
use openshell_core::proto::{
|
|
AddAllowRules as ProtoAddAllowRules, AddDenyRules as ProtoAddDenyRules,
|
|
ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest,
|
|
ApproveDraftChunkResponse, ClearDraftChunksRequest, ClearDraftChunksResponse,
|
|
DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, EffectiveSetting,
|
|
GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse,
|
|
GetGatewayConfigRequest, GetGatewayConfigResponse, GetSandboxConfigRequest,
|
|
GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse,
|
|
GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse,
|
|
GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse,
|
|
L7RuleTarget as ProtoL7RuleTarget, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse,
|
|
PolicyChunk, PolicyMergeOperation, PolicySource, PolicyStatus, ProviderReadinessReason,
|
|
PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest,
|
|
RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse,
|
|
SandboxLogLine, SandboxPolicyRevision, SettingScope, SettingValue, SubmitPolicyAnalysisRequest,
|
|
SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse,
|
|
UpdateConfigRequest, UpdateConfigResponse,
|
|
};
|
|
use openshell_core::proto::{
|
|
L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox,
|
|
SandboxPolicy as ProtoSandboxPolicy, StaticCredentialEndpointBinding,
|
|
};
|
|
use openshell_core::telemetry::{
|
|
LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome,
|
|
};
|
|
use openshell_core::{
|
|
GetResourceVersion,
|
|
endpoint_path::EndpointPathPattern,
|
|
host_pattern::{host_matches, host_patterns_overlap},
|
|
settings::{self, SettingValueKind},
|
|
};
|
|
use openshell_ocsf::{ConfigStateChangeBuilder, OcsfEvent, SeverityId, StateId, StatusId};
|
|
use openshell_policy::{
|
|
L7BinaryScope, L7RuleTarget, PolicyMergeOp, ProviderPolicyLayer, canonicalize_advisor_add_rule,
|
|
compose_effective_policy, merge_policy, policy_covers_rule, serialize_sandbox_policy,
|
|
strip_provider_rule_names,
|
|
};
|
|
use openshell_prover::{
|
|
credentials::{Credential, CredentialSet},
|
|
finding::{Finding, FindingPath},
|
|
model::build_model,
|
|
policy::parse_policy_str,
|
|
queries::run_all_queries,
|
|
registry::load_embedded_binary_registry,
|
|
report::finding_shorthand,
|
|
};
|
|
use prost::Message;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::net::IpAddr;
|
|
use std::sync::Arc;
|
|
use tonic::{Request, Response, Status};
|
|
use tracing::{debug, info, warn};
|
|
|
|
use super::validation::{
|
|
level_matches, source_matches, validate_and_canonicalize_policy, validate_annotations,
|
|
validate_no_reserved_provider_policy_keys, validate_policy_safety,
|
|
validate_static_fields_unchanged,
|
|
};
|
|
use super::{StoredSettingValue, StoredSettings};
|
|
use crate::persistence::current_time_ms;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Internal object type for durable gateway-global settings.
|
|
const GLOBAL_SETTINGS_OBJECT_TYPE: &str = "gateway_settings";
|
|
const GLOBAL_SETTINGS_NAME: &str = "global";
|
|
/// Internal object type for durable sandbox-scoped settings.
|
|
pub const SANDBOX_SETTINGS_OBJECT_TYPE: &str = "sandbox_settings";
|
|
const PROVIDER_COMPOSITION_VALIDATION_PAGE_SIZE: u32 = 1000;
|
|
/// Reserved settings key used to store global policy payload.
|
|
const POLICY_SETTING_KEY: &str = "policy";
|
|
/// Sentinel `sandbox_id` used to store global policy revisions.
|
|
const GLOBAL_POLICY_SANDBOX_ID: &str = "__global__";
|
|
/// Stable labels used when stored policy state fails validation.
|
|
const STORED_POLICY_SOURCE_HISTORY: &str = "sandbox policy history";
|
|
const STORED_POLICY_SOURCE_SPEC: &str = "sandbox spec policy";
|
|
const STORED_POLICY_SOURCE_GLOBAL: &str = "global policy setting";
|
|
/// Maximum number of optimistic retry attempts for policy version conflicts.
|
|
const MERGE_RETRY_LIMIT: usize = 5;
|
|
|
|
// Private wire-only compatibility types for policy history written before
|
|
// 0.1.0. Public generated bindings intentionally reserve NetworkBinary tag 2,
|
|
// but stored policies still need its former advisor-provenance value migrated
|
|
// before the unknown field is discarded.
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct LegacyStoredNetworkBinary {
|
|
#[prost(string, tag = "1")]
|
|
path: String,
|
|
#[prost(bool, tag = "2")]
|
|
advisor_proposed: bool,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct LegacyStoredNetworkPolicyRule {
|
|
#[prost(string, tag = "1")]
|
|
name: String,
|
|
#[prost(message, repeated, tag = "2")]
|
|
endpoints: Vec<NetworkEndpoint>,
|
|
#[prost(message, repeated, tag = "3")]
|
|
binaries: Vec<LegacyStoredNetworkBinary>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct LegacyStoredSandboxPolicy {
|
|
#[prost(map = "string, message", tag = "5")]
|
|
network_policies: HashMap<String, LegacyStoredNetworkPolicyRule>,
|
|
}
|
|
|
|
fn decode_stored_policy(payload: &[u8]) -> Result<ProtoSandboxPolicy, prost::DecodeError> {
|
|
let mut policy = ProtoSandboxPolicy::decode(payload)?;
|
|
let legacy = LegacyStoredSandboxPolicy::decode(payload)?;
|
|
|
|
for (rule_key, legacy_rule) in legacy.network_policies {
|
|
let advisor_paths = legacy_rule
|
|
.binaries
|
|
.into_iter()
|
|
.filter(|binary| binary.advisor_proposed)
|
|
.map(|binary| binary.path)
|
|
.collect::<HashSet<_>>();
|
|
if advisor_paths.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let Some(mut explicit_rule) = policy.network_policies.remove(&rule_key) else {
|
|
continue;
|
|
};
|
|
let mut advisor_rule = explicit_rule.clone();
|
|
advisor_rule
|
|
.binaries
|
|
.retain(|binary| advisor_paths.contains(&binary.path));
|
|
explicit_rule
|
|
.binaries
|
|
.retain(|binary| !advisor_paths.contains(&binary.path));
|
|
if advisor_rule.binaries.is_empty() {
|
|
policy.network_policies.insert(rule_key, explicit_rule);
|
|
continue;
|
|
}
|
|
for endpoint in &mut advisor_rule.endpoints {
|
|
endpoint.advisor_proposed = true;
|
|
}
|
|
|
|
if explicit_rule.binaries.is_empty() {
|
|
policy.network_policies.insert(rule_key, advisor_rule);
|
|
continue;
|
|
}
|
|
|
|
policy
|
|
.network_policies
|
|
.insert(rule_key.clone(), explicit_rule);
|
|
let key_base = format!("{rule_key}__legacy_advisor");
|
|
let mut advisor_key = key_base.clone();
|
|
let mut suffix = 2_u32;
|
|
while policy.network_policies.contains_key(&advisor_key) {
|
|
advisor_key = format!("{key_base}_{suffix}");
|
|
suffix += 1;
|
|
}
|
|
advisor_rule.name.clone_from(&advisor_key);
|
|
policy.network_policies.insert(advisor_key, advisor_rule);
|
|
}
|
|
|
|
Ok(policy)
|
|
}
|
|
|
|
fn emit_sandbox_policy_update_success() {
|
|
openshell_core::telemetry::emit_lifecycle(
|
|
LifecycleResource::SandboxPolicy,
|
|
LifecycleOperation::Update,
|
|
TelemetryOutcome::Success,
|
|
);
|
|
}
|
|
|
|
fn emit_sandbox_policy_update_failure() {
|
|
openshell_core::telemetry::emit_lifecycle(
|
|
LifecycleResource::SandboxPolicy,
|
|
LifecycleOperation::Update,
|
|
TelemetryOutcome::Failure,
|
|
);
|
|
}
|
|
|
|
fn should_emit_config_update_policy_telemetry(sandbox_caller: bool) -> bool {
|
|
!sandbox_caller
|
|
}
|
|
|
|
fn emit_config_update_policy_success(sandbox_caller: bool) {
|
|
if should_emit_config_update_policy_telemetry(sandbox_caller) {
|
|
emit_sandbox_policy_update_success();
|
|
}
|
|
}
|
|
|
|
fn should_emit_full_policy_update_telemetry(sandbox_caller: bool, next_version: i64) -> bool {
|
|
!sandbox_caller && next_version > 1
|
|
}
|
|
|
|
fn emit_full_policy_update_success(sandbox_caller: bool, next_version: i64) {
|
|
if should_emit_full_policy_update_telemetry(sandbox_caller, next_version) {
|
|
emit_sandbox_policy_update_success();
|
|
}
|
|
}
|
|
|
|
fn emit_policy_decision_success(operation: PolicyDecisionOperation, rule_count: u64) {
|
|
openshell_core::telemetry::emit_policy_decision(
|
|
operation,
|
|
TelemetryOutcome::Success,
|
|
rule_count,
|
|
);
|
|
}
|
|
|
|
fn emit_policy_decision_failure(operation: PolicyDecisionOperation, rule_count: u64) {
|
|
openshell_core::telemetry::emit_policy_decision(
|
|
operation,
|
|
TelemetryOutcome::Failure,
|
|
rule_count,
|
|
);
|
|
}
|
|
|
|
fn emit_gateway_policy_audit_log(
|
|
sandbox_id: &str,
|
|
sandbox_name: &str,
|
|
state_label: &str,
|
|
detail: impl Into<String>,
|
|
version: i64,
|
|
policy_hash: &str,
|
|
) {
|
|
openshell_ocsf::ocsf_emit!(build_gateway_policy_audit_event(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
state_label,
|
|
detail,
|
|
version,
|
|
policy_hash,
|
|
&[],
|
|
));
|
|
}
|
|
|
|
/// Emit a `CONFIG:APPROVED` audit event for an auto-approval — same event
|
|
/// class as a human approval, with extra unmapped fields carrying the
|
|
/// safety reasoning so the audit is reconstructable. `source` records the
|
|
/// proposer (`mechanistic` or `agent_authored`) for provenance.
|
|
/// `resolved_from` records the scope that supplied the `auto` mode setting
|
|
/// (`gateway`, `sandbox`, or `default`) so operators can see why a given
|
|
/// approval was auto vs manual.
|
|
fn emit_gateway_policy_auto_approve_audit_log(
|
|
sandbox_id: &str,
|
|
sandbox_name: &str,
|
|
detail: impl Into<String>,
|
|
version: i64,
|
|
policy_hash: &str,
|
|
source: &str,
|
|
resolved_from: &str,
|
|
) {
|
|
let extra = [
|
|
("auto", "true".to_string()),
|
|
("source", source.to_string()),
|
|
("prover_delta", "empty".to_string()),
|
|
("resolved_from", resolved_from.to_string()),
|
|
];
|
|
openshell_ocsf::ocsf_emit!(build_gateway_policy_audit_event(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
"approved",
|
|
detail,
|
|
version,
|
|
policy_hash,
|
|
&extra,
|
|
));
|
|
}
|
|
|
|
fn build_gateway_policy_audit_event(
|
|
sandbox_id: &str,
|
|
sandbox_name: &str,
|
|
state_label: &str,
|
|
detail: impl Into<String>,
|
|
version: i64,
|
|
policy_hash: &str,
|
|
extra_fields: &[(&str, String)],
|
|
) -> OcsfEvent {
|
|
let ctx = crate::gateway_ocsf::context(sandbox_id, sandbox_name);
|
|
let mut builder = ConfigStateChangeBuilder::new(&ctx)
|
|
.state(StateId::Other, state_label)
|
|
.severity(SeverityId::Informational)
|
|
.status(StatusId::Success)
|
|
.message(detail.into());
|
|
if version > 0 {
|
|
builder = builder.unmapped("policy_version", format!("v{version}"));
|
|
}
|
|
if !policy_hash.is_empty() {
|
|
builder = builder.unmapped("policy_hash", policy_hash.to_string());
|
|
}
|
|
for (key, value) in extra_fields {
|
|
builder = builder.unmapped(key, value.clone());
|
|
}
|
|
builder.build()
|
|
}
|
|
|
|
fn summarize_cli_policy_merge_op(operation: &PolicyMergeOp) -> String {
|
|
match operation {
|
|
PolicyMergeOp::AddRule { rule_name, rule } => summarize_add_endpoint(rule_name, rule),
|
|
PolicyMergeOp::RemoveEndpoint {
|
|
rule_name,
|
|
host,
|
|
port,
|
|
} => rule_name.as_ref().map_or_else(
|
|
|| format!("remove-endpoint {host}:{port}"),
|
|
|rule_name| format!("remove-endpoint {host}:{port} from rule {rule_name}"),
|
|
),
|
|
PolicyMergeOp::RemoveRule { rule_name } => format!("remove-rule {rule_name}"),
|
|
PolicyMergeOp::AddDenyRules { target, deny_rules } => format!(
|
|
"add-deny {} [{}]",
|
|
summarize_l7_target(target),
|
|
deny_rules
|
|
.iter()
|
|
.map(summarize_l7_deny_rule)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
PolicyMergeOp::AddAllowRules { target, rules } => format!(
|
|
"add-allow {} [{}]",
|
|
summarize_l7_target(target),
|
|
rules
|
|
.iter()
|
|
.map(summarize_l7_rule)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
PolicyMergeOp::RemoveBinary {
|
|
rule_name,
|
|
binary_path,
|
|
} => format!("remove-binary {rule_name} {binary_path}"),
|
|
}
|
|
}
|
|
|
|
fn summarize_l7_target(target: &L7RuleTarget) -> String {
|
|
let ports = target
|
|
.ports
|
|
.iter()
|
|
.map(u32::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let binaries = match &target.binaries {
|
|
L7BinaryScope::Any => "any".to_string(),
|
|
L7BinaryScope::Restricted(binaries) => binaries
|
|
.iter()
|
|
.map(|binary| binary.path.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
};
|
|
// Preserve optional path presence in the audit trail: None means unique
|
|
// endpoint selection, while Some("") selects an endpoint with no path scope.
|
|
format!(
|
|
"{}:{ports} rule={} endpoint-path={:?} binaries=[{binaries}]",
|
|
target.host, target.rule_name, target.path,
|
|
)
|
|
}
|
|
|
|
fn ensure_chunk_belongs_to_sandbox(
|
|
chunk: &DraftChunkRecord,
|
|
sandbox_id: &str,
|
|
) -> Result<(), Status> {
|
|
if chunk.sandbox_id != sandbox_id {
|
|
return Err(Status::not_found("chunk not found"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn summarize_add_endpoint(rule_name: &str, rule: &NetworkPolicyRule) -> String {
|
|
let endpoints = rule
|
|
.endpoints
|
|
.iter()
|
|
.map(summarize_endpoint)
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let binaries = summarize_binaries(&rule.binaries);
|
|
format!("add-endpoint {rule_name} endpoints=[{endpoints}] binaries=[{binaries}]")
|
|
}
|
|
|
|
fn summarize_add_rule(rule_name: &str, rule: &NetworkPolicyRule) -> String {
|
|
let endpoints = rule
|
|
.endpoints
|
|
.iter()
|
|
.map(summarize_endpoint)
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let binaries = summarize_binaries(&rule.binaries);
|
|
format!("add-rule {rule_name} endpoints=[{endpoints}] binaries=[{binaries}]")
|
|
}
|
|
|
|
fn summarize_endpoint(endpoint: &NetworkEndpoint) -> String {
|
|
let mut parts = vec![format!("{}:{}", endpoint.host, endpoint.port)];
|
|
if !endpoint.protocol.is_empty() {
|
|
parts.push(format!("protocol={}", endpoint.protocol));
|
|
}
|
|
if endpoint.access != 0 {
|
|
parts.push(format!(
|
|
"access={}",
|
|
openshell_policy::network_access_preset_to_str(endpoint.access).unwrap_or("unknown")
|
|
));
|
|
}
|
|
if endpoint.enforcement != 0 {
|
|
parts.push(format!(
|
|
"enforcement={}",
|
|
openshell_policy::network_enforcement_mode_to_str(endpoint.enforcement)
|
|
.unwrap_or("unknown")
|
|
));
|
|
}
|
|
if endpoint.tls != 0 {
|
|
parts.push(format!(
|
|
"tls={}",
|
|
openshell_policy::network_tls_mode_to_str(endpoint.tls).unwrap_or("unknown")
|
|
));
|
|
}
|
|
if endpoint.websocket_credential_rewrite {
|
|
parts.push("websocket_credential_rewrite=true".to_string());
|
|
}
|
|
if endpoint.request_body_credential_rewrite {
|
|
parts.push("request_body_credential_rewrite=true".to_string());
|
|
}
|
|
if endpoint.allow_uninspected_credentials {
|
|
parts.push("allow_uninspected_credentials=true".to_string());
|
|
}
|
|
if !endpoint.allowed_ips.is_empty() {
|
|
parts.push(format!("allowed_ips={}", endpoint.allowed_ips.len()));
|
|
}
|
|
if !endpoint.ports.is_empty() {
|
|
parts.push(format!("ports={}", endpoint.ports.len()));
|
|
}
|
|
if !endpoint.rules.is_empty() {
|
|
parts.push(format!(
|
|
"allow=[{}]",
|
|
endpoint
|
|
.rules
|
|
.iter()
|
|
.map(summarize_l7_rule)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
}
|
|
if !endpoint.deny_rules.is_empty() {
|
|
parts.push(format!(
|
|
"deny=[{}]",
|
|
endpoint
|
|
.deny_rules
|
|
.iter()
|
|
.map(summarize_l7_deny_rule)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
}
|
|
parts.join(" ")
|
|
}
|
|
|
|
fn summarize_l7_rule(rule: &L7Rule) -> String {
|
|
let Some(allow) = rule.allow.as_ref() else {
|
|
return "allow".to_string();
|
|
};
|
|
summarize_l7_match(
|
|
&allow.method,
|
|
&allow.path,
|
|
&allow.command,
|
|
allow.query.len(),
|
|
)
|
|
}
|
|
|
|
fn summarize_l7_deny_rule(rule: &L7DenyRule) -> String {
|
|
summarize_l7_match(&rule.method, &rule.path, &rule.command, rule.query.len())
|
|
}
|
|
|
|
fn summarize_l7_match(method: &str, path: &str, command: &str, query_count: usize) -> String {
|
|
let mut parts = Vec::new();
|
|
if !method.is_empty() {
|
|
parts.push(method.to_string());
|
|
}
|
|
if !path.is_empty() {
|
|
parts.push(path.to_string());
|
|
}
|
|
if !command.is_empty() {
|
|
parts.push(format!("command={}", truncate_for_log(command, 48)));
|
|
}
|
|
if query_count > 0 {
|
|
parts.push(format!("query_keys={query_count}"));
|
|
}
|
|
if parts.is_empty() {
|
|
"rule".to_string()
|
|
} else {
|
|
parts.join(" ")
|
|
}
|
|
}
|
|
|
|
fn summarize_binaries(binaries: &[NetworkBinary]) -> String {
|
|
binaries
|
|
.iter()
|
|
.map(|binary| binary.path.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn summarize_draft_chunk_rule(chunk: &DraftChunkRecord) -> Result<String, Status> {
|
|
let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?;
|
|
Ok(summarize_add_rule(&chunk.rule_name, &rule))
|
|
}
|
|
|
|
/// Run prover queries against the merged policy and render a short
|
|
/// human-readable verdict for the reviewer. The verdict reports only the
|
|
/// **delta** — findings the proposal introduces on top of the current policy.
|
|
/// Baseline gaps (pre-existing findings) are intentionally not surfaced here;
|
|
/// they belong on a posture surface, not on the per-proposal approval moment.
|
|
///
|
|
/// The string is the entire output — no taxonomy, no greppable prefixes; the
|
|
/// reviewer reads it like an OCSF shorthand line. One of:
|
|
///
|
|
/// - `prover: no new findings`
|
|
/// - `prover: N new finding(s)` followed by one ` <category>: <detail>`
|
|
/// line per finding path (categorical shorthand from `openshell-prover`)
|
|
/// - `merge failed: <one-line error>` — proposal won't merge into the current
|
|
/// policy
|
|
/// - `policy invalid: <one-line error>` — merged policy fails the cheap
|
|
/// structural safety check
|
|
/// - `validation unavailable` — gateway-side infrastructure failure (registry
|
|
/// load, YAML serialize/parse). Internal error detail is logged via
|
|
/// `warn!`, never exposed to the reviewer.
|
|
#[cfg(test)]
|
|
fn validation_result_for_agent_proposal(
|
|
current_policy: ProtoSandboxPolicy,
|
|
rule_name: &str,
|
|
proposed_rule: &NetworkPolicyRule,
|
|
credentials: &CredentialSet,
|
|
) -> String {
|
|
let merge_op = PolicyMergeOp::AddRule {
|
|
rule_name: rule_name.to_string(),
|
|
rule: proposed_rule.clone(),
|
|
};
|
|
let merged = match merge_policy(current_policy.clone(), &[merge_op]) {
|
|
Ok(result) => result.policy,
|
|
Err(error) => return format!("merge failed: {}", one_line(&error.to_string())),
|
|
};
|
|
if let Err(error) = validate_policy_safety(&merged) {
|
|
return format!("policy invalid: {}", one_line(&error.to_string()));
|
|
}
|
|
|
|
let merged_findings = match run_prover_findings(&merged, credentials) {
|
|
Ok(findings) => findings,
|
|
Err(error) => {
|
|
warn!(error = %error, "prover validation unavailable for merged policy");
|
|
return "validation unavailable".to_string();
|
|
}
|
|
};
|
|
// If the baseline prover run fails (e.g. the current policy uses a shape
|
|
// the prover hasn't caught up to yet), fall back to an empty baseline so
|
|
// every merged finding surfaces as new. Safer to over-warn than miss a
|
|
// real regression introduced by the proposal.
|
|
let base_findings = match run_prover_findings(¤t_policy, credentials) {
|
|
Ok(findings) => findings,
|
|
Err(error) => {
|
|
warn!(error = %error, "prover baseline run failed; treating baseline as empty");
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
let new_findings = finding_delta(&base_findings, &merged_findings);
|
|
if new_findings.is_empty() {
|
|
return "prover: no new findings".to_string();
|
|
}
|
|
let count = new_findings.len();
|
|
let mut out = format!(
|
|
"prover: {} new finding{}",
|
|
count,
|
|
if count == 1 { "" } else { "s" }
|
|
);
|
|
for finding in &new_findings {
|
|
out.push_str("\n ");
|
|
out.push_str(&finding_shorthand(finding));
|
|
}
|
|
out
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ProposalEvaluation {
|
|
rule_name: String,
|
|
rule: NetworkPolicyRule,
|
|
current_effective_policy: ProtoSandboxPolicy,
|
|
candidate_effective_policy: Option<ProtoSandboxPolicy>,
|
|
validation_result: String,
|
|
application_error: String,
|
|
review_token: String,
|
|
}
|
|
|
|
impl ProposalEvaluation {
|
|
fn current_hash(&self) -> String {
|
|
deterministic_policy_hash(&self.current_effective_policy)
|
|
}
|
|
|
|
fn candidate_hash(&self) -> String {
|
|
self.candidate_effective_policy
|
|
.as_ref()
|
|
.map(deterministic_policy_hash)
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
fn proposal_prover_result(
|
|
current_effective_policy: &ProtoSandboxPolicy,
|
|
candidate_effective_policy: &ProtoSandboxPolicy,
|
|
credentials: &CredentialSet,
|
|
) -> String {
|
|
let candidate_findings = match run_prover_findings(candidate_effective_policy, credentials) {
|
|
Ok(findings) => findings,
|
|
Err(error) => {
|
|
warn!(error = %error, "prover validation unavailable for candidate policy");
|
|
return "validation unavailable".to_string();
|
|
}
|
|
};
|
|
let base_findings = match run_prover_findings(current_effective_policy, credentials) {
|
|
Ok(findings) => findings,
|
|
Err(error) => {
|
|
warn!(error = %error, "prover baseline run failed; treating baseline as empty");
|
|
Vec::new()
|
|
}
|
|
};
|
|
let new_findings = finding_delta(&base_findings, &candidate_findings);
|
|
if new_findings.is_empty() {
|
|
return "prover: no new findings".to_string();
|
|
}
|
|
let mut out = format!(
|
|
"prover: {} new finding{}",
|
|
new_findings.len(),
|
|
if new_findings.len() == 1 { "" } else { "s" }
|
|
);
|
|
for finding in &new_findings {
|
|
out.push_str("\n ");
|
|
out.push_str(&finding_shorthand(finding));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn compute_proposal_review_token(
|
|
rule_name: &str,
|
|
rule: &NetworkPolicyRule,
|
|
current_effective_policy: &ProtoSandboxPolicy,
|
|
candidate_effective_policy: &ProtoSandboxPolicy,
|
|
credentials: &CredentialSet,
|
|
) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(b"openshell-proposal-evaluator-v2\0");
|
|
hasher.update(rule_name.as_bytes());
|
|
hasher.update(canonical_rule_bytes(rule));
|
|
hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes());
|
|
hasher.update(deterministic_policy_hash(candidate_effective_policy).as_bytes());
|
|
|
|
let mut credential_fingerprints = credentials
|
|
.credentials
|
|
.iter()
|
|
.map(|credential| {
|
|
let mut scopes = credential.scopes.clone();
|
|
scopes.sort();
|
|
let mut hosts = credential.target_hosts.clone();
|
|
hosts.sort();
|
|
format!(
|
|
"{}\0{}\0{}\0{}\0{}",
|
|
credential.name,
|
|
credential.cred_type,
|
|
credential.injected_via,
|
|
scopes.join("\0"),
|
|
hosts.join("\0")
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
credential_fingerprints.sort();
|
|
for fingerprint in credential_fingerprints {
|
|
hasher.update(fingerprint.as_bytes());
|
|
}
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
fn compute_failed_proposal_evaluation_hash(
|
|
rule_name: &str,
|
|
rule: &NetworkPolicyRule,
|
|
current_effective_policy: &ProtoSandboxPolicy,
|
|
application_error: &str,
|
|
) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(b"openshell-proposal-evaluator-v2-failed\0");
|
|
hasher.update(rule_name.as_bytes());
|
|
hasher.update(canonical_rule_bytes(rule));
|
|
hasher.update(deterministic_policy_hash(current_effective_policy).as_bytes());
|
|
hasher.update(application_error.as_bytes());
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
fn available_proposal_rule_name(
|
|
base_policy: &ProtoSandboxPolicy,
|
|
current_effective_policy: &ProtoSandboxPolicy,
|
|
requested_rule_name: &str,
|
|
) -> String {
|
|
let mut candidate = requested_rule_name.to_string();
|
|
let mut suffix = 2_u32;
|
|
while base_policy.network_policies.contains_key(&candidate)
|
|
|| current_effective_policy
|
|
.network_policies
|
|
.contains_key(&candidate)
|
|
{
|
|
candidate = format!("{requested_rule_name}_{suffix}");
|
|
suffix += 1;
|
|
}
|
|
candidate
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn evaluate_proposal_candidate(
|
|
base_policy: &ProtoSandboxPolicy,
|
|
current_effective_policy: &ProtoSandboxPolicy,
|
|
requested_rule_name: &str,
|
|
proposed_rule: &NetworkPolicyRule,
|
|
analysis_mode: &str,
|
|
credentials: &CredentialSet,
|
|
validation_context: PolicyMergeValidationContext<'_>,
|
|
reuse_validation_result: Option<&str>,
|
|
) -> ProposalEvaluation {
|
|
// The gateway owns advisor provenance. Do not rely on a supervisor or an
|
|
// agent-authored proposal to set this internal marker correctly: every
|
|
// proposed endpoint must remain ineligible for exact-host private-address
|
|
// trust until an explicit declaration or allowed_ips grants that trust.
|
|
let mut proposed_rule = proposed_rule.clone();
|
|
for endpoint in &mut proposed_rule.endpoints {
|
|
endpoint.advisor_proposed = true;
|
|
}
|
|
|
|
let canonical = if analysis_mode == "mechanistic" {
|
|
canonicalize_advisor_add_rule(
|
|
base_policy,
|
|
current_effective_policy,
|
|
requested_rule_name,
|
|
&proposed_rule,
|
|
)
|
|
} else {
|
|
let rule_name = available_proposal_rule_name(
|
|
base_policy,
|
|
current_effective_policy,
|
|
requested_rule_name,
|
|
);
|
|
proposed_rule.name.clone_from(&rule_name);
|
|
Ok((rule_name, proposed_rule.clone()))
|
|
};
|
|
let (rule_name, rule) = match canonical {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
let application_error = format!("candidate canonicalization failed: {error}");
|
|
return ProposalEvaluation {
|
|
rule_name: requested_rule_name.to_string(),
|
|
rule: proposed_rule.clone(),
|
|
current_effective_policy: current_effective_policy.clone(),
|
|
candidate_effective_policy: None,
|
|
validation_result: String::new(),
|
|
review_token: compute_failed_proposal_evaluation_hash(
|
|
requested_rule_name,
|
|
&proposed_rule,
|
|
current_effective_policy,
|
|
&application_error,
|
|
),
|
|
application_error,
|
|
};
|
|
}
|
|
};
|
|
|
|
let operations = [PolicyMergeOp::AddRule {
|
|
rule_name: rule_name.clone(),
|
|
rule: rule.clone(),
|
|
}];
|
|
let candidate_base = match merge_policy(base_policy.clone(), &operations) {
|
|
Ok(result) => result.policy,
|
|
Err(error) => {
|
|
let application_error = format!("merge failed: {}", one_line(&error.to_string()));
|
|
let review_token = compute_failed_proposal_evaluation_hash(
|
|
&rule_name,
|
|
&rule,
|
|
current_effective_policy,
|
|
&application_error,
|
|
);
|
|
return ProposalEvaluation {
|
|
rule_name,
|
|
rule,
|
|
current_effective_policy: current_effective_policy.clone(),
|
|
candidate_effective_policy: None,
|
|
validation_result: String::new(),
|
|
review_token,
|
|
application_error,
|
|
};
|
|
}
|
|
};
|
|
|
|
let validation = (|| -> Result<ProtoSandboxPolicy, Status> {
|
|
validate_policy_safety(&candidate_base)?;
|
|
validate_candidate_effective_policy(&candidate_base, validation_context.provider_layers)?;
|
|
let mut effective = if validation_context.provider_layers.is_empty() {
|
|
candidate_base.clone()
|
|
} else {
|
|
compose_effective_policy(&candidate_base, validation_context.provider_layers)
|
|
};
|
|
let bindings = policy_static_credential_endpoint_bindings(Some(&effective))?;
|
|
if let Some(context) = validation_context.credential_binding {
|
|
validate_operator_merged_credential_policy(&mut effective, &bindings, context)?;
|
|
}
|
|
Ok(effective)
|
|
})();
|
|
|
|
let candidate_effective_policy = match validation {
|
|
Ok(policy) => policy,
|
|
Err(error) => {
|
|
let application_error = format!("candidate invalid: {}", one_line(error.message()));
|
|
let review_token = compute_failed_proposal_evaluation_hash(
|
|
&rule_name,
|
|
&rule,
|
|
current_effective_policy,
|
|
&application_error,
|
|
);
|
|
return ProposalEvaluation {
|
|
rule_name,
|
|
rule,
|
|
current_effective_policy: current_effective_policy.clone(),
|
|
candidate_effective_policy: None,
|
|
validation_result: String::new(),
|
|
review_token,
|
|
application_error,
|
|
};
|
|
}
|
|
};
|
|
let validation_result = reuse_validation_result.map_or_else(
|
|
|| {
|
|
proposal_prover_result(
|
|
current_effective_policy,
|
|
&candidate_effective_policy,
|
|
credentials,
|
|
)
|
|
},
|
|
ToString::to_string,
|
|
);
|
|
let application_error = if validation_result == "validation unavailable" {
|
|
"prover validation unavailable; proposal cannot be reviewed".to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
let review_token = if application_error.is_empty() {
|
|
compute_proposal_review_token(
|
|
&rule_name,
|
|
&rule,
|
|
current_effective_policy,
|
|
&candidate_effective_policy,
|
|
credentials,
|
|
)
|
|
} else {
|
|
compute_failed_proposal_evaluation_hash(
|
|
&rule_name,
|
|
&rule,
|
|
current_effective_policy,
|
|
&application_error,
|
|
)
|
|
};
|
|
ProposalEvaluation {
|
|
rule_name,
|
|
rule,
|
|
current_effective_policy: current_effective_policy.clone(),
|
|
candidate_effective_policy: Some(candidate_effective_policy),
|
|
validation_result,
|
|
application_error,
|
|
review_token,
|
|
}
|
|
}
|
|
|
|
/// Run the prover end-to-end against a single policy with the given
|
|
/// credential set. Returns the raw finding list, or a short error string
|
|
/// identifying which infrastructure step failed.
|
|
///
|
|
/// The credential set is passed in because it's stable across all chunks in
|
|
/// one `SubmitPolicyAnalysis` batch — the caller builds it once and shares.
|
|
fn run_prover_findings(
|
|
policy: &ProtoSandboxPolicy,
|
|
credentials: &CredentialSet,
|
|
) -> Result<Vec<Finding>, String> {
|
|
let yaml =
|
|
serialize_sandbox_policy(policy).map_err(|e| format!("serialize policy failed: {e}"))?;
|
|
let prover_policy = parse_policy_str(&yaml).map_err(|e| format!("parse policy failed: {e}"))?;
|
|
let registry =
|
|
load_embedded_binary_registry().map_err(|e| format!("load registry failed: {e}"))?;
|
|
let model = build_model(prover_policy, credentials.clone(), registry);
|
|
Ok(run_all_queries(&model))
|
|
}
|
|
|
|
/// Build a `CredentialSet` for the sandbox by walking its attached providers.
|
|
///
|
|
/// v1 models "credential is present in scope for these hosts" — no scope
|
|
/// modeling. Each attached provider produces one [`Credential`] entry whose
|
|
/// `target_hosts` lists the hosts from the provider's profile endpoints.
|
|
/// Missing providers or providers whose type has no profile are skipped with
|
|
/// 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_with_catalog(
|
|
store: &Store,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
) -> Result<CredentialSet, Status> {
|
|
let mut credentials = Vec::new();
|
|
|
|
for name in provider_names {
|
|
let Some(provider) = store
|
|
.get_message_by_name::<Provider>(workspace, name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))?
|
|
else {
|
|
warn!(provider_name = %name, "provider not found while building credential set; skipping");
|
|
continue;
|
|
};
|
|
|
|
let provider_type = provider.r#type.trim();
|
|
let Some(profile) = super::provider::get_provider_type_profile_for_scope(
|
|
catalog,
|
|
provider_type,
|
|
&provider.profile_workspace,
|
|
) else {
|
|
warn!(
|
|
provider_name = %name,
|
|
provider_type,
|
|
"provider type has no profile; skipping credential entry"
|
|
);
|
|
continue;
|
|
};
|
|
|
|
let target_hosts: Vec<String> = profile
|
|
.endpoints
|
|
.iter()
|
|
.map(|ep| ep.host.to_lowercase())
|
|
.filter(|h| !h.is_empty())
|
|
.collect();
|
|
|
|
if target_hosts.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
credentials.push(Credential {
|
|
name: name.clone(),
|
|
cred_type: provider_type.to_string(),
|
|
scopes: Vec::new(),
|
|
injected_via: String::new(),
|
|
target_hosts,
|
|
});
|
|
}
|
|
|
|
Ok(CredentialSet {
|
|
credentials,
|
|
api_registries: HashMap::new(),
|
|
})
|
|
}
|
|
|
|
/// Stable identity key for a finding path. Deliberately excludes
|
|
/// `policy_name`: two paths with identical (binary, endpoint, mechanism) are
|
|
/// the same security gap whether they live in rule `foo` or rule `bar`. This
|
|
/// keeps the delta from spuriously surfacing baseline gaps just because the
|
|
/// proposal added a new rule name that produces the same gap shape.
|
|
fn finding_path_key(path: &FindingPath) -> String {
|
|
let FindingPath::Exfil(p) = path;
|
|
// Include the category and (for capability_expansion) the method so
|
|
// adding a new method on an already-reached host surfaces as a new
|
|
// path; reuse of an existing method does not.
|
|
format!(
|
|
"exfil|{}|{}:{}|{}|{}",
|
|
p.binary, p.endpoint_host, p.endpoint_port, p.category, p.method
|
|
)
|
|
}
|
|
|
|
/// Return the merged-policy findings that aren't already present in the
|
|
/// baseline. Comparison is per-(query, path) so that a single finding whose
|
|
/// evidence grew (e.g. a new method allowed on an already-reached host)
|
|
/// surfaces only the new evidence paths.
|
|
///
|
|
/// **Category suppression:** `capability_expansion` paths whose (binary,
|
|
/// host, port) tuple appears in the `credential_reach_expansion` delta
|
|
/// are suppressed. A brand-new credentialed reach is described by the
|
|
/// reach-expansion finding alone; we don't double-report by also
|
|
/// flagging every method as a separate `capability_expansion`.
|
|
fn finding_delta(base: &[Finding], merged: &[Finding]) -> Vec<Finding> {
|
|
use openshell_prover::finding::category;
|
|
|
|
let base_keys: HashSet<(String, String)> = base
|
|
.iter()
|
|
.flat_map(|f| {
|
|
let query = f.query.clone();
|
|
f.paths
|
|
.iter()
|
|
.map(move |p| (query.clone(), finding_path_key(p)))
|
|
})
|
|
.collect();
|
|
let mut delta: Vec<Finding> = Vec::new();
|
|
for finding in merged {
|
|
let new_paths: Vec<FindingPath> = finding
|
|
.paths
|
|
.iter()
|
|
.filter(|p| !base_keys.contains(&(finding.query.clone(), finding_path_key(p))))
|
|
.cloned()
|
|
.collect();
|
|
if new_paths.is_empty() {
|
|
continue;
|
|
}
|
|
delta.push(Finding {
|
|
paths: new_paths,
|
|
..finding.clone()
|
|
});
|
|
}
|
|
|
|
// Suppress capability_expansion paths whose (binary, host, port)
|
|
// appears in the credential_reach_expansion delta — a new reach is
|
|
// described once, by the reach-expansion category, not also by per-
|
|
// method capability findings.
|
|
let reach_tuples: HashSet<(String, String, u16)> = delta
|
|
.iter()
|
|
.filter(|f| f.query == category::CREDENTIAL_REACH_EXPANSION)
|
|
.flat_map(|f| {
|
|
f.paths.iter().map(|p| {
|
|
let FindingPath::Exfil(e) = p;
|
|
(e.binary.clone(), e.endpoint_host.clone(), e.endpoint_port)
|
|
})
|
|
})
|
|
.collect();
|
|
delta.retain_mut(|f| {
|
|
if f.query != category::CAPABILITY_EXPANSION {
|
|
return true;
|
|
}
|
|
f.paths.retain(|p| {
|
|
let FindingPath::Exfil(e) = p;
|
|
!reach_tuples.contains(&(e.binary.clone(), e.endpoint_host.clone(), e.endpoint_port))
|
|
});
|
|
!f.paths.is_empty()
|
|
});
|
|
|
|
delta
|
|
}
|
|
|
|
/// Collapse multi-line / multi-message error text to a single line so the
|
|
/// `validation_result` stays a clean, scannable string.
|
|
fn one_line(s: &str) -> String {
|
|
s.split('\n')
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
}
|
|
|
|
async fn reconcile_pending_chunks_covered_by_policy(
|
|
state: &Arc<ServerState>,
|
|
sandbox_id: &str,
|
|
effective_policy: &ProtoSandboxPolicy,
|
|
policy_version: i64,
|
|
) -> Result<u32, Status> {
|
|
let pending = state
|
|
.store
|
|
.list_draft_chunks(sandbox_id, Some("pending"))
|
|
.await
|
|
.map_err(|error| Status::internal(format!("list pending chunks failed: {error}")))?;
|
|
let mut reconciled = 0;
|
|
for chunk in pending {
|
|
let Some(rule) = decode_draft_chunk_rule(&chunk)? else {
|
|
continue;
|
|
};
|
|
if !policy_covers_rule(effective_policy, &rule) {
|
|
continue;
|
|
}
|
|
let reason = format!("covered by active policy revision {policy_version}");
|
|
if state
|
|
.store
|
|
.conditionally_reject_draft_chunk(&chunk.id, current_time_ms(), &reason)
|
|
.await
|
|
.map_err(|error| Status::internal(format!("reconcile covered chunk failed: {error}")))?
|
|
{
|
|
reconciled += 1;
|
|
}
|
|
}
|
|
if reconciled > 0 {
|
|
state.sandbox_watch_bus.notify(sandbox_id);
|
|
}
|
|
Ok(reconciled)
|
|
}
|
|
|
|
async fn reconcile_pending_chunks_after_policy_change(
|
|
state: &Arc<ServerState>,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
) -> Result<u32, Status> {
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), workspace)
|
|
.await?;
|
|
let effective = current_effective_policy_for_sandbox(
|
|
state,
|
|
&catalog,
|
|
workspace,
|
|
sandbox,
|
|
sandbox.object_id(),
|
|
)
|
|
.await?;
|
|
let version = state
|
|
.store
|
|
.get_latest_policy(sandbox.object_id())
|
|
.await
|
|
.map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))?
|
|
.map_or(0, |record| record.version);
|
|
reconcile_pending_chunks_covered_by_policy(state, sandbox.object_id(), &effective, version)
|
|
.await
|
|
}
|
|
|
|
/// Auto-reject any pending chunks for the same sandbox that share the
|
|
/// `(host, port, binary)` of the newly-submitted chunk. Mode-agnostic: the
|
|
/// rule is "the latest submission for this endpoint wins; older pending
|
|
/// proposals are stale."
|
|
///
|
|
/// In practice this implements the supersede behavior for the
|
|
/// `mechanistic`→`agent_authored` refinement loop: when the agent submits a
|
|
/// narrow L7 proposal in response to a denial, any pending mechanistic L4
|
|
/// draft for the same key gets auto-rejected here, without the agent or the
|
|
/// proto needing an explicit `supersedes_chunk_id` field.
|
|
///
|
|
/// Failures (DB error, scan error) are logged via `warn!` and the function
|
|
/// returns silently. The new chunk's persistence has already succeeded;
|
|
/// failing this cleanup pass should not abort the submission flow.
|
|
async fn supersede_other_pending_chunks_for_endpoint(
|
|
state: &Arc<ServerState>,
|
|
sandbox_id: &str,
|
|
new_chunk_id: &str,
|
|
host: &str,
|
|
port: i32,
|
|
binary: &str,
|
|
) {
|
|
// Empty host/port/binary should not supersede anything — the matcher would
|
|
// accidentally cover unrelated chunks. Defensive skip.
|
|
if host.is_empty() || port == 0 || binary.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let pending = match state
|
|
.store
|
|
.list_draft_chunks(sandbox_id, Some("pending"))
|
|
.await
|
|
{
|
|
Ok(records) => records,
|
|
Err(err) => {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
error = %err,
|
|
"supersede scan failed; older pending chunks (if any) remain pending"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let now_ms = current_time_ms();
|
|
for other in pending {
|
|
if other.id == new_chunk_id
|
|
|| other.host != host
|
|
|| other.port != port
|
|
|| other.binary != binary
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let reason = format!("superseded by chunk {new_chunk_id}");
|
|
match state
|
|
.store
|
|
.update_draft_chunk_status(&other.id, "rejected", Some(now_ms), Some(&reason))
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
superseded_chunk = %other.id,
|
|
by_chunk = %new_chunk_id,
|
|
host = %host,
|
|
port = port,
|
|
binary = %binary,
|
|
"Auto-rejected pending chunk: superseded by newer submission for same (host, port, binary)"
|
|
);
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
chunk_id = %other.id,
|
|
error = %err,
|
|
"supersede auto-reject failed; chunk remains pending"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// If the just-submitted mechanistic chunk targets a `(host, port, binary)`
|
|
/// already covered by an approved `agent_authored` chunk, auto-reject the
|
|
/// mechanistic chunk on arrival. The agent has already handled this access
|
|
/// decision; the mechanistic draft would only add approval-queue noise.
|
|
///
|
|
/// `agent_authored` submissions are NEVER self-rejected — that path remains
|
|
/// open for refinement. Only the mechanistic side is asymmetric.
|
|
async fn self_reject_mechanistic_if_already_covered(
|
|
state: &Arc<ServerState>,
|
|
sandbox_id: &str,
|
|
new_chunk_id: &str,
|
|
host: &str,
|
|
port: i32,
|
|
binary: &str,
|
|
) {
|
|
if host.is_empty() || port == 0 || binary.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// `put_draft_chunk` dedups mechanistic submissions on `(host, port,
|
|
// binary)` and returns the id of whatever row now owns that key. On a
|
|
// dedup hit that id belongs to a pre-existing row, which may already be
|
|
// `approved`. Self-reject must only fire on a genuinely fresh, still
|
|
// `pending` submission: flipping a non-pending row here would move an
|
|
// already-decided chunk to `rejected` with no human action, and because
|
|
// this path never un-merges the rule (unlike the human reject handler),
|
|
// the live policy would keep enforcing an access the ledger now reports as
|
|
// revoked.
|
|
match state.store.get_draft_chunk(new_chunk_id).await {
|
|
Ok(Some(chunk)) if chunk.status == "pending" => {}
|
|
Ok(_) => return,
|
|
Err(err) => {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %new_chunk_id,
|
|
error = %err,
|
|
"self-reject status check failed; mechanistic chunk remains pending"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let approved = match state
|
|
.store
|
|
.list_draft_chunks(sandbox_id, Some("approved"))
|
|
.await
|
|
{
|
|
Ok(records) => records,
|
|
Err(err) => {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
error = %err,
|
|
"approved-chunk scan for self-reject failed; mechanistic chunk remains pending"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// If any *other* approved chunk for this sandbox already targets the same
|
|
// (host, port, binary), the mechanistic submission is redundant. Excluding
|
|
// `new_chunk_id` is load-bearing: the dedup upsert can alias the incoming
|
|
// id onto an approved row for this endpoint, and without the exclusion that
|
|
// row would be found as its own "covering" chunk and reject itself.
|
|
let covered_by = approved
|
|
.iter()
|
|
.find(|c| c.id != new_chunk_id && c.host == host && c.port == port && c.binary == binary);
|
|
let Some(covering) = covered_by else {
|
|
return;
|
|
};
|
|
|
|
let reason = format!(
|
|
"already covered by approved chunk {} (agent_authored or prior auto-approval)",
|
|
covering.id
|
|
);
|
|
match state
|
|
.store
|
|
.conditionally_reject_draft_chunk(new_chunk_id, current_time_ms(), &reason)
|
|
.await
|
|
{
|
|
Ok(true) => {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %new_chunk_id,
|
|
covering_chunk = %covering.id,
|
|
host = %host,
|
|
port = port,
|
|
binary = %binary,
|
|
"Auto-rejected incoming mechanistic chunk: endpoint already covered by an approved chunk"
|
|
);
|
|
}
|
|
Ok(false) => {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %new_chunk_id,
|
|
"mechanistic self-reject skipped: chunk no longer pending (decided concurrently)"
|
|
);
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
chunk_id = %new_chunk_id,
|
|
error = %err,
|
|
"mechanistic self-reject failed; chunk remains pending"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Internally approve a chunk on the auto-approval path: merge into the
|
|
/// active policy, flip status to "approved", notify watchers, and emit a
|
|
/// `CONFIG:APPROVED` audit event carrying `auto=true`, `source=<mode>`,
|
|
/// `prover_delta=empty` so the audit trail records why no human approved
|
|
/// this chunk.
|
|
///
|
|
/// `source` is the `analysis_mode` of the originating submission
|
|
/// (`mechanistic` or `agent_authored`). The audit copy says "auto-approved:
|
|
/// no new prover findings" — never "safe" — because the claim is about the
|
|
/// prover's reasoning, not the world.
|
|
/// Resolve the effective proposal-approval mode for a sandbox.
|
|
///
|
|
/// Precedence (matches the rest of the settings model): gateway scope wins
|
|
/// over sandbox scope. A reviewer can pin manual mode fleet-wide by setting
|
|
/// it globally; per-sandbox overrides only apply when no global is set.
|
|
///
|
|
/// Returns `(auto_approve_enabled, resolved_from)` where `resolved_from`
|
|
/// is `"gateway"`, `"sandbox"`, or `"default"`. Only an exact `"auto"`
|
|
/// value enables auto-approval; any other string (including future-
|
|
/// reserved modes like `"auto_on_low_risk"`) is conservatively treated as
|
|
/// manual.
|
|
async fn resolve_proposal_approval_mode(
|
|
store: &Store,
|
|
workspace: &str,
|
|
sandbox_name: &str,
|
|
) -> Result<(bool, &'static str), Status> {
|
|
let global = load_global_settings(store).await?;
|
|
if let Some(StoredSettingValue::String(value)) =
|
|
global.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY)
|
|
{
|
|
return Ok((value == "auto", "gateway"));
|
|
}
|
|
|
|
let sandbox = load_sandbox_settings(store, workspace, sandbox_name).await?;
|
|
if let Some(StoredSettingValue::String(value)) =
|
|
sandbox.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY)
|
|
{
|
|
return Ok((value == "auto", "sandbox"));
|
|
}
|
|
|
|
Ok((false, "default"))
|
|
}
|
|
|
|
fn apply_evaluation_to_chunk(chunk: &mut DraftChunkRecord, evaluation: &ProposalEvaluation) {
|
|
chunk.rule_name.clone_from(&evaluation.rule_name);
|
|
chunk.proposed_rule = evaluation.rule.encode_to_vec();
|
|
chunk
|
|
.validation_result
|
|
.clone_from(&evaluation.validation_result);
|
|
chunk
|
|
.application_error
|
|
.clone_from(&evaluation.application_error);
|
|
chunk.review_token.clone_from(&evaluation.review_token);
|
|
chunk.current_effective_policy_hash = evaluation.current_hash();
|
|
chunk.candidate_effective_policy_hash = evaluation.candidate_hash();
|
|
chunk.current_effective_policy = Some(evaluation.current_effective_policy.clone());
|
|
chunk
|
|
.candidate_effective_policy
|
|
.clone_from(&evaluation.candidate_effective_policy);
|
|
chunk.last_seen_ms = current_time_ms();
|
|
}
|
|
|
|
async fn evaluate_stored_chunk_against_live_inputs(
|
|
state: &Arc<ServerState>,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
chunk: &DraftChunkRecord,
|
|
reuse_validation_result: Option<&str>,
|
|
) -> Result<ProposalEvaluation, Status> {
|
|
let rule = decode_draft_chunk_rule(chunk)?
|
|
.ok_or_else(|| Status::failed_precondition("draft chunk has no proposed rule"))?;
|
|
let provider_names = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.clone())
|
|
.unwrap_or_default();
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), workspace)
|
|
.await?;
|
|
let current_effective = current_effective_policy_for_sandbox(
|
|
state,
|
|
&catalog,
|
|
workspace,
|
|
sandbox,
|
|
sandbox.object_id(),
|
|
)
|
|
.await?;
|
|
let current_base = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?;
|
|
let credentials = build_credential_set_for_sandbox_with_catalog(
|
|
state.store.as_ref(),
|
|
&catalog,
|
|
workspace,
|
|
&provider_names,
|
|
)
|
|
.await?;
|
|
let merge_validation = sandbox_policy_merge_validation_data_with_catalog(
|
|
state,
|
|
workspace,
|
|
sandbox,
|
|
&provider_names,
|
|
&catalog,
|
|
)
|
|
.await?;
|
|
let credential_binding_context = merge_validation.credential_binding_context();
|
|
Ok(evaluate_proposal_candidate(
|
|
¤t_base,
|
|
¤t_effective,
|
|
&chunk.rule_name,
|
|
&rule,
|
|
"stored",
|
|
&credentials,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &merge_validation.provider_layers,
|
|
credential_binding: Some(&credential_binding_context),
|
|
},
|
|
reuse_validation_result,
|
|
))
|
|
}
|
|
|
|
async fn persist_refreshed_evaluation(
|
|
state: &Arc<ServerState>,
|
|
chunk: &DraftChunkRecord,
|
|
evaluation: &ProposalEvaluation,
|
|
) -> Result<DraftChunkRecord, Status> {
|
|
let mut refreshed = chunk.clone();
|
|
apply_evaluation_to_chunk(&mut refreshed, evaluation);
|
|
let updated = state
|
|
.store
|
|
.update_draft_chunk_evaluation(&refreshed)
|
|
.await
|
|
.map_err(|error| {
|
|
Status::internal(format!("persist proposal evaluation failed: {error}"))
|
|
})?;
|
|
if !updated {
|
|
return Err(Status::failed_precondition(
|
|
"proposal is no longer pending; refetch before deciding",
|
|
));
|
|
}
|
|
state.sandbox_watch_bus.notify(&chunk.sandbox_id);
|
|
Ok(refreshed)
|
|
}
|
|
|
|
async fn persist_pending_application_error(
|
|
state: &Arc<ServerState>,
|
|
chunk_id: &str,
|
|
error: &Status,
|
|
) {
|
|
let Ok(Some(mut chunk)) = state.store.get_draft_chunk(chunk_id).await else {
|
|
return;
|
|
};
|
|
if chunk.status != "pending" {
|
|
return;
|
|
}
|
|
chunk.application_error = one_line(error.message());
|
|
chunk.last_seen_ms = current_time_ms();
|
|
if let Err(persist_error) = state.store.update_draft_chunk_evaluation(&chunk).await {
|
|
warn!(
|
|
chunk_id,
|
|
error = %persist_error,
|
|
"failed to persist proposal application error"
|
|
);
|
|
return;
|
|
}
|
|
state.sandbox_watch_bus.notify(&chunk.sandbox_id);
|
|
}
|
|
|
|
async fn clear_pending_application_error(state: &Arc<ServerState>, chunk_id: &str) {
|
|
let Ok(Some(mut chunk)) = state.store.get_draft_chunk(chunk_id).await else {
|
|
return;
|
|
};
|
|
if chunk.status != "pending" || chunk.application_error.is_empty() {
|
|
return;
|
|
}
|
|
chunk.application_error.clear();
|
|
chunk.last_seen_ms = current_time_ms();
|
|
if let Err(error) = state.store.update_draft_chunk_evaluation(&chunk).await {
|
|
warn!(chunk_id, error = %error, "failed to clear proposal application error");
|
|
}
|
|
}
|
|
|
|
async fn require_current_proposal_evaluation(
|
|
state: &Arc<ServerState>,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
chunk: &DraftChunkRecord,
|
|
supplied_review_token: Option<&str>,
|
|
) -> Result<ProposalEvaluation, Status> {
|
|
if !chunk.review_token.is_empty()
|
|
&& supplied_review_token.is_some_and(|token| token != chunk.review_token)
|
|
{
|
|
return Err(Status::failed_precondition(
|
|
"review token does not match the fetched proposal; refetch and review again",
|
|
));
|
|
}
|
|
|
|
let reuse = (!chunk.review_token.is_empty()).then_some(chunk.validation_result.as_str());
|
|
let live =
|
|
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, reuse).await?;
|
|
if !live.application_error.is_empty() {
|
|
persist_refreshed_evaluation(state, chunk, &live).await?;
|
|
return Err(Status::failed_precondition(format!(
|
|
"proposal is not applicable: {}",
|
|
live.application_error
|
|
)));
|
|
}
|
|
|
|
if chunk.review_token.is_empty() {
|
|
// Compatibility path for proposals stored before review tokens were
|
|
// introduced. Evaluate once, persist the token, and allow the legacy
|
|
// approval request to proceed against that exact live candidate.
|
|
let evaluated =
|
|
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None)
|
|
.await?;
|
|
persist_refreshed_evaluation(state, chunk, &evaluated).await?;
|
|
return Ok(evaluated);
|
|
}
|
|
|
|
if live.review_token != chunk.review_token {
|
|
let refreshed =
|
|
evaluate_stored_chunk_against_live_inputs(state, workspace, sandbox, chunk, None)
|
|
.await?;
|
|
persist_refreshed_evaluation(state, chunk, &refreshed).await?;
|
|
return Err(Status::failed_precondition(
|
|
"proposal inputs changed; evaluation refreshed, refetch and review again",
|
|
));
|
|
}
|
|
Ok(live)
|
|
}
|
|
|
|
struct AutoApproveChunkContext<'a> {
|
|
sandbox: &'a Sandbox,
|
|
workspace: &'a str,
|
|
source: &'a str,
|
|
resolved_from: &'a str,
|
|
}
|
|
|
|
async fn auto_approve_chunk(
|
|
state: &Arc<ServerState>,
|
|
chunk_id: &str,
|
|
context: AutoApproveChunkContext<'_>,
|
|
) -> Result<(), Status> {
|
|
let sandbox_id = context.sandbox.object_id();
|
|
let sandbox_name = context.sandbox.object_name();
|
|
|
|
// Same gate the human-driven approve paths apply: if a global policy is
|
|
// active, sandbox-scoped chunk approvals are meaningless because
|
|
// `GetSandboxConfig` prefers the global policy. Auto-approving here
|
|
// would persist a sandbox revision that the runtime silently ignores
|
|
// and leave a misleading "approved" chunk in the table. Bail before
|
|
// touching state; the calling site logs this as `warn!` and leaves the
|
|
// chunk pending.
|
|
require_no_global_policy(state).await?;
|
|
|
|
let chunk = state
|
|
.store
|
|
.get_draft_chunk(chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
|
|
// The chunk may have been superseded or rejected by something else
|
|
// between persist and auto-approve. Only approve from a pending state.
|
|
if chunk.status != "pending" {
|
|
return Ok(());
|
|
}
|
|
|
|
let live_evaluation = require_current_proposal_evaluation(
|
|
state,
|
|
context.workspace,
|
|
context.sandbox,
|
|
&chunk,
|
|
None,
|
|
)
|
|
.await?;
|
|
let validation_result = live_evaluation.validation_result;
|
|
if validation_result != "prover: no new findings" {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
validation_result = %validation_result,
|
|
source = %context.source,
|
|
resolved_from = %context.resolved_from,
|
|
"Auto-approval skipped: current stored rule has prover findings"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let security_notes = current_draft_chunk_security_notes(&chunk)?;
|
|
if !security_notes.is_empty() {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
security_notes = %security_notes,
|
|
source = %context.source,
|
|
resolved_from = %context.resolved_from,
|
|
"Auto-approval skipped: current rule is security-flagged"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let provider_names = context
|
|
.sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.as_slice())
|
|
.unwrap_or_default();
|
|
let merge_validation = sandbox_policy_merge_validation_data(
|
|
state,
|
|
context.workspace,
|
|
context.sandbox,
|
|
provider_names,
|
|
)
|
|
.await?;
|
|
let credential_binding_context = merge_validation.credential_binding_context();
|
|
let merge_result = merge_chunk_into_policy_with_validation(
|
|
state.store.as_ref(),
|
|
sandbox_id,
|
|
context.workspace,
|
|
&chunk,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &merge_validation.provider_layers,
|
|
credential_binding: Some(&credential_binding_context),
|
|
},
|
|
)
|
|
.await;
|
|
let (version, hash) = match merge_result {
|
|
Ok(result) => result,
|
|
Err(status) => {
|
|
persist_pending_application_error(state, chunk_id, &status).await;
|
|
return Err(status);
|
|
}
|
|
};
|
|
let chunk_summary = summarize_draft_chunk_rule(&chunk)?;
|
|
|
|
let now_ms = current_time_ms();
|
|
clear_pending_application_error(state, chunk_id).await;
|
|
state
|
|
.store
|
|
.update_draft_chunk_status(chunk_id, "approved", Some(now_ms), None)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
state.sandbox_watch_bus.notify(sandbox_id);
|
|
if let Err(error) =
|
|
reconcile_pending_chunks_after_policy_change(state, context.workspace, context.sandbox)
|
|
.await
|
|
{
|
|
warn!(
|
|
sandbox_id,
|
|
error = %error,
|
|
"failed to reconcile pending policy proposals after auto-approval"
|
|
);
|
|
}
|
|
|
|
let source_label = if context.source.is_empty() {
|
|
"unspecified"
|
|
} else {
|
|
context.source
|
|
};
|
|
emit_gateway_policy_auto_approve_audit_log(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
format!(
|
|
"auto-approved: no new prover findings (source={source_label}) — chunk {chunk_id}: {chunk_summary}"
|
|
),
|
|
version,
|
|
&hash,
|
|
source_label,
|
|
context.resolved_from,
|
|
);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
version = version,
|
|
policy_hash = %hash,
|
|
source = %source_label,
|
|
resolved_from = %context.resolved_from,
|
|
"Auto-approved chunk: no new prover findings"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// TODO: share effective-policy lookup with `load_sandbox_policy` /
|
|
// `GetSandboxConfig`. They re-implement very similar global-settings and
|
|
// profile-composition logic; consolidating them is out of scope for the
|
|
// agent-authored proposal validation slice.
|
|
async fn current_effective_policy_for_sandbox(
|
|
state: &ServerState,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
sandbox_id: &str,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
let provider_names = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.clone())
|
|
.unwrap_or_default();
|
|
let records = super::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
workspace,
|
|
&provider_names,
|
|
)
|
|
.await?;
|
|
current_effective_policy_from_records(state, catalog, sandbox, sandbox_id, &records).await
|
|
}
|
|
|
|
async fn current_effective_policy_from_records(
|
|
state: &ServerState,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
sandbox: &Sandbox,
|
|
sandbox_id: &str,
|
|
records: &[super::provider::ProviderEnvironmentRecord],
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? {
|
|
// A global policy is the complete effective policy. Dormant sandbox
|
|
// history and specs may predate the current schema, but they must not
|
|
// prevent the valid global policy from being served.
|
|
return apply_captured_policy_context(
|
|
provider_policy_context_from_records(catalog, records),
|
|
global_policy,
|
|
PolicySource::Global,
|
|
);
|
|
}
|
|
|
|
let policy = if let Some(record) = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?
|
|
{
|
|
canonical_policy_record_identity(&record)?.0
|
|
} else {
|
|
match sandbox.spec.as_ref().and_then(|spec| spec.policy.clone()) {
|
|
Some(policy) => {
|
|
validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_SPEC)?
|
|
}
|
|
None => ProtoSandboxPolicy::default(),
|
|
}
|
|
};
|
|
|
|
apply_captured_policy_context(
|
|
provider_policy_context_from_records(catalog, records),
|
|
policy,
|
|
PolicySource::Sandbox,
|
|
)
|
|
}
|
|
|
|
async fn effective_policy_for_source(
|
|
state: &ServerState,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
mut policy: ProtoSandboxPolicy,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let policy_source = decode_policy_from_global_settings(&global_settings)?.map_or(
|
|
PolicySource::Sandbox,
|
|
|global_policy| {
|
|
policy = global_policy;
|
|
PolicySource::Global
|
|
},
|
|
);
|
|
|
|
apply_effective_policy_context(
|
|
state,
|
|
catalog,
|
|
workspace,
|
|
provider_names,
|
|
policy,
|
|
policy_source,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn apply_effective_policy_context(
|
|
state: &ServerState,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
policy: ProtoSandboxPolicy,
|
|
policy_source: PolicySource,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
let provider_context = provider_policy_context_with_catalog(
|
|
state.store.as_ref(),
|
|
catalog,
|
|
workspace,
|
|
provider_names,
|
|
)
|
|
.await?;
|
|
apply_captured_policy_context(provider_context, policy, policy_source)
|
|
}
|
|
|
|
fn apply_captured_policy_context(
|
|
mut provider_context: ProviderPolicyContext,
|
|
mut policy: ProtoSandboxPolicy,
|
|
policy_source: PolicySource,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
clear_provider_credentialed_markers(&mut policy);
|
|
if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() {
|
|
policy = compose_effective_policy(&policy, &provider_context.layers);
|
|
}
|
|
let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&policy))?;
|
|
extend_credentialed_scopes_from_policy_bindings(
|
|
&mut provider_context.credentialed_scopes,
|
|
&policy_credential_bindings,
|
|
&provider_context.endpointless_provider_names,
|
|
);
|
|
stamp_provider_credentialed_endpoints(&mut policy, &provider_context.credentialed_scopes);
|
|
|
|
Ok(policy)
|
|
}
|
|
|
|
fn validate_endpoint_ambiguities(policy: &ProtoSandboxPolicy) -> Result<(), Status> {
|
|
let ambiguities = openshell_policy::find_endpoint_ambiguities(policy);
|
|
if ambiguities.is_empty() {
|
|
return Ok(());
|
|
}
|
|
Err(Status::failed_precondition(format!(
|
|
"network endpoint ambiguity validation failed:\n{}",
|
|
ambiguities
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)))
|
|
}
|
|
|
|
pub(super) fn validate_candidate_effective_policy(
|
|
base_policy: &ProtoSandboxPolicy,
|
|
provider_layers: &[ProviderPolicyLayer],
|
|
) -> Result<(), Status> {
|
|
let effective_policy = if provider_layers.is_empty() {
|
|
base_policy.clone()
|
|
} else {
|
|
compose_effective_policy(base_policy, provider_layers)
|
|
};
|
|
validate_endpoint_ambiguities(&effective_policy)
|
|
}
|
|
|
|
fn policy_static_credential_endpoint_bindings(
|
|
policy: Option<&ProtoSandboxPolicy>,
|
|
) -> Result<HashMap<String, Vec<StaticCredentialEndpointBinding>>, Status> {
|
|
let mut bindings = HashMap::<String, Vec<StaticCredentialEndpointBinding>>::new();
|
|
let Some(policy) = policy else {
|
|
return Ok(bindings);
|
|
};
|
|
|
|
for rule in policy.network_policies.values() {
|
|
for endpoint in &rule.endpoints {
|
|
let Some(binding) = endpoint.credential_binding.as_ref() else {
|
|
continue;
|
|
};
|
|
let provider = binding.provider.trim();
|
|
if provider.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"credential_binding.provider is required for endpoint '{}'",
|
|
endpoint.host
|
|
)));
|
|
}
|
|
if provider != binding.provider {
|
|
return Err(Status::invalid_argument(format!(
|
|
"credential_binding.provider '{}' must not contain leading or trailing whitespace",
|
|
binding.provider
|
|
)));
|
|
}
|
|
if endpoint.host.trim().is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"credential-bound endpoint for provider '{provider}' must define a host"
|
|
)));
|
|
}
|
|
let ports = if endpoint.ports.is_empty() {
|
|
vec![endpoint.port]
|
|
} else {
|
|
endpoint.ports.clone()
|
|
};
|
|
if ports
|
|
.iter()
|
|
.any(|port| *port == 0 || *port > u32::from(u16::MAX))
|
|
{
|
|
return Err(Status::invalid_argument(format!(
|
|
"credential-bound endpoint '{}' for provider '{provider}' must define ports in range 1..=65535",
|
|
endpoint.host
|
|
)));
|
|
}
|
|
let provider_bindings = bindings.entry(provider.to_string()).or_default();
|
|
for port in ports {
|
|
let candidate = StaticCredentialEndpointBinding {
|
|
host: endpoint.host.clone(),
|
|
port,
|
|
path: endpoint.path.clone(),
|
|
};
|
|
if !provider_bindings.contains(&candidate) {
|
|
provider_bindings.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for endpoints in bindings.values_mut() {
|
|
endpoints.sort_by(|left, right| {
|
|
(&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path))
|
|
});
|
|
}
|
|
Ok(bindings)
|
|
}
|
|
|
|
pub(super) fn policy_has_credential_binding_for_provider(
|
|
policy: &ProtoSandboxPolicy,
|
|
provider_name: &str,
|
|
) -> bool {
|
|
policy.network_policies.values().any(|rule| {
|
|
rule.endpoints.iter().any(|endpoint| {
|
|
endpoint
|
|
.credential_binding
|
|
.as_ref()
|
|
.is_some_and(|binding| binding.provider == provider_name)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn validate_policy_credential_binding_context(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
records: &[super::provider::ProviderEnvironmentRecord],
|
|
policy: &ProtoSandboxPolicy,
|
|
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
|
|
) -> Result<(), Status> {
|
|
for provider_name in bindings.keys() {
|
|
let record = records
|
|
.iter()
|
|
.find(|record| record.name == *provider_name)
|
|
.ok_or_else(|| {
|
|
Status::failed_precondition(format!(
|
|
"credential_binding references provider '{provider_name}', but that provider is not attached to the sandbox"
|
|
))
|
|
})?;
|
|
let profile = super::provider::get_provider_type_profile_for_scope(
|
|
catalog,
|
|
&record.provider.r#type,
|
|
&record.provider.profile_workspace,
|
|
)
|
|
.ok_or_else(|| {
|
|
Status::failed_precondition(format!(
|
|
"credential_binding provider '{provider_name}' has no provider profile"
|
|
))
|
|
})?;
|
|
if !profile.to_proto().endpoints.is_empty() {
|
|
return Err(Status::failed_precondition(format!(
|
|
"credential_binding provider '{provider_name}' profile already defines endpoints; \
|
|
profile endpoints remain the credential boundary"
|
|
)));
|
|
}
|
|
}
|
|
|
|
validate_policy_signing_credential_sources(catalog, records, policy)?;
|
|
Ok(())
|
|
}
|
|
|
|
const SIGV4_REQUIRED_CREDENTIAL_KEYS: [&str; 2] = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
|
|
|
|
fn validate_policy_signing_credential_sources(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
records: &[super::provider::ProviderEnvironmentRecord],
|
|
policy: &ProtoSandboxPolicy,
|
|
) -> Result<(), Status> {
|
|
for rule in policy.network_policies.values() {
|
|
for endpoint in &rule.endpoints {
|
|
if endpoint.credential_signing.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let source = endpoint.credential_binding.as_ref().map_or_else(
|
|
|| {
|
|
records.iter().find_map(|record| {
|
|
signing_profile_for_record(catalog, record).filter(|profile| {
|
|
profile_declares_sigv4_credentials(profile)
|
|
&& !profile.endpoints.is_empty()
|
|
&& signed_endpoint_is_covered(
|
|
endpoint,
|
|
&profile.to_proto().endpoints,
|
|
)
|
|
})
|
|
})
|
|
},
|
|
|binding| {
|
|
records
|
|
.iter()
|
|
.find(|record| record.name == binding.provider)
|
|
.and_then(|record| signing_profile_for_record(catalog, record))
|
|
.filter(|profile| {
|
|
profile_declares_sigv4_credentials(profile)
|
|
&& profile.endpoints.is_empty()
|
|
})
|
|
},
|
|
);
|
|
|
|
if source.is_none() {
|
|
let selector = format_endpoint_selector(endpoint);
|
|
return Err(Status::failed_precondition(format!(
|
|
"credential_signing endpoint '{selector}' has no resolvable AWS credential source; attach an endpoint-bearing provider profile that declares AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and covers this endpoint, or set credential_binding.provider to an attached endpointless profile that declares those credentials"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn signing_profile_for_record(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
record: &super::provider::ProviderEnvironmentRecord,
|
|
) -> Option<openshell_providers::ProviderTypeProfile> {
|
|
super::provider::get_provider_type_profile_for_scope(
|
|
catalog,
|
|
&record.provider.r#type,
|
|
&record.provider.profile_workspace,
|
|
)
|
|
}
|
|
|
|
fn profile_declares_sigv4_credentials(profile: &openshell_providers::ProviderTypeProfile) -> bool {
|
|
let env_vars = profile.credential_env_vars();
|
|
SIGV4_REQUIRED_CREDENTIAL_KEYS
|
|
.iter()
|
|
.all(|required| env_vars.contains(required))
|
|
}
|
|
|
|
fn signed_endpoint_is_covered(
|
|
signed: &NetworkEndpoint,
|
|
profile_endpoints: &[NetworkEndpoint],
|
|
) -> bool {
|
|
endpoint_ports_for_validation(signed)
|
|
.into_iter()
|
|
.all(|port| {
|
|
profile_endpoints.iter().any(|profile| {
|
|
endpoint_ports_for_validation(profile).contains(&port)
|
|
&& host_pattern_covers(&profile.host, &signed.host)
|
|
&& path_pattern_covers(&profile.path, &signed.path)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn endpoint_ports_for_validation(endpoint: &NetworkEndpoint) -> Vec<u32> {
|
|
if endpoint.ports.is_empty() {
|
|
vec![endpoint.port]
|
|
} else {
|
|
endpoint.ports.clone()
|
|
}
|
|
}
|
|
|
|
fn host_pattern_covers(binding_pattern: &str, policy_pattern: &str) -> bool {
|
|
if binding_pattern.eq_ignore_ascii_case(policy_pattern) {
|
|
return true;
|
|
}
|
|
if contains_glob_syntax(policy_pattern) {
|
|
return false;
|
|
}
|
|
host_matches(binding_pattern, policy_pattern).unwrap_or(false)
|
|
}
|
|
|
|
fn path_pattern_covers(binding_pattern: &str, policy_pattern: &str) -> bool {
|
|
if binding_pattern == policy_pattern || matches!(binding_pattern, "" | "**" | "/**") {
|
|
return true;
|
|
}
|
|
if contains_glob_syntax(policy_pattern) {
|
|
return false;
|
|
}
|
|
EndpointPathPattern::new(binding_pattern).matches(policy_pattern)
|
|
}
|
|
|
|
fn contains_glob_syntax(value: &str) -> bool {
|
|
value
|
|
.chars()
|
|
.any(|character| matches!(character, '*' | '?' | '['))
|
|
}
|
|
|
|
fn format_endpoint_selector(endpoint: &NetworkEndpoint) -> String {
|
|
let ports = endpoint_ports_for_validation(endpoint)
|
|
.iter()
|
|
.map(u32::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
format!("{}:{ports}{}", endpoint.host, endpoint.path)
|
|
}
|
|
|
|
async fn validate_policy_credential_bindings_for_sandbox(
|
|
state: &ServerState,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
policy: &ProtoSandboxPolicy,
|
|
) -> Result<HashMap<String, Vec<StaticCredentialEndpointBinding>>, Status> {
|
|
let bindings = policy_static_credential_endpoint_bindings(Some(policy))?;
|
|
let has_signing = policy.network_policies.values().any(|rule| {
|
|
rule.endpoints
|
|
.iter()
|
|
.any(|endpoint| !endpoint.credential_signing.is_empty())
|
|
});
|
|
if bindings.is_empty() && !has_signing {
|
|
return Ok(bindings);
|
|
}
|
|
let records = super::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
workspace,
|
|
provider_names,
|
|
)
|
|
.await?;
|
|
validate_policy_credential_binding_context(catalog, &records, policy, &bindings)?;
|
|
Ok(bindings)
|
|
}
|
|
|
|
async fn provider_policy_layers_for_sandbox(
|
|
state: &ServerState,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
provider_names: &[String],
|
|
) -> Result<Vec<ProviderPolicyLayer>, Status> {
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
if decode_policy_from_global_settings(&global_settings)?.is_some() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), workspace)
|
|
.await?;
|
|
let layers = provider_policy_context_with_catalog(
|
|
state.store.as_ref(),
|
|
&catalog,
|
|
workspace,
|
|
provider_names,
|
|
)
|
|
.await?
|
|
.layers;
|
|
debug!(
|
|
sandbox_id = %sandbox.object_id(),
|
|
provider_layer_count = layers.len(),
|
|
"Composed candidate provider policy layers for ambiguity validation"
|
|
);
|
|
Ok(layers)
|
|
}
|
|
|
|
pub(super) async fn current_base_policy_for_sandbox(
|
|
store: &Store,
|
|
sandbox: &Sandbox,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
if let Some(record) = store
|
|
.get_latest_policy(sandbox.object_id())
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?
|
|
{
|
|
let (policy, _) = canonical_policy_record_identity(&record)?;
|
|
return Ok(policy);
|
|
}
|
|
sandbox
|
|
.spec
|
|
.as_ref()
|
|
.and_then(|spec| spec.policy.clone())
|
|
.map_or_else(
|
|
|| Ok(ProtoSandboxPolicy::default()),
|
|
|policy| validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_SPEC),
|
|
)
|
|
}
|
|
|
|
pub(super) async fn validate_candidate_provider_attachments(
|
|
state: &ServerState,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
provider_names: &[String],
|
|
) -> Result<(), Status> {
|
|
let base_policy = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?;
|
|
let provider_layers =
|
|
provider_policy_layers_for_sandbox(state, workspace, sandbox, provider_names).await?;
|
|
validate_candidate_effective_policy(&base_policy, &provider_layers)?;
|
|
let effective_policy = if provider_layers.is_empty() {
|
|
base_policy
|
|
} else {
|
|
compose_effective_policy(&base_policy, &provider_layers)
|
|
};
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), workspace)
|
|
.await?;
|
|
validate_policy_credential_bindings_for_sandbox(
|
|
state,
|
|
&catalog,
|
|
workspace,
|
|
provider_names,
|
|
&effective_policy,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result<bool, Status> {
|
|
let global_settings = load_global_settings(store).await?;
|
|
provider_policy_composition_enabled_in(&global_settings)
|
|
}
|
|
|
|
fn provider_policy_composition_enabled_in(settings: &StoredSettings) -> Result<bool, Status> {
|
|
Ok(decode_policy_from_global_settings(settings)?.is_none())
|
|
}
|
|
|
|
async fn validate_provider_composition_for_existing_sandboxes(
|
|
state: &ServerState,
|
|
) -> Result<(), Status> {
|
|
let mut catalogs = HashMap::<String, EffectiveProviderProfileCatalog>::new();
|
|
let mut cursor = None;
|
|
loop {
|
|
let page = state
|
|
.store
|
|
.list_message_page::<Sandbox>(
|
|
ObjectListQuery::AllWorkspaces,
|
|
cursor.as_ref(),
|
|
PROVIDER_COMPOSITION_VALIDATION_PAGE_SIZE,
|
|
)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?;
|
|
|
|
for sandbox in page.messages {
|
|
let provider_names = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.as_slice())
|
|
.unwrap_or_default();
|
|
if provider_names.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
if !catalogs.contains_key(&workspace) {
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), &workspace)
|
|
.await?;
|
|
catalogs.insert(workspace.clone(), catalog);
|
|
}
|
|
let catalog = catalogs
|
|
.get(&workspace)
|
|
.expect("catalog was inserted for sandbox workspace");
|
|
let base_policy =
|
|
current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
|
|
let provider_layers = provider_policy_context_with_catalog(
|
|
state.store.as_ref(),
|
|
catalog,
|
|
&workspace,
|
|
provider_names,
|
|
)
|
|
.await?
|
|
.layers;
|
|
validate_candidate_effective_policy(&base_policy, &provider_layers).map_err(|error| {
|
|
Status::failed_precondition(format!(
|
|
"cannot activate provider policy composition: sandbox '{}/{}' has an invalid effective policy: {}",
|
|
workspace,
|
|
sandbox.object_name(),
|
|
error.message()
|
|
))
|
|
})?;
|
|
}
|
|
|
|
let Some(next_cursor) = page.next_cursor else {
|
|
return Ok(());
|
|
};
|
|
cursor = Some(next_cursor);
|
|
}
|
|
}
|
|
|
|
pub async fn validate_provider_composition_startup_preflight(
|
|
state: &ServerState,
|
|
) -> Result<(), Status> {
|
|
if provider_policy_composition_enabled(state.store.as_ref()).await? {
|
|
validate_provider_composition_for_existing_sandboxes(state).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn validate_candidate_sandbox_credential_policy(
|
|
state: &ServerState,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
policy: Option<&ProtoSandboxPolicy>,
|
|
) -> Result<(), Status> {
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), workspace)
|
|
.await?;
|
|
let effective = effective_policy_for_source(
|
|
state,
|
|
&catalog,
|
|
workspace,
|
|
provider_names,
|
|
policy.cloned().unwrap_or_default(),
|
|
)
|
|
.await?;
|
|
validate_uninspected_credentialed_endpoints(&effective)
|
|
}
|
|
|
|
fn truncate_for_log(input: &str, max_chars: usize) -> String {
|
|
let mut chars = input.chars();
|
|
let truncated: String = chars.by_ref().take(max_chars).collect();
|
|
if chars.next().is_some() {
|
|
format!("{truncated}...")
|
|
} else {
|
|
truncated
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn is_sandbox_caller<T>(request: &Request<T>) -> bool {
|
|
matches!(
|
|
request.extensions().get::<Principal>(),
|
|
Some(Principal::Sandbox(_))
|
|
)
|
|
}
|
|
|
|
/// Sandbox-class callers may only perform sandbox-scoped policy sync. They
|
|
/// must not mutate global config or sandbox settings.
|
|
fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Status> {
|
|
if req.global {
|
|
return Err(Status::permission_denied(
|
|
"sandbox callers cannot mutate global config",
|
|
));
|
|
}
|
|
if req.delete_setting {
|
|
return Err(Status::permission_denied(
|
|
"sandbox callers cannot delete settings",
|
|
));
|
|
}
|
|
if req.sandbox.is_empty() {
|
|
return Err(Status::permission_denied(
|
|
"sandbox callers may only perform sandbox policy sync",
|
|
));
|
|
}
|
|
if req.policy.is_none() || !req.setting_key.trim().is_empty() {
|
|
return Err(Status::permission_denied(
|
|
"sandbox callers may only perform sandbox policy sync",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn sandbox_metadata_annotations(sandbox: &Sandbox) -> HashMap<String, String> {
|
|
sandbox
|
|
.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.annotations.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn update_config_response(
|
|
version: u32,
|
|
policy_hash: impl Into<String>,
|
|
settings_revision: u64,
|
|
deleted: bool,
|
|
annotations: HashMap<String, String>,
|
|
) -> Response<UpdateConfigResponse> {
|
|
Response::new(UpdateConfigResponse {
|
|
version,
|
|
policy_hash: policy_hash.into(),
|
|
settings_revision,
|
|
deleted,
|
|
annotations,
|
|
})
|
|
}
|
|
|
|
async fn persist_update_config_annotations(
|
|
state: &Arc<ServerState>,
|
|
sandbox_id: &str,
|
|
expected_resource_version: u64,
|
|
annotations: &HashMap<String, String>,
|
|
current_annotations: &HashMap<String, String>,
|
|
) -> Result<HashMap<String, String>, 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, _>(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<ServerState>,
|
|
sandbox_id: &str,
|
|
expected_resource_version: u64,
|
|
annotations: &HashMap<String, String>,
|
|
current_annotations: &HashMap<String, String>,
|
|
backfill_policy: Option<&ProtoSandboxPolicy>,
|
|
) -> Result<HashMap<String, String>, 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, _>(sandbox_id, expected_resource_version, |sandbox| {
|
|
let startup_blocked =
|
|
crate::policy_store::permits_initial_static_policy_repair(sandbox);
|
|
if let Some(policy) = backfill_policy.as_ref()
|
|
&& let Some(spec) = sandbox.spec.as_mut()
|
|
&& (spec.policy.is_none() || startup_blocked)
|
|
{
|
|
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,
|
|
workspace: &str,
|
|
principal: &Principal,
|
|
name: &str,
|
|
) -> Result<Sandbox, Status> {
|
|
let sandbox = store
|
|
.get_message_by_name::<Sandbox>(workspace, name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?;
|
|
|
|
match principal {
|
|
Principal::Sandbox(_) => {
|
|
let Some(sandbox) = sandbox else {
|
|
return Err(Status::permission_denied(
|
|
"sandbox not found or not owned by caller",
|
|
));
|
|
};
|
|
crate::auth::guard::ensure_sandbox_scope(principal, sandbox.object_id()).map_err(
|
|
|status| {
|
|
if status.code() == tonic::Code::PermissionDenied {
|
|
Status::permission_denied("sandbox not found or not owned by caller")
|
|
} else {
|
|
status
|
|
}
|
|
},
|
|
)?;
|
|
Ok(sandbox)
|
|
}
|
|
Principal::User(_) => sandbox.ok_or_else(|| Status::not_found("sandbox not found")),
|
|
Principal::Peer(_) => Err(Status::permission_denied(
|
|
"gateway peer principals may not resolve sandbox configuration",
|
|
)),
|
|
Principal::Anonymous => Err(Status::unauthenticated(
|
|
"sandbox-scoped methods require an authenticated caller",
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(super) async fn handle_get_sandbox_config(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetSandboxConfigRequest>,
|
|
) -> Result<Response<GetSandboxConfigResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let sandbox_name = request.get_ref().name.clone();
|
|
let workspace = match (&principal, request.get_ref().workspace_scope.as_ref()) {
|
|
(Principal::Sandbox(_), None) => String::new(),
|
|
(_, selector) => {
|
|
crate::auth::workspace_authz::selected_workspace_name(selector)?.to_string()
|
|
}
|
|
};
|
|
let result = handle_get_sandbox_config_inner(state, request).await;
|
|
match result {
|
|
Err(error)
|
|
if matches!(principal, Principal::Sandbox(_))
|
|
&& matches!(
|
|
error.code(),
|
|
tonic::Code::FailedPrecondition | tonic::Code::InvalidArgument
|
|
) =>
|
|
{
|
|
// A malformed stored candidate must not prevent a supervisor
|
|
// from registering its startup fence and waiting for repair.
|
|
// Do not expose parser payloads or copy malformed policy history.
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&sandbox_name,
|
|
&workspace,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?;
|
|
Ok(Response::new(GetSandboxConfigResponse {
|
|
configuration_admitted: false,
|
|
configuration_error: configuration_failure_diagnostic(&error).to_string(),
|
|
configuration_instance_id: sandbox
|
|
.status
|
|
.as_ref()
|
|
.and_then(|status| status.configuration_admission.as_ref())
|
|
.map_or_else(String::new, |admission| admission.instance_id.clone()),
|
|
workspace: sandbox.object_workspace().to_string(),
|
|
..Default::default()
|
|
}))
|
|
}
|
|
result => result,
|
|
}
|
|
}
|
|
|
|
async fn handle_get_sandbox_config_inner(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetSandboxConfigRequest>,
|
|
) -> Result<Response<GetSandboxConfigResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let req = request.into_inner();
|
|
let workspace = match &principal {
|
|
Principal::Sandbox(_) if req.workspace_scope.is_none() => "",
|
|
_ => crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
};
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.name,
|
|
workspace,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?;
|
|
Ok(Response::new(load_sandbox_config(state, &sandbox).await?))
|
|
}
|
|
|
|
/// Resolve the same effective configuration for authenticated RPCs and trusted
|
|
/// readiness snapshots. Callers must authorize external access before entering.
|
|
pub(super) async fn load_sandbox_config(
|
|
state: &Arc<ServerState>,
|
|
sandbox: &Sandbox,
|
|
) -> Result<GetSandboxConfigResponse, Status> {
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
let sandbox_provider_names = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.clone())
|
|
.unwrap_or_default();
|
|
let provider_profile_catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), &workspace)
|
|
.await?;
|
|
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let global_policy = decode_policy_from_global_settings(&global_settings)?;
|
|
let mut global_policy_version: u32 = 0;
|
|
|
|
// Try to get the latest policy from the policy history table. Under a
|
|
// global override, only the sandbox version metadata is observed; the
|
|
// dormant payload is neither decoded nor validated.
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?;
|
|
|
|
let (mut policy, version, mut policy_hash, policy_source) = if let Some(global_policy) =
|
|
global_policy
|
|
{
|
|
let version = latest
|
|
.as_ref()
|
|
.map(|record| u32::try_from(record.version).unwrap_or(0))
|
|
.filter(|version| *version > 0)
|
|
.unwrap_or(1);
|
|
let hash = deterministic_policy_hash(&global_policy);
|
|
(Some(global_policy), version, hash, PolicySource::Global)
|
|
} else if let Some(record) = latest {
|
|
let (policy, hash) = canonical_policy_record_identity(&record)?;
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
version = record.version,
|
|
"GetSandboxConfig served from policy history"
|
|
);
|
|
(
|
|
Some(policy),
|
|
u32::try_from(record.version).unwrap_or(0),
|
|
hash,
|
|
PolicySource::Sandbox,
|
|
)
|
|
} else {
|
|
// Lazy backfill: no policy history exists yet.
|
|
let spec = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
|
|
|
match spec.policy.clone() {
|
|
None => {
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
"GetSandboxConfig: no policy configured, returning empty response"
|
|
);
|
|
(None, 0, String::new(), PolicySource::Sandbox)
|
|
}
|
|
Some(spec_policy) => {
|
|
// Stored specs may predate the current schema. Validate before
|
|
// creating policy history so malformed state is never copied or
|
|
// marked loaded, and hash the canonical representation.
|
|
let spec_policy = validate_and_canonicalize_stored_policy(
|
|
spec_policy,
|
|
STORED_POLICY_SOURCE_SPEC,
|
|
)?;
|
|
let hash = deterministic_policy_hash(&spec_policy);
|
|
let payload = spec_policy.encode_to_vec();
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
if let Err(e) = state
|
|
.store
|
|
.put_policy_revision(&policy_id, &sandbox_id, &workspace, 1, &payload, &hash)
|
|
.await
|
|
{
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
error = %e,
|
|
"Failed to backfill policy version 1"
|
|
);
|
|
} else if let Err(e) = state
|
|
.store
|
|
.update_policy_status(&sandbox_id, 1, "loaded", None, None)
|
|
.await
|
|
{
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
error = %e,
|
|
"Failed to mark backfilled policy as loaded"
|
|
);
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
"GetSandboxConfig served from spec (backfilled version 1)"
|
|
);
|
|
|
|
(Some(spec_policy), 1, hash, PolicySource::Sandbox)
|
|
}
|
|
}
|
|
};
|
|
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let sandbox_settings =
|
|
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?;
|
|
let provider_records = super::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
&workspace,
|
|
&sandbox_provider_names,
|
|
)
|
|
.await?;
|
|
let mut provider_policy_context =
|
|
provider_policy_context_from_records(&provider_profile_catalog, &provider_records);
|
|
|
|
if matches!(policy_source, PolicySource::Global)
|
|
&& let Ok(Some(global_rev)) = state
|
|
.store
|
|
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
|
|
.await
|
|
{
|
|
global_policy_version = u32::try_from(global_rev.version).unwrap_or(0);
|
|
}
|
|
|
|
if let Some(source_policy) = policy.as_mut() {
|
|
// Never trust provenance supplied by a persisted/user-authored policy.
|
|
// The gateway derives it from the attached provider catalog below.
|
|
clear_provider_credentialed_markers(source_policy);
|
|
}
|
|
|
|
if !matches!(policy_source, PolicySource::Global)
|
|
&& let Some(source_policy) = policy.as_ref()
|
|
&& !provider_policy_context.layers.is_empty()
|
|
{
|
|
let effective_policy =
|
|
compose_effective_policy(source_policy, &provider_policy_context.layers);
|
|
let effective_policy =
|
|
validate_and_canonicalize_policy(effective_policy).map_err(|error| {
|
|
Status::failed_precondition(format!(
|
|
"provider composition produced an invalid effective policy: {}",
|
|
error.message()
|
|
))
|
|
})?;
|
|
validate_policy_safety(&effective_policy).map_err(|error| {
|
|
Status::failed_precondition(format!(
|
|
"provider composition produced an invalid effective policy: {}",
|
|
error.message()
|
|
))
|
|
})?;
|
|
policy_hash = deterministic_policy_hash(&effective_policy);
|
|
policy = Some(effective_policy);
|
|
}
|
|
|
|
let policy_credential_bindings = policy_static_credential_endpoint_bindings(policy.as_ref())?;
|
|
extend_credentialed_scopes_from_policy_bindings(
|
|
&mut provider_policy_context.credentialed_scopes,
|
|
&policy_credential_bindings,
|
|
&provider_policy_context.endpointless_provider_names,
|
|
);
|
|
let mut configuration_error = String::new();
|
|
if let Some(effective_policy) = policy.as_mut() {
|
|
stamp_provider_credentialed_endpoints(
|
|
effective_policy,
|
|
&provider_policy_context.credentialed_scopes,
|
|
);
|
|
if let Err(error) = validate_uninspected_credentialed_endpoints(effective_policy) {
|
|
configuration_error = bounded_configuration_diagnostic(error.message());
|
|
}
|
|
policy_hash = deterministic_policy_hash(effective_policy);
|
|
}
|
|
|
|
if let Some(policy) = policy.as_ref() {
|
|
state
|
|
.middleware_registry
|
|
.ensure_policy_middlewares_registered(policy)
|
|
.map_err(|error| {
|
|
Status::failed_precondition(format!(
|
|
"effective policy middleware registration is invalid: {error}"
|
|
))
|
|
})?;
|
|
}
|
|
|
|
let settings = merge_effective_settings(&global_settings, &sandbox_settings)?;
|
|
let supervisor_middleware_services =
|
|
state.middleware_registry.required_services(policy.as_ref());
|
|
let config_revision = compute_config_revision_with_validation_mode(
|
|
policy.as_ref(),
|
|
&settings,
|
|
policy_source,
|
|
&supervisor_middleware_services,
|
|
state.config.policy_validation_failure_mode,
|
|
state.extension_jwt_issuer.is_some(),
|
|
);
|
|
if let Some(policy) = policy.as_ref() {
|
|
validate_policy_credential_binding_context(
|
|
&provider_profile_catalog,
|
|
&provider_records,
|
|
policy,
|
|
&policy_credential_bindings,
|
|
)?;
|
|
}
|
|
let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings(
|
|
&provider_profile_catalog,
|
|
&provider_records,
|
|
&policy_credential_bindings,
|
|
)?;
|
|
|
|
Ok(GetSandboxConfigResponse {
|
|
configuration_instance_id: sandbox
|
|
.status
|
|
.as_ref()
|
|
.and_then(|status| status.configuration_admission.as_ref())
|
|
.map_or_else(String::new, |admission| admission.instance_id.clone()),
|
|
configuration_admitted: policy.is_some() && configuration_error.is_empty(),
|
|
configuration_error,
|
|
policy,
|
|
version,
|
|
policy_hash,
|
|
settings,
|
|
config_revision,
|
|
policy_source: policy_source.into(),
|
|
global_policy_version,
|
|
provider_env_revision,
|
|
supervisor_middleware_services,
|
|
workspace,
|
|
policy_validation_failure_mode: state
|
|
.config
|
|
.policy_validation_failure_mode
|
|
.as_str()
|
|
.to_string(),
|
|
extension_authentication_enabled: state.extension_jwt_issuer.is_some(),
|
|
provider_attachment_epoch: sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.provider_attachment_epoch.clone())
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn compute_provider_env_revision(
|
|
store: &Store,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
) -> Result<u64, Status> {
|
|
let catalog = ProviderProfileSources::with_default_sources()
|
|
.snapshot_catalog(store, workspace)
|
|
.await?;
|
|
compute_provider_env_revision_with_catalog(store, &catalog, workspace, provider_names).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) async fn compute_provider_env_revision_with_catalog(
|
|
store: &Store,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
) -> Result<u64, Status> {
|
|
compute_provider_env_revision_with_catalog_and_policy_bindings(
|
|
store,
|
|
catalog,
|
|
workspace,
|
|
provider_names,
|
|
&HashMap::new(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn compute_provider_env_revision_with_catalog_and_policy_bindings(
|
|
store: &Store,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
policy_bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
|
|
) -> Result<u64, Status> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(b"openshell-provider-env-revision-v4");
|
|
|
|
for provider_name in provider_names {
|
|
hasher.update(provider_name.as_bytes());
|
|
match store
|
|
.get_by_name(Provider::object_type(), workspace, provider_name)
|
|
.await
|
|
.map_err(|e| {
|
|
Status::internal(format!("fetch provider '{provider_name}' failed: {e}"))
|
|
})? {
|
|
Some(record) => {
|
|
hasher.update(record.id.as_bytes());
|
|
hasher.update(record.resource_version.to_le_bytes());
|
|
|
|
let provider = Provider::decode(record.payload.as_slice()).map_err(|e| {
|
|
Status::internal(format!("decode provider '{provider_name}' failed: {e}"))
|
|
})?;
|
|
let refresh_states =
|
|
crate::provider_refresh::list_refresh_states_for_provider(store, &record.id)
|
|
.await?;
|
|
hash_provider_refresh_states(&refresh_states, &mut hasher)?;
|
|
hasher.update(provider.r#type.as_bytes());
|
|
hash_provider_profile_revision(
|
|
catalog,
|
|
&provider.r#type,
|
|
&provider.profile_workspace,
|
|
&mut hasher,
|
|
);
|
|
|
|
let mut credential_keys: Vec<_> = provider.credentials.keys().collect();
|
|
credential_keys.sort();
|
|
for key in credential_keys {
|
|
hasher.update(key.as_bytes());
|
|
}
|
|
let mut expiry_keys: Vec<_> = provider.credential_expiration_times.keys().collect();
|
|
expiry_keys.sort();
|
|
for key in expiry_keys {
|
|
hasher.update(key.as_bytes());
|
|
let value = &provider.credential_expiration_times[key];
|
|
hasher.update(value.seconds.to_le_bytes());
|
|
hasher.update(value.nanos.to_le_bytes());
|
|
}
|
|
}
|
|
None => {
|
|
hasher.update(b"missing");
|
|
}
|
|
}
|
|
}
|
|
|
|
hash_policy_credential_bindings(policy_bindings, &mut hasher);
|
|
|
|
let digest = hasher.finalize();
|
|
Ok(u64::from_le_bytes(digest[..8].try_into().map_err(
|
|
|_| Status::internal("provider env revision digest too short"),
|
|
)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn compute_provider_env_revision_from_records(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
records: &[super::provider::ProviderEnvironmentRecord],
|
|
) -> Result<u64, Status> {
|
|
compute_provider_env_revision_from_records_and_policy_bindings(
|
|
catalog,
|
|
records,
|
|
&HashMap::new(),
|
|
)
|
|
}
|
|
|
|
fn compute_provider_env_revision_from_records_and_policy_bindings(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
records: &[super::provider::ProviderEnvironmentRecord],
|
|
policy_bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
|
|
) -> Result<u64, Status> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(b"openshell-provider-env-revision-v4");
|
|
|
|
for record in records {
|
|
hasher.update(record.name.as_bytes());
|
|
hasher.update(record.object_id.as_bytes());
|
|
hasher.update(record.resource_version.to_le_bytes());
|
|
|
|
let provider = &record.provider;
|
|
hash_provider_refresh_states(&record.refresh_states, &mut hasher)?;
|
|
hasher.update(provider.r#type.as_bytes());
|
|
hash_provider_profile_revision(
|
|
catalog,
|
|
&provider.r#type,
|
|
&provider.profile_workspace,
|
|
&mut hasher,
|
|
);
|
|
|
|
let mut credential_keys: Vec<_> = provider.credentials.keys().collect();
|
|
credential_keys.sort();
|
|
for key in credential_keys {
|
|
hasher.update(key.as_bytes());
|
|
}
|
|
let mut expiry_keys: Vec<_> = provider.credential_expiration_times.keys().collect();
|
|
expiry_keys.sort();
|
|
for key in expiry_keys {
|
|
hasher.update(key.as_bytes());
|
|
let value = &provider.credential_expiration_times[key];
|
|
hasher.update(value.seconds.to_le_bytes());
|
|
hasher.update(value.nanos.to_le_bytes());
|
|
}
|
|
}
|
|
|
|
hash_policy_credential_bindings(policy_bindings, &mut hasher);
|
|
|
|
let digest = hasher.finalize();
|
|
Ok(u64::from_le_bytes(digest[..8].try_into().map_err(
|
|
|_| Status::internal("provider env revision digest too short"),
|
|
)?))
|
|
}
|
|
|
|
fn hash_provider_refresh_states(
|
|
states: &[StoredProviderCredentialRefreshState],
|
|
hasher: &mut Sha256,
|
|
) -> Result<(), Status> {
|
|
let mut states = states.iter().collect::<Vec<_>>();
|
|
states.sort_by(|left, right| {
|
|
left.credential_key
|
|
.cmp(&right.credential_key)
|
|
.then_with(|| {
|
|
left.metadata
|
|
.as_ref()
|
|
.map(|metadata| metadata.id.as_str())
|
|
.cmp(&right.metadata.as_ref().map(|metadata| metadata.id.as_str()))
|
|
})
|
|
});
|
|
for state in states {
|
|
hasher.update(b"provider-refresh-state");
|
|
hasher.update(state.credential_key.as_bytes());
|
|
hasher.update(state.strategy.to_le_bytes());
|
|
hasher.update(crate::provider_refresh::effective_authorization_epoch(state)?.as_bytes());
|
|
if let Some(metadata) = &state.metadata {
|
|
hasher.update(metadata.id.as_bytes());
|
|
hasher.update(metadata.resource_version.to_le_bytes());
|
|
}
|
|
let mut outputs = state.additional_output_keys.iter().collect::<Vec<_>>();
|
|
outputs.sort();
|
|
for (output, key) in outputs {
|
|
hasher.update(output.as_bytes());
|
|
hasher.update(key.as_bytes());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn hash_policy_credential_bindings(
|
|
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
|
|
hasher: &mut Sha256,
|
|
) {
|
|
let mut provider_names: Vec<_> = bindings.keys().collect();
|
|
provider_names.sort();
|
|
for provider_name in provider_names {
|
|
hasher.update(provider_name.as_bytes());
|
|
let mut endpoints = bindings[provider_name].clone();
|
|
endpoints.sort_by(|left, right| {
|
|
(&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path))
|
|
});
|
|
for endpoint in endpoints {
|
|
hasher.update(endpoint.host.as_bytes());
|
|
hasher.update(endpoint.port.to_le_bytes());
|
|
hasher.update(endpoint.path.as_bytes());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hash_provider_profile_revision(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
provider_type: &str,
|
|
profile_workspace: &str,
|
|
hasher: &mut Sha256,
|
|
) {
|
|
catalog.hash_type_profile_revision_for_scope(provider_type, profile_workspace, hasher);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn profile_provider_policy_layers(
|
|
store: &Store,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
) -> Result<Vec<ProviderPolicyLayer>, Status> {
|
|
let catalog = ProviderProfileSources::with_default_sources()
|
|
.snapshot_catalog(store, workspace)
|
|
.await?;
|
|
profile_provider_policy_layers_with_catalog(store, &catalog, workspace, provider_names).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn profile_provider_policy_layers_with_catalog(
|
|
store: &Store,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
) -> Result<Vec<ProviderPolicyLayer>, Status> {
|
|
Ok(
|
|
provider_policy_context_with_catalog(store, catalog, workspace, provider_names)
|
|
.await?
|
|
.layers,
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(super) struct CredentialedEndpointScope {
|
|
host: String,
|
|
ports: Vec<u32>,
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ProviderPolicyContext {
|
|
layers: Vec<ProviderPolicyLayer>,
|
|
credentialed_scopes: Vec<CredentialedEndpointScope>,
|
|
endpointless_provider_names: HashSet<String>,
|
|
}
|
|
|
|
async fn provider_policy_context_with_catalog(
|
|
store: &Store,
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
workspace: &str,
|
|
provider_names: &[String],
|
|
) -> Result<ProviderPolicyContext, Status> {
|
|
let records =
|
|
super::provider::load_provider_environment_records(store, workspace, provider_names)
|
|
.await?;
|
|
Ok(provider_policy_context_from_records(catalog, &records))
|
|
}
|
|
|
|
fn provider_policy_context_from_records(
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
records: &[super::provider::ProviderEnvironmentRecord],
|
|
) -> ProviderPolicyContext {
|
|
let mut layers = Vec::new();
|
|
let mut credentialed_scopes = Vec::new();
|
|
let mut endpointless_provider_names = HashSet::new();
|
|
|
|
for record in records {
|
|
let name = &record.name;
|
|
let provider = &record.provider;
|
|
|
|
let provider_type = provider.r#type.trim();
|
|
let Some(profile) = super::provider::get_provider_type_profile_for_scope(
|
|
catalog,
|
|
provider_type,
|
|
&provider.profile_workspace,
|
|
) else {
|
|
warn!(
|
|
provider_name = %name,
|
|
provider_type,
|
|
"provider type has no profile; skipping provider policy layer"
|
|
);
|
|
continue;
|
|
};
|
|
|
|
if !super::provider::provider_profile_endpoints_are_active(&profile, provider) {
|
|
endpointless_provider_names.insert(name.clone());
|
|
continue;
|
|
}
|
|
|
|
let rule_name = openshell_policy::provider_rule_name(provider.object_name());
|
|
let mut rule = profile.network_policy_rule(&rule_name);
|
|
if rule.endpoints.is_empty() {
|
|
endpointless_provider_names.insert(name.clone());
|
|
}
|
|
if profile.has_credentialed_endpoints() {
|
|
for endpoint in &mut rule.endpoints {
|
|
endpoint.provider_credentialed = true;
|
|
let scope = CredentialedEndpointScope {
|
|
host: endpoint.host.to_ascii_lowercase(),
|
|
ports: endpoint_ports(endpoint),
|
|
path: canonical_endpoint_path(&endpoint.path),
|
|
};
|
|
if !credentialed_scopes.contains(&scope) {
|
|
credentialed_scopes.push(scope);
|
|
}
|
|
}
|
|
}
|
|
layers.push(ProviderPolicyLayer {
|
|
rule_name: rule_name.clone(),
|
|
rule,
|
|
});
|
|
}
|
|
|
|
ProviderPolicyContext {
|
|
layers,
|
|
credentialed_scopes,
|
|
endpointless_provider_names,
|
|
}
|
|
}
|
|
|
|
fn endpoint_ports(endpoint: &NetworkEndpoint) -> Vec<u32> {
|
|
if endpoint.ports.is_empty() {
|
|
(endpoint.port > 0)
|
|
.then_some(endpoint.port)
|
|
.into_iter()
|
|
.collect()
|
|
} else {
|
|
endpoint.ports.clone()
|
|
}
|
|
}
|
|
|
|
fn extend_credentialed_scopes_from_policy_bindings(
|
|
scopes: &mut Vec<CredentialedEndpointScope>,
|
|
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
|
|
endpointless_provider_names: &HashSet<String>,
|
|
) {
|
|
for (provider_name, provider_bindings) in bindings {
|
|
if !endpointless_provider_names.contains(provider_name) {
|
|
continue;
|
|
}
|
|
for binding in provider_bindings {
|
|
let scope = CredentialedEndpointScope {
|
|
host: binding.host.to_ascii_lowercase(),
|
|
ports: vec![binding.port],
|
|
path: canonical_endpoint_path(&binding.path),
|
|
};
|
|
if !scopes.contains(&scope) {
|
|
scopes.push(scope);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn endpoint_matches_credentialed_scope(
|
|
endpoint: &NetworkEndpoint,
|
|
scope: &CredentialedEndpointScope,
|
|
) -> bool {
|
|
if !host_patterns_overlap(&endpoint.host, &scope.host).unwrap_or(false) {
|
|
return false;
|
|
}
|
|
let endpoint_ports = endpoint_ports(endpoint);
|
|
let port_matches = endpoint_ports.is_empty()
|
|
|| scope.ports.is_empty()
|
|
|| endpoint_ports.iter().any(|port| scope.ports.contains(port));
|
|
port_matches && path_pattern_covers(&scope.path, &canonical_endpoint_path(&endpoint.path))
|
|
}
|
|
|
|
fn canonical_endpoint_path(path: &str) -> String {
|
|
match path.trim() {
|
|
"" | "**" | "/**" => "/**".to_string(),
|
|
path => path.to_string(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn clear_provider_credentialed_markers(policy: &mut ProtoSandboxPolicy) {
|
|
for rule in policy.network_policies.values_mut() {
|
|
for endpoint in &mut rule.endpoints {
|
|
endpoint.provider_credentialed = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn stamp_provider_credentialed_endpoints(
|
|
policy: &mut ProtoSandboxPolicy,
|
|
scopes: &[CredentialedEndpointScope],
|
|
) {
|
|
for rule in policy.network_policies.values_mut() {
|
|
for endpoint in &mut rule.endpoints {
|
|
endpoint.provider_credentialed = scopes
|
|
.iter()
|
|
.any(|scope| endpoint_matches_credentialed_scope(endpoint, scope));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A credentialed endpoint whose configured mode disables the L7 inspection a
|
|
/// reviewer would expect, without an explicit `allow_uninspected_credentials`.
|
|
struct UninspectedCredentialedEndpoint {
|
|
rule_name: String,
|
|
host: String,
|
|
port: u32,
|
|
mode: &'static str,
|
|
}
|
|
|
|
/// Scan the effective policy for credentialed endpoints on uninspected modes.
|
|
///
|
|
/// Explicit opt-ins are logged and skipped. Never logs credential names,
|
|
/// placeholders, or secret values.
|
|
fn find_uninspected_credentialed_endpoint(
|
|
policy: &ProtoSandboxPolicy,
|
|
) -> Option<UninspectedCredentialedEndpoint> {
|
|
for (rule_name, rule) in &policy.network_policies {
|
|
for endpoint in &rule.endpoints {
|
|
if !endpoint.provider_credentialed {
|
|
continue;
|
|
}
|
|
|
|
let mode = if endpoint.protocol.trim().is_empty() {
|
|
"L4-only"
|
|
} else if openshell_policy::network_tls_mode_to_str(endpoint.tls) == Some("skip") {
|
|
"tls: skip"
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
if endpoint.allow_uninspected_credentials {
|
|
warn!(
|
|
rule_name,
|
|
host = %endpoint.host,
|
|
ports = ?endpoint_ports(endpoint),
|
|
mode,
|
|
"credentialed endpoint explicitly allows uninspected traffic"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
return Some(UninspectedCredentialedEndpoint {
|
|
rule_name: rule_name.clone(),
|
|
host: endpoint.host.clone(),
|
|
port: endpoint_ports(endpoint)
|
|
.first()
|
|
.copied()
|
|
.unwrap_or(endpoint.port),
|
|
mode,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Admission gate for policy-authoring paths (create, attach, operator config
|
|
/// update). Rejects credentialed endpoints that would lose L7 inspection.
|
|
fn validate_uninspected_credentialed_endpoints(policy: &ProtoSandboxPolicy) -> Result<(), Status> {
|
|
let Some(violation) = find_uninspected_credentialed_endpoint(policy) else {
|
|
return Ok(());
|
|
};
|
|
|
|
warn!(
|
|
rule_name = %violation.rule_name,
|
|
host = %violation.host,
|
|
port = violation.port,
|
|
mode = violation.mode,
|
|
"rejecting uninspected credentialed endpoint"
|
|
);
|
|
Err(Status::failed_precondition(format!(
|
|
"credentialed endpoint '{}:{}' in rule '{}' uses {}; configure L7 inspection or explicitly set allow_uninspected_credentials: true",
|
|
violation.host, violation.port, violation.rule_name, violation.mode
|
|
)))
|
|
}
|
|
|
|
pub(super) async fn handle_get_gateway_config(
|
|
state: &Arc<ServerState>,
|
|
_request: Request<GetGatewayConfigRequest>,
|
|
) -> Result<Response<GetGatewayConfigResponse>, Status> {
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let settings = materialize_global_settings(&global_settings)?;
|
|
Ok(Response::new(GetGatewayConfigResponse {
|
|
settings,
|
|
settings_revision: global_settings.revision,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_get_sandbox_provider_environment(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetSandboxProviderEnvironmentRequest>,
|
|
) -> Result<Response<GetSandboxProviderEnvironmentResponse>, Status> {
|
|
let sandbox_id = request.get_ref().sandbox_id.clone();
|
|
let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings;
|
|
crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?;
|
|
drop(request);
|
|
|
|
let sandbox = state
|
|
.store
|
|
.get_message::<Sandbox>(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let environment =
|
|
load_sandbox_provider_environment(state, &sandbox, supports_static_credential_bindings)
|
|
.await?;
|
|
Ok(Response::new(environment))
|
|
}
|
|
|
|
/// Materialize a privileged provider snapshot after the caller has authorized
|
|
/// access. Omission reasons remain separate from a legitimately empty snapshot.
|
|
pub(super) async fn load_sandbox_provider_environment(
|
|
state: &Arc<ServerState>,
|
|
sandbox: &Sandbox,
|
|
supports_static_credential_bindings: bool,
|
|
) -> Result<GetSandboxProviderEnvironmentResponse, Status> {
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
|
|
let spec = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
|
|
|
let provider_names = spec.providers.clone();
|
|
let provider_profile_catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), &workspace)
|
|
.await?;
|
|
let provider_records = super::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
&workspace,
|
|
&provider_names,
|
|
)
|
|
.await?;
|
|
let effective_policy = current_effective_policy_from_records(
|
|
state.as_ref(),
|
|
&provider_profile_catalog,
|
|
sandbox,
|
|
&sandbox_id,
|
|
&provider_records,
|
|
)
|
|
.await?;
|
|
let policy_credential_bindings =
|
|
policy_static_credential_endpoint_bindings(Some(&effective_policy))?;
|
|
validate_policy_credential_binding_context(
|
|
&provider_profile_catalog,
|
|
&provider_records,
|
|
&effective_policy,
|
|
&policy_credential_bindings,
|
|
)?;
|
|
let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings(
|
|
&provider_profile_catalog,
|
|
&provider_records,
|
|
&policy_credential_bindings,
|
|
)?;
|
|
let mut provider_environment =
|
|
super::provider::resolve_provider_environment_from_records_with_policy_bindings_and_credentials(
|
|
state.store.as_ref(),
|
|
&provider_profile_catalog,
|
|
&provider_records,
|
|
&policy_credential_bindings,
|
|
&state.credentials,
|
|
Some(&sandbox_id),
|
|
)
|
|
.await?;
|
|
|
|
let mut readiness_reason = provider_environment.readiness_reason;
|
|
|
|
if supports_static_credential_bindings {
|
|
let unbound_static_keys = provider_environment
|
|
.static_credential_keys
|
|
.iter()
|
|
.filter(|key| {
|
|
!provider_environment
|
|
.static_credential_bindings
|
|
.contains_key(*key)
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if !unbound_static_keys.is_empty() {
|
|
readiness_reason = ProviderReadinessReason::CredentialsWithheld;
|
|
}
|
|
for key in unbound_static_keys {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
key = %key,
|
|
"withholding unbound static provider credential from binding-capable supervisor"
|
|
);
|
|
provider_environment.environment.remove(&key);
|
|
provider_environment
|
|
.credential_expiration_times
|
|
.remove(&key);
|
|
provider_environment.static_credential_keys.remove(&key);
|
|
}
|
|
} else {
|
|
if !provider_environment.static_credential_keys.is_empty() {
|
|
readiness_reason = ProviderReadinessReason::UnsupportedSupervisor;
|
|
}
|
|
for key in &provider_environment.static_credential_keys {
|
|
provider_environment.environment.remove(key);
|
|
provider_environment.credential_expiration_times.remove(key);
|
|
}
|
|
provider_environment.static_credential_bindings.clear();
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
provider_count = provider_names.len(),
|
|
env_count = provider_environment.environment.len(),
|
|
provider_env_revision,
|
|
"GetSandboxProviderEnvironment request completed successfully"
|
|
);
|
|
|
|
let non_secret_environment_keys = provider_environment
|
|
.environment
|
|
.keys()
|
|
.filter(|key| !provider_environment.static_credential_keys.contains(*key))
|
|
.cloned()
|
|
.collect();
|
|
|
|
let credential_expiration_times = provider_environment
|
|
.credential_expiration_times
|
|
.into_iter()
|
|
.filter_map(|(key, value)| {
|
|
openshell_core::time::optional_timestamp_from_legacy_millis(value)
|
|
.ok()
|
|
.flatten()
|
|
.map(|timestamp| (key, timestamp))
|
|
})
|
|
.collect();
|
|
Ok(GetSandboxProviderEnvironmentResponse {
|
|
environment: provider_environment.environment,
|
|
files: provider_environment.files,
|
|
provider_env_revision,
|
|
credential_expiration_times,
|
|
dynamic_credentials: provider_environment.dynamic_credentials,
|
|
static_credential_bindings: provider_environment.static_credential_bindings,
|
|
non_secret_environment_keys,
|
|
provider_attachment_epoch: spec.provider_attachment_epoch.clone(),
|
|
policy_hash: deterministic_policy_hash(&effective_policy),
|
|
readiness_reason: readiness_reason.into(),
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update config handler (policy + settings mutations)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(super) async fn handle_update_config(
|
|
state: &Arc<ServerState>,
|
|
request: Request<UpdateConfigRequest>,
|
|
) -> Result<Response<UpdateConfigResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let sandbox_caller = matches!(&principal, Principal::Sandbox(_));
|
|
let update = request.get_ref();
|
|
let should_emit_policy_failure = should_emit_config_update_policy_telemetry(sandbox_caller)
|
|
&& (update.policy.is_some() || !update.merge_operations.is_empty());
|
|
let result = Box::pin(handle_update_config_inner(
|
|
state,
|
|
request,
|
|
&principal,
|
|
sandbox_caller,
|
|
))
|
|
.await;
|
|
if result.is_err() && should_emit_policy_failure {
|
|
emit_sandbox_policy_update_failure();
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn handle_update_config_inner(
|
|
state: &Arc<ServerState>,
|
|
request: Request<UpdateConfigRequest>,
|
|
principal: &Principal,
|
|
sandbox_caller: bool,
|
|
) -> Result<Response<UpdateConfigResponse>, Status> {
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
validate_annotations(&req.annotations, "annotations")?;
|
|
let sandbox = if req.global {
|
|
if !req.sandbox.is_empty() || req.workspace_scope.is_some() {
|
|
return Err(Status::invalid_argument(
|
|
"sandbox and workspace must be omitted when global is true",
|
|
));
|
|
}
|
|
require_platform_admin(&state.admin_role, principal)?;
|
|
replay_facts.global()?;
|
|
None
|
|
} else {
|
|
let min_role = if sandbox_caller {
|
|
MinWorkspaceRole::User
|
|
} else {
|
|
MinWorkspaceRole::Admin
|
|
};
|
|
Some(
|
|
super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(
|
|
req.workspace_scope.as_ref(),
|
|
)?,
|
|
min_role,
|
|
)
|
|
.await?,
|
|
)
|
|
};
|
|
let workspace = sandbox.as_ref().map_or_else(String::new, |sandbox| {
|
|
sandbox.object_workspace().to_string()
|
|
});
|
|
if sandbox_caller {
|
|
validate_sandbox_caller_update(&req)?;
|
|
}
|
|
let key = req.setting_key.trim();
|
|
let has_policy = req.policy.is_some();
|
|
let has_setting = !key.is_empty();
|
|
let has_merge_ops = !req.merge_operations.is_empty();
|
|
let mut mutation_count = 0_u8;
|
|
mutation_count += u8::from(has_policy);
|
|
mutation_count += u8::from(has_setting);
|
|
mutation_count += u8::from(has_merge_ops);
|
|
|
|
if mutation_count > 1 {
|
|
return Err(Status::invalid_argument(
|
|
"policy, setting_key, and merge_operations are mutually exclusive",
|
|
));
|
|
}
|
|
if mutation_count == 0 {
|
|
return Err(Status::invalid_argument(
|
|
"one of policy, setting_key, or merge_operations must be provided",
|
|
));
|
|
}
|
|
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;
|
|
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
|
|
super::persistence_error_to_status(error, "acquire policy mutation lock")
|
|
})?;
|
|
|
|
if has_merge_ops {
|
|
return Err(Status::invalid_argument(
|
|
"merge_operations are not supported for global policy updates",
|
|
));
|
|
}
|
|
|
|
if has_policy {
|
|
if req.delete_setting {
|
|
return Err(Status::invalid_argument(
|
|
"delete_setting cannot be combined with policy payload",
|
|
));
|
|
}
|
|
let mut new_policy = req.policy.ok_or_else(|| {
|
|
Status::invalid_argument("policy is required for global policy update")
|
|
})?;
|
|
clear_provider_credentialed_markers(&mut new_policy);
|
|
validate_no_reserved_provider_policy_keys(&new_policy)?;
|
|
new_policy = validate_and_canonicalize_policy(new_policy)?;
|
|
validate_policy_safety(&new_policy)?;
|
|
crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy)
|
|
.await?;
|
|
validate_candidate_effective_policy(&new_policy, &[])?;
|
|
if !policy_static_credential_endpoint_bindings(Some(&new_policy))?.is_empty() {
|
|
return Err(Status::failed_precondition(
|
|
"credential_binding is sandbox-scoped and cannot be used in a global policy",
|
|
));
|
|
}
|
|
|
|
let payload = new_policy.encode_to_vec();
|
|
let hash = deterministic_policy_hash(&new_policy);
|
|
|
|
// Global policy determines the report's effective configuration.
|
|
// Serialize its writes after validation so a report cannot commit
|
|
// evidence derived from the policy this update has replaced.
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest global policy failed: {e}")))?;
|
|
|
|
if let Some(ref current) = latest
|
|
&& canonical_policy_record_matches_for_deduplication(current, &hash)
|
|
&& current.status == "loaded"
|
|
{
|
|
let mut global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let stored_value = StoredSettingValue::Bytes(hex::encode(&payload));
|
|
let changed = upsert_setting_value(
|
|
&mut global_settings.settings,
|
|
POLICY_SETTING_KEY,
|
|
stored_value,
|
|
);
|
|
if changed {
|
|
global_settings.revision = global_settings.revision.wrapping_add(1);
|
|
save_global_settings(state.store.as_ref(), &global_settings).await?;
|
|
}
|
|
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);
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
&policy_id,
|
|
GLOBAL_POLICY_SANDBOX_ID,
|
|
"",
|
|
next_version,
|
|
&payload,
|
|
&hash,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
Status::internal(format!("persist global policy revision failed: {e}"))
|
|
})?;
|
|
|
|
let now_ms = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_millis() as i64);
|
|
let _ = state
|
|
.store
|
|
.update_policy_status(
|
|
GLOBAL_POLICY_SANDBOX_ID,
|
|
next_version,
|
|
"loaded",
|
|
None,
|
|
Some(now_ms),
|
|
)
|
|
.await;
|
|
let _ = state
|
|
.store
|
|
.supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, next_version)
|
|
.await;
|
|
|
|
let mut global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let stored_value = StoredSettingValue::Bytes(hex::encode(&payload));
|
|
let changed = upsert_setting_value(
|
|
&mut global_settings.settings,
|
|
POLICY_SETTING_KEY,
|
|
stored_value,
|
|
);
|
|
if changed {
|
|
global_settings.revision = global_settings.revision.wrapping_add(1);
|
|
save_global_settings(state.store.as_ref(), &global_settings).await?;
|
|
}
|
|
|
|
return Ok(update_config_response(
|
|
u32::try_from(next_version).unwrap_or(0),
|
|
hash,
|
|
global_settings.revision,
|
|
false,
|
|
HashMap::new(),
|
|
));
|
|
}
|
|
|
|
// Global setting mutation.
|
|
if key == POLICY_SETTING_KEY && !req.delete_setting {
|
|
return Err(Status::invalid_argument(
|
|
"reserved key 'policy' must be set via the policy field",
|
|
));
|
|
}
|
|
if key != POLICY_SETTING_KEY {
|
|
validate_registered_setting_key(key)?;
|
|
}
|
|
|
|
// Deleting global policy changes the report's effective configuration.
|
|
// Keep settings -> sandbox lock order for all global policy mutations.
|
|
let mut global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let provider_composition_was_enabled =
|
|
provider_policy_composition_enabled_in(&global_settings)?;
|
|
let changed = if req.delete_setting {
|
|
global_settings.settings.remove(key).is_some()
|
|
} else {
|
|
let setting = req
|
|
.setting_value
|
|
.as_ref()
|
|
.ok_or_else(|| Status::invalid_argument("setting_value is required"))?;
|
|
let stored = proto_setting_to_stored(key, setting)?;
|
|
upsert_setting_value(&mut global_settings.settings, key, stored)
|
|
};
|
|
|
|
if changed {
|
|
let provider_composition_is_enabled =
|
|
provider_policy_composition_enabled_in(&global_settings)?;
|
|
if !provider_composition_was_enabled && provider_composition_is_enabled {
|
|
validate_provider_composition_for_existing_sandboxes(state).await?;
|
|
}
|
|
|
|
global_settings.revision = global_settings.revision.wrapping_add(1);
|
|
save_global_settings(state.store.as_ref(), &global_settings).await?;
|
|
|
|
if req.delete_setting
|
|
&& key == POLICY_SETTING_KEY
|
|
&& let Ok(Some(latest)) = state
|
|
.store
|
|
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
|
|
.await
|
|
{
|
|
let _ = state
|
|
.store
|
|
.supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, latest.version + 1)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
return Ok(update_config_response(
|
|
0,
|
|
String::new(),
|
|
global_settings.revision,
|
|
req.delete_setting && changed,
|
|
HashMap::new(),
|
|
));
|
|
}
|
|
|
|
let sandbox = sandbox.expect("non-global config update resolves a sandbox");
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
let mut response_annotations = sandbox_metadata_annotations(&sandbox);
|
|
|
|
if has_setting {
|
|
let _settings_guard = state.settings_mutex.lock().await;
|
|
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
|
|
super::persistence_error_to_status(error, "acquire policy mutation lock")
|
|
})?;
|
|
|
|
if key == POLICY_SETTING_KEY {
|
|
return Err(Status::invalid_argument(
|
|
"reserved key 'policy' must be set via policy commands",
|
|
));
|
|
}
|
|
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let globally_managed = global_settings.settings.contains_key(key);
|
|
|
|
if req.delete_setting {
|
|
if globally_managed {
|
|
return Err(Status::failed_precondition(format!(
|
|
"setting '{key}' is managed globally; delete the global setting first"
|
|
)));
|
|
}
|
|
|
|
let mut sandbox_settings =
|
|
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name())
|
|
.await?;
|
|
let removed = sandbox_settings.settings.remove(key).is_some();
|
|
if removed {
|
|
sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1);
|
|
save_sandbox_settings(
|
|
state.store.as_ref(),
|
|
&workspace,
|
|
sandbox.object_name(),
|
|
&sandbox_settings,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
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 {
|
|
return Err(Status::failed_precondition(format!(
|
|
"setting '{key}' is managed globally; delete the global setting before sandbox update"
|
|
)));
|
|
}
|
|
|
|
let setting = req
|
|
.setting_value
|
|
.as_ref()
|
|
.ok_or_else(|| Status::invalid_argument("setting_value is required"))?;
|
|
let stored = proto_setting_to_stored(key, setting)?;
|
|
|
|
let mut sandbox_settings =
|
|
load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?;
|
|
let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored);
|
|
if changed {
|
|
sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1);
|
|
save_sandbox_settings(
|
|
state.store.as_ref(),
|
|
&workspace,
|
|
sandbox.object_name(),
|
|
&sandbox_settings,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
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,
|
|
));
|
|
}
|
|
|
|
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
|
|
super::persistence_error_to_status(error, "acquire policy mutation lock")
|
|
})?;
|
|
if has_merge_ops {
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
if global_settings.settings.contains_key(POLICY_SETTING_KEY) {
|
|
return Err(Status::failed_precondition(
|
|
"policy is managed globally; delete global policy before sandbox policy update",
|
|
));
|
|
}
|
|
|
|
let spec = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.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 merge_validation =
|
|
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, &spec.providers)
|
|
.await?;
|
|
let credential_binding_context = merge_validation.credential_binding_context();
|
|
let atomic_context = AtomicPolicyWriteContext {
|
|
expected_resource_version: req.expected_resource_version,
|
|
provenance: &req.annotations,
|
|
annotations: &req.annotations,
|
|
};
|
|
let baseline_policy = spec.policy.clone();
|
|
let (version, hash, updated_sandbox) = apply_merge_operations_with_retry(
|
|
state.store.as_ref(),
|
|
&sandbox_id,
|
|
&workspace,
|
|
baseline_policy.as_ref(),
|
|
&merge_ops,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &merge_validation.provider_layers,
|
|
credential_binding: Some(&credential_binding_context),
|
|
},
|
|
None,
|
|
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(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"merged",
|
|
format!(
|
|
"gateway merged {} incremental policy operation(s)",
|
|
merge_ops.len()
|
|
),
|
|
version,
|
|
&hash,
|
|
);
|
|
for operation in &merge_ops {
|
|
emit_gateway_policy_audit_log(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"merged",
|
|
format!(
|
|
"gateway merged incremental policy op: {}",
|
|
summarize_cli_policy_merge_op(operation)
|
|
),
|
|
version,
|
|
&hash,
|
|
);
|
|
}
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
version,
|
|
policy_hash = %hash,
|
|
operation_count = merge_ops.len(),
|
|
"UpdateConfig: merged incremental policy operations"
|
|
);
|
|
emit_config_update_policy_success(sandbox_caller);
|
|
|
|
return Ok(update_config_response(
|
|
u32::try_from(version).unwrap_or(0),
|
|
hash,
|
|
0,
|
|
false,
|
|
response_annotations,
|
|
));
|
|
}
|
|
|
|
// Sandbox-scoped policy update.
|
|
let mut new_policy = req
|
|
.policy
|
|
.ok_or_else(|| Status::invalid_argument("policy is required"))?;
|
|
clear_provider_credentialed_markers(&mut new_policy);
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
if global_settings.settings.contains_key(POLICY_SETTING_KEY) {
|
|
return Err(Status::failed_precondition(
|
|
"policy is managed globally; delete global policy before sandbox policy update",
|
|
));
|
|
}
|
|
|
|
let spec = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
|
|
|
if sandbox_caller {
|
|
if strip_provider_rule_names(&mut new_policy) {
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
"UpdateConfig: stripped provider-derived policy entries from sandbox sync"
|
|
);
|
|
}
|
|
} else {
|
|
validate_no_reserved_provider_policy_keys(&new_policy)?;
|
|
}
|
|
|
|
let startup_blocked = crate::policy_store::permits_initial_static_policy_repair(&sandbox);
|
|
let should_backfill_policy = if startup_blocked && !sandbox_caller {
|
|
// No child has consumed static restrictions yet. A complete replacement
|
|
// must be able to repair every field before the first activation.
|
|
true
|
|
} else if let Some(baseline_policy) = spec.policy.as_ref() {
|
|
let comparable_baseline = baseline_policy.clone();
|
|
validate_static_fields_unchanged(&comparable_baseline, &new_policy)?;
|
|
false
|
|
} else {
|
|
true
|
|
};
|
|
|
|
new_policy = validate_and_canonicalize_policy(new_policy)?;
|
|
let backfill_policy = should_backfill_policy.then(|| new_policy.clone());
|
|
validate_policy_safety(&new_policy)?;
|
|
crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy).await?;
|
|
let provider_layers =
|
|
provider_policy_layers_for_sandbox(state, &workspace, &sandbox, &spec.providers).await?;
|
|
validate_candidate_effective_policy(&new_policy, &provider_layers)?;
|
|
let provider_profile_catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), &workspace)
|
|
.await?;
|
|
let effective_policy = if provider_layers.is_empty() {
|
|
new_policy.clone()
|
|
} else {
|
|
compose_effective_policy(&new_policy, &provider_layers)
|
|
};
|
|
validate_policy_credential_bindings_for_sandbox(
|
|
state.as_ref(),
|
|
&provider_profile_catalog,
|
|
&workspace,
|
|
&spec.providers,
|
|
&effective_policy,
|
|
)
|
|
.await?;
|
|
// Image discovery persists the desired candidate for management repair.
|
|
// It never admits workload activation: GetSandboxConfig applies the complete
|
|
// composition gate and ReportSandboxConfiguration checks the exact result.
|
|
if !sandbox_caller {
|
|
validate_candidate_sandbox_credential_policy(
|
|
state,
|
|
&workspace,
|
|
&spec.providers,
|
|
Some(&new_policy),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
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
|
|
&& canonical_policy_record_matches_for_deduplication(current, &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.as_ref().map_or(1, |record| record.version + 1);
|
|
let write = AtomicPolicyRevisionWrite {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
sandbox_id: sandbox_id.clone(),
|
|
workspace: workspace.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(),
|
|
};
|
|
|
|
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;
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
if backfill_policy.is_some() {
|
|
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}")))?;
|
|
|
|
let payload = new_policy.encode_to_vec();
|
|
let hash = deterministic_policy_hash(&new_policy);
|
|
|
|
if let Some(ref current) = latest
|
|
&& canonical_policy_record_matches_for_deduplication(current, &hash)
|
|
{
|
|
return Ok(Response::new(UpdateConfigResponse {
|
|
version: u32::try_from(current.version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
settings_revision: 0,
|
|
deleted: false,
|
|
annotations: response_annotations,
|
|
}));
|
|
}
|
|
|
|
let next_version = latest.map_or(1, |r| r.version + 1);
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
&policy_id,
|
|
&sandbox_id,
|
|
&workspace,
|
|
next_version,
|
|
&payload,
|
|
&hash,
|
|
)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?;
|
|
|
|
let _ = state
|
|
.store
|
|
.supersede_older_policies(&sandbox_id, next_version)
|
|
.await;
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
version = next_version,
|
|
policy_hash = %hash,
|
|
"UpdateConfig: new policy version persisted"
|
|
);
|
|
emit_full_policy_update_success(sandbox_caller, next_version);
|
|
|
|
Ok(update_config_response(
|
|
u32::try_from(next_version).unwrap_or(0),
|
|
hash,
|
|
0,
|
|
false,
|
|
response_annotations,
|
|
))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Policy status handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(super) async fn handle_get_sandbox_policy_status(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetSandboxPolicyStatusRequest>,
|
|
) -> Result<Response<GetSandboxPolicyStatusResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let req = request.into_inner();
|
|
let sandbox = if req.global {
|
|
if !req.sandbox.is_empty() || req.workspace_scope.is_some() {
|
|
return Err(Status::invalid_argument(
|
|
"sandbox and workspace must be omitted when global is true",
|
|
));
|
|
}
|
|
require_platform_admin(&state.admin_role, &principal)?;
|
|
None
|
|
} else {
|
|
Some(
|
|
super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(
|
|
req.workspace_scope.as_ref(),
|
|
)?,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?,
|
|
)
|
|
};
|
|
|
|
let (policy_id, active_version) = if req.global {
|
|
(GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32)
|
|
} else {
|
|
let sandbox = sandbox.as_ref().expect("sandbox query resolved a sandbox");
|
|
(
|
|
sandbox.object_id().to_string(),
|
|
sandbox.current_policy_version(),
|
|
)
|
|
};
|
|
|
|
let record = if req.version == 0 {
|
|
state
|
|
.store
|
|
.get_latest_policy(&policy_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch policy failed: {e}")))?
|
|
} else {
|
|
state
|
|
.store
|
|
.get_policy_by_version(&policy_id, i64::from(req.version))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch policy failed: {e}")))?
|
|
};
|
|
|
|
let not_found_msg = if req.global {
|
|
"no global policy revision found"
|
|
} else {
|
|
"no policy revision found for this sandbox"
|
|
};
|
|
let record = record.ok_or_else(|| Status::not_found(not_found_msg))?;
|
|
|
|
Ok(Response::new(GetSandboxPolicyStatusResponse {
|
|
revision: Some(policy_record_to_revision(&record, true)?),
|
|
active_version,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_list_sandbox_policies(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ListSandboxPoliciesRequest>,
|
|
) -> Result<Response<ListSandboxPoliciesResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let req = request.into_inner();
|
|
let sandbox = if req.global {
|
|
if !req.sandbox.is_empty() || req.workspace_scope.is_some() {
|
|
return Err(Status::invalid_argument(
|
|
"sandbox and workspace must be omitted when global is true",
|
|
));
|
|
}
|
|
require_platform_admin(&state.admin_role, &principal)?;
|
|
None
|
|
} else {
|
|
Some(
|
|
super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(
|
|
req.workspace_scope.as_ref(),
|
|
)?,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?,
|
|
)
|
|
};
|
|
|
|
let policy_id = if req.global {
|
|
GLOBAL_POLICY_SANDBOX_ID.to_string()
|
|
} else {
|
|
let sandbox = sandbox.as_ref().expect("sandbox query resolved a sandbox");
|
|
sandbox.object_id().to_string()
|
|
};
|
|
|
|
let pagination = Pagination::new(
|
|
req.page_size,
|
|
&req.page_token,
|
|
"ListSandboxPolicies",
|
|
&[
|
|
&req.sandbox,
|
|
if req.global { "true" } else { "false" },
|
|
sandbox
|
|
.as_ref()
|
|
.map_or("", |sandbox| sandbox.object_workspace()),
|
|
],
|
|
)?;
|
|
let mut records = state
|
|
.store
|
|
.list_policies_before(
|
|
&policy_id,
|
|
pagination.page_size() + 1,
|
|
pagination.policy_cursor()?,
|
|
)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list policies failed: {e}")))?;
|
|
let page_size = usize::try_from(pagination.page_size())
|
|
.map_err(|_| Status::internal("page size does not fit usize"))?;
|
|
let has_more = records.len() > page_size;
|
|
records.truncate(page_size);
|
|
let next_page_token = pagination.next_policy_token(
|
|
has_more
|
|
.then(|| records.last().map(|record| record.version))
|
|
.flatten(),
|
|
);
|
|
|
|
let revisions = records
|
|
.iter()
|
|
.map(|r| policy_record_to_revision(r, false))
|
|
.collect::<Result<Vec<_>, Status>>()?;
|
|
|
|
Ok(Response::new(ListSandboxPoliciesResponse {
|
|
revisions,
|
|
next_page_token,
|
|
}))
|
|
}
|
|
|
|
fn bounded_configuration_diagnostic(message: &str) -> String {
|
|
message
|
|
.chars()
|
|
.filter(|character| !character.is_control())
|
|
.take(512)
|
|
.collect()
|
|
}
|
|
|
|
fn configuration_failure_diagnostic(error: &Status) -> &'static str {
|
|
let message = error.message();
|
|
if message.contains("middleware") {
|
|
"Effective middleware configuration is invalid; repair the policy middleware bindings or registered services"
|
|
} else if message.contains("credential") || message.contains("provider") {
|
|
"Effective provider configuration is invalid; repair credential bindings, attached providers, or their policy layers"
|
|
} else {
|
|
"Stored policy structure or safety validation failed; submit a complete valid replacement policy"
|
|
}
|
|
}
|
|
|
|
fn configuration_generation_matches(
|
|
admission: &openshell_core::proto::SandboxConfigurationAdmission,
|
|
config: &GetSandboxConfigResponse,
|
|
) -> bool {
|
|
(
|
|
admission.policy_version,
|
|
&admission.policy_hash,
|
|
admission.config_revision,
|
|
admission.provider_env_revision,
|
|
) == (
|
|
config.version,
|
|
&config.policy_hash,
|
|
config.config_revision,
|
|
config.provider_env_revision,
|
|
)
|
|
}
|
|
|
|
pub(super) async fn handle_report_sandbox_configuration(
|
|
state: &Arc<ServerState>,
|
|
request: Request<openshell_core::proto::ReportSandboxConfigurationRequest>,
|
|
) -> Result<Response<openshell_core::proto::ReportSandboxConfigurationResponse>, Status> {
|
|
use openshell_core::proto::ConfigurationAdmissionState;
|
|
let sandbox_id = request.get_ref().sandbox_id.clone();
|
|
crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?;
|
|
let mut admission = request
|
|
.get_ref()
|
|
.admission
|
|
.clone()
|
|
.ok_or_else(|| Status::invalid_argument("admission is required"))?;
|
|
if uuid::Uuid::parse_str(&admission.instance_id).is_err() {
|
|
return Err(Status::invalid_argument("instance_id must be a UUID"));
|
|
}
|
|
let reported = ConfigurationAdmissionState::try_from(admission.state)
|
|
.map_err(|_| Status::invalid_argument("invalid admission state"))?;
|
|
if reported == ConfigurationAdmissionState::Unspecified {
|
|
return Err(Status::invalid_argument("admission state is required"));
|
|
}
|
|
let _guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
|
|
super::persistence_error_to_status(error, "acquire configuration admission lock")
|
|
})?;
|
|
let mut sandbox = state
|
|
.store
|
|
.get_message::<Sandbox>(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_name = sandbox.object_name().to_string();
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
crate::compute::provisioning_deadline::refresh_configuration(
|
|
&state.store,
|
|
&mut sandbox,
|
|
current_time_ms(),
|
|
)
|
|
.await
|
|
.map_err(Status::internal)?;
|
|
if crate::compute::provisioning_deadline::timed_out(&sandbox) {
|
|
return Err(Status::failed_precondition(
|
|
"provisioning repair window expired; explicitly start the sandbox after cleanup",
|
|
));
|
|
}
|
|
let current = sandbox
|
|
.status
|
|
.as_ref()
|
|
.and_then(|status| status.configuration_admission.as_ref());
|
|
if reported == ConfigurationAdmissionState::Pending
|
|
&& current.is_some_and(|current| current.instance_id != admission.instance_id)
|
|
&& current.map_or("", |current| current.instance_id.as_str())
|
|
!= request.get_ref().expected_instance_id
|
|
{
|
|
return Err(Status::failed_precondition(
|
|
"supervisor registration fence has changed",
|
|
));
|
|
}
|
|
if reported != ConfigurationAdmissionState::Pending
|
|
&& current.is_none_or(|current| current.instance_id != admission.instance_id)
|
|
{
|
|
return Err(Status::failed_precondition(
|
|
"supervisor configuration instance has changed",
|
|
));
|
|
}
|
|
if reported == ConfigurationAdmissionState::Accepted {
|
|
let mut config_request = Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace.clone())),
|
|
});
|
|
*config_request.extensions_mut() = request.extensions().clone();
|
|
let config = handle_get_sandbox_config(state, config_request)
|
|
.await?
|
|
.into_inner();
|
|
if !config.configuration_admitted || !configuration_generation_matches(&admission, &config)
|
|
{
|
|
return Err(Status::aborted(
|
|
"configuration changed or is not admitted; fetch and validate again",
|
|
));
|
|
}
|
|
admission.error.clear();
|
|
} else if reported == ConfigurationAdmissionState::Rejected {
|
|
// Runtime error strings may contain parser payloads. Only gateway-authored
|
|
// diagnostics may be exposed verbatim through public sandbox status.
|
|
let mut config_request = Request::new(GetSandboxConfigRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
|
|
name: sandbox_name,
|
|
});
|
|
*config_request.extensions_mut() = request.extensions().clone();
|
|
admission.error = match handle_get_sandbox_config(state, config_request).await {
|
|
Ok(config) if !configuration_generation_matches(&admission, config.get_ref()) => {
|
|
return Err(Status::aborted("rejected configuration generation has changed"));
|
|
}
|
|
Ok(config) if !config.get_ref().configuration_error.is_empty() => config.into_inner().configuration_error,
|
|
_ => "Effective configuration could not be activated; replace the policy or repair attached providers".to_string(),
|
|
};
|
|
if let Some(current) = current
|
|
&& current.state == i32::from(ConfigurationAdmissionState::Accepted)
|
|
{
|
|
// A rejected desired update does not invalidate an accepted runtime.
|
|
let error = admission.error;
|
|
admission = current.clone();
|
|
admission.error = error;
|
|
}
|
|
} else {
|
|
admission.error.clear();
|
|
if let Some(current) = current
|
|
&& current.instance_id == admission.instance_id
|
|
{
|
|
admission = current.clone();
|
|
}
|
|
}
|
|
let expected_version = sandbox
|
|
.metadata
|
|
.as_ref()
|
|
.map_or(0, |metadata| metadata.resource_version);
|
|
let now_ms = current_time_ms();
|
|
let mut provisioning = sandbox
|
|
.status
|
|
.as_ref()
|
|
.and_then(|status| status.provisioning.clone());
|
|
if let Some(record) = provisioning.as_mut() {
|
|
if !crate::compute::provisioning_deadline::allows_admission(record, now_ms) {
|
|
state
|
|
.compute
|
|
.claim_provisioning_timeout(&sandbox, now_ms)
|
|
.await
|
|
.map_err(Status::internal)?;
|
|
return Err(Status::failed_precondition(
|
|
"provisioning repair window expired",
|
|
));
|
|
}
|
|
if reported == ConfigurationAdmissionState::Rejected {
|
|
crate::compute::provisioning_deadline::record_rejection(record, now_ms)
|
|
.map_err(Status::internal)?;
|
|
}
|
|
}
|
|
let updated = state
|
|
.store
|
|
.update_message_cas::<Sandbox, _>(&sandbox_id, expected_version, |sandbox| {
|
|
sandbox
|
|
.status
|
|
.get_or_insert_with(Default::default)
|
|
.provisioning
|
|
.clone_from(&provisioning);
|
|
sandbox
|
|
.status
|
|
.get_or_insert_with(Default::default)
|
|
.configuration_admission = Some(admission.clone());
|
|
if reported == ConfigurationAdmissionState::Accepted {
|
|
sandbox
|
|
.status
|
|
.as_mut()
|
|
.expect("status initialized")
|
|
.configuration_activated = Some(true);
|
|
}
|
|
crate::compute::apply_configuration_readiness(sandbox);
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
super::persistence_error_to_status(error, "report configuration admission")
|
|
})?;
|
|
state.sandbox_index.update_from_sandbox(&updated);
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
Ok(Response::new(
|
|
openshell_core::proto::ReportSandboxConfigurationResponse {},
|
|
))
|
|
}
|
|
|
|
pub(super) async fn handle_report_policy_status(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ReportPolicyStatusRequest>,
|
|
) -> Result<Response<ReportPolicyStatusResponse>, Status> {
|
|
let sandbox_id = request.get_ref().sandbox_id.clone();
|
|
crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?;
|
|
let req = request.into_inner();
|
|
if req.sandbox_id.is_empty() {
|
|
return Err(Status::invalid_argument("sandbox_id is required"));
|
|
}
|
|
if req.version == 0 {
|
|
return Err(Status::invalid_argument("version is required"));
|
|
}
|
|
|
|
let version = i64::from(req.version);
|
|
let status_str = match PolicyStatus::try_from(req.status) {
|
|
Ok(PolicyStatus::Loaded) => "loaded",
|
|
Ok(PolicyStatus::Failed) => "failed",
|
|
_ => return Err(Status::invalid_argument("status must be LOADED or FAILED")),
|
|
};
|
|
|
|
let loaded_at_ms = if status_str == "loaded" {
|
|
Some(current_time_ms())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let load_error = if status_str == "failed" && !req.load_error.is_empty() {
|
|
Some(req.load_error.as_str())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let updated = state
|
|
.store
|
|
.update_policy_status(
|
|
&req.sandbox_id,
|
|
version,
|
|
status_str,
|
|
load_error,
|
|
loaded_at_ms,
|
|
)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update policy status failed: {e}")))?;
|
|
|
|
if !updated {
|
|
return Err(Status::not_found("policy revision not found"));
|
|
}
|
|
|
|
if status_str == "loaded" {
|
|
let _ = state
|
|
.store
|
|
.supersede_older_policies(&req.sandbox_id, version)
|
|
.await;
|
|
|
|
let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await.map_err(|error| {
|
|
super::persistence_error_to_status(error, "acquire policy mutation lock")
|
|
})?;
|
|
let sandbox = state
|
|
.store
|
|
.get_message::<Sandbox>(&req.sandbox_id)
|
|
.await
|
|
.map_err(|error| Status::internal(format!("fetch sandbox failed: {error}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let endpoint_reset = endpoint_status::endpoint_status_reset_for_loaded_policy(
|
|
state.as_ref(),
|
|
&sandbox,
|
|
version,
|
|
)
|
|
.await?;
|
|
let expected_resource_version = sandbox.get_resource_version();
|
|
let version_to_set = req.version;
|
|
let updated = state
|
|
.store
|
|
.update_message_cas::<Sandbox, _>(
|
|
&req.sandbox_id,
|
|
expected_resource_version,
|
|
|sandbox| {
|
|
sandbox.set_current_policy_version(version_to_set);
|
|
if let Some(endpoints) = &endpoint_reset {
|
|
endpoint_status::reconcile_endpoint_statuses(
|
|
sandbox,
|
|
endpoints,
|
|
&HashSet::new(),
|
|
&prost_types::Timestamp::default(),
|
|
);
|
|
}
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| super::persistence_error_to_status(e, "update current_policy_version"))?;
|
|
|
|
state.sandbox_index.update_from_sandbox(&updated);
|
|
state.sandbox_watch_bus.notify(&req.sandbox_id);
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %req.sandbox_id,
|
|
version = req.version,
|
|
status = %status_str,
|
|
"ReportPolicyStatus: sandbox reported policy load result"
|
|
);
|
|
|
|
Ok(Response::new(ReportPolicyStatusResponse {}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sandbox logs handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(super) async fn handle_get_sandbox_logs(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetSandboxLogsRequest>,
|
|
) -> Result<Response<GetSandboxLogsResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?;
|
|
let sandbox_id = sandbox.object_id();
|
|
|
|
let lines = if req.lines == 0 { 2000 } else { req.lines };
|
|
let tail = state.tracing_log_bus.tail(sandbox_id, lines as usize);
|
|
|
|
let buffer_total = tail.len() as u32;
|
|
|
|
if let Some(since_time) = req.since_time.as_ref() {
|
|
openshell_core::time::validate_timestamp(since_time)
|
|
.map_err(|error| Status::invalid_argument(error.to_string()))?;
|
|
}
|
|
let since_time = req.since_time;
|
|
|
|
let logs: Vec<SandboxLogLine> = tail
|
|
.into_iter()
|
|
.filter_map(|evt| {
|
|
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(log)) =
|
|
evt.event.payload
|
|
{
|
|
if let Some(since_time) = since_time.as_ref() {
|
|
let event_time = log.event_time.as_ref()?;
|
|
if openshell_core::time::compare_timestamps(event_time, since_time).ok()?
|
|
== std::cmp::Ordering::Less
|
|
{
|
|
return None;
|
|
}
|
|
}
|
|
if !req.sources.is_empty() && !source_matches(&log.source, &req.sources) {
|
|
return None;
|
|
}
|
|
if !level_matches(&log.level, &req.min_level) {
|
|
return None;
|
|
}
|
|
Some(log)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Response::new(GetSandboxLogsResponse { logs, buffer_total }))
|
|
}
|
|
|
|
pub(super) async fn handle_push_sandbox_logs(
|
|
state: &Arc<ServerState>,
|
|
request: Request<tonic::Streaming<PushSandboxLogsRequest>>,
|
|
) -> Result<Response<PushSandboxLogsResponse>, Status> {
|
|
let principal = request
|
|
.extensions()
|
|
.get::<Principal>()
|
|
.cloned()
|
|
.ok_or_else(|| Status::unauthenticated("missing principal"))?;
|
|
let mut stream = request.into_inner();
|
|
let mut validated_sandbox_id = None;
|
|
|
|
while let Some(batch) = stream
|
|
.message()
|
|
.await
|
|
.map_err(|e| Status::internal(format!("stream error: {e}")))?
|
|
{
|
|
if batch.sandbox_id.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
ensure_log_stream_sandbox_scope(
|
|
state,
|
|
&principal,
|
|
&batch.sandbox_id,
|
|
&mut validated_sandbox_id,
|
|
)
|
|
.await?;
|
|
|
|
let logs: Vec<_> = batch.logs.into_iter().take(100).collect();
|
|
validate_sandbox_log_timestamps(&logs)?;
|
|
|
|
for log in logs {
|
|
let mut log = log;
|
|
log.source = "sandbox".to_string();
|
|
log.sandbox_id.clone_from(&batch.sandbox_id);
|
|
state.tracing_log_bus.publish_external(log);
|
|
}
|
|
}
|
|
|
|
Ok(Response::new(PushSandboxLogsResponse {}))
|
|
}
|
|
|
|
fn validate_sandbox_log_timestamps(logs: &[SandboxLogLine]) -> Result<(), Status> {
|
|
for (index, log) in logs.iter().enumerate() {
|
|
if let Some(event_time) = log.event_time.as_ref() {
|
|
openshell_core::time::validate_timestamp(event_time).map_err(|error| {
|
|
Status::invalid_argument(format!("logs[{index}].event_time: {error}"))
|
|
})?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn ensure_log_stream_sandbox_scope(
|
|
state: &Arc<ServerState>,
|
|
principal: &Principal,
|
|
sandbox_id: &str,
|
|
validated_sandbox_id: &mut Option<String>,
|
|
) -> Result<(), Status> {
|
|
if let Some(validated) = validated_sandbox_id.as_deref() {
|
|
if sandbox_id != validated {
|
|
return Err(Status::permission_denied(
|
|
"log stream sandbox_id changed after validation",
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
crate::auth::guard::ensure_sandbox_scope(principal, sandbox_id)?;
|
|
state
|
|
.store
|
|
.get_message::<Sandbox>(sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
*validated_sandbox_id = Some(sandbox_id.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Draft policy recommendation handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(super) async fn handle_submit_policy_analysis(
|
|
state: &Arc<ServerState>,
|
|
request: Request<SubmitPolicyAnalysisRequest>,
|
|
) -> Result<Response<SubmitPolicyAnalysisResponse>, Status> {
|
|
let principal = request
|
|
.extensions()
|
|
.get::<Principal>()
|
|
.cloned()
|
|
.ok_or_else(|| Status::unauthenticated("missing principal"))?;
|
|
let req = request.into_inner();
|
|
let workspace = super::workspace::resolve_workspace(
|
|
state.store.as_ref(),
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
)
|
|
.await?
|
|
.name;
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
let sandbox = resolve_sandbox_by_name_for_principal(
|
|
state.store.as_ref(),
|
|
&workspace,
|
|
&principal,
|
|
&req.name,
|
|
)
|
|
.await?;
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
for summary in &req.network_activity_summaries {
|
|
state
|
|
.telemetry
|
|
.record_network_activity(&sandbox_id, summary);
|
|
}
|
|
if req.proposed_chunks.is_empty()
|
|
&& req.summaries.is_empty()
|
|
&& !req.network_activity_summaries.is_empty()
|
|
{
|
|
return Ok(Response::new(SubmitPolicyAnalysisResponse {
|
|
accepted_chunks: 0,
|
|
rejected_chunks: 0,
|
|
rejection_reasons: Vec::new(),
|
|
accepted_chunk_ids: Vec::new(),
|
|
}));
|
|
}
|
|
|
|
// `current_policy` is captured ONCE at the top of the batch and frozen
|
|
// for every chunk's delta computation, even if an earlier chunk in the
|
|
// batch auto-approves and merges. This is intentional v1 behavior:
|
|
// multi-chunk batches with overlapping endpoints would otherwise have
|
|
// chunk N+1 fail to see chunk N's contribution, which is a degenerate
|
|
// 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 provider_profile_catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), &workspace)
|
|
.await?;
|
|
let current_policy = current_effective_policy_for_sandbox(
|
|
state,
|
|
&provider_profile_catalog,
|
|
&workspace,
|
|
&sandbox,
|
|
&sandbox_id,
|
|
)
|
|
.await?;
|
|
let active_policy_version = state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.map_err(|error| Status::internal(format!("fetch latest policy failed: {error}")))?
|
|
.map_or(0, |record| record.version);
|
|
reconcile_pending_chunks_covered_by_policy(
|
|
state,
|
|
&sandbox_id,
|
|
¤t_policy,
|
|
active_policy_version,
|
|
)
|
|
.await?;
|
|
let current_base_policy =
|
|
current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).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
|
|
// and managed fleet-wide. Default (no setting, or any value other than
|
|
// exact "auto") preserves OpenShell's default-deny posture: every
|
|
// proposal lands in `pending` for a human reviewer.
|
|
let (auto_approve_enabled, resolved_from) =
|
|
resolve_proposal_approval_mode(state.store.as_ref(), &workspace, sandbox.object_name())
|
|
.await?;
|
|
|
|
// The credential set is stable across all chunks in this batch, so build
|
|
// it once. v1 captures presence only — no scope modeling — so the prover
|
|
// can answer "is there a credential in scope for this host?" but not
|
|
// "what action class does that credential authorize?"
|
|
let provider_names_for_creds: Vec<String> = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.clone())
|
|
.unwrap_or_default();
|
|
let credential_set = build_credential_set_for_sandbox_with_catalog(
|
|
state.store.as_ref(),
|
|
&provider_profile_catalog,
|
|
&workspace,
|
|
&provider_names_for_creds,
|
|
)
|
|
.await?;
|
|
let merge_validation = sandbox_policy_merge_validation_data_with_catalog(
|
|
state,
|
|
&workspace,
|
|
&sandbox,
|
|
&provider_names_for_creds,
|
|
&provider_profile_catalog,
|
|
)
|
|
.await?;
|
|
let credential_binding_context = merge_validation.credential_binding_context();
|
|
let proposal_validation_context = PolicyMergeValidationContext {
|
|
provider_layers: &merge_validation.provider_layers,
|
|
credential_binding: Some(&credential_binding_context),
|
|
};
|
|
|
|
let current_version = state
|
|
.store
|
|
.get_draft_version(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("get draft version failed: {e}")))?;
|
|
let draft_version = current_version + 1;
|
|
|
|
let mut accepted: u32 = 0;
|
|
let mut rejected: u32 = 0;
|
|
let mut rejection_reasons: Vec<String> = Vec::new();
|
|
let mut accepted_chunk_ids: Vec<String> = Vec::new();
|
|
|
|
for chunk in &req.proposed_chunks {
|
|
if chunk.rule_name.is_empty() {
|
|
rejected += 1;
|
|
rejection_reasons.push("chunk missing rule_name".to_string());
|
|
continue;
|
|
}
|
|
// `_provider_*` is the reserved namespace for rules synthesized from
|
|
// provider profiles during composition. Agent submissions that target
|
|
// those keys would merge directly into the provider rule and bypass
|
|
// the merge.rs guard that splits agent-authored chunks into their
|
|
// own rule so the prover sees their contribution honestly. Reject at
|
|
// the entry boundary — the agent never has reason to address a
|
|
// provider rule by name.
|
|
if openshell_policy::is_provider_rule_name(&chunk.rule_name) {
|
|
rejected += 1;
|
|
rejection_reasons.push(format!(
|
|
"chunk '{}' uses reserved '_provider_' rule-name prefix",
|
|
chunk.rule_name
|
|
));
|
|
continue;
|
|
}
|
|
if chunk.proposed_rule.is_none() {
|
|
rejected += 1;
|
|
rejection_reasons.push(format!("chunk '{}' missing proposed_rule", chunk.rule_name));
|
|
continue;
|
|
}
|
|
let first_seen_ms = match chunk
|
|
.first_seen_time
|
|
.as_ref()
|
|
.map(openshell_core::time::timestamp_to_millis)
|
|
.transpose()
|
|
{
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
rejected += 1;
|
|
rejection_reasons.push(format!(
|
|
"chunk '{}' has invalid first_seen_time: {error}",
|
|
chunk.rule_name
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
let last_seen_ms = match chunk
|
|
.last_seen_time
|
|
.as_ref()
|
|
.map(openshell_core::time::timestamp_to_millis)
|
|
.transpose()
|
|
{
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
rejected += 1;
|
|
rejection_reasons.push(format!(
|
|
"chunk '{}' has invalid last_seen_time: {error}",
|
|
chunk.rule_name
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let rule_ref = chunk.proposed_rule.as_ref().expect("checked above");
|
|
if req.analysis_mode == "agent_authored"
|
|
&& let Some(reason) = rule_ref.endpoints.iter().find_map(|endpoint| {
|
|
openshell_policy::agent_authored_transport_rejection(
|
|
&endpoint.protocol,
|
|
openshell_policy::network_tls_mode_to_str(endpoint.tls).unwrap_or(""),
|
|
)
|
|
})
|
|
{
|
|
rejected += 1;
|
|
rejection_reasons.push(format!("chunk '{}': {reason}", chunk.rule_name));
|
|
continue;
|
|
}
|
|
let incoming_observation_key = rule_ref.endpoints.first().and_then(|endpoint| {
|
|
rule_ref.binaries.first().map(|binary| {
|
|
(
|
|
endpoint.host.to_lowercase(),
|
|
endpoint.port as i32,
|
|
binary.path.clone(),
|
|
)
|
|
})
|
|
});
|
|
let existing_mechanistic = if req.analysis_mode == "mechanistic" {
|
|
let chunks = state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, None)
|
|
.await
|
|
.map_err(|error| {
|
|
Status::internal(format!("list draft chunks for dedup failed: {error}"))
|
|
})?;
|
|
incoming_observation_key
|
|
.as_ref()
|
|
.and_then(|(host, port, binary)| {
|
|
chunks.into_iter().find(|existing| {
|
|
existing.host == *host
|
|
&& existing.port == *port
|
|
&& existing.binary == *binary
|
|
})
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
// A duplicate observation updates the existing pending decision; it
|
|
// must not replace an edited rule with the new mapper payload. Build
|
|
// and hash the candidate from the stored rule, while retaining the
|
|
// incoming observation key for the persistence-layer dedup lookup.
|
|
let existing_pending_rule = existing_mechanistic
|
|
.as_ref()
|
|
.filter(|existing| existing.status == "pending")
|
|
.map(decode_draft_chunk_rule)
|
|
.transpose()?
|
|
.flatten();
|
|
let evaluation_rule = existing_pending_rule.as_ref().unwrap_or(rule_ref);
|
|
let evaluation_rule_name = existing_mechanistic
|
|
.as_ref()
|
|
.filter(|existing| existing.status == "pending")
|
|
.map_or(chunk.rule_name.as_str(), |existing| {
|
|
existing.rule_name.as_str()
|
|
});
|
|
let evaluation_mode = if existing_pending_rule.is_some() {
|
|
"stored"
|
|
} else {
|
|
req.analysis_mode.as_str()
|
|
};
|
|
let reusable_validation = existing_mechanistic
|
|
.as_ref()
|
|
.filter(|existing| existing.status == "pending" && !existing.review_token.is_empty())
|
|
.map(|existing| existing.validation_result.as_str());
|
|
let mut evaluation = evaluate_proposal_candidate(
|
|
¤t_base_policy,
|
|
¤t_policy,
|
|
evaluation_rule_name,
|
|
evaluation_rule,
|
|
evaluation_mode,
|
|
&credential_set,
|
|
proposal_validation_context,
|
|
reusable_validation,
|
|
);
|
|
if let Some(existing) = &existing_mechanistic
|
|
&& !existing.review_token.is_empty()
|
|
&& evaluation.review_token != existing.review_token
|
|
{
|
|
evaluation = evaluate_proposal_candidate(
|
|
¤t_base_policy,
|
|
¤t_policy,
|
|
evaluation_rule_name,
|
|
evaluation_rule,
|
|
evaluation_mode,
|
|
&credential_set,
|
|
proposal_validation_context,
|
|
None,
|
|
);
|
|
}
|
|
if req.analysis_mode != "mechanistic" && !evaluation.application_error.is_empty() {
|
|
rejected += 1;
|
|
rejection_reasons.push(format!(
|
|
"chunk '{}': {}",
|
|
chunk.rule_name, evaluation.application_error
|
|
));
|
|
continue;
|
|
}
|
|
|
|
let now_ms = current_time_ms();
|
|
let proposed_rule_bytes = evaluation.rule.encode_to_vec();
|
|
let (ep_host, ep_port) = rule_ref
|
|
.endpoints
|
|
.first()
|
|
.map(|ep| (ep.host.to_lowercase(), ep.port as i32))
|
|
.unwrap_or_default();
|
|
let ep_binary = rule_ref
|
|
.binaries
|
|
.first()
|
|
.map(|b| b.path.clone())
|
|
.unwrap_or_default();
|
|
|
|
let record = DraftChunkRecord {
|
|
// The handler proposes an id; the store may swap it for an
|
|
// existing row's id on dedup. Always trust `effective_id` for
|
|
// anything user-facing.
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
sandbox_id: sandbox_id.clone(),
|
|
draft_version,
|
|
status: "pending".to_string(),
|
|
rule_name: evaluation.rule_name.clone(),
|
|
proposed_rule: proposed_rule_bytes,
|
|
rationale: chunk.rationale.clone(),
|
|
security_notes: generate_security_notes(&evaluation.rule),
|
|
confidence: f64::from(chunk.confidence.clamp(0.0, 1.0)),
|
|
created_at_ms: now_ms,
|
|
decided_at_ms: None,
|
|
host: ep_host,
|
|
port: ep_port,
|
|
binary: ep_binary,
|
|
hit_count: chunk.hit_count.clamp(1, 100),
|
|
first_seen_ms: first_seen_ms.unwrap_or(now_ms),
|
|
last_seen_ms: last_seen_ms.unwrap_or(now_ms),
|
|
validation_result: evaluation.validation_result.clone(),
|
|
rejection_reason: String::new(),
|
|
application_error: evaluation.application_error.clone(),
|
|
review_token: evaluation.review_token.clone(),
|
|
current_effective_policy_hash: evaluation.current_hash(),
|
|
candidate_effective_policy_hash: evaluation.candidate_hash(),
|
|
current_effective_policy: Some(evaluation.current_effective_policy.clone()),
|
|
candidate_effective_policy: evaluation.candidate_effective_policy.clone(),
|
|
};
|
|
// Mechanistic mode dedups N denials targeting the same endpoint
|
|
// into one chunk. All other modes (agent-authored proposals, future
|
|
// modes) submit each chunk as a distinct row — the redraft loop
|
|
// relies on it, and the conservative default for an unknown mode
|
|
// is to keep the proposal rather than silently fold it away.
|
|
let dedup_key = matches!(req.analysis_mode.as_str(), "mechanistic")
|
|
.then(|| crate::policy_store::observation_dedup_key(&record));
|
|
let effective_id = state
|
|
.store
|
|
.put_draft_chunk(&record, dedup_key.as_deref(), &workspace)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?;
|
|
if effective_id != record.id
|
|
&& existing_mechanistic.as_ref().is_some_and(|existing| {
|
|
existing.status == "pending" && existing.review_token != evaluation.review_token
|
|
})
|
|
&& let Some(existing) = existing_mechanistic.as_ref()
|
|
{
|
|
persist_refreshed_evaluation(state, existing, &evaluation).await?;
|
|
}
|
|
accepted += 1;
|
|
|
|
// Implicit supersede: any other pending chunk for the same
|
|
// (host, port, binary) in this sandbox is now stale because this
|
|
// newer submission covers the same access decision. Auto-reject the
|
|
// older chunks with a clear reason. This is what lets the agent
|
|
// refine a mechanistic L4 draft into an L7 narrow proposal without
|
|
// any explicit `supersedes_chunk_id` plumbing — the gateway figures
|
|
// out the relationship by structural overlap.
|
|
supersede_other_pending_chunks_for_endpoint(
|
|
state,
|
|
&sandbox_id,
|
|
&effective_id,
|
|
&record.host,
|
|
record.port,
|
|
&record.binary,
|
|
)
|
|
.await;
|
|
|
|
// Asymmetric self-reject: if this is a mechanistic proposal that
|
|
// arrived AFTER an already-approved agent_authored chunk covered the
|
|
// same (host, port, binary), the mechanistic submission is
|
|
// redundant — the agent already handled it. Auto-reject so it
|
|
// doesn't pile up as approval-queue noise. Agent_authored
|
|
// submissions never self-reject; refinement is always allowed.
|
|
if req.analysis_mode == "mechanistic" {
|
|
self_reject_mechanistic_if_already_covered(
|
|
state,
|
|
&sandbox_id,
|
|
&effective_id,
|
|
&record.host,
|
|
record.port,
|
|
&record.binary,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Auto-approval gate (proposer-agnostic, opt-in): only fire when
|
|
// BOTH the prover found nothing new in this proposal's delta AND
|
|
// the reviewer opted in via the `proposal_approval_mode` setting
|
|
// (gateway or sandbox scope). On any failure (merge conflict,
|
|
// status update error), the chunk stays pending so a human can
|
|
// review — never silently lose a proposal. The `validation_result`
|
|
// literal here is the canonical empty-delta verdict; any other
|
|
// string means findings or infrastructure error, both of which
|
|
// require human attention.
|
|
if auto_approve_enabled
|
|
&& let Err(err) = Box::pin(auto_approve_chunk(
|
|
state,
|
|
&effective_id,
|
|
AutoApproveChunkContext {
|
|
sandbox: &sandbox,
|
|
workspace: &workspace,
|
|
source: &req.analysis_mode,
|
|
resolved_from,
|
|
},
|
|
))
|
|
.await
|
|
{
|
|
persist_pending_application_error(state, &effective_id, &err).await;
|
|
warn!(
|
|
chunk_id = %effective_id,
|
|
sandbox_id = %sandbox_id,
|
|
error = %err,
|
|
"auto-approval failed; chunk remains pending for human review"
|
|
);
|
|
}
|
|
|
|
accepted_chunk_ids.push(effective_id);
|
|
}
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
accepted = accepted,
|
|
rejected = rejected,
|
|
draft_version = draft_version,
|
|
summaries = req.summaries.len(),
|
|
"SubmitPolicyAnalysis: persisted draft chunks"
|
|
);
|
|
|
|
Ok(Response::new(SubmitPolicyAnalysisResponse {
|
|
accepted_chunks: accepted,
|
|
rejected_chunks: rejected,
|
|
rejection_reasons,
|
|
accepted_chunk_ids,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_get_draft_policy(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetDraftPolicyRequest>,
|
|
) -> Result<Response<GetDraftPolicyResponse>, Status> {
|
|
let principal = request
|
|
.extensions()
|
|
.get::<Principal>()
|
|
.cloned()
|
|
.ok_or_else(|| Status::unauthenticated("missing principal"))?;
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?;
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
|
|
let status_filter = if req.status_filter.is_empty() {
|
|
None
|
|
} else {
|
|
Some(req.status_filter.as_str())
|
|
};
|
|
|
|
let records = state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, status_filter)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
|
|
|
|
let draft_version = state
|
|
.store
|
|
.get_draft_version(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("get draft version failed: {e}")))?;
|
|
|
|
let chunks: Vec<PolicyChunk> = records
|
|
.into_iter()
|
|
.map(|r| draft_chunk_record_to_proto(&r))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
let last_analyzed_time = chunks
|
|
.iter()
|
|
.filter_map(|chunk| chunk.created_time)
|
|
.max_by_key(|value| (value.seconds, value.nanos));
|
|
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_count = chunks.len(),
|
|
draft_version = draft_version,
|
|
"GetDraftPolicy: served draft chunks"
|
|
);
|
|
|
|
Ok(Response::new(GetDraftPolicyResponse {
|
|
chunks,
|
|
rolling_summary: String::new(),
|
|
draft_version: u64::try_from(draft_version).unwrap_or(0),
|
|
last_analyzed_time,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_approve_draft_chunk(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ApproveDraftChunkRequest>,
|
|
) -> Result<Response<ApproveDraftChunkResponse>, Status> {
|
|
let result = handle_approve_draft_chunk_inner(state, request).await;
|
|
if result.is_err() {
|
|
emit_policy_decision_failure(PolicyDecisionOperation::Approve, 1);
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn handle_approve_draft_chunk_inner(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ApproveDraftChunkRequest>,
|
|
) -> Result<Response<ApproveDraftChunkResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::Admin,
|
|
)
|
|
.await?;
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
|
|
require_no_global_policy(state).await?;
|
|
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
|
|
let chunk = state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
|
|
|
|
if chunk.status != "pending" && chunk.status != "rejected" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'pending' or 'rejected'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
require_current_proposal_evaluation(
|
|
state,
|
|
&workspace,
|
|
&sandbox,
|
|
&chunk,
|
|
Some(&req.review_token),
|
|
)
|
|
.await?;
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
hit_count = chunk.hit_count,
|
|
prev_status = %chunk.status,
|
|
"ApproveDraftChunk: merging rule into active policy"
|
|
);
|
|
|
|
let provider_names = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.as_slice())
|
|
.unwrap_or_default();
|
|
let merge_validation =
|
|
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?;
|
|
let credential_binding_context = merge_validation.credential_binding_context();
|
|
let merge_result = merge_chunk_into_policy_with_validation(
|
|
state.store.as_ref(),
|
|
&sandbox_id,
|
|
&workspace,
|
|
&chunk,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &merge_validation.provider_layers,
|
|
credential_binding: Some(&credential_binding_context),
|
|
},
|
|
)
|
|
.await;
|
|
let (version, hash) = match merge_result {
|
|
Ok(result) => result,
|
|
Err(status) => {
|
|
persist_pending_application_error(state, &req.chunk_id, &status).await;
|
|
return Err(status);
|
|
}
|
|
};
|
|
let chunk_summary = summarize_draft_chunk_rule(&chunk)?;
|
|
|
|
let now_ms = current_time_ms();
|
|
clear_pending_application_error(state, &req.chunk_id).await;
|
|
state
|
|
.store
|
|
.update_draft_chunk_status(&req.chunk_id, "approved", Some(now_ms), None)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
if let Err(error) =
|
|
reconcile_pending_chunks_after_policy_change(state, &workspace, &sandbox).await
|
|
{
|
|
warn!(
|
|
sandbox_id,
|
|
error = %error,
|
|
"failed to reconcile pending policy proposals after approval"
|
|
);
|
|
}
|
|
emit_gateway_policy_audit_log(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"approved",
|
|
format!(
|
|
"gateway approved draft chunk {}: {chunk_summary}",
|
|
req.chunk_id
|
|
),
|
|
version,
|
|
&hash,
|
|
);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
version = version,
|
|
policy_hash = %hash,
|
|
"ApproveDraftChunk: rule merged successfully"
|
|
);
|
|
emit_sandbox_policy_update_success();
|
|
emit_policy_decision_success(PolicyDecisionOperation::Approve, 1);
|
|
|
|
Ok(Response::new(ApproveDraftChunkResponse {
|
|
policy_version: u32::try_from(version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_reject_draft_chunk(
|
|
state: &Arc<ServerState>,
|
|
request: Request<RejectDraftChunkRequest>,
|
|
) -> Result<Response<RejectDraftChunkResponse>, Status> {
|
|
let result = handle_reject_draft_chunk_inner(state, request).await;
|
|
if result.is_err() {
|
|
emit_policy_decision_failure(PolicyDecisionOperation::Reject, 1);
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn handle_reject_draft_chunk_inner(
|
|
state: &Arc<ServerState>,
|
|
request: Request<RejectDraftChunkRequest>,
|
|
) -> Result<Response<RejectDraftChunkResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::Admin,
|
|
)
|
|
.await?;
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
|
|
let chunk = state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
|
|
|
|
if chunk.status != "pending" && chunk.status != "approved" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'pending' or 'approved'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
let was_approved = chunk.status == "approved";
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
reason = %req.reason,
|
|
prev_status = %chunk.status,
|
|
"RejectDraftChunk: rejecting chunk"
|
|
);
|
|
|
|
if was_approved {
|
|
require_no_global_policy(state).await?;
|
|
let (version, hash) =
|
|
remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?;
|
|
emit_gateway_policy_audit_log(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"removed",
|
|
format!(
|
|
"gateway removed previously approved draft chunk {}: remove-binary {} {}",
|
|
req.chunk_id, chunk.rule_name, chunk.binary
|
|
),
|
|
version,
|
|
&hash,
|
|
);
|
|
emit_sandbox_policy_update_success();
|
|
}
|
|
|
|
let now_ms = current_time_ms();
|
|
// Persist the reviewer's free-form `reason` into the chunk's
|
|
// `rejection_reason` field so the in-sandbox agent can read it back via
|
|
// GetDraftPolicy / policy.local and revise the proposal.
|
|
let persisted_reason = if req.reason.is_empty() {
|
|
None
|
|
} else {
|
|
Some(req.reason.as_str())
|
|
};
|
|
state
|
|
.store
|
|
.update_draft_chunk_status(&req.chunk_id, "rejected", Some(now_ms), persisted_reason)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
emit_policy_decision_success(PolicyDecisionOperation::Reject, 1);
|
|
|
|
Ok(Response::new(RejectDraftChunkResponse {}))
|
|
}
|
|
|
|
pub(super) async fn handle_approve_all_draft_chunks(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ApproveAllDraftChunksRequest>,
|
|
) -> Result<Response<ApproveAllDraftChunksResponse>, Status> {
|
|
let result = handle_approve_all_draft_chunks_inner(state, request).await;
|
|
if result.is_err() {
|
|
emit_policy_decision_failure(PolicyDecisionOperation::ApproveAll, 0);
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn handle_approve_all_draft_chunks_inner(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ApproveAllDraftChunksRequest>,
|
|
) -> Result<Response<ApproveAllDraftChunksResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::Admin,
|
|
)
|
|
.await?;
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
|
|
require_no_global_policy(state).await?;
|
|
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
|
|
let pending_chunks = state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, Some("pending"))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
|
|
|
|
if pending_chunks.is_empty() {
|
|
return Err(Status::failed_precondition("no pending chunks to approve"));
|
|
}
|
|
|
|
let chunks_to_approve = if req.approvals.is_empty() {
|
|
pending_chunks.clone()
|
|
} else {
|
|
let by_id = pending_chunks
|
|
.iter()
|
|
.map(|chunk| (chunk.id.as_str(), chunk))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut selected = Vec::with_capacity(req.approvals.len());
|
|
for approval in &req.approvals {
|
|
let chunk = by_id.get(approval.chunk_id.as_str()).ok_or_else(|| {
|
|
Status::failed_precondition(format!(
|
|
"chunk '{}' is not pending; refetch before bulk approval",
|
|
approval.chunk_id
|
|
))
|
|
})?;
|
|
selected.push((*chunk).clone());
|
|
}
|
|
selected
|
|
};
|
|
let review_tokens = req
|
|
.approvals
|
|
.iter()
|
|
.map(|approval| (approval.chunk_id.as_str(), approval.review_token.as_str()))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
pending_count = pending_chunks.len(),
|
|
include_security_flagged = req.include_security_flagged,
|
|
"ApproveAllDraftChunks: starting bulk approval"
|
|
);
|
|
|
|
let mut chunks_skipped: u32 = 0;
|
|
let provider_names = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.map(|spec| spec.providers.as_slice())
|
|
.unwrap_or_default();
|
|
let merge_validation =
|
|
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names).await?;
|
|
let credential_binding_context = merge_validation.credential_binding_context();
|
|
let merge_validation_context = PolicyMergeValidationContext {
|
|
provider_layers: &merge_validation.provider_layers,
|
|
credential_binding: Some(&credential_binding_context),
|
|
};
|
|
let mut staged_policy = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
|
|
let mut expected_effective_hash: Option<String> = None;
|
|
let mut accepted = Vec::<(DraftChunkRecord, PolicyMergeOp, String)>::new();
|
|
for chunk in &chunks_to_approve {
|
|
let security_notes = current_draft_chunk_security_notes(chunk)?;
|
|
if !req.include_security_flagged && !security_notes.is_empty() {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk.id,
|
|
rule_name = %chunk.rule_name,
|
|
security_notes = %security_notes,
|
|
"ApproveAllDraftChunks: skipping security-flagged chunk"
|
|
);
|
|
chunks_skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
let supplied_token = review_tokens.get(chunk.id.as_str()).copied().unwrap_or("");
|
|
let evaluation = match require_current_proposal_evaluation(
|
|
state,
|
|
&workspace,
|
|
&sandbox,
|
|
chunk,
|
|
Some(supplied_token),
|
|
)
|
|
.await
|
|
{
|
|
Ok(evaluation) => evaluation,
|
|
Err(status) if status.code() == tonic::Code::FailedPrecondition => {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk.id,
|
|
reason = %status.message(),
|
|
"ApproveAllDraftChunks: skipping stale or invalid candidate"
|
|
);
|
|
chunks_skipped += 1;
|
|
continue;
|
|
}
|
|
Err(status) => return Err(status),
|
|
};
|
|
|
|
let current_hash = evaluation.current_hash();
|
|
if let Some(expected_hash) = expected_effective_hash.as_deref()
|
|
&& current_hash != expected_hash
|
|
{
|
|
return Err(Status::failed_precondition(
|
|
"proposal inputs changed during bulk review; refetch and review again",
|
|
));
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk.id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
"ApproveAllDraftChunks: staging chunk"
|
|
);
|
|
|
|
let operation = PolicyMergeOp::AddRule {
|
|
rule_name: evaluation.rule_name,
|
|
rule: evaluation.rule,
|
|
};
|
|
let candidate = match stage_validated_merge_operation(
|
|
&staged_policy,
|
|
&operation,
|
|
merge_validation_context,
|
|
) {
|
|
Ok(candidate) => candidate,
|
|
Err(status) => {
|
|
persist_pending_application_error(state, &chunk.id, &status).await;
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk.id,
|
|
reason = %status.message(),
|
|
"ApproveAllDraftChunks: skipping chunk that conflicts with the staged batch"
|
|
);
|
|
chunks_skipped += 1;
|
|
continue;
|
|
}
|
|
};
|
|
let chunk_summary = summarize_draft_chunk_rule(chunk)?;
|
|
if expected_effective_hash.is_none() {
|
|
expected_effective_hash = Some(current_hash);
|
|
}
|
|
staged_policy = candidate;
|
|
accepted.push((chunk.clone(), operation, chunk_summary));
|
|
}
|
|
|
|
let (last_version, last_hash) = if accepted.is_empty() {
|
|
(0, String::new())
|
|
} else {
|
|
// Rebuild the staged candidate from fresh live inputs immediately before
|
|
// persistence. This reuses each unchanged chunk's cached prover result;
|
|
// it does not silently bind the request to a newly refreshed token.
|
|
for (chunk, _, _) in &accepted {
|
|
let supplied_token = review_tokens.get(chunk.id.as_str()).copied().unwrap_or("");
|
|
require_current_proposal_evaluation(
|
|
state,
|
|
&workspace,
|
|
&sandbox,
|
|
chunk,
|
|
Some(supplied_token),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let final_merge_validation =
|
|
sandbox_policy_merge_validation_data(state, &workspace, &sandbox, provider_names)
|
|
.await?;
|
|
let final_credential_binding_context = final_merge_validation.credential_binding_context();
|
|
let final_validation_context = PolicyMergeValidationContext {
|
|
provider_layers: &final_merge_validation.provider_layers,
|
|
credential_binding: Some(&final_credential_binding_context),
|
|
};
|
|
let final_base = current_base_policy_for_sandbox(state.store.as_ref(), &sandbox).await?;
|
|
let mut rebuilt_policy = final_base.clone();
|
|
for (_, operation, _) in &accepted {
|
|
rebuilt_policy = stage_validated_merge_operation(
|
|
&rebuilt_policy,
|
|
operation,
|
|
final_validation_context,
|
|
)?;
|
|
}
|
|
if deterministic_policy_hash(&rebuilt_policy) != deterministic_policy_hash(&staged_policy) {
|
|
return Err(Status::failed_precondition(
|
|
"proposal inputs changed during bulk review; refetch and review again",
|
|
));
|
|
}
|
|
|
|
let operations = accepted
|
|
.iter()
|
|
.map(|(_, operation, _)| operation.clone())
|
|
.collect::<Vec<_>>();
|
|
let expected_hash = expected_effective_hash.as_deref().ok_or_else(|| {
|
|
Status::failed_precondition("bulk approval has no reviewed policy snapshot")
|
|
})?;
|
|
match apply_merge_operations_with_retry(
|
|
state.store.as_ref(),
|
|
&sandbox_id,
|
|
&workspace,
|
|
Some(&final_base),
|
|
&operations,
|
|
final_validation_context,
|
|
Some(expected_hash),
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok((version, hash, _)) => (version, hash),
|
|
Err(status) => {
|
|
for (chunk, _, _) in &accepted {
|
|
persist_pending_application_error(state, &chunk.id, &status).await;
|
|
}
|
|
return Err(status);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (chunk, _, chunk_summary) in &accepted {
|
|
let now_ms = current_time_ms();
|
|
clear_pending_application_error(state, &chunk.id).await;
|
|
state
|
|
.store
|
|
.update_draft_chunk_status(&chunk.id, "approved", Some(now_ms), None)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
emit_gateway_policy_audit_log(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"approved",
|
|
format!("gateway approved draft chunk {}: {chunk_summary}", chunk.id),
|
|
last_version,
|
|
&last_hash,
|
|
);
|
|
emit_sandbox_policy_update_success();
|
|
}
|
|
let chunks_approved = u32::try_from(accepted.len()).unwrap_or(u32::MAX);
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
if let Err(error) =
|
|
reconcile_pending_chunks_after_policy_change(state, &workspace, &sandbox).await
|
|
{
|
|
warn!(
|
|
sandbox_id,
|
|
error = %error,
|
|
"failed to reconcile pending policy proposals after bulk approval"
|
|
);
|
|
}
|
|
emit_gateway_policy_audit_log(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"merged",
|
|
format!(
|
|
"gateway bulk-approved {chunks_approved} draft chunk(s) and skipped {chunks_skipped}"
|
|
),
|
|
last_version,
|
|
&last_hash,
|
|
);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunks_approved = chunks_approved,
|
|
chunks_skipped = chunks_skipped,
|
|
version = last_version,
|
|
policy_hash = %last_hash,
|
|
"ApproveAllDraftChunks: bulk approval complete"
|
|
);
|
|
emit_policy_decision_success(
|
|
PolicyDecisionOperation::ApproveAll,
|
|
u64::from(chunks_approved),
|
|
);
|
|
|
|
Ok(Response::new(ApproveAllDraftChunksResponse {
|
|
policy_version: u32::try_from(last_version).unwrap_or(0),
|
|
policy_hash: last_hash,
|
|
chunks_approved,
|
|
chunks_skipped,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_edit_draft_chunk(
|
|
state: &Arc<ServerState>,
|
|
request: Request<EditDraftChunkRequest>,
|
|
) -> Result<Response<EditDraftChunkResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::Admin,
|
|
)
|
|
.await?;
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
let proposed_rule = req
|
|
.proposed_rule
|
|
.ok_or_else(|| Status::invalid_argument("proposed_rule is required"))?;
|
|
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
|
|
let chunk = state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
|
|
|
|
if chunk.status != "pending" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'pending'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
let mut edited_chunk = chunk.clone();
|
|
edited_chunk.proposed_rule = proposed_rule.encode_to_vec();
|
|
edited_chunk.review_token.clear();
|
|
edited_chunk.validation_result.clear();
|
|
edited_chunk.application_error.clear();
|
|
edited_chunk.current_effective_policy = None;
|
|
edited_chunk.candidate_effective_policy = None;
|
|
let evaluation =
|
|
evaluate_stored_chunk_against_live_inputs(state, &workspace, &sandbox, &edited_chunk, None)
|
|
.await?;
|
|
persist_refreshed_evaluation(state, &edited_chunk, &evaluation).await?;
|
|
|
|
info!(
|
|
chunk_id = %req.chunk_id,
|
|
"EditDraftChunk: proposed rule updated"
|
|
);
|
|
|
|
Ok(Response::new(EditDraftChunkResponse {}))
|
|
}
|
|
|
|
pub(super) async fn handle_undo_draft_chunk(
|
|
state: &Arc<ServerState>,
|
|
request: Request<UndoDraftChunkRequest>,
|
|
) -> Result<Response<UndoDraftChunkResponse>, Status> {
|
|
let result = handle_undo_draft_chunk_inner(state, request).await;
|
|
if result.is_err() {
|
|
emit_policy_decision_failure(PolicyDecisionOperation::Undo, 1);
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn handle_undo_draft_chunk_inner(
|
|
state: &Arc<ServerState>,
|
|
request: Request<UndoDraftChunkRequest>,
|
|
) -> Result<Response<UndoDraftChunkResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::Admin,
|
|
)
|
|
.await?;
|
|
let workspace = sandbox.object_workspace().to_string();
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
|
|
let chunk = state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
ensure_chunk_belongs_to_sandbox(&chunk, &sandbox_id)?;
|
|
|
|
if chunk.status != "approved" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'approved'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
"UndoDraftChunk: removing rule from active policy"
|
|
);
|
|
|
|
let (version, hash) = remove_chunk_from_policy(state, &sandbox_id, &workspace, &chunk).await?;
|
|
|
|
// Clear any prior rejection_reason on the way back to "pending" so an
|
|
// agent reading the chunk via policy.local cannot see a stale guidance
|
|
// string left over from a previous reject → undo round.
|
|
state
|
|
.store
|
|
.update_draft_chunk_status(&req.chunk_id, "pending", None, Some(""))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
emit_gateway_policy_audit_log(
|
|
&sandbox_id,
|
|
sandbox.object_name(),
|
|
"removed",
|
|
format!(
|
|
"gateway reverted approved draft chunk {}: remove-binary {} {}",
|
|
req.chunk_id, chunk.rule_name, chunk.binary
|
|
),
|
|
version,
|
|
&hash,
|
|
);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
version = version,
|
|
policy_hash = %hash,
|
|
"UndoDraftChunk: rule removed, chunk reverted to pending"
|
|
);
|
|
emit_sandbox_policy_update_success();
|
|
emit_policy_decision_success(PolicyDecisionOperation::Undo, 1);
|
|
|
|
Ok(Response::new(UndoDraftChunkResponse {
|
|
policy_version: u32::try_from(version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_clear_draft_chunks(
|
|
state: &Arc<ServerState>,
|
|
request: Request<ClearDraftChunksRequest>,
|
|
) -> Result<Response<ClearDraftChunksResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request);
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::Admin,
|
|
)
|
|
.await?;
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
replay_facts.resource(&sandbox)?;
|
|
|
|
let deleted = state
|
|
.store
|
|
.delete_draft_chunks(&sandbox_id, "pending")
|
|
.await
|
|
.map_err(|e| Status::internal(format!("delete draft chunks failed: {e}")))?;
|
|
|
|
state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunks_cleared = deleted,
|
|
"ClearDraftChunks: pending chunks cleared"
|
|
);
|
|
|
|
Ok(Response::new(ClearDraftChunksResponse {
|
|
chunks_cleared: u32::try_from(deleted).unwrap_or(0),
|
|
}))
|
|
}
|
|
|
|
pub(super) async fn handle_get_draft_history(
|
|
state: &Arc<ServerState>,
|
|
request: Request<GetDraftHistoryRequest>,
|
|
) -> Result<Response<GetDraftHistoryResponse>, Status> {
|
|
let principal = super::extract_principal(&request)?;
|
|
let req = request.into_inner();
|
|
let sandbox = super::sandbox::resolve_and_authorize_sandbox_name(
|
|
state,
|
|
&principal,
|
|
&req.sandbox,
|
|
crate::auth::workspace_authz::selected_workspace_name(req.workspace_scope.as_ref())?,
|
|
MinWorkspaceRole::User,
|
|
)
|
|
.await?;
|
|
let sandbox_id = sandbox.object_id().to_string();
|
|
|
|
let all_chunks = state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, None)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
|
|
|
|
let mut entries: Vec<DraftHistoryEntry> = Vec::new();
|
|
|
|
for chunk in &all_chunks {
|
|
entries.push(DraftHistoryEntry {
|
|
event_time: openshell_core::time::optional_timestamp_from_legacy_millis(
|
|
chunk.created_at_ms,
|
|
)
|
|
.ok()
|
|
.flatten(),
|
|
event_type: "proposed".to_string(),
|
|
description: format!(
|
|
"Rule '{}' proposed (confidence: {:.0}%)",
|
|
chunk.rule_name,
|
|
chunk.confidence * 100.0
|
|
),
|
|
chunk_id: chunk.id.clone(),
|
|
});
|
|
|
|
if let Some(decided_at) = chunk.decided_at_ms {
|
|
entries.push(DraftHistoryEntry {
|
|
event_time: openshell_core::time::optional_timestamp_from_legacy_millis(decided_at)
|
|
.ok()
|
|
.flatten(),
|
|
event_type: chunk.status.clone(),
|
|
description: format!("Rule '{}' {}", chunk.rule_name, chunk.status),
|
|
chunk_id: chunk.id.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
entries.sort_by_key(|entry| {
|
|
entry
|
|
.event_time
|
|
.as_ref()
|
|
.map_or((0, 0), |value| (value.seconds, value.nanos))
|
|
});
|
|
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
entry_count = entries.len(),
|
|
"GetDraftHistory: served draft history"
|
|
);
|
|
|
|
Ok(Response::new(GetDraftHistoryResponse { entries }))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Policy helper functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Rebuilds a policy revision's identity from its checked canonical payload.
|
|
///
|
|
/// `PolicyRecord::policy_hash` is persisted metadata and cannot prove what the
|
|
/// payload contains. Decode and validate every record before using its identity
|
|
/// so legacy encodings deduplicate semantically and damaged rows fail closed.
|
|
fn canonical_policy_record_identity(
|
|
record: &PolicyRecord,
|
|
) -> Result<(ProtoSandboxPolicy, String), Status> {
|
|
let decoded = decode_stored_policy(record.policy_payload.as_slice())
|
|
.map_err(|error| Status::internal(format!("decode policy revision failed: {error}")))?;
|
|
let policy = validate_and_canonicalize_stored_policy(decoded, STORED_POLICY_SOURCE_HISTORY)?;
|
|
let hash = deterministic_policy_hash(&policy);
|
|
Ok((policy, hash))
|
|
}
|
|
|
|
/// Compare a stored revision during no-op detection without blocking repair.
|
|
///
|
|
/// Invalid durable state remains unusable everywhere that loads or serves a
|
|
/// policy. A full, already-validated replacement is different: treating an
|
|
/// unreadable current row as non-matching lets the write path append a good
|
|
/// revision instead of making the corrupt or legacy row permanently terminal.
|
|
fn canonical_policy_record_matches_for_deduplication(
|
|
record: &PolicyRecord,
|
|
expected_hash: &str,
|
|
) -> bool {
|
|
match canonical_policy_record_identity(record) {
|
|
Ok((_, hash)) => hash == expected_hash,
|
|
Err(error) => {
|
|
warn!(
|
|
policy_id = %record.id,
|
|
sandbox_id = %record.sandbox_id,
|
|
version = record.version,
|
|
error = %error,
|
|
"Invalid stored policy revision cannot satisfy deduplication; a valid replacement may proceed"
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute a fingerprint for the effective sandbox configuration.
|
|
fn compute_config_revision_with_validation_mode(
|
|
policy: Option<&ProtoSandboxPolicy>,
|
|
settings: &HashMap<String, EffectiveSetting>,
|
|
policy_source: PolicySource,
|
|
supervisor_middleware_services: &[openshell_core::proto::SupervisorMiddlewareService],
|
|
policy_validation_failure_mode: openshell_core::PolicyValidationFailureMode,
|
|
extension_authentication_enabled: bool,
|
|
) -> u64 {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update((policy_source as i32).to_le_bytes());
|
|
hasher.update(policy_validation_failure_mode.as_str().as_bytes());
|
|
hasher.update([u8::from(extension_authentication_enabled)]);
|
|
if let Some(policy) = policy {
|
|
hasher.update(deterministic_policy_hash(policy).as_bytes());
|
|
}
|
|
let mut entries: Vec<_> = settings.iter().collect();
|
|
entries.sort_by_key(|(k, _)| k.as_str());
|
|
for (key, setting) in entries {
|
|
hasher.update(key.as_bytes());
|
|
hasher.update(setting.scope.to_le_bytes());
|
|
if let Some(value) = setting.value.as_ref().and_then(|v| v.value.as_ref()) {
|
|
match value {
|
|
setting_value::Value::StringValue(v) => {
|
|
hasher.update([0]);
|
|
hasher.update(v.as_bytes());
|
|
}
|
|
setting_value::Value::BoolValue(v) => {
|
|
hasher.update([1]);
|
|
hasher.update([u8::from(*v)]);
|
|
}
|
|
setting_value::Value::IntValue(v) => {
|
|
hasher.update([2]);
|
|
hasher.update(v.to_le_bytes());
|
|
}
|
|
setting_value::Value::BytesValue(v) => {
|
|
hasher.update([3]);
|
|
hasher.update(v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut middleware = supervisor_middleware_services.iter().collect::<Vec<_>>();
|
|
middleware.sort_by(|left, right| left.name.cmp(&right.name));
|
|
for service in middleware {
|
|
hasher.update(service.encode_to_vec());
|
|
}
|
|
|
|
let digest = hasher.finalize();
|
|
let mut bytes = [0_u8; 8];
|
|
bytes.copy_from_slice(&digest[..8]);
|
|
u64::from_le_bytes(bytes)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn compute_config_revision(
|
|
policy: Option<&ProtoSandboxPolicy>,
|
|
settings: &HashMap<String, EffectiveSetting>,
|
|
policy_source: PolicySource,
|
|
supervisor_middleware_services: &[openshell_core::proto::SupervisorMiddlewareService],
|
|
) -> u64 {
|
|
compute_config_revision_with_validation_mode(
|
|
policy,
|
|
settings,
|
|
policy_source,
|
|
supervisor_middleware_services,
|
|
openshell_core::PolicyValidationFailureMode::default(),
|
|
false,
|
|
)
|
|
}
|
|
|
|
fn decode_draft_chunk_rule(record: &DraftChunkRecord) -> Result<Option<NetworkPolicyRule>, Status> {
|
|
if record.proposed_rule.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
NetworkPolicyRule::decode(record.proposed_rule.as_slice())
|
|
.map(Some)
|
|
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))
|
|
}
|
|
}
|
|
|
|
fn current_draft_chunk_security_notes(record: &DraftChunkRecord) -> Result<String, Status> {
|
|
Ok(decode_draft_chunk_rule(record)?
|
|
.as_ref()
|
|
.map_or_else(String::new, generate_security_notes))
|
|
}
|
|
|
|
fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result<PolicyChunk, Status> {
|
|
let proposed_rule = decode_draft_chunk_rule(record)?;
|
|
let security_notes = proposed_rule
|
|
.as_ref()
|
|
.map_or_else(String::new, generate_security_notes);
|
|
|
|
Ok(PolicyChunk {
|
|
id: record.id.clone(),
|
|
status: record.status.clone(),
|
|
rule_name: record.rule_name.clone(),
|
|
proposed_rule,
|
|
rationale: record.rationale.clone(),
|
|
security_notes,
|
|
confidence: record.confidence as f32,
|
|
created_time: openshell_core::time::optional_timestamp_from_legacy_millis(
|
|
record.created_at_ms,
|
|
)
|
|
.ok()
|
|
.flatten(),
|
|
decided_time: record
|
|
.decided_at_ms
|
|
.and_then(|value| openshell_core::time::timestamp_from_millis(value).ok()),
|
|
hit_count: record.hit_count,
|
|
first_seen_time: openshell_core::time::optional_timestamp_from_legacy_millis(
|
|
record.first_seen_ms,
|
|
)
|
|
.ok()
|
|
.flatten(),
|
|
last_seen_time: openshell_core::time::optional_timestamp_from_legacy_millis(
|
|
record.last_seen_ms,
|
|
)
|
|
.ok()
|
|
.flatten(),
|
|
binary: record.binary.clone(),
|
|
validation_result: record.validation_result.clone(),
|
|
rejection_reason: record.rejection_reason.clone(),
|
|
application_error: record.application_error.clone(),
|
|
review_token: record.review_token.clone(),
|
|
current_effective_policy_hash: record.current_effective_policy_hash.clone(),
|
|
candidate_effective_policy_hash: record.candidate_effective_policy_hash.clone(),
|
|
current_effective_policy: record.current_effective_policy.clone(),
|
|
candidate_effective_policy: record.candidate_effective_policy.clone(),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn policy_record_to_revision(
|
|
record: &PolicyRecord,
|
|
include_policy: bool,
|
|
) -> Result<SandboxPolicyRevision, Status> {
|
|
let stored_status = match record.status.as_str() {
|
|
"pending" => PolicyStatus::Pending,
|
|
"loaded" => PolicyStatus::Loaded,
|
|
"failed" => PolicyStatus::Failed,
|
|
"superseded" => PolicyStatus::Superseded,
|
|
_ => PolicyStatus::Unspecified,
|
|
};
|
|
|
|
match canonical_policy_record_identity(record) {
|
|
Ok((policy, policy_hash)) => Ok(SandboxPolicyRevision {
|
|
version: u32::try_from(record.version).unwrap_or(0),
|
|
policy_hash,
|
|
status: stored_status.into(),
|
|
load_error: record.load_error.clone().unwrap_or_default(),
|
|
created_time: openshell_core::time::timestamp_from_millis(record.created_at_ms).ok(),
|
|
loaded_time: record
|
|
.loaded_at_ms
|
|
.and_then(|value| openshell_core::time::timestamp_from_millis(value).ok()),
|
|
policy: include_policy.then_some(policy),
|
|
provenance: record.provenance.clone(),
|
|
}),
|
|
Err(error) if !include_policy => {
|
|
// History listing is a recovery surface, not an enforcement path.
|
|
// Preserve row metadata so one legacy or damaged payload cannot
|
|
// hide every usable revision in the page, but blank the untrusted
|
|
// hash and mark the projection failed. Detail and runtime callers
|
|
// still receive the hard error through the branch below.
|
|
let identity_error = format!(
|
|
"policy revision is invalid under the current schema: {}",
|
|
error.message()
|
|
);
|
|
let load_error = record.load_error.as_deref().map_or_else(
|
|
|| identity_error.clone(),
|
|
|stored_error| {
|
|
if stored_error.is_empty() {
|
|
identity_error.clone()
|
|
} else {
|
|
format!("{stored_error}; {identity_error}")
|
|
}
|
|
},
|
|
);
|
|
Ok(SandboxPolicyRevision {
|
|
version: u32::try_from(record.version).unwrap_or(0),
|
|
policy_hash: String::new(),
|
|
status: PolicyStatus::Failed.into(),
|
|
load_error,
|
|
created_time: openshell_core::time::timestamp_from_millis(record.created_at_ms)
|
|
.ok(),
|
|
loaded_time: record
|
|
.loaded_at_ms
|
|
.and_then(|value| openshell_core::time::timestamp_from_millis(value).ok()),
|
|
policy: None,
|
|
provenance: record.provenance.clone(),
|
|
})
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
fn allowed_ip_is_internal(entry: &str) -> bool {
|
|
use openshell_core::net::{is_always_blocked_net, is_internal_net};
|
|
|
|
let parsed = entry.parse::<ipnet::IpNet>().or_else(|_| {
|
|
entry.parse::<IpAddr>().map(|ip| match ip {
|
|
IpAddr::V4(v4) => ipnet::IpNet::V4(ipnet::Ipv4Net::from(v4)),
|
|
IpAddr::V6(v6) => ipnet::IpNet::V6(ipnet::Ipv6Net::from(v6)),
|
|
})
|
|
});
|
|
let Ok(net) = parsed else {
|
|
return false;
|
|
};
|
|
|
|
// These remain hard validation failures, not warning-only destinations.
|
|
if is_always_blocked_net(net) {
|
|
return false;
|
|
}
|
|
|
|
is_internal_net(net)
|
|
}
|
|
|
|
/// Re-validate security notes server-side for a proposed policy chunk.
|
|
fn generate_security_notes(rule: &NetworkPolicyRule) -> String {
|
|
let mut notes = Vec::new();
|
|
|
|
for endpoint in &rule.endpoints {
|
|
let host = endpoint.host.to_lowercase();
|
|
|
|
if endpoint.allow_uninspected_credentials {
|
|
notes.push(format!(
|
|
"Endpoint '{host}' explicitly allows credentials on traffic OpenShell cannot inspect or rewrite."
|
|
));
|
|
}
|
|
|
|
// Flag destinations that are an internal/private address. Parse the host as
|
|
// an IP literal and defer to the canonical RFC-accurate classifier
|
|
// (openshell-core net::is_internal_ip) rather than naive string prefixes:
|
|
// `starts_with("172.")` wrongly matched 172.0-15 / 172.32-255 (RFC 1918 is
|
|
// only 172.16.0.0/12) and missed CGNAT (100.64.0.0/10), IPv6 ULA, etc. The
|
|
// Always-blocked destinations are rejected by merge validation instead
|
|
// of being presented as advisory findings. See #1777.
|
|
let resolves_advisory_internal = host
|
|
.parse::<IpAddr>()
|
|
.is_ok_and(|ip| is_internal_ip(ip) && !is_always_blocked_ip(ip));
|
|
if resolves_advisory_internal {
|
|
notes.push(format!(
|
|
"Destination '{host}' appears to be an internal/private address."
|
|
));
|
|
}
|
|
|
|
if host.contains('*') {
|
|
notes.push(format!(
|
|
"Host '{host}' contains a wildcard — this may match unintended destinations."
|
|
));
|
|
}
|
|
|
|
for allowed_ip in &endpoint.allowed_ips {
|
|
if allowed_ip_is_internal(allowed_ip) {
|
|
notes.push(format!(
|
|
"allowed_ips includes private/internal range '{allowed_ip}'."
|
|
));
|
|
}
|
|
}
|
|
if host.trim().is_empty() && !endpoint.allowed_ips.is_empty() {
|
|
notes.push(
|
|
"allowed_ips allowlist is hostless and may match any hostname resolving within the configured range."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let ports = if endpoint.ports.is_empty() {
|
|
std::slice::from_ref(&endpoint.port)
|
|
} else {
|
|
endpoint.ports.as_slice()
|
|
};
|
|
for raw_port in ports {
|
|
let port = u16::try_from(*raw_port).unwrap_or(0);
|
|
if port > 49152 {
|
|
notes.push(format!(
|
|
"Port {port} is in the ephemeral range — this may be a temporary service."
|
|
));
|
|
}
|
|
|
|
const DB_PORTS: [u16; 7] = [5432, 3306, 6379, 27017, 9200, 11211, 5672];
|
|
if DB_PORTS.contains(&port) {
|
|
notes.push(format!(
|
|
"Port {port} is a well-known database/service port."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
notes.join(" ")
|
|
}
|
|
|
|
/// Reject proposed rules whose endpoints or `allowed_ips` target
|
|
/// always-blocked addresses (loopback, link-local, unspecified).
|
|
///
|
|
/// This is defense-in-depth: the proxy blocks these at runtime, so
|
|
/// merging them into the active policy would be silently un-enforceable.
|
|
fn validate_host_not_always_blocked(host: &str) -> Result<(), Status> {
|
|
use openshell_core::net::{is_always_blocked_ip, is_known_metadata_hostname};
|
|
use std::net::IpAddr;
|
|
|
|
let host = host.trim();
|
|
// Check if the host is a literal always-blocked IP.
|
|
if let Ok(ip) = host.parse::<IpAddr>()
|
|
&& is_always_blocked_ip(ip)
|
|
{
|
|
return Err(Status::invalid_argument(format!(
|
|
"proposed rule endpoint host '{host}' is an always-blocked address \
|
|
(loopback/link-local/unspecified); the proxy will deny traffic \
|
|
to this destination regardless of policy"
|
|
)));
|
|
}
|
|
let host_lc = host.to_lowercase();
|
|
if host_lc == "localhost" || host_lc == "localhost." {
|
|
return Err(Status::invalid_argument(
|
|
"proposed rule endpoint host 'localhost' is always blocked; \
|
|
the proxy will deny traffic to loopback regardless of policy"
|
|
.to_string(),
|
|
));
|
|
}
|
|
if is_known_metadata_hostname(host) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"proposed rule endpoint host '{host}' is a known cloud metadata hostname; \
|
|
the proxy will deny traffic to this destination regardless of policy"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_rule_not_always_blocked(rule: &NetworkPolicyRule) -> Result<(), Status> {
|
|
use openshell_core::net::is_always_blocked_net;
|
|
use std::net::IpAddr;
|
|
|
|
for ep in &rule.endpoints {
|
|
validate_host_not_always_blocked(&ep.host)?;
|
|
|
|
// Check allowed_ips entries.
|
|
for entry in &ep.allowed_ips {
|
|
let parsed = entry.parse::<ipnet::IpNet>().or_else(|_| {
|
|
entry.parse::<IpAddr>().map(|ip| match ip {
|
|
IpAddr::V4(v4) => ipnet::IpNet::V4(ipnet::Ipv4Net::from(v4)),
|
|
IpAddr::V6(v6) => ipnet::IpNet::V6(ipnet::Ipv6Net::from(v6)),
|
|
})
|
|
});
|
|
if let Ok(net) = parsed
|
|
&& is_always_blocked_net(net)
|
|
{
|
|
return Err(Status::invalid_argument(format!(
|
|
"proposed rule contains always-blocked `allowed_ips` entry '{entry}'; \
|
|
SSRF hardening prevents traffic to these destinations \
|
|
regardless of policy"
|
|
)));
|
|
}
|
|
// Invalid entries are not our concern here — the sandbox's
|
|
// parse_allowed_ips handles syntax validation.
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn require_no_global_policy(state: &ServerState) -> Result<(), Status> {
|
|
let global = load_global_settings(state.store.as_ref()).await?;
|
|
if global.settings.contains_key(POLICY_SETTING_KEY) {
|
|
return Err(Status::failed_precondition(
|
|
"cannot approve rules while a global policy is active; \
|
|
delete the global policy to manage per-sandbox rules",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_merge_operations(
|
|
proto_ops: &[PolicyMergeOperation],
|
|
) -> Result<Vec<PolicyMergeOp>, Status> {
|
|
proto_ops
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, operation)| {
|
|
let Some(operation) = operation.operation.as_ref() else {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}] is missing an operation"
|
|
)));
|
|
};
|
|
|
|
match operation {
|
|
policy_merge_operation::Operation::AddRule(add_rule) => {
|
|
let rule_name = add_rule.rule_name.trim();
|
|
if rule_name.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].add_rule.rule_name is required"
|
|
)));
|
|
}
|
|
if add_rule.rule.as_ref().is_none_or(|rule| rule.endpoints.is_empty()) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].add_rule.rule must contain at least one endpoint"
|
|
)));
|
|
}
|
|
Ok(PolicyMergeOp::AddRule {
|
|
rule_name: rule_name.to_string(),
|
|
rule: add_rule.rule.clone().unwrap_or_default(),
|
|
})
|
|
}
|
|
policy_merge_operation::Operation::RemoveEndpoint(remove_endpoint) => {
|
|
if remove_endpoint.host.trim().is_empty() || remove_endpoint.port == 0 {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].remove_endpoint requires host and non-zero port"
|
|
)));
|
|
}
|
|
let rule_name = if remove_endpoint.rule_name.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(remove_endpoint.rule_name.trim().to_string())
|
|
};
|
|
Ok(PolicyMergeOp::RemoveEndpoint {
|
|
rule_name,
|
|
host: remove_endpoint.host.trim().to_string(),
|
|
port: remove_endpoint.port,
|
|
})
|
|
}
|
|
policy_merge_operation::Operation::RemoveRule(remove_rule) => {
|
|
let rule_name = remove_rule.rule_name.trim();
|
|
if rule_name.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].remove_rule.rule_name is required"
|
|
)));
|
|
}
|
|
Ok(PolicyMergeOp::RemoveRule {
|
|
rule_name: rule_name.to_string(),
|
|
})
|
|
}
|
|
policy_merge_operation::Operation::AddDenyRules(add_deny_rules) => {
|
|
parse_proto_add_deny_rules(index, add_deny_rules)
|
|
}
|
|
policy_merge_operation::Operation::AddAllowRules(add_allow_rules) => {
|
|
parse_proto_add_allow_rules(index, add_allow_rules)
|
|
}
|
|
policy_merge_operation::Operation::RemoveBinary(remove_binary) => {
|
|
let rule_name = remove_binary.rule_name.trim();
|
|
let binary_path = remove_binary.binary_path.trim();
|
|
if rule_name.is_empty() || binary_path.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].remove_binary requires rule_name and binary_path"
|
|
)));
|
|
}
|
|
Ok(PolicyMergeOp::RemoveBinary {
|
|
rule_name: rule_name.to_string(),
|
|
binary_path: binary_path.to_string(),
|
|
})
|
|
}
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_proto_add_deny_rules(
|
|
index: usize,
|
|
add_deny_rules: &ProtoAddDenyRules,
|
|
) -> Result<PolicyMergeOp, Status> {
|
|
if add_deny_rules.deny_rules.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].add_deny_rules requires at least one deny rule"
|
|
)));
|
|
}
|
|
|
|
Ok(PolicyMergeOp::AddDenyRules {
|
|
target: parse_proto_l7_target(index, add_deny_rules.target.as_ref())?,
|
|
deny_rules: add_deny_rules.deny_rules.clone(),
|
|
})
|
|
}
|
|
|
|
fn parse_proto_add_allow_rules(
|
|
index: usize,
|
|
add_allow_rules: &ProtoAddAllowRules,
|
|
) -> Result<PolicyMergeOp, Status> {
|
|
if add_allow_rules.rules.is_empty() {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].add_allow_rules requires at least one allow rule"
|
|
)));
|
|
}
|
|
if add_allow_rules
|
|
.rules
|
|
.iter()
|
|
.any(|rule| rule.allow.as_ref().is_none())
|
|
{
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].add_allow_rules rules must include allow payloads"
|
|
)));
|
|
}
|
|
|
|
Ok(PolicyMergeOp::AddAllowRules {
|
|
target: parse_proto_l7_target(index, add_allow_rules.target.as_ref())?,
|
|
rules: add_allow_rules.rules.clone(),
|
|
})
|
|
}
|
|
|
|
fn parse_proto_l7_target(
|
|
index: usize,
|
|
target: Option<&ProtoL7RuleTarget>,
|
|
) -> Result<L7RuleTarget, Status> {
|
|
let target = target.ok_or_else(|| {
|
|
Status::invalid_argument(format!("merge_operations[{index}] requires an L7 target"))
|
|
})?;
|
|
// An omitted binary declaration must never become any-binary authority.
|
|
// The wire format permits both fields, so enforce the exclusive choice here.
|
|
let binaries = match (target.any_binary, target.binaries.is_empty()) {
|
|
(true, true) => L7BinaryScope::Any,
|
|
(false, false) => L7BinaryScope::Restricted(target.binaries.clone()),
|
|
_ => {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge_operations[{index}].target requires exactly one of any_binary=true or nonempty binaries"
|
|
)));
|
|
}
|
|
};
|
|
let target = L7RuleTarget {
|
|
rule_name: target.rule_name.clone(),
|
|
host: target.host.clone(),
|
|
ports: target.ports.clone(),
|
|
path: target.path.clone(),
|
|
binaries,
|
|
};
|
|
target.validate(index).map_err(map_policy_merge_error)?;
|
|
Ok(target)
|
|
}
|
|
|
|
fn validate_merge_operations_for_server(operations: &[PolicyMergeOp]) -> Result<(), Status> {
|
|
for operation in operations {
|
|
match operation {
|
|
PolicyMergeOp::AddRule { rule_name, rule } => {
|
|
if openshell_policy::is_provider_rule_name(rule_name) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"merge operation add_rule rule_name '{rule_name}' uses reserved '_provider_' prefix for provider composition"
|
|
)));
|
|
}
|
|
validate_rule_not_always_blocked(rule)?;
|
|
}
|
|
PolicyMergeOp::AddAllowRules { target, .. } => {
|
|
validate_host_not_always_blocked(&target.host)?;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn map_policy_merge_error(error: openshell_policy::PolicyMergeError) -> Status {
|
|
match error {
|
|
openshell_policy::PolicyMergeError::InvalidOperationPolicy { .. }
|
|
| openshell_policy::PolicyMergeError::MissingRuleNameForAddRule
|
|
| openshell_policy::PolicyMergeError::EmptyAddRuleEndpoints { .. }
|
|
| openshell_policy::PolicyMergeError::InvalidEndpointReference { .. }
|
|
| openshell_policy::PolicyMergeError::InvalidL7Target { .. }
|
|
| openshell_policy::PolicyMergeError::UnsupportedAccessPreset { .. } => {
|
|
Status::invalid_argument(error.to_string())
|
|
}
|
|
openshell_policy::PolicyMergeError::InvalidInputPolicy { .. }
|
|
| openshell_policy::PolicyMergeError::McpContractConflict { .. }
|
|
| openshell_policy::PolicyMergeError::NewBinaryWouldInheritAuthorization { .. }
|
|
| openshell_policy::PolicyMergeError::ExistingBinariesWouldInheritAuthorization {
|
|
..
|
|
}
|
|
| openshell_policy::PolicyMergeError::UndeclaredPortWouldChange { .. }
|
|
| openshell_policy::PolicyMergeError::ConflictingInspectionContracts { .. }
|
|
| openshell_policy::PolicyMergeError::AmbiguousL7Target { .. }
|
|
| openshell_policy::PolicyMergeError::L7BinaryScopeMismatch { .. }
|
|
| openshell_policy::PolicyMergeError::L7PortScopeMismatch { .. }
|
|
| openshell_policy::PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope { .. }
|
|
| openshell_policy::PolicyMergeError::EndpointNotFound { .. }
|
|
| openshell_policy::PolicyMergeError::EndpointHasNoL7Inspection { .. }
|
|
| openshell_policy::PolicyMergeError::UnsupportedEndpointProtocol { .. }
|
|
| openshell_policy::PolicyMergeError::EndpointHasNoAllowBase { .. } => {
|
|
Status::failed_precondition(error.to_string())
|
|
}
|
|
openshell_policy::PolicyMergeError::InvalidMergedPolicy { .. } => {
|
|
Status::internal(error.to_string())
|
|
}
|
|
openshell_policy::PolicyMergeError::L7TargetNotFound { .. } => {
|
|
Status::not_found(error.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AtomicPolicyWriteContext<'a> {
|
|
expected_resource_version: u64,
|
|
provenance: &'a HashMap<String, String>,
|
|
annotations: &'a HashMap<String, String>,
|
|
}
|
|
|
|
struct PolicyCredentialBindingValidationContext<'a> {
|
|
catalog: &'a EffectiveProviderProfileCatalog,
|
|
records: &'a [super::provider::ProviderEnvironmentRecord],
|
|
credentialed_scopes: &'a [CredentialedEndpointScope],
|
|
endpointless_provider_names: &'a HashSet<String>,
|
|
}
|
|
|
|
struct SandboxPolicyMergeValidationData {
|
|
provider_layers: Vec<ProviderPolicyLayer>,
|
|
catalog: EffectiveProviderProfileCatalog,
|
|
records: Vec<super::provider::ProviderEnvironmentRecord>,
|
|
credentialed_scopes: Vec<CredentialedEndpointScope>,
|
|
endpointless_provider_names: HashSet<String>,
|
|
}
|
|
|
|
impl SandboxPolicyMergeValidationData {
|
|
fn credential_binding_context(&self) -> PolicyCredentialBindingValidationContext<'_> {
|
|
PolicyCredentialBindingValidationContext {
|
|
catalog: &self.catalog,
|
|
records: &self.records,
|
|
credentialed_scopes: &self.credentialed_scopes,
|
|
endpointless_provider_names: &self.endpointless_provider_names,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn sandbox_policy_merge_validation_data(
|
|
state: &ServerState,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
provider_names: &[String],
|
|
) -> Result<SandboxPolicyMergeValidationData, Status> {
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), workspace)
|
|
.await?;
|
|
sandbox_policy_merge_validation_data_with_catalog(
|
|
state,
|
|
workspace,
|
|
sandbox,
|
|
provider_names,
|
|
&catalog,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn sandbox_policy_merge_validation_data_with_catalog(
|
|
state: &ServerState,
|
|
workspace: &str,
|
|
sandbox: &Sandbox,
|
|
provider_names: &[String],
|
|
catalog: &EffectiveProviderProfileCatalog,
|
|
) -> Result<SandboxPolicyMergeValidationData, Status> {
|
|
let global_settings = load_global_settings(state.store.as_ref()).await?;
|
|
let composition_enabled = provider_policy_composition_enabled_in(&global_settings)?;
|
|
let ProviderPolicyContext {
|
|
layers,
|
|
credentialed_scopes,
|
|
endpointless_provider_names,
|
|
} = provider_policy_context_with_catalog(
|
|
state.store.as_ref(),
|
|
catalog,
|
|
workspace,
|
|
provider_names,
|
|
)
|
|
.await?;
|
|
let provider_layers = if composition_enabled {
|
|
layers
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
debug!(
|
|
sandbox_id = %sandbox.object_id(),
|
|
provider_layer_count = provider_layers.len(),
|
|
"Composed provider policy and credential context for merge validation"
|
|
);
|
|
let records = super::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
workspace,
|
|
provider_names,
|
|
)
|
|
.await?;
|
|
Ok(SandboxPolicyMergeValidationData {
|
|
provider_layers,
|
|
catalog: catalog.clone(),
|
|
records,
|
|
credentialed_scopes,
|
|
endpointless_provider_names,
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct PolicyMergeValidationContext<'a> {
|
|
provider_layers: &'a [ProviderPolicyLayer],
|
|
credential_binding: Option<&'a PolicyCredentialBindingValidationContext<'a>>,
|
|
}
|
|
|
|
fn validate_operator_merged_credential_policy(
|
|
effective_policy: &mut ProtoSandboxPolicy,
|
|
bindings: &HashMap<String, Vec<StaticCredentialEndpointBinding>>,
|
|
context: &PolicyCredentialBindingValidationContext<'_>,
|
|
) -> Result<(), Status> {
|
|
validate_policy_credential_binding_context(
|
|
context.catalog,
|
|
context.records,
|
|
effective_policy,
|
|
bindings,
|
|
)?;
|
|
let mut credentialed_scopes = context.credentialed_scopes.to_vec();
|
|
extend_credentialed_scopes_from_policy_bindings(
|
|
&mut credentialed_scopes,
|
|
bindings,
|
|
context.endpointless_provider_names,
|
|
);
|
|
clear_provider_credentialed_markers(effective_policy);
|
|
stamp_provider_credentialed_endpoints(effective_policy, &credentialed_scopes);
|
|
validate_uninspected_credentialed_endpoints(effective_policy)
|
|
}
|
|
|
|
fn stage_validated_merge_operation(
|
|
current_policy: &ProtoSandboxPolicy,
|
|
operation: &PolicyMergeOp,
|
|
validation_context: PolicyMergeValidationContext<'_>,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
validate_merge_operations_for_server(std::slice::from_ref(operation))?;
|
|
let merged = merge_policy(current_policy.clone(), std::slice::from_ref(operation))
|
|
.map_err(map_policy_merge_error)?;
|
|
let candidate = merged.policy;
|
|
validate_policy_safety(&candidate)?;
|
|
validate_candidate_effective_policy(&candidate, validation_context.provider_layers)?;
|
|
let mut effective = if validation_context.provider_layers.is_empty() {
|
|
candidate.clone()
|
|
} else {
|
|
compose_effective_policy(&candidate, validation_context.provider_layers)
|
|
};
|
|
let bindings = policy_static_credential_endpoint_bindings(Some(&effective))?;
|
|
if let Some(context) = validation_context.credential_binding {
|
|
validate_operator_merged_credential_policy(&mut effective, &bindings, context)?;
|
|
}
|
|
Ok(candidate)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn apply_merge_operations_with_retry(
|
|
store: &Store,
|
|
sandbox_id: &str,
|
|
workspace: &str,
|
|
baseline_policy: Option<&ProtoSandboxPolicy>,
|
|
operations: &[PolicyMergeOp],
|
|
validation_context: PolicyMergeValidationContext<'_>,
|
|
expected_current_effective_hash: Option<&str>,
|
|
atomic_context: Option<&AtomicPolicyWriteContext<'_>>,
|
|
) -> Result<(i64, String, Option<Sandbox>), Status> {
|
|
let provider_layers = validation_context.provider_layers;
|
|
for attempt in 1..=MERGE_RETRY_LIMIT {
|
|
let latest = store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
|
|
|
|
let (current_policy, current_hash) = if let Some(ref record) = latest {
|
|
let (policy, hash) = canonical_policy_record_identity(record)?;
|
|
(policy, Some(hash))
|
|
} else {
|
|
(baseline_policy.cloned().unwrap_or_default(), None)
|
|
};
|
|
|
|
if let Some(expected_hash) = expected_current_effective_hash {
|
|
let mut current_effective = if provider_layers.is_empty() {
|
|
current_policy.clone()
|
|
} else {
|
|
compose_effective_policy(¤t_policy, provider_layers)
|
|
};
|
|
let bindings = policy_static_credential_endpoint_bindings(Some(¤t_effective))?;
|
|
if let Some(context) = validation_context.credential_binding {
|
|
validate_operator_merged_credential_policy(
|
|
&mut current_effective,
|
|
&bindings,
|
|
context,
|
|
)?;
|
|
}
|
|
if deterministic_policy_hash(¤t_effective) != expected_hash {
|
|
return Err(Status::failed_precondition(
|
|
"proposal inputs changed before persistence; refetch and review again",
|
|
));
|
|
}
|
|
}
|
|
|
|
let merged = merge_policy(current_policy, operations).map_err(map_policy_merge_error)?;
|
|
let new_policy = merged.policy;
|
|
let hash = deterministic_policy_hash(&new_policy);
|
|
|
|
if let Some(baseline_policy) = baseline_policy {
|
|
validate_static_fields_unchanged(baseline_policy, &new_policy)?;
|
|
}
|
|
validate_policy_safety(&new_policy)?;
|
|
validate_candidate_effective_policy(&new_policy, provider_layers)?;
|
|
let mut effective_policy = if provider_layers.is_empty() {
|
|
new_policy.clone()
|
|
} else {
|
|
compose_effective_policy(&new_policy, provider_layers)
|
|
};
|
|
let bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?;
|
|
if let Some(context) = validation_context.credential_binding {
|
|
validate_operator_merged_credential_policy(&mut effective_policy, &bindings, context)?;
|
|
}
|
|
|
|
if let Some(ref current) = latest
|
|
&& current_hash.as_deref() == Some(hash.as_str())
|
|
&& atomic_context.is_none_or(|context| current.provenance == *context.provenance)
|
|
{
|
|
return Ok((current.version, hash, None));
|
|
}
|
|
|
|
if latest.is_none() && !merged.changed {
|
|
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();
|
|
|
|
let write_result = if let Some(context) = atomic_context {
|
|
store
|
|
.put_policy_revision_atomic(&AtomicPolicyRevisionWrite {
|
|
id: policy_id,
|
|
sandbox_id: sandbox_id.to_string(),
|
|
workspace: workspace.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,
|
|
workspace,
|
|
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!(
|
|
sandbox_id = %sandbox_id,
|
|
attempt,
|
|
version = next_version,
|
|
operation_count = operations.len(),
|
|
"apply_merge_operations_with_retry: succeeded after version conflict retry"
|
|
);
|
|
}
|
|
|
|
return Ok((next_version, hash, updated_sandbox));
|
|
}
|
|
Err(e) => {
|
|
if e.is_unique_violation_on("objects_version_uq") {
|
|
if expected_current_effective_hash.is_some() {
|
|
return Err(Status::failed_precondition(
|
|
"policy changed while applying reviewed proposal; refetch and review again",
|
|
));
|
|
}
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
attempt,
|
|
conflicting_version = next_version,
|
|
operation_count = operations.len(),
|
|
"apply_merge_operations_with_retry: version conflict, retrying"
|
|
);
|
|
tokio::task::yield_now().await;
|
|
continue;
|
|
}
|
|
return Err(Status::internal(format!(
|
|
"persist policy revision failed: {e}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(Status::aborted(format!(
|
|
"apply_merge_operations_with_retry: gave up after {MERGE_RETRY_LIMIT} version conflict retries"
|
|
)))
|
|
}
|
|
|
|
async fn merge_chunk_into_policy_with_validation(
|
|
store: &Store,
|
|
sandbox_id: &str,
|
|
workspace: &str,
|
|
chunk: &DraftChunkRecord,
|
|
validation_context: PolicyMergeValidationContext<'_>,
|
|
) -> Result<(i64, String), Status> {
|
|
let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?;
|
|
let operations = [PolicyMergeOp::AddRule {
|
|
rule_name: chunk.rule_name.clone(),
|
|
rule,
|
|
}];
|
|
validate_merge_operations_for_server(&operations)?;
|
|
let mut baseline_policy = chunk.current_effective_policy.clone();
|
|
if let Some(policy) = &mut baseline_policy {
|
|
strip_provider_rule_names(policy);
|
|
clear_provider_credentialed_markers(policy);
|
|
}
|
|
apply_merge_operations_with_retry(
|
|
store,
|
|
sandbox_id,
|
|
workspace,
|
|
baseline_policy.as_ref(),
|
|
&operations,
|
|
validation_context,
|
|
(!chunk.current_effective_policy_hash.is_empty())
|
|
.then_some(chunk.current_effective_policy_hash.as_str()),
|
|
None,
|
|
)
|
|
.await
|
|
.map(|(version, hash, _)| (version, hash))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn merge_chunk_into_policy(
|
|
store: &Store,
|
|
sandbox_id: &str,
|
|
workspace: &str,
|
|
chunk: &DraftChunkRecord,
|
|
provider_layers: &[ProviderPolicyLayer],
|
|
) -> Result<(i64, String), Status> {
|
|
merge_chunk_into_policy_with_validation(
|
|
store,
|
|
sandbox_id,
|
|
workspace,
|
|
chunk,
|
|
PolicyMergeValidationContext {
|
|
provider_layers,
|
|
credential_binding: None,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn remove_chunk_from_policy(
|
|
state: &ServerState,
|
|
sandbox_id: &str,
|
|
workspace: &str,
|
|
chunk: &DraftChunkRecord,
|
|
) -> Result<(i64, String), Status> {
|
|
apply_merge_operations_with_retry(
|
|
state.store.as_ref(),
|
|
sandbox_id,
|
|
workspace,
|
|
None,
|
|
&[PolicyMergeOp::RemoveBinary {
|
|
rule_name: chunk.rule_name.clone(),
|
|
binary_path: chunk.binary.clone(),
|
|
}],
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map(|(version, hash, _)| (version, hash))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn validate_registered_setting_key(
|
|
key: &str,
|
|
) -> Result<&'static settings::RegisteredSetting, Status> {
|
|
settings::setting_for_key(key).ok_or_else(|| {
|
|
Status::invalid_argument(format!(
|
|
"unknown setting key '{key}'. Allowed keys: {}",
|
|
settings::registered_keys_csv()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn proto_setting_to_stored(key: &str, value: &SettingValue) -> Result<StoredSettingValue, Status> {
|
|
let setting = validate_registered_setting_key(key)?;
|
|
let expected = setting.kind;
|
|
let inner = value
|
|
.value
|
|
.as_ref()
|
|
.ok_or_else(|| Status::invalid_argument("setting_value.value is required"))?;
|
|
let stored = match (expected, inner) {
|
|
(SettingValueKind::String, setting_value::Value::StringValue(v)) => {
|
|
// Enforce per-key string whitelist at configure time so typos
|
|
// (e.g. `proposal_approval_mode=autom`) get rejected here instead
|
|
// of silently falling back to the default at runtime.
|
|
if let Err(allowed) = setting.validate_string_value(v) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"setting '{key}' expects one of [{}]; got '{}'",
|
|
allowed.join(", "),
|
|
v
|
|
)));
|
|
}
|
|
StoredSettingValue::String(v.clone())
|
|
}
|
|
(SettingValueKind::Bool, setting_value::Value::BoolValue(v)) => {
|
|
StoredSettingValue::Bool(*v)
|
|
}
|
|
(SettingValueKind::Int, setting_value::Value::IntValue(v)) => StoredSettingValue::Int(*v),
|
|
(_, setting_value::Value::BytesValue(_)) => {
|
|
return Err(Status::invalid_argument(format!(
|
|
"setting '{key}' expects {} value; bytes are not supported for this key",
|
|
expected.as_str()
|
|
)));
|
|
}
|
|
(expected_kind, _) => {
|
|
return Err(Status::invalid_argument(format!(
|
|
"setting '{key}' expects {} value",
|
|
expected_kind.as_str()
|
|
)));
|
|
}
|
|
};
|
|
Ok(stored)
|
|
}
|
|
|
|
fn stored_setting_to_proto(value: &StoredSettingValue) -> Result<SettingValue, Status> {
|
|
let proto = match value {
|
|
StoredSettingValue::String(v) => SettingValue {
|
|
value: Some(setting_value::Value::StringValue(v.clone())),
|
|
},
|
|
StoredSettingValue::Bool(v) => SettingValue {
|
|
value: Some(setting_value::Value::BoolValue(*v)),
|
|
},
|
|
StoredSettingValue::Int(v) => SettingValue {
|
|
value: Some(setting_value::Value::IntValue(*v)),
|
|
},
|
|
StoredSettingValue::Bytes(v) => {
|
|
let decoded = hex::decode(v)
|
|
.map_err(|e| Status::internal(format!("stored bytes decode failed: {e}")))?;
|
|
SettingValue {
|
|
value: Some(setting_value::Value::BytesValue(decoded)),
|
|
}
|
|
}
|
|
};
|
|
Ok(proto)
|
|
}
|
|
|
|
fn upsert_setting_value(
|
|
map: &mut BTreeMap<String, StoredSettingValue>,
|
|
key: &str,
|
|
value: StoredSettingValue,
|
|
) -> bool {
|
|
match map.get(key) {
|
|
Some(existing) if existing == &value => false,
|
|
_ => {
|
|
map.insert(key.to_string(), value);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn load_global_settings(store: &Store) -> Result<StoredSettings, Status> {
|
|
load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, "", GLOBAL_SETTINGS_NAME).await
|
|
}
|
|
|
|
pub(super) async fn save_global_settings(
|
|
store: &Store,
|
|
settings: &StoredSettings,
|
|
) -> Result<(), Status> {
|
|
save_settings_record(
|
|
store,
|
|
GLOBAL_SETTINGS_OBJECT_TYPE,
|
|
"",
|
|
GLOBAL_SETTINGS_NAME,
|
|
settings,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(super) async fn load_sandbox_settings(
|
|
store: &Store,
|
|
workspace: &str,
|
|
sandbox_name: &str,
|
|
) -> Result<StoredSettings, Status> {
|
|
load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, workspace, sandbox_name).await
|
|
}
|
|
|
|
pub(super) async fn save_sandbox_settings(
|
|
store: &Store,
|
|
workspace: &str,
|
|
sandbox_name: &str,
|
|
settings: &StoredSettings,
|
|
) -> Result<(), Status> {
|
|
save_settings_record(
|
|
store,
|
|
SANDBOX_SETTINGS_OBJECT_TYPE,
|
|
workspace,
|
|
sandbox_name,
|
|
settings,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn load_settings_record(
|
|
store: &Store,
|
|
object_type: &str,
|
|
workspace: &str,
|
|
name: &str,
|
|
) -> Result<StoredSettings, Status> {
|
|
let record = store
|
|
.get_by_name(object_type, workspace, name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch settings failed: {e}")))?;
|
|
if let Some(record) = record {
|
|
let mut settings = serde_json::from_slice::<StoredSettings>(&record.payload)
|
|
.map_err(|e| Status::internal(format!("decode settings payload failed: {e}")))?;
|
|
settings.resource_version = record.resource_version;
|
|
for key in settings.settings.keys() {
|
|
settings
|
|
.change_clocks
|
|
.entry(key.clone())
|
|
.or_insert_with(|| super::SettingChangeClock {
|
|
id: format!("{}:{}:{key}", record.id, record.resource_version),
|
|
committed_at_ms: record.updated_at_ms,
|
|
});
|
|
}
|
|
Ok(settings)
|
|
} else {
|
|
Ok(StoredSettings::default())
|
|
}
|
|
}
|
|
|
|
async fn save_settings_record(
|
|
store: &Store,
|
|
object_type: &str,
|
|
workspace: &str,
|
|
name: &str,
|
|
settings: &StoredSettings,
|
|
) -> Result<(), Status> {
|
|
use crate::persistence::WriteCondition;
|
|
|
|
let previous = load_settings_record(store, object_type, workspace, name).await?;
|
|
let mut persisted = settings.clone();
|
|
persisted.change_clocks = previous.change_clocks.clone();
|
|
let now_ms = current_time_ms();
|
|
for key in previous.settings.keys().chain(settings.settings.keys()) {
|
|
if previous.settings.get(key) != settings.settings.get(key) {
|
|
persisted.change_clocks.insert(
|
|
key.clone(),
|
|
super::SettingChangeClock {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
committed_at_ms: now_ms,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
let payload = serde_json::to_vec(&persisted)
|
|
.map_err(|e| Status::internal(format!("encode settings payload failed: {e}")))?;
|
|
|
|
let (id, condition) = if settings.resource_version == 0 {
|
|
(uuid::Uuid::new_v4().to_string(), WriteCondition::MustCreate)
|
|
} else {
|
|
let existing = store
|
|
.get_by_name(object_type, workspace, name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch settings for CAS failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("settings disappeared since load"))?;
|
|
|
|
(
|
|
existing.id,
|
|
WriteCondition::MatchResourceVersion(settings.resource_version),
|
|
)
|
|
};
|
|
|
|
store
|
|
.put_if(object_type, &id, name, workspace, &payload, None, condition)
|
|
.await
|
|
.map_err(|e| match e {
|
|
crate::persistence::PersistenceError::Conflict { .. } => {
|
|
Status::aborted("settings were modified concurrently; please retry")
|
|
}
|
|
crate::persistence::PersistenceError::UniqueViolation { .. } => {
|
|
Status::aborted("settings were created concurrently; please retry")
|
|
}
|
|
other => super::persistence_error_to_status(other, "persist settings"),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn decode_policy_from_global_settings(
|
|
global: &StoredSettings,
|
|
) -> Result<Option<ProtoSandboxPolicy>, Status> {
|
|
let Some(value) = global.settings.get(POLICY_SETTING_KEY) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let StoredSettingValue::Bytes(encoded) = value else {
|
|
return Err(Status::internal(
|
|
"global policy setting has invalid value type; expected bytes",
|
|
));
|
|
};
|
|
|
|
let raw = hex::decode(encoded)
|
|
.map_err(|e| Status::internal(format!("global policy decode failed: {e}")))?;
|
|
let policy = decode_stored_policy(raw.as_slice())
|
|
.map_err(|e| Status::internal(format!("global policy protobuf decode failed: {e}")))?;
|
|
validate_and_canonicalize_stored_policy(policy, STORED_POLICY_SOURCE_GLOBAL).map(Some)
|
|
}
|
|
|
|
/// Validate a decoded stored policy before it is trusted, hashed, or copied.
|
|
///
|
|
/// Stored rows may have been written by an older schema or damaged outside the
|
|
/// normal request path. Treat invalid durable state as a failed precondition and
|
|
/// return the canonical value so callers cannot accidentally reuse raw bytes.
|
|
fn validate_and_canonicalize_stored_policy(
|
|
policy: ProtoSandboxPolicy,
|
|
source: &'static str,
|
|
) -> Result<ProtoSandboxPolicy, Status> {
|
|
openshell_policy::validate_and_canonicalize_sandbox_policy(policy).map_err(|error| {
|
|
Status::failed_precondition(format!(
|
|
"stored policy source '{source}' is invalid: {error}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn merge_effective_settings(
|
|
global: &StoredSettings,
|
|
sandbox: &StoredSettings,
|
|
) -> Result<HashMap<String, EffectiveSetting>, Status> {
|
|
let mut merged = HashMap::new();
|
|
|
|
for registered in settings::REGISTERED_SETTINGS {
|
|
merged.insert(
|
|
registered.key.to_string(),
|
|
EffectiveSetting {
|
|
value: None,
|
|
scope: SettingScope::Unspecified.into(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (key, value) in &sandbox.settings {
|
|
if key == POLICY_SETTING_KEY || settings::setting_for_key(key).is_none() {
|
|
continue;
|
|
}
|
|
merged.insert(
|
|
key.clone(),
|
|
EffectiveSetting {
|
|
value: Some(stored_setting_to_proto(value)?),
|
|
scope: SettingScope::Sandbox.into(),
|
|
},
|
|
);
|
|
}
|
|
|
|
for (key, value) in &global.settings {
|
|
if key == POLICY_SETTING_KEY || settings::setting_for_key(key).is_none() {
|
|
continue;
|
|
}
|
|
merged.insert(
|
|
key.clone(),
|
|
EffectiveSetting {
|
|
value: Some(stored_setting_to_proto(value)?),
|
|
scope: SettingScope::Global.into(),
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(merged)
|
|
}
|
|
|
|
fn materialize_global_settings(
|
|
global: &StoredSettings,
|
|
) -> Result<HashMap<String, SettingValue>, Status> {
|
|
let mut materialized = HashMap::new();
|
|
for registered in settings::REGISTERED_SETTINGS {
|
|
materialized.insert(registered.key.to_string(), SettingValue { value: None });
|
|
}
|
|
|
|
for (key, value) in &global.settings {
|
|
if key == POLICY_SETTING_KEY {
|
|
continue;
|
|
}
|
|
if settings::setting_for_key(key).is_none() {
|
|
continue;
|
|
}
|
|
materialized.insert(key.clone(), stored_setting_to_proto(value)?);
|
|
}
|
|
|
|
Ok(materialized)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::auth::identity::{Identity, IdentityProvider};
|
|
use crate::auth::principal::{
|
|
Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal,
|
|
};
|
|
use crate::grpc::test_support::{authed_request, test_server_state};
|
|
|
|
/// An in-memory store with the example profiles imported at platform scope.
|
|
///
|
|
/// Provider profiles are import-only, so a gateway resolves only what an
|
|
/// operator imported. Tests that expect `github`, `openai` or
|
|
/// `google-cloud` to resolve have to import them first.
|
|
async fn test_store() -> Store {
|
|
let store = crate::persistence::test_store().await;
|
|
for profile in openshell_providers::example_profiles::load_all() {
|
|
store
|
|
.put_message(&crate::provider_profile_sources::stored_provider_profile(
|
|
profile.to_proto(),
|
|
))
|
|
.await
|
|
.expect("store example provider profile");
|
|
}
|
|
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
|
|
/// the test caller as a CLI user. Most handler tests exercise
|
|
/// user-facing behavior and should not trip sandbox equality checks.
|
|
fn with_user<T>(mut request: Request<T>) -> Request<T> {
|
|
request
|
|
.extensions_mut()
|
|
.insert(Principal::User(UserPrincipal {
|
|
identity: Identity {
|
|
subject: "test-user".to_string(),
|
|
display_name: None,
|
|
roles: vec![],
|
|
scopes: vec![],
|
|
provider: IdentityProvider::Oidc,
|
|
},
|
|
}));
|
|
request
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_log_timestamp_validation_rejects_malformed_batches_atomically() {
|
|
let logs = vec![
|
|
SandboxLogLine {
|
|
event_time: openshell_core::time::timestamp_from_millis(0).ok(),
|
|
..Default::default()
|
|
},
|
|
SandboxLogLine {
|
|
event_time: Some(prost_types::Timestamp {
|
|
seconds: 0,
|
|
nanos: -1,
|
|
}),
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
let error = validate_sandbox_log_timestamps(&logs).unwrap_err();
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
assert!(error.message().contains("logs[1].event_time"));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_policy_decode_migrates_legacy_provenance_and_preserves_unknown_fields() {
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct FutureStoredNetworkBinary {
|
|
#[prost(string, tag = "1")]
|
|
path: String,
|
|
#[prost(bool, tag = "2")]
|
|
advisor_proposed: bool,
|
|
#[prost(string, tag = "99")]
|
|
future_metadata: String,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct FutureStoredNetworkPolicyRule {
|
|
#[prost(string, tag = "1")]
|
|
name: String,
|
|
#[prost(message, repeated, tag = "2")]
|
|
endpoints: Vec<NetworkEndpoint>,
|
|
#[prost(message, repeated, tag = "3")]
|
|
binaries: Vec<FutureStoredNetworkBinary>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct FutureStoredSandboxPolicy {
|
|
#[prost(map = "string, message", tag = "5")]
|
|
network_policies: HashMap<String, FutureStoredNetworkPolicyRule>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Message)]
|
|
struct LegacyStoredPolicyRevisionPayload {
|
|
#[prost(message, optional, tag = "1")]
|
|
policy: Option<FutureStoredSandboxPolicy>,
|
|
}
|
|
|
|
let legacy = FutureStoredSandboxPolicy {
|
|
network_policies: HashMap::from([(
|
|
"cargo_registry".to_string(),
|
|
FutureStoredNetworkPolicyRule {
|
|
name: "cargo-registry".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "index.crates.io".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![
|
|
FutureStoredNetworkBinary {
|
|
path: "/usr/bin/cargo".to_string(),
|
|
advisor_proposed: false,
|
|
future_metadata: "keep-explicit".to_string(),
|
|
},
|
|
FutureStoredNetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
advisor_proposed: true,
|
|
future_metadata: "keep-advisor".to_string(),
|
|
},
|
|
],
|
|
},
|
|
)]),
|
|
};
|
|
|
|
let wrapped = LegacyStoredPolicyRevisionPayload {
|
|
policy: Some(legacy),
|
|
}
|
|
.encode_to_vec();
|
|
let record = crate::policy_store::policy_record_from_parts(
|
|
"revision-1".to_string(),
|
|
"sandbox-1".to_string(),
|
|
1,
|
|
"loaded".to_string(),
|
|
&wrapped,
|
|
1,
|
|
)
|
|
.unwrap();
|
|
let rewrapped = crate::policy_store::policy_payload_from_record(&record).unwrap();
|
|
let future_payload = LegacyStoredPolicyRevisionPayload::decode(rewrapped.as_slice())
|
|
.expect("rewrapped policy should retain unknown nested fields");
|
|
let future_rule = &future_payload.policy.unwrap().network_policies["cargo_registry"];
|
|
assert!(!future_rule.binaries[0].advisor_proposed);
|
|
assert_eq!(future_rule.binaries[0].future_metadata, "keep-explicit");
|
|
assert!(future_rule.binaries[1].advisor_proposed);
|
|
assert_eq!(future_rule.binaries[1].future_metadata, "keep-advisor");
|
|
|
|
let record = crate::policy_store::policy_record_from_parts(
|
|
"revision-2".to_string(),
|
|
"sandbox-1".to_string(),
|
|
1,
|
|
"loaded".to_string(),
|
|
&rewrapped,
|
|
1,
|
|
)
|
|
.unwrap();
|
|
let decoded = decode_stored_policy(&record.policy_payload).unwrap();
|
|
let explicit = &decoded.network_policies["cargo_registry"];
|
|
assert_eq!(explicit.binaries[0].path, "/usr/bin/cargo");
|
|
assert!(!explicit.endpoints[0].advisor_proposed);
|
|
|
|
let advisor = &decoded.network_policies["cargo_registry__legacy_advisor"];
|
|
assert_eq!(advisor.binaries[0].path, "/usr/bin/curl");
|
|
assert!(advisor.endpoints[0].advisor_proposed);
|
|
|
|
let round_tripped = decode_stored_policy(&decoded.encode_to_vec()).unwrap();
|
|
assert_eq!(round_tripped, decoded);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_authored_candidate_avoids_explicit_rule_name_collision() {
|
|
let mut base = ProtoSandboxPolicy::default();
|
|
base.network_policies.insert(
|
|
"allow_index_crates_io_443".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "explicit-index".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "index.crates.io".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/cargo".to_string(),
|
|
}],
|
|
},
|
|
);
|
|
let proposal = NetworkPolicyRule {
|
|
name: "allow_index_crates_io_443".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "index.crates.io".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let evaluation = evaluate_proposal_candidate(
|
|
&base,
|
|
&base,
|
|
"allow_index_crates_io_443",
|
|
&proposal,
|
|
"agent_authored",
|
|
&CredentialSet::default(),
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
None,
|
|
);
|
|
|
|
assert!(evaluation.application_error.is_empty());
|
|
assert_eq!(evaluation.rule_name, "allow_index_crates_io_443_2");
|
|
assert!(evaluation.rule.endpoints[0].advisor_proposed);
|
|
let candidate = evaluation.candidate_effective_policy.unwrap();
|
|
assert_eq!(
|
|
candidate.network_policies["allow_index_crates_io_443"].binaries[0].path,
|
|
"/usr/bin/cargo"
|
|
);
|
|
assert!(
|
|
candidate.network_policies["allow_index_crates_io_443_2"].endpoints[0].advisor_proposed
|
|
);
|
|
}
|
|
|
|
/// Wrap a request with a sandbox `Principal` bound to `sandbox_id`.
|
|
/// Use for tests that exercise sandbox-caller code paths.
|
|
#[allow(dead_code)]
|
|
pub(super) fn with_sandbox<T>(mut request: Request<T>, sandbox_id: &str) -> Request<T> {
|
|
request
|
|
.extensions_mut()
|
|
.insert(Principal::Sandbox(SandboxPrincipal {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
source: SandboxIdentitySource::BootstrapJwt {
|
|
issuer: "openshell-gateway:test".to_string(),
|
|
},
|
|
trust_domain: Some("openshell".to_string()),
|
|
}));
|
|
request
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provisioning_timeout_rejects_supervisor_registration() {
|
|
use openshell_core::proto::{
|
|
ConfigurationAdmissionState, ReportSandboxConfigurationRequest,
|
|
SandboxConfigurationAdmission, SandboxPhase, SandboxProvisioning,
|
|
};
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-timeout-registration";
|
|
let mut sandbox = test_sandbox(
|
|
sandbox_id,
|
|
"timeout-registration",
|
|
openshell_policy::restrictive_default_policy(),
|
|
Vec::new(),
|
|
);
|
|
sandbox.set_phase(SandboxPhase::Error.into());
|
|
sandbox.status.as_mut().unwrap().provisioning = Some(SandboxProvisioning {
|
|
timeout_time: openshell_core::time::timestamp_from_millis(300_000).ok(),
|
|
..Default::default()
|
|
});
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
let error = handle_report_sandbox_configuration(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(ReportSandboxConfigurationRequest {
|
|
sandbox_id: sandbox_id.into(),
|
|
admission: Some(SandboxConfigurationAdmission {
|
|
instance_id: uuid::Uuid::new_v4().to_string(),
|
|
state: ConfigurationAdmissionState::Pending.into(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("repair window expired"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configuration_admission_rejects_stale_generation_and_instance() {
|
|
use openshell_core::proto::{
|
|
ConfigurationAdmissionState as Admission, ReportSandboxConfigurationRequest,
|
|
SandboxConfigurationAdmission,
|
|
};
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-admission";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
"admission",
|
|
openshell_policy::restrictive_default_policy(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let instance_id = uuid::Uuid::new_v4().to_string();
|
|
let report = |admission| {
|
|
with_sandbox(
|
|
Request::new(ReportSandboxConfigurationRequest {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
admission: Some(admission),
|
|
expected_instance_id: String::new(),
|
|
}),
|
|
sandbox_id,
|
|
)
|
|
};
|
|
handle_report_sandbox_configuration(
|
|
&state,
|
|
report(SandboxConfigurationAdmission {
|
|
instance_id: instance_id.clone(),
|
|
state: Admission::Pending.into(),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let config = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "admission".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(config.configuration_admitted);
|
|
let accepted = SandboxConfigurationAdmission {
|
|
instance_id: instance_id.clone(),
|
|
state: Admission::Accepted.into(),
|
|
policy_version: config.version,
|
|
policy_hash: config.policy_hash,
|
|
config_revision: config.config_revision,
|
|
provider_env_revision: config.provider_env_revision,
|
|
error: String::new(),
|
|
};
|
|
let mut outdated_admission = accepted.clone();
|
|
outdated_admission.provider_env_revision =
|
|
outdated_admission.provider_env_revision.wrapping_add(1);
|
|
assert_eq!(
|
|
handle_report_sandbox_configuration(&state, report(outdated_admission))
|
|
.await
|
|
.unwrap_err()
|
|
.code(),
|
|
Code::Aborted
|
|
);
|
|
handle_report_sandbox_configuration(&state, report(accepted.clone()))
|
|
.await
|
|
.unwrap();
|
|
let mut stale_rejection = accepted.clone();
|
|
stale_rejection.state = Admission::Rejected.into();
|
|
stale_rejection.policy_version += 1;
|
|
assert_eq!(
|
|
handle_report_sandbox_configuration(&state, report(stale_rejection))
|
|
.await
|
|
.unwrap_err()
|
|
.code(),
|
|
Code::Aborted,
|
|
"a delayed rejection must not mark the accepted current generation invalid"
|
|
);
|
|
let restart_instance = uuid::Uuid::new_v4().to_string();
|
|
let mut restart = report(SandboxConfigurationAdmission {
|
|
instance_id: restart_instance.clone(),
|
|
state: Admission::Pending.into(),
|
|
..Default::default()
|
|
});
|
|
restart.get_mut().expected_instance_id = instance_id.clone();
|
|
handle_report_sandbox_configuration(&state, restart)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
handle_report_sandbox_configuration(
|
|
&state,
|
|
report(SandboxConfigurationAdmission {
|
|
instance_id,
|
|
state: Admission::Pending.into(),
|
|
..Default::default()
|
|
})
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.code(),
|
|
Code::FailedPrecondition,
|
|
"delayed old Pending must not reclaim registration"
|
|
);
|
|
assert_eq!(
|
|
handle_report_sandbox_configuration(&state, report(accepted.clone()))
|
|
.await
|
|
.unwrap_err()
|
|
.code(),
|
|
Code::FailedPrecondition
|
|
);
|
|
for admission_state in [Admission::Pending, Admission::Rejected] {
|
|
if admission_state == Admission::Rejected {
|
|
let mut rejected = accepted.clone();
|
|
rejected.instance_id = restart_instance.clone();
|
|
rejected.state = Admission::Rejected.into();
|
|
handle_report_sandbox_configuration(&state, report(rejected))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let persisted = state
|
|
.store
|
|
.get_message::<Sandbox>(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
persisted.status.as_ref().unwrap().configuration_activated,
|
|
Some(true)
|
|
);
|
|
assert!(!crate::policy_store::permits_initial_static_policy_repair(
|
|
&persisted
|
|
));
|
|
let mut replacement = persisted.spec.as_ref().unwrap().policy.clone().unwrap();
|
|
replacement.filesystem.as_mut().unwrap().read_only.clear();
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "admission".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(replacement),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
assert!(error.message().contains("filesystem"), "{error}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configuration_diagnostic_is_bounded_and_removes_control_characters() {
|
|
let diagnostic = bounded_configuration_diagnostic(&format!("rule\n{}", "é".repeat(1000)));
|
|
assert_eq!(diagnostic.chars().count(), 512);
|
|
assert!(!diagnostic.contains('\n'));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configuration_admission_retains_invalid_image_composition_for_repair() {
|
|
use openshell_core::proto::{
|
|
ConfigurationAdmissionState as Admission, ReportSandboxConfigurationRequest,
|
|
SandboxConfigurationAdmission,
|
|
};
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-image-admission";
|
|
let mut sandbox = test_sandbox(
|
|
sandbox_id,
|
|
"image-admission",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["work-github".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
sandbox.status.as_mut().unwrap().configuration_activated = Some(false);
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
let instance_id = uuid::Uuid::new_v4().to_string();
|
|
handle_report_sandbox_configuration(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(ReportSandboxConfigurationRequest {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
expected_instance_id: String::new(),
|
|
admission: Some(SandboxConfigurationAdmission {
|
|
instance_id,
|
|
state: Admission::Pending.into(),
|
|
..Default::default()
|
|
}),
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let image = test_policy_with_rule("image_github", "api.github.com");
|
|
handle_update_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(UpdateConfigRequest {
|
|
sandbox: "image-admission".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(image),
|
|
..Default::default()
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect("image candidate remains available for repair");
|
|
let rejected = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "image-admission".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(!rejected.configuration_admitted);
|
|
assert!(rejected.configuration_error.contains("image_github"));
|
|
assert!(!rejected.configuration_error.contains("ghp-test"));
|
|
assert!(rejected.policy.is_some());
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "image-admission".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(openshell_policy::restrictive_default_policy()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("operator can replace static sections before first launch");
|
|
let repaired = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "image-admission".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(
|
|
repaired.configuration_admitted,
|
|
"{}",
|
|
repaired.configuration_error
|
|
);
|
|
}
|
|
|
|
fn security_notes_for_host(host: &str) -> String {
|
|
generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: host.to_string(),
|
|
port: 80,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
pub(super) fn mcp_policy_with_versions(versions: &[&str]) -> ProtoSandboxPolicy {
|
|
let mut policy = openshell_policy::restrictive_default_policy();
|
|
policy.network_policies.insert(
|
|
"mcp".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "mcp".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "mcp.example.com".to_string(),
|
|
port: 443,
|
|
protocol: "mcp".to_string(),
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "tools/list".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
mcp: Some(openshell_core::proto::McpOptions {
|
|
versions: versions.iter().map(ToString::to_string).collect(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
},
|
|
);
|
|
policy
|
|
}
|
|
|
|
fn mcp_policy_without_options() -> ProtoSandboxPolicy {
|
|
let mut policy = mcp_policy_with_versions(&[]);
|
|
policy
|
|
.network_policies
|
|
.get_mut("mcp")
|
|
.expect("MCP test rule")
|
|
.endpoints
|
|
.first_mut()
|
|
.expect("MCP test endpoint")
|
|
.mcp = None;
|
|
policy
|
|
}
|
|
|
|
fn legacy_non_mcp_policy_with_mcp_options() -> ProtoSandboxPolicy {
|
|
let mut policy = mcp_policy_with_versions(&[]);
|
|
let endpoint = policy
|
|
.network_policies
|
|
.get_mut("mcp")
|
|
.expect("MCP test rule")
|
|
.endpoints
|
|
.first_mut()
|
|
.expect("MCP test endpoint");
|
|
endpoint.protocol = "rest".to_string();
|
|
endpoint.mcp = Some(openshell_core::proto::McpOptions {
|
|
strict_tool_names: Some(true),
|
|
..Default::default()
|
|
});
|
|
policy
|
|
}
|
|
|
|
fn mcp_versions(policy: &ProtoSandboxPolicy) -> &[String] {
|
|
policy
|
|
.network_policies
|
|
.get("mcp")
|
|
.and_then(|rule| rule.endpoints.first())
|
|
.and_then(|endpoint| endpoint.mcp.as_ref())
|
|
.map(|mcp| mcp.versions.as_slice())
|
|
.expect("canonical MCP test endpoint options")
|
|
}
|
|
|
|
fn defaulted_mcp_policy_cases() -> [(&'static str, ProtoSandboxPolicy); 3] {
|
|
[
|
|
("omitted-options", mcp_policy_without_options()),
|
|
("empty-versions", mcp_policy_with_versions(&[])),
|
|
(
|
|
"explicit-default",
|
|
mcp_policy_with_versions(&["2025-11-25"]),
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Install a global policy through the normal write path and return the
|
|
/// canonical value that subsequent read paths must serve.
|
|
async fn install_test_global_policy(state: &Arc<ServerState>) -> ProtoSandboxPolicy {
|
|
handle_update_config(
|
|
state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(mcp_policy_with_versions(&[])),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("test global policy update must succeed");
|
|
|
|
let settings = load_global_settings(state.store.as_ref())
|
|
.await
|
|
.expect("test global settings lookup");
|
|
decode_policy_from_global_settings(&settings)
|
|
.expect("test global policy must decode")
|
|
.expect("test global policy must be present")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_sandbox_policies_traverses_multiple_pages_exactly_once() {
|
|
let state = test_server_state().await;
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let payload = policy.encode_to_vec();
|
|
for version in 1..=7 {
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
&format!("global-policy-{version}"),
|
|
GLOBAL_POLICY_SANDBOX_ID,
|
|
"",
|
|
version,
|
|
&payload,
|
|
&format!("hash-{version}"),
|
|
)
|
|
.await
|
|
.expect("store policy revision");
|
|
}
|
|
|
|
let mut versions = Vec::new();
|
|
let mut page_token = String::new();
|
|
let mut page_size = 2;
|
|
loop {
|
|
let page = handle_list_sandbox_policies(
|
|
&state,
|
|
authed_request(ListSandboxPoliciesRequest {
|
|
page_size,
|
|
page_token,
|
|
global: true,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
versions.extend(page.revisions.into_iter().map(|revision| revision.version));
|
|
if page.next_page_token.is_empty() {
|
|
break;
|
|
}
|
|
page_token = page.next_page_token;
|
|
page_size = 3;
|
|
}
|
|
|
|
assert_eq!(versions, vec![7, 6, 5, 4, 3, 2, 1]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_global_policies_rejects_sandbox_name() {
|
|
let state = test_server_state().await;
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let payload = policy.encode_to_vec();
|
|
for version in 1..=2 {
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
&format!("global-policy-{version}"),
|
|
GLOBAL_POLICY_SANDBOX_ID,
|
|
"",
|
|
version,
|
|
&payload,
|
|
&format!("hash-{version}"),
|
|
)
|
|
.await
|
|
.expect("store policy revision");
|
|
}
|
|
let first = handle_list_sandbox_policies(
|
|
&state,
|
|
authed_request(ListSandboxPoliciesRequest {
|
|
page_size: 1,
|
|
global: true,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(!first.next_page_token.is_empty());
|
|
|
|
let error = handle_list_sandbox_policies(
|
|
&state,
|
|
authed_request(ListSandboxPoliciesRequest {
|
|
page_size: 1,
|
|
page_token: first.next_page_token,
|
|
global: true,
|
|
sandbox: "different".to_string(),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_config_rejects_invalid_spec_policy_before_history_backfill() {
|
|
let state = test_server_state().await;
|
|
let cases = [
|
|
(
|
|
"duplicate",
|
|
mcp_policy_with_versions(&["2025-11-25", "2025-11-25"]),
|
|
),
|
|
("unsupported", mcp_policy_with_versions(&["latest"])),
|
|
];
|
|
|
|
for (case, policy) in cases {
|
|
let sandbox_id = format!("stored-invalid-{case}");
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
&sandbox_id,
|
|
&format!("stored-invalid-{case}"),
|
|
policy,
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.expect("store legacy sandbox spec");
|
|
|
|
let error = handle_get_sandbox_config_inner(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_id.clone(),
|
|
workspace_scope: None,
|
|
}),
|
|
&sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect_err("invalid stored spec must fail before history backfill");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition, "{case}");
|
|
assert!(
|
|
error.message().contains(STORED_POLICY_SOURCE_SPEC),
|
|
"{case}"
|
|
);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.is_none(),
|
|
"{case} must not create policy history"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_config_backfills_canonical_spec_policy_bytes_and_hash() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "stored-canonical-backfill";
|
|
let raw = mcp_policy_with_versions(&["2025-11-25", "2025-03-26", "2025-06-18"]);
|
|
let canonical = validate_and_canonicalize_policy(raw.clone())
|
|
.expect("supported stored MCP policy must canonicalize");
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
"stored-canonical-backfill",
|
|
raw,
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.expect("store legacy sandbox spec");
|
|
|
|
let response = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_id.to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect("valid stored spec must backfill")
|
|
.into_inner();
|
|
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
assert_eq!(response.policy.as_ref(), Some(&canonical));
|
|
assert_eq!(response.policy_hash, canonical_hash);
|
|
assert_eq!(response.version, 1);
|
|
|
|
let persisted = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.expect("canonical history revision");
|
|
assert_eq!(persisted.policy_payload, canonical.encode_to_vec());
|
|
assert_eq!(persisted.policy_hash, canonical_hash);
|
|
assert_eq!(persisted.status, "loaded");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_config_backfills_defaulted_mcp_policy_as_canonical_bytes_and_hash() {
|
|
let state = test_server_state().await;
|
|
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
assert_eq!(mcp_versions(&canonical), &["2025-11-25".to_string()]);
|
|
let canonical_payload = canonical.encode_to_vec();
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
let cases = [
|
|
("omitted-options", mcp_policy_without_options()),
|
|
("empty-versions", mcp_policy_with_versions(&[])),
|
|
(
|
|
"explicit-default",
|
|
mcp_policy_with_versions(&["2025-11-25"]),
|
|
),
|
|
];
|
|
|
|
for (case, raw) in cases {
|
|
let sandbox_id = format!("stored-default-{case}");
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(&sandbox_id, &sandbox_id, raw, Vec::new()))
|
|
.await
|
|
.expect("store legacy sandbox spec");
|
|
|
|
let response = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_id.clone(),
|
|
workspace_scope: None,
|
|
}),
|
|
&sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect("defaulted stored spec must backfill")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.policy.as_ref(), Some(&canonical), "{case}");
|
|
assert_eq!(response.policy_hash, canonical_hash, "{case}");
|
|
assert_eq!(response.version, 1, "{case}");
|
|
assert_eq!(
|
|
mcp_versions(response.policy.as_ref().expect("backfilled policy")),
|
|
&["2025-11-25".to_string()],
|
|
"{case}"
|
|
);
|
|
|
|
let persisted = state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.expect("canonical history revision");
|
|
assert_eq!(persisted.policy_payload, canonical_payload, "{case}");
|
|
assert_eq!(persisted.policy_hash, canonical_hash, "{case}");
|
|
assert_eq!(persisted.status, "loaded", "{case}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_defaulted_mcp_policy_canonicalizes_for_current_base_and_history_export() {
|
|
let store = test_store().await;
|
|
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
let cases = [
|
|
("omitted-options", mcp_policy_without_options()),
|
|
("empty-versions", mcp_policy_with_versions(&[])),
|
|
(
|
|
"explicit-default",
|
|
mcp_policy_with_versions(&["2025-11-25"]),
|
|
),
|
|
];
|
|
|
|
for (case, raw) in cases {
|
|
let sandbox_id = format!("legacy-default-history-{case}");
|
|
let sandbox = test_sandbox(&sandbox_id, &sandbox_id, raw.clone(), Vec::new());
|
|
|
|
let spec_base = current_base_policy_for_sandbox(&store, &sandbox)
|
|
.await
|
|
.expect("legacy spec policy must canonicalize");
|
|
assert_eq!(spec_base, canonical, "{case}");
|
|
assert_eq!(
|
|
mcp_versions(&spec_base),
|
|
&["2025-11-25".to_string()],
|
|
"{case}"
|
|
);
|
|
|
|
store
|
|
.put_policy_revision(
|
|
&format!("legacy-default-history-revision-{case}"),
|
|
&sandbox_id,
|
|
"default",
|
|
1,
|
|
&raw.encode_to_vec(),
|
|
"legacy-uncanonicalized-hash",
|
|
)
|
|
.await
|
|
.expect("store legacy policy history");
|
|
|
|
let history_base = current_base_policy_for_sandbox(&store, &sandbox)
|
|
.await
|
|
.expect("legacy history policy must canonicalize");
|
|
assert_eq!(history_base, canonical, "{case}");
|
|
|
|
let record = store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.expect("legacy policy history");
|
|
let revision = policy_record_to_revision(&record, true)
|
|
.expect("legacy history export must canonicalize");
|
|
assert_eq!(revision.policy_hash, canonical_hash, "{case}");
|
|
let exported = revision.policy.expect("exported history policy");
|
|
assert_eq!(exported, canonical, "{case}");
|
|
assert_eq!(
|
|
mcp_versions(&exported),
|
|
&["2025-11-25".to_string()],
|
|
"{case}"
|
|
);
|
|
|
|
let listed = policy_record_to_revision(&record, false)
|
|
.expect("legacy history list projection must canonicalize");
|
|
assert_eq!(listed.policy_hash, canonical_hash, "{case}");
|
|
assert!(listed.policy.is_none(), "{case}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_legacy_latest_history_fails_closed_but_remains_listable() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "stored-invalid-history";
|
|
let valid = validate_and_canonicalize_policy(mcp_policy_with_versions(&[]))
|
|
.expect("valid history policy must canonicalize");
|
|
let valid_hash = deterministic_policy_hash(&valid);
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
"stored-invalid-history",
|
|
valid.clone(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.expect("store sandbox");
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"stored-valid-history-revision",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&valid.encode_to_vec(),
|
|
&valid_hash,
|
|
)
|
|
.await
|
|
.expect("store valid history");
|
|
let invalid = legacy_non_mcp_policy_with_mcp_options();
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"stored-invalid-history-revision",
|
|
sandbox_id,
|
|
"default",
|
|
2,
|
|
&invalid.encode_to_vec(),
|
|
"uncanonicalized-hash",
|
|
)
|
|
.await
|
|
.expect("store legacy invalid history");
|
|
|
|
let rejected = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_id.to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect("invalid latest history must remain repairable")
|
|
.into_inner();
|
|
|
|
assert!(!rejected.configuration_admitted);
|
|
assert!(rejected.policy.is_none());
|
|
assert!(!rejected.configuration_error.is_empty());
|
|
|
|
let record = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.expect("invalid policy history");
|
|
let listed_invalid = policy_record_to_revision(&record, false)
|
|
.expect("list projection must preserve invalid legacy history metadata");
|
|
assert_eq!(listed_invalid.version, 2);
|
|
assert_eq!(listed_invalid.status, PolicyStatus::Failed as i32);
|
|
assert!(listed_invalid.policy_hash.is_empty());
|
|
assert!(listed_invalid.policy.is_none());
|
|
assert!(
|
|
listed_invalid
|
|
.load_error
|
|
.contains(STORED_POLICY_SOURCE_HISTORY)
|
|
);
|
|
|
|
let detail_error = handle_get_sandbox_policy_status(
|
|
&state,
|
|
with_user(Request::new(GetSandboxPolicyStatusRequest {
|
|
sandbox: "stored-invalid-history".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
version: 2,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("invalid history detail must remain fail-closed");
|
|
assert_eq!(detail_error.code(), Code::FailedPrecondition);
|
|
assert!(
|
|
detail_error
|
|
.message()
|
|
.contains(STORED_POLICY_SOURCE_HISTORY)
|
|
);
|
|
|
|
let listed = handle_list_sandbox_policies(
|
|
&state,
|
|
with_user(Request::new(ListSandboxPoliciesRequest {
|
|
sandbox: "stored-invalid-history".to_string(),
|
|
page_size: 10,
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("one invalid revision must not hide the rest of the history")
|
|
.into_inner();
|
|
|
|
assert_eq!(listed.revisions.len(), 2);
|
|
assert_eq!(listed.revisions[0], listed_invalid);
|
|
assert_eq!(listed.revisions[1].version, 1);
|
|
assert_eq!(listed.revisions[1].policy_hash, valid_hash);
|
|
assert_eq!(listed.revisions[1].status, PolicyStatus::Pending as i32);
|
|
assert!(listed.revisions[1].load_error.is_empty());
|
|
assert!(listed.revisions[1].policy.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn valid_global_policy_overrides_invalid_legacy_history() {
|
|
let state = test_server_state().await;
|
|
let global_policy = install_test_global_policy(&state).await;
|
|
let sandbox_id = "global-overrides-invalid-history";
|
|
let sandbox = test_sandbox(
|
|
sandbox_id,
|
|
sandbox_id,
|
|
ProtoSandboxPolicy::default(),
|
|
Vec::new(),
|
|
);
|
|
state
|
|
.store
|
|
.put_message(&sandbox)
|
|
.await
|
|
.expect("store sandbox");
|
|
|
|
let invalid = legacy_non_mcp_policy_with_mcp_options();
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"global-overrides-invalid-history-revision",
|
|
sandbox_id,
|
|
"default",
|
|
2,
|
|
&invalid.encode_to_vec(),
|
|
"untrusted-legacy-hash",
|
|
)
|
|
.await
|
|
.expect("store invalid legacy policy history");
|
|
|
|
let response = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_id.to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect("valid global policy must override invalid local history")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.policy.as_ref(), Some(&global_policy));
|
|
assert_eq!(
|
|
response.policy_hash,
|
|
deterministic_policy_hash(&global_policy)
|
|
);
|
|
assert_eq!(response.policy_source, PolicySource::Global as i32);
|
|
assert_eq!(response.version, 2);
|
|
assert_eq!(response.global_policy_version, 1);
|
|
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), "default")
|
|
.await
|
|
.expect("provider profile catalog");
|
|
let effective = current_effective_policy_for_sandbox(
|
|
state.as_ref(),
|
|
&catalog,
|
|
"default",
|
|
&sandbox,
|
|
sandbox_id,
|
|
)
|
|
.await
|
|
.expect("effective-policy lookup must bypass invalid local history");
|
|
assert_eq!(effective, global_policy);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn valid_global_policy_overrides_invalid_legacy_spec_without_backfill() {
|
|
let state = test_server_state().await;
|
|
let global_policy = install_test_global_policy(&state).await;
|
|
let sandbox_id = "global-overrides-invalid-spec";
|
|
let sandbox = test_sandbox(
|
|
sandbox_id,
|
|
sandbox_id,
|
|
legacy_non_mcp_policy_with_mcp_options(),
|
|
Vec::new(),
|
|
);
|
|
state
|
|
.store
|
|
.put_message(&sandbox)
|
|
.await
|
|
.expect("store sandbox with invalid legacy spec");
|
|
|
|
let response = handle_get_sandbox_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: sandbox_id.to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
sandbox_id,
|
|
),
|
|
)
|
|
.await
|
|
.expect("valid global policy must override invalid local spec")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.policy.as_ref(), Some(&global_policy));
|
|
assert_eq!(
|
|
response.policy_hash,
|
|
deterministic_policy_hash(&global_policy)
|
|
);
|
|
assert_eq!(response.policy_source, PolicySource::Global as i32);
|
|
assert_eq!(response.version, 1);
|
|
assert_eq!(response.global_policy_version, 1);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.is_none(),
|
|
"global override must not backfill invalid dormant spec state"
|
|
);
|
|
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), "default")
|
|
.await
|
|
.expect("provider profile catalog");
|
|
let effective = current_effective_policy_for_sandbox(
|
|
state.as_ref(),
|
|
&catalog,
|
|
"default",
|
|
&sandbox,
|
|
sandbox_id,
|
|
)
|
|
.await
|
|
.expect("effective-policy lookup must bypass invalid local spec");
|
|
assert_eq!(effective, global_policy);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.expect("policy history lookup")
|
|
.is_none(),
|
|
"effective-policy lookup must not create policy history"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn canonical_mcp_version_order_produces_identical_policy_bytes_and_hashes() {
|
|
let state = test_server_state().await;
|
|
let forward = mcp_policy_with_versions(&["2025-03-26", "2025-06-18", "2025-11-25"]);
|
|
let forward =
|
|
validate_and_canonicalize_policy(forward).expect("forward policy must validate");
|
|
let reverse = mcp_policy_with_versions(&["2025-11-25", "2025-06-18", "2025-03-26"]);
|
|
let reverse =
|
|
validate_and_canonicalize_policy(reverse).expect("reverse policy must validate");
|
|
|
|
assert_eq!(forward.encode_to_vec(), reverse.encode_to_vec());
|
|
assert_eq!(
|
|
deterministic_policy_hash(&forward),
|
|
deterministic_policy_hash(&reverse)
|
|
);
|
|
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(mcp_policy_with_versions(&[
|
|
"2025-11-25",
|
|
"2025-06-18",
|
|
"2025-03-26",
|
|
])),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("global policy ingress must accept supported versions");
|
|
|
|
let persisted = state
|
|
.store
|
|
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
|
|
.await
|
|
.expect("global policy lookup")
|
|
.expect("global policy revision");
|
|
assert_eq!(persisted.policy_payload, forward.encode_to_vec());
|
|
assert_eq!(persisted.policy_hash, deterministic_policy_hash(&forward));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn global_policy_ingress_persists_defaulted_mcp_versions_identically() {
|
|
let state = test_server_state().await;
|
|
let canonical = mcp_policy_with_versions(&["2025-11-25"]);
|
|
let canonical = validate_and_canonicalize_policy(canonical)
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
let canonical_payload = canonical.encode_to_vec();
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
let cases = [
|
|
("omitted-options", mcp_policy_without_options()),
|
|
("empty-versions", mcp_policy_with_versions(&[])),
|
|
(
|
|
"explicit-default",
|
|
mcp_policy_with_versions(&["2025-11-25"]),
|
|
),
|
|
];
|
|
|
|
for (case, policy) in cases {
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(policy),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("defaulted global policy ingress must succeed")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 1, "{case}");
|
|
assert_eq!(response.policy_hash, canonical_hash, "{case}");
|
|
let persisted = state
|
|
.store
|
|
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
|
|
.await
|
|
.expect("global policy lookup")
|
|
.expect("global policy revision");
|
|
assert_eq!(persisted.policy_payload, canonical_payload, "{case}");
|
|
assert_eq!(persisted.policy_hash, canonical_hash, "{case}");
|
|
|
|
let global_settings = load_global_settings(state.store.as_ref())
|
|
.await
|
|
.expect("global settings lookup");
|
|
let stored_policy = decode_policy_from_global_settings(&global_settings)
|
|
.expect("stored global policy must decode")
|
|
.expect("stored global policy");
|
|
assert_eq!(stored_policy, canonical, "{case}");
|
|
assert_eq!(
|
|
mcp_versions(&stored_policy),
|
|
&["2025-11-25".to_string()],
|
|
"{case}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_record_identity_global_deduplicates_defaulted_mcp_history() {
|
|
for (case, legacy_policy) in defaulted_mcp_policy_cases() {
|
|
let state = test_server_state().await;
|
|
let canonical = mcp_policy_with_versions(&["2025-11-25"]);
|
|
let canonical = validate_and_canonicalize_policy(canonical)
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
&format!("global-legacy-default-{case}"),
|
|
GLOBAL_POLICY_SANDBOX_ID,
|
|
"",
|
|
1,
|
|
&legacy_policy.encode_to_vec(),
|
|
"untrusted-legacy-hash",
|
|
)
|
|
.await
|
|
.expect("store legacy global policy revision");
|
|
state
|
|
.store
|
|
.update_policy_status(
|
|
GLOBAL_POLICY_SANDBOX_ID,
|
|
1,
|
|
"loaded",
|
|
None,
|
|
Some(current_time_ms()),
|
|
)
|
|
.await
|
|
.expect("mark legacy global policy loaded");
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(mcp_policy_with_versions(&["2025-11-25"])),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("semantic default must reuse the legacy global revision")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 1, "{case}");
|
|
assert_eq!(response.policy_hash, canonical_hash, "{case}");
|
|
let revisions = state
|
|
.store
|
|
.list_policies(GLOBAL_POLICY_SANDBOX_ID, 10, 0)
|
|
.await
|
|
.expect("list global policy revisions");
|
|
assert_eq!(revisions.len(), 1, "{case}");
|
|
let settings = load_global_settings(state.store.as_ref())
|
|
.await
|
|
.expect("load canonical global setting");
|
|
assert_eq!(
|
|
decode_policy_from_global_settings(&settings)
|
|
.expect("decode canonical global setting")
|
|
.expect("global policy setting"),
|
|
canonical,
|
|
"{case}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_record_identity_sandbox_deduplicates_defaulted_mcp_history() {
|
|
let state = test_server_state().await;
|
|
let canonical = mcp_policy_with_versions(&["2025-11-25"]);
|
|
let canonical = validate_and_canonicalize_policy(canonical)
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
|
|
for (case, legacy_policy) in defaulted_mcp_policy_cases() {
|
|
let sandbox_id = format!("identity-default-{case}");
|
|
let sandbox_name = format!("identity-default-{case}");
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
&sandbox_id,
|
|
&sandbox_name,
|
|
canonical.clone(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.expect("store sandbox");
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
&format!("policy-identity-default-{case}"),
|
|
&sandbox_id,
|
|
"default",
|
|
1,
|
|
&legacy_policy.encode_to_vec(),
|
|
"untrusted-legacy-hash",
|
|
)
|
|
.await
|
|
.expect("store legacy sandbox policy revision");
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(mcp_policy_with_versions(&["2025-11-25"])),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("semantic default must reuse the legacy sandbox revision")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 1, "{case}");
|
|
assert_eq!(response.policy_hash, canonical_hash, "{case}");
|
|
let revisions = state
|
|
.store
|
|
.list_policies(&sandbox_id, 10, 0)
|
|
.await
|
|
.expect("list sandbox policy revisions");
|
|
assert_eq!(revisions.len(), 1, "{case}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_record_identity_merge_deduplicates_defaulted_mcp_history() {
|
|
let store = test_store().await;
|
|
let canonical = validate_and_canonicalize_policy(mcp_policy_with_versions(&["2025-11-25"]))
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
let canonical_hash = deterministic_policy_hash(&canonical);
|
|
|
|
for (case, legacy_policy) in defaulted_mcp_policy_cases() {
|
|
let sandbox_id = format!("identity-merge-default-{case}");
|
|
store
|
|
.put_policy_revision(
|
|
&format!("policy-identity-merge-default-{case}"),
|
|
&sandbox_id,
|
|
"default",
|
|
1,
|
|
&legacy_policy.encode_to_vec(),
|
|
"untrusted-legacy-hash",
|
|
)
|
|
.await
|
|
.expect("store legacy merge base");
|
|
|
|
let (version, hash, _) = apply_merge_operations_with_retry(
|
|
&store,
|
|
&sandbox_id,
|
|
"default",
|
|
None,
|
|
&[],
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("empty merge must reuse the semantic policy revision");
|
|
|
|
assert_eq!(version, 1, "{case}");
|
|
assert_eq!(hash, canonical_hash, "{case}");
|
|
let revisions = store
|
|
.list_policies(&sandbox_id, 10, 0)
|
|
.await
|
|
.expect("list merge policy revisions");
|
|
assert_eq!(revisions.len(), 1, "{case}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_record_identity_ignores_stale_matching_metadata() {
|
|
let state = test_server_state().await;
|
|
let candidate = mcp_policy_with_versions(&["2025-11-25"]);
|
|
let candidate = validate_and_canonicalize_policy(candidate)
|
|
.expect("candidate MCP policy must canonicalize");
|
|
let candidate_hash = deterministic_policy_hash(&candidate);
|
|
let different = mcp_policy_with_versions(&["2025-06-18"]);
|
|
let different = validate_and_canonicalize_policy(different)
|
|
.expect("different MCP policy must canonicalize");
|
|
let sandbox_id = "identity-stale-metadata";
|
|
let sandbox_name = "identity-stale-metadata";
|
|
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
candidate.clone(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.expect("store sandbox");
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"policy-identity-stale-metadata",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&different.encode_to_vec(),
|
|
&candidate_hash,
|
|
)
|
|
.await
|
|
.expect("store policy with stale matching metadata");
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(candidate.clone()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("different payload must create a new revision")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 2);
|
|
assert_eq!(response.policy_hash, candidate_hash);
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.expect("load latest policy")
|
|
.expect("new policy revision");
|
|
assert_eq!(latest.version, 2);
|
|
assert_eq!(latest.policy_payload, candidate.encode_to_vec());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn valid_full_replacement_recovers_from_malformed_payload_with_matching_metadata() {
|
|
let state = test_server_state().await;
|
|
let candidate = mcp_policy_with_versions(&["2025-11-25"]);
|
|
let candidate = validate_and_canonicalize_policy(candidate)
|
|
.expect("candidate MCP policy must canonicalize");
|
|
let candidate_hash = deterministic_policy_hash(&candidate);
|
|
let malformed = mcp_policy_with_versions(&["2025-11-25", "2025-11-25"]);
|
|
let sandbox_id = "identity-malformed-payload";
|
|
let sandbox_name = "identity-malformed-payload";
|
|
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
candidate.clone(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.expect("store sandbox");
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"policy-identity-malformed-payload",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&malformed.encode_to_vec(),
|
|
&candidate_hash,
|
|
)
|
|
.await
|
|
.expect("store malformed policy payload");
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(candidate.clone()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("valid full replacement must repair malformed latest history")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 2);
|
|
assert_eq!(response.policy_hash, candidate_hash);
|
|
let revisions = state
|
|
.store
|
|
.list_policies(sandbox_id, 10, 0)
|
|
.await
|
|
.expect("list sandbox policy revisions");
|
|
assert_eq!(revisions.len(), 2);
|
|
assert_eq!(revisions[0].version, 2);
|
|
assert_eq!(revisions[0].policy_payload, candidate.encode_to_vec());
|
|
assert_eq!(revisions[0].policy_hash, candidate_hash);
|
|
assert_eq!(revisions[1].version, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_credentialed_stamping_matches_host_ports_and_paths() {
|
|
let mut policy = ProtoSandboxPolicy {
|
|
network_policies: HashMap::from([(
|
|
"test".to_string(),
|
|
NetworkPolicyRule {
|
|
endpoints: vec![
|
|
NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
path: "/mcp/private/**".to_string(),
|
|
provider_credentialed: true,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 8443,
|
|
path: "/mcp/private/**".to_string(),
|
|
provider_credentialed: true,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "*.api.example.com".to_string(),
|
|
port: 443,
|
|
path: "/mcp/private/**".to_string(),
|
|
provider_credentialed: true,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
path: "/mcp/public/**".to_string(),
|
|
provider_credentialed: true,
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
},
|
|
)]),
|
|
..Default::default()
|
|
};
|
|
let scopes = vec![CredentialedEndpointScope {
|
|
host: "*.example.com".to_string(),
|
|
ports: vec![443],
|
|
path: "/mcp/private/**".to_string(),
|
|
}];
|
|
|
|
clear_provider_credentialed_markers(&mut policy);
|
|
stamp_provider_credentialed_endpoints(&mut policy, &scopes);
|
|
|
|
let endpoints = &policy.network_policies["test"].endpoints;
|
|
assert!(endpoints[0].provider_credentialed);
|
|
assert!(!endpoints[1].provider_credentialed);
|
|
assert!(!endpoints[2].provider_credentialed);
|
|
assert!(!endpoints[3].provider_credentialed);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_bindings_add_scopes_only_for_attached_endpointless_providers() {
|
|
let mut scopes = vec![CredentialedEndpointScope {
|
|
host: "profile.example.com".to_string(),
|
|
ports: vec![443],
|
|
path: "/**".to_string(),
|
|
}];
|
|
let bindings = HashMap::from([
|
|
(
|
|
"bound".to_string(),
|
|
vec![
|
|
StaticCredentialEndpointBinding {
|
|
host: "API.Bound.Example".to_string(),
|
|
port: 8443,
|
|
path: "/v1".to_string(),
|
|
},
|
|
StaticCredentialEndpointBinding {
|
|
host: "api.bound.example".to_string(),
|
|
port: 8443,
|
|
path: "/v2".to_string(),
|
|
},
|
|
],
|
|
),
|
|
(
|
|
"endpointful".to_string(),
|
|
vec![StaticCredentialEndpointBinding {
|
|
host: "profile-bound.example".to_string(),
|
|
port: 443,
|
|
path: String::new(),
|
|
}],
|
|
),
|
|
(
|
|
"unattached".to_string(),
|
|
vec![StaticCredentialEndpointBinding {
|
|
host: "unattached.example".to_string(),
|
|
port: 443,
|
|
path: String::new(),
|
|
}],
|
|
),
|
|
]);
|
|
|
|
extend_credentialed_scopes_from_policy_bindings(
|
|
&mut scopes,
|
|
&bindings,
|
|
&HashSet::from(["bound".to_string()]),
|
|
);
|
|
|
|
assert_eq!(
|
|
scopes,
|
|
vec![
|
|
CredentialedEndpointScope {
|
|
host: "profile.example.com".to_string(),
|
|
ports: vec![443],
|
|
path: "/**".to_string(),
|
|
},
|
|
CredentialedEndpointScope {
|
|
host: "api.bound.example".to_string(),
|
|
ports: vec![8443],
|
|
path: "/v1".to_string(),
|
|
},
|
|
CredentialedEndpointScope {
|
|
host: "api.bound.example".to_string(),
|
|
ports: vec![8443],
|
|
path: "/v2".to_string(),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn credentialed_l4_and_tls_skip_require_explicit_opt_in() {
|
|
let endpoint = |protocol: &str, tls: &str, allow: bool| NetworkEndpoint {
|
|
host: "api.vendor.example".to_string(),
|
|
port: 443,
|
|
protocol: protocol.to_string(),
|
|
tls: openshell_policy::network_tls_mode_from_str(tls).expect("valid test TLS mode")
|
|
as i32,
|
|
provider_credentialed: true,
|
|
allow_uninspected_credentials: allow,
|
|
..Default::default()
|
|
};
|
|
let policy = |endpoint| ProtoSandboxPolicy {
|
|
network_policies: HashMap::from([(
|
|
"vendor".to_string(),
|
|
NetworkPolicyRule {
|
|
endpoints: vec![endpoint],
|
|
..Default::default()
|
|
},
|
|
)]),
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(
|
|
validate_uninspected_credentialed_endpoints(&policy(endpoint("", "", false))).is_err()
|
|
);
|
|
assert!(
|
|
validate_uninspected_credentialed_endpoints(&policy(endpoint("rest", "skip", false)))
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
validate_uninspected_credentialed_endpoints(&policy(endpoint("", "", true))).is_ok()
|
|
);
|
|
|
|
let mut plain = endpoint("", "", false);
|
|
plain.provider_credentialed = false;
|
|
assert!(validate_uninspected_credentialed_endpoints(&policy(plain)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_flag_allow_uninspected_credentials() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.vendor.example".to_string(),
|
|
port: 443,
|
|
allow_uninspected_credentials: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
assert!(notes.contains("cannot inspect or rewrite"));
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_use_canonical_internal_ip_classifier() {
|
|
// RFC 1918 is 172.16.0.0/12 only: the old starts_with("172.") prefix
|
|
// wrongly flagged 172.15/172.32 and missed CGNAT (100.64.0.0/10). #1777.
|
|
assert!(security_notes_for_host("172.16.0.1").contains("internal/private"));
|
|
assert!(!security_notes_for_host("172.15.0.1").contains("internal/private"));
|
|
assert!(!security_notes_for_host("172.32.0.1").contains("internal/private"));
|
|
assert!(security_notes_for_host("100.64.0.1").contains("internal/private"));
|
|
assert!(security_notes_for_host("10.0.0.1").contains("internal/private"));
|
|
assert!(security_notes_for_host("192.168.1.1").contains("internal/private"));
|
|
assert!(!security_notes_for_host("8.8.8.8").contains("internal/private"));
|
|
// Hostnames that merely start with a private-range prefix must NOT be
|
|
// flagged: classification parses an IP literal, not a string prefix. #1824.
|
|
assert!(!security_notes_for_host("10.example.com").contains("internal/private"));
|
|
assert!(!security_notes_for_host("172.example.com").contains("internal/private"));
|
|
// IPv6 ULA (fc00::/7, RFC 4193) is internal/private.
|
|
assert!(security_notes_for_host("fd00::1").contains("internal/private"));
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_exclude_always_blocked_destinations() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![
|
|
NetworkEndpoint {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "169.254.169.254".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "localhost".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "metadata.google.internal".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec![
|
|
"127.0.0.0/8".to_string(),
|
|
"169.254.0.0/16".to_string(),
|
|
"::/128".to_string(),
|
|
],
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(notes.is_empty(), "{notes}");
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_flag_private_allowed_ips() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["10.0.0.0/8".to_string()],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(notes.contains("allowed_ips includes private/internal range '10.0.0.0/8'."));
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_flag_cidr_overlapping_cgnat() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["100.0.0.0/9".to_string()],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(notes.contains("allowed_ips includes private/internal range '100.0.0.0/9'."));
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_do_not_flag_large_public_allowed_ips() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["8.8.0.0/16".to_string(), "8.8.8.0/24".to_string()],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(notes.is_empty(), "{notes}");
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_flag_hostless_allowed_ips() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![NetworkEndpoint {
|
|
allowed_ips: vec!["192.168.0.0/16".to_string()],
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(notes.contains("allowed_ips includes private/internal range '192.168.0.0/16'."));
|
|
assert!(notes.contains(
|
|
"allowed_ips allowlist is hostless and may match any hostname resolving within the configured range."
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn security_notes_inspect_all_endpoints_and_effective_ports() {
|
|
let notes = generate_security_notes(&NetworkPolicyRule {
|
|
endpoints: vec![
|
|
NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
},
|
|
NetworkEndpoint {
|
|
host: "*.internal.example".to_string(),
|
|
port: 3306,
|
|
ports: vec![5432, 50_000],
|
|
allowed_ips: vec!["172.16.0.0/12".to_string()],
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
});
|
|
|
|
assert!(notes.contains("Host '*.internal.example' contains a wildcard"));
|
|
assert!(notes.contains("allowed_ips includes private/internal range '172.16.0.0/12'."));
|
|
assert!(notes.contains("Port 5432 is a well-known database/service port."));
|
|
assert!(notes.contains("Port 50000 is in the ephemeral range"));
|
|
assert!(!notes.contains("Port 3306"));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_caller_update_validation_allows_sandbox_policy_sync() {
|
|
let req = UpdateConfigRequest {
|
|
sandbox: "sandbox-1".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(ProtoSandboxPolicy::default()),
|
|
..Default::default()
|
|
};
|
|
assert!(validate_sandbox_caller_update(&req).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_caller_update_validation_rejects_global_mutation() {
|
|
let req = UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(ProtoSandboxPolicy::default()),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_caller_update(&req).unwrap_err();
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_caller_update_validation_rejects_setting_mutation() {
|
|
let req = UpdateConfigRequest {
|
|
sandbox: "sandbox-1".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
setting_key: "inference.model".to_string(),
|
|
setting_value: Some(SettingValue { value: None }),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_caller_update(&req).unwrap_err();
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_caller_detected_from_principal_extension() {
|
|
use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
|
|
let mut req = Request::new(());
|
|
req.extensions_mut()
|
|
.insert(Principal::Sandbox(SandboxPrincipal {
|
|
sandbox_id: "test-sandbox".to_string(),
|
|
source: SandboxIdentitySource::BootstrapJwt {
|
|
issuer: "openshell-gateway:test".to_string(),
|
|
},
|
|
trust_domain: None,
|
|
}));
|
|
assert!(is_sandbox_caller(&req));
|
|
}
|
|
|
|
#[test]
|
|
fn user_principal_not_treated_as_sandbox_caller() {
|
|
use crate::auth::identity::{Identity, IdentityProvider};
|
|
use crate::auth::principal::{Principal, UserPrincipal};
|
|
let mut req = Request::new(());
|
|
req.extensions_mut().insert(Principal::User(UserPrincipal {
|
|
identity: Identity {
|
|
subject: "alice".to_string(),
|
|
display_name: None,
|
|
roles: vec![],
|
|
scopes: vec![],
|
|
provider: IdentityProvider::Oidc,
|
|
},
|
|
}));
|
|
assert!(!is_sandbox_caller(&req));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_logs_authorizes_persisted_sandbox_workspace() {
|
|
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
|
use openshell_core::proto::{WorkspaceMember, WorkspaceRole};
|
|
|
|
let mut state = test_server_state().await;
|
|
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
|
|
let sandbox = Sandbox {
|
|
metadata: Some(ObjectMeta {
|
|
id: "sandbox-b-id".to_string(),
|
|
name: "sandbox-b".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "workspace-b".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
..Sandbox::default()
|
|
};
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let member = WorkspaceMember {
|
|
metadata: Some(ObjectMeta {
|
|
id: "member-a-id".to_string(),
|
|
name: "test-user".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
principal_subject: "test-user".to_string(),
|
|
role: WorkspaceRole::User.into(),
|
|
};
|
|
state.store.put_message(&member).await.unwrap();
|
|
|
|
let error = handle_get_sandbox_logs(
|
|
&state,
|
|
with_user(Request::new(GetSandboxLogsRequest {
|
|
sandbox: "sandbox-b".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..GetSandboxLogsRequest::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(
|
|
error.code(),
|
|
Code::NotFound,
|
|
"cross-workspace sandbox access must return NotFound to prevent CWE-203 oracle"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_logs_rejects_invalid_since_time() {
|
|
let state = test_server_state().await;
|
|
let sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sandbox-id".to_string(),
|
|
name: "sandbox".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_get_sandbox_logs(
|
|
&state,
|
|
with_user(Request::new(GetSandboxLogsRequest {
|
|
sandbox: "sandbox".to_string(),
|
|
since_time: Some(prost_types::Timestamp {
|
|
seconds: 253_402_300_800,
|
|
nanos: 0,
|
|
}),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_logs_filters_with_nanosecond_precision() {
|
|
let state = test_server_state().await;
|
|
let sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sandbox-id".to_string(),
|
|
name: "sandbox".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
for (timestamp, message) in [
|
|
(
|
|
prost_types::Timestamp {
|
|
seconds: -1,
|
|
nanos: 999_999_999,
|
|
},
|
|
"pre-epoch",
|
|
),
|
|
(
|
|
prost_types::Timestamp {
|
|
seconds: 0,
|
|
nanos: 0,
|
|
},
|
|
"epoch",
|
|
),
|
|
] {
|
|
state.tracing_log_bus.publish_external(SandboxLogLine {
|
|
sandbox_id: "sandbox-id".to_string(),
|
|
event_time: Some(timestamp),
|
|
message: message.to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
for (nanos, message) in [(100, "before"), (900, "at-boundary"), (950, "after")] {
|
|
state.tracing_log_bus.publish_external(SandboxLogLine {
|
|
sandbox_id: "sandbox-id".to_string(),
|
|
event_time: Some(prost_types::Timestamp { seconds: 1, nanos }),
|
|
message: message.to_string(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
let response = handle_get_sandbox_logs(
|
|
&state,
|
|
with_user(Request::new(GetSandboxLogsRequest {
|
|
sandbox: "sandbox".to_string(),
|
|
since_time: Some(prost_types::Timestamp {
|
|
seconds: 1,
|
|
nanos: 900,
|
|
}),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(
|
|
response
|
|
.logs
|
|
.iter()
|
|
.map(|log| log.message.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["at-boundary", "after"]
|
|
);
|
|
|
|
let response = handle_get_sandbox_logs(
|
|
&state,
|
|
with_user(Request::new(GetSandboxLogsRequest {
|
|
sandbox: "sandbox".to_string(),
|
|
since_time: Some(prost_types::Timestamp {
|
|
seconds: 0,
|
|
nanos: 0,
|
|
}),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let messages = response
|
|
.logs
|
|
.iter()
|
|
.map(|log| log.message.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert!(!messages.contains(&"pre-epoch"));
|
|
assert!(messages.contains(&"epoch"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submit_policy_analysis_rejects_only_chunks_with_invalid_timestamps() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "invalid-policy-chunk-timestamps";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-invalid-policy-chunk-timestamps",
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let chunk = |name: &str| PolicyChunk {
|
|
rule_name: name.to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: name.to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: format!("{name}.example.com"),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let invalid = prost_types::Timestamp {
|
|
seconds: 253_402_300_800,
|
|
nanos: 0,
|
|
};
|
|
let mut invalid_first = chunk("invalid_first");
|
|
invalid_first.first_seen_time = Some(invalid);
|
|
let mut invalid_last = chunk("invalid_last");
|
|
invalid_last.last_seen_time = Some(invalid);
|
|
|
|
let response = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![invalid_first, invalid_last, chunk("valid_absent")],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(response.accepted_chunks, 1);
|
|
assert_eq!(response.rejected_chunks, 2);
|
|
assert!(
|
|
response
|
|
.rejection_reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("invalid first_seen_time"))
|
|
);
|
|
assert!(
|
|
response
|
|
.rejection_reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("invalid last_seen_time"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_global_requires_platform_admin() {
|
|
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
|
use openshell_core::proto::{WorkspaceMember, WorkspaceRole};
|
|
|
|
let mut state = test_server_state().await;
|
|
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
|
|
let member = WorkspaceMember {
|
|
metadata: Some(ObjectMeta {
|
|
id: "default-admin-member-id".to_string(),
|
|
name: "test-user".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
principal_subject: "test-user".to_string(),
|
|
role: WorkspaceRole::Admin.into(),
|
|
};
|
|
state.store.put_message(&member).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
request_id: String::new(),
|
|
global: true,
|
|
setting_key: "log_level".to_string(),
|
|
delete_setting: true,
|
|
..UpdateConfigRequest::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn global_policy_reads_require_platform_admin() {
|
|
let mut state = test_server_state().await;
|
|
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
|
|
|
|
let get_error = handle_get_sandbox_policy_status(
|
|
&state,
|
|
with_user(Request::new(GetSandboxPolicyStatusRequest {
|
|
global: true,
|
|
..GetSandboxPolicyStatusRequest::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(get_error.code(), Code::PermissionDenied);
|
|
assert!(get_error.message().contains("platform admin role required"));
|
|
|
|
let list_error = handle_list_sandbox_policies(
|
|
&state,
|
|
with_user(Request::new(ListSandboxPoliciesRequest {
|
|
global: true,
|
|
..ListSandboxPoliciesRequest::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(list_error.code(), Code::PermissionDenied);
|
|
assert!(
|
|
list_error
|
|
.message()
|
|
.contains("platform admin role required")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn global_policy_requests_reject_workspace_selectors() {
|
|
let state = test_server_state().await;
|
|
|
|
let update_error = handle_update_config(
|
|
&state,
|
|
authed_request(UpdateConfigRequest {
|
|
global: true,
|
|
sandbox: String::new(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(update_error.code(), Code::InvalidArgument);
|
|
|
|
let get_error = handle_get_sandbox_policy_status(
|
|
&state,
|
|
authed_request(GetSandboxPolicyStatusRequest {
|
|
global: true,
|
|
sandbox: String::new(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(get_error.code(), Code::InvalidArgument);
|
|
|
|
let list_error = handle_list_sandbox_policies(
|
|
&state,
|
|
authed_request(ListSandboxPoliciesRequest {
|
|
global: true,
|
|
sandbox: String::new(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(list_error.code(), Code::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_missing_principal() {
|
|
let state = test_server_state().await;
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
Request::new(UpdateConfigRequest {
|
|
request_id: String::new(),
|
|
global: true,
|
|
setting_key: "log_level".to_string(),
|
|
delete_setting: true,
|
|
..UpdateConfigRequest::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error.code(), Code::Internal);
|
|
assert_eq!(error.message(), "missing principal");
|
|
}
|
|
|
|
#[test]
|
|
fn merge_operation_validation_rejects_reserved_provider_add_rule_name() {
|
|
let err = validate_merge_operations_for_server(&[PolicyMergeOp::AddRule {
|
|
rule_name: "_provider_work_github".to_string(),
|
|
rule: NetworkPolicyRule::default(),
|
|
}])
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("_provider_work_github"));
|
|
assert!(err.message().contains("reserved '_provider_' prefix"));
|
|
}
|
|
|
|
fn l7_scope_target() -> ProtoL7RuleTarget {
|
|
ProtoL7RuleTarget {
|
|
rule_name: "selected".to_string(),
|
|
host: "api.example.com".to_string(),
|
|
ports: vec![443, 8443],
|
|
path: Some(String::new()),
|
|
binaries: vec![
|
|
NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
},
|
|
NetworkBinary {
|
|
path: "/usr/bin/python3".to_string(),
|
|
},
|
|
],
|
|
any_binary: false,
|
|
}
|
|
}
|
|
|
|
fn l7_scope_operation(target: Option<ProtoL7RuleTarget>, deny: bool) -> PolicyMergeOperation {
|
|
let operation = if deny {
|
|
policy_merge_operation::Operation::AddDenyRules(ProtoAddDenyRules {
|
|
target,
|
|
deny_rules: vec![L7DenyRule {
|
|
method: "POST".to_string(),
|
|
path: "/admin".to_string(),
|
|
..Default::default()
|
|
}],
|
|
})
|
|
} else {
|
|
policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules {
|
|
target,
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "POST".to_string(),
|
|
path: "/admin".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
})
|
|
};
|
|
PolicyMergeOperation {
|
|
operation: Some(operation),
|
|
}
|
|
}
|
|
|
|
fn l7_scope_policy() -> ProtoSandboxPolicy {
|
|
let endpoint = NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
ports: vec![443, 8443],
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
|
|
..Default::default()
|
|
};
|
|
let selected = NetworkPolicyRule {
|
|
name: "selected".to_string(),
|
|
endpoints: vec![
|
|
endpoint.clone(),
|
|
NetworkEndpoint {
|
|
path: "/v1/**".to_string(),
|
|
..endpoint.clone()
|
|
},
|
|
],
|
|
binaries: l7_scope_target().binaries,
|
|
};
|
|
let sibling = NetworkPolicyRule {
|
|
name: "sibling".to_string(),
|
|
endpoints: vec![endpoint],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/wget".to_string(),
|
|
}],
|
|
};
|
|
validate_and_canonicalize_policy(ProtoSandboxPolicy {
|
|
network_policies: HashMap::from([
|
|
("selected".to_string(), selected),
|
|
("sibling".to_string(), sibling),
|
|
]),
|
|
..Default::default()
|
|
})
|
|
.expect("L7 scope fixture must be a valid policy")
|
|
}
|
|
|
|
#[test]
|
|
fn l7_target_scope_ingress_requires_target_and_exclusive_binary_declaration() {
|
|
for deny in [false, true] {
|
|
let missing = parse_merge_operations(&[l7_scope_operation(None, deny)]).unwrap_err();
|
|
assert_eq!(missing.code(), Code::InvalidArgument);
|
|
|
|
let mut omitted = l7_scope_target();
|
|
omitted.binaries.clear();
|
|
let mut contradictory = l7_scope_target();
|
|
contradictory.any_binary = true;
|
|
for target in [omitted, contradictory] {
|
|
let error = parse_merge_operations(&[l7_scope_operation(Some(target), deny)])
|
|
.expect_err("binary scope must be explicit and exclusive");
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
assert!(error.message().contains("exactly one"));
|
|
}
|
|
|
|
let mut any = l7_scope_target();
|
|
any.any_binary = true;
|
|
any.binaries.clear();
|
|
let operations =
|
|
parse_merge_operations(&[l7_scope_operation(Some(any), deny)]).unwrap();
|
|
let (PolicyMergeOp::AddAllowRules { target, .. }
|
|
| PolicyMergeOp::AddDenyRules { target, .. }) = &operations[0]
|
|
else {
|
|
panic!("L7 operation expected");
|
|
};
|
|
assert_eq!(target.binaries, L7BinaryScope::Any);
|
|
assert_eq!(target.path.as_deref(), Some(""));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn l7_target_scope_ingress_rejects_malformed_targets() {
|
|
let mut invalid_targets = Vec::new();
|
|
let mut target = l7_scope_target();
|
|
target.rule_name.clear();
|
|
invalid_targets.push(target);
|
|
let mut target = l7_scope_target();
|
|
target.rule_name = "_provider_example".to_string();
|
|
invalid_targets.push(target);
|
|
let mut target = l7_scope_target();
|
|
target.host = "https://api.example.com".to_string();
|
|
invalid_targets.push(target);
|
|
for ports in [vec![], vec![0], vec![65536]] {
|
|
let mut target = l7_scope_target();
|
|
target.ports = ports;
|
|
invalid_targets.push(target);
|
|
}
|
|
let mut target = l7_scope_target();
|
|
target.binaries[0].path.clear();
|
|
invalid_targets.push(target);
|
|
|
|
for target in invalid_targets {
|
|
for deny in [false, true] {
|
|
let error =
|
|
parse_merge_operations(&[l7_scope_operation(Some(target.clone()), deny)])
|
|
.expect_err("malformed L7 target must fail at request ingress");
|
|
assert_eq!(error.code(), Code::InvalidArgument, "{target:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn l7_target_scope_ingress_preserves_existing_payload_guards() {
|
|
for operation in [
|
|
policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules {
|
|
target: Some(l7_scope_target()),
|
|
rules: vec![],
|
|
}),
|
|
policy_merge_operation::Operation::AddDenyRules(ProtoAddDenyRules {
|
|
target: Some(l7_scope_target()),
|
|
deny_rules: vec![],
|
|
}),
|
|
policy_merge_operation::Operation::AddAllowRules(ProtoAddAllowRules {
|
|
target: Some(l7_scope_target()),
|
|
rules: vec![L7Rule { allow: None }],
|
|
}),
|
|
] {
|
|
let error = parse_merge_operations(&[PolicyMergeOperation {
|
|
operation: Some(operation),
|
|
}])
|
|
.expect_err("target presence does not make an empty payload valid");
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn l7_target_scope_update_config_rejects_batch_without_persistence() {
|
|
let state = test_server_state().await;
|
|
let policy = l7_scope_policy();
|
|
let sandbox_id = "l7-scope-rejected";
|
|
let mut sandbox = test_sandbox(sandbox_id, sandbox_id, policy.clone(), Vec::new());
|
|
sandbox.metadata.as_mut().unwrap().annotations =
|
|
HashMap::from([("example.com/owner".to_string(), "original".to_string())]);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"l7-scope-seed",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&policy.encode_to_vec(),
|
|
&deterministic_policy_hash(&policy),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let before = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let before_revision = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let mut missing_binary = l7_scope_target();
|
|
missing_binary.binaries.pop();
|
|
let mut missing_port = l7_scope_target();
|
|
missing_port.ports = vec![443];
|
|
let mut ambiguous = l7_scope_target();
|
|
ambiguous.path = None;
|
|
let mut missing_rule = l7_scope_target();
|
|
missing_rule.rule_name = "absent".to_string();
|
|
let mut wrong_any = l7_scope_target();
|
|
wrong_any.binaries.clear();
|
|
wrong_any.any_binary = true;
|
|
let cases = [
|
|
(None, Code::InvalidArgument, "target"),
|
|
(
|
|
Some(missing_binary),
|
|
Code::FailedPrecondition,
|
|
"/usr/bin/python3",
|
|
),
|
|
(Some(missing_port), Code::FailedPrecondition, "8443"),
|
|
(Some(ambiguous), Code::FailedPrecondition, "/v1/**"),
|
|
(Some(missing_rule), Code::NotFound, "absent"),
|
|
(Some(wrong_any), Code::FailedPrecondition, "/usr/bin/curl"),
|
|
];
|
|
|
|
for (target, code, detail) in cases {
|
|
for deny in [false, true] {
|
|
// The first operation is valid and changes the private candidate.
|
|
// A failure in the second must roll back policy and metadata together.
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_id.to_string(),
|
|
merge_operations: vec![
|
|
l7_scope_operation(Some(l7_scope_target()), false),
|
|
l7_scope_operation(target.clone(), deny),
|
|
],
|
|
expected_resource_version: before
|
|
.metadata
|
|
.as_ref()
|
|
.unwrap()
|
|
.resource_version,
|
|
annotations: HashMap::from([(
|
|
"example.com/change".to_string(),
|
|
"rejected".to_string(),
|
|
)]),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("incomplete or ambiguous target must reject the batch");
|
|
assert_eq!(error.code(), code, "{detail}, deny={deny}: {error}");
|
|
assert!(error.message().contains(detail), "{error}");
|
|
let after = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
after, before,
|
|
"rejected batch must preserve all sandbox metadata and spec"
|
|
);
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(revision.version, before_revision.version);
|
|
assert_eq!(revision.policy_hash, before_revision.policy_hash);
|
|
assert_eq!(revision.policy_payload, before_revision.policy_payload);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.list_policies(sandbox_id, 10, 0)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn l7_target_scope_update_config_changes_only_selected_endpoint() {
|
|
for deny in [false, true] {
|
|
let state = test_server_state().await;
|
|
let policy = l7_scope_policy();
|
|
let sandbox_id = "l7-scope-selected";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_id,
|
|
policy.clone(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let initial_hash = deterministic_policy_hash(&policy);
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"l7-scope-seed",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&policy.encode_to_vec(),
|
|
&initial_hash,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let before = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let mut target = l7_scope_target();
|
|
target.host = "API.EXAMPLE.COM".to_string();
|
|
target.ports = vec![8443, 443, 8443];
|
|
target.binaries.reverse();
|
|
target.binaries.push(target.binaries[0].clone());
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_id.to_string(),
|
|
merge_operations: vec![l7_scope_operation(Some(target), deny)],
|
|
expected_resource_version: before.metadata.as_ref().unwrap().resource_version,
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("complete explicit scope must resolve the selected endpoint")
|
|
.into_inner();
|
|
assert_eq!(response.version, 2);
|
|
assert_ne!(response.policy_hash, initial_hash);
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let after = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
|
|
assert_eq!(
|
|
after.network_policies["sibling"],
|
|
policy.network_policies["sibling"]
|
|
);
|
|
let before_rule = &policy.network_policies["selected"];
|
|
let after_rule = &after.network_policies["selected"];
|
|
assert_eq!(after_rule.binaries, before_rule.binaries);
|
|
assert_eq!(after_rule.endpoints.len(), before_rule.endpoints.len());
|
|
assert_eq!(
|
|
after_rule
|
|
.endpoints
|
|
.iter()
|
|
.find(|endpoint| !endpoint.path.is_empty()),
|
|
before_rule
|
|
.endpoints
|
|
.iter()
|
|
.find(|endpoint| !endpoint.path.is_empty()),
|
|
);
|
|
let endpoint = after_rule
|
|
.endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.path.is_empty())
|
|
.unwrap();
|
|
if deny {
|
|
assert!(
|
|
endpoint
|
|
.deny_rules
|
|
.iter()
|
|
.any(|rule| rule.method == "POST" && rule.path == "/admin")
|
|
);
|
|
} else {
|
|
assert!(endpoint.rules.iter().any(|rule| {
|
|
rule.allow
|
|
.as_ref()
|
|
.is_some_and(|allow| allow.method == "POST" && allow.path == "/admin")
|
|
}));
|
|
}
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.list_policies(sandbox_id, 10, 0)
|
|
.await
|
|
.unwrap()
|
|
.len(),
|
|
2
|
|
);
|
|
assert_eq!(revision.policy_hash, response.policy_hash);
|
|
assert_eq!(revision.policy_hash, deterministic_policy_hash(&after));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn policy_merge_error_mapping_distinguishes_request_shape_from_state_conflicts() {
|
|
let invalid_operation =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidOperationPolicy {
|
|
operation_index: 0,
|
|
violations: Vec::new(),
|
|
});
|
|
assert_eq!(invalid_operation.code(), Code::InvalidArgument);
|
|
|
|
let empty =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::EmptyAddRuleEndpoints {
|
|
operation_index: 0,
|
|
rule_name: "empty".to_string(),
|
|
});
|
|
assert_eq!(empty.code(), Code::InvalidArgument);
|
|
|
|
let contract =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::McpContractConflict {
|
|
operation_index: 1,
|
|
host: "mcp.example.com".to_string(),
|
|
port: 443,
|
|
existing: "mcp(max_body_bytes=65536)".to_string(),
|
|
incoming: "mcp(max_body_bytes=131072)".to_string(),
|
|
});
|
|
assert_eq!(contract.code(), Code::FailedPrecondition);
|
|
|
|
let inheritance = map_policy_merge_error(
|
|
openshell_policy::PolicyMergeError::NewBinaryWouldInheritAuthorization {
|
|
operation_index: 2,
|
|
rule_name: "existing".to_string(),
|
|
binary_scope: "binary '/usr/bin/client'".to_string(),
|
|
host: "mcp.example.com".to_string(),
|
|
ports: vec![443],
|
|
},
|
|
);
|
|
assert_eq!(inheritance.code(), Code::FailedPrecondition);
|
|
// The proposer has to know which binary scope triggered the rejection.
|
|
assert!(inheritance.message().contains("/usr/bin/client"));
|
|
|
|
let existing_scope = map_policy_merge_error(
|
|
openshell_policy::PolicyMergeError::ExistingBinariesWouldInheritAuthorization {
|
|
operation_index: 3,
|
|
rule_name: "existing".to_string(),
|
|
host: "api.example.com".to_string(),
|
|
ports: vec![443],
|
|
undeclared_binaries: vec!["/usr/bin/other".to_string()],
|
|
},
|
|
);
|
|
assert_eq!(existing_scope.code(), Code::FailedPrecondition);
|
|
// The proposer has to know which binaries to add, so the remediation
|
|
// detail must survive into the status message.
|
|
assert!(existing_scope.message().contains("/usr/bin/other"));
|
|
|
|
let invalid_target =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidL7Target {
|
|
operation_index: 0,
|
|
reason: "ports are required".to_string(),
|
|
});
|
|
assert_eq!(invalid_target.code(), Code::InvalidArgument);
|
|
|
|
let missing =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::L7TargetNotFound {
|
|
rule_name: "missing".to_string(),
|
|
host: "api.example.com".to_string(),
|
|
ports: vec![443],
|
|
path: None,
|
|
});
|
|
assert_eq!(missing.code(), Code::NotFound);
|
|
|
|
// Ambiguous or incomplete declarations describe a well-formed request
|
|
// the current policy forbids, so they are preconditions.
|
|
let ambiguous =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::AmbiguousL7Target {
|
|
rule_name: "selected".to_string(),
|
|
host: "api.example.com".to_string(),
|
|
ports: vec![443],
|
|
paths: vec!["/v1/**".to_string(), "/v2/**".to_string()],
|
|
});
|
|
assert_eq!(ambiguous.code(), Code::FailedPrecondition);
|
|
assert!(ambiguous.message().contains("/v1/**"));
|
|
assert!(ambiguous.message().contains("/v2/**"));
|
|
|
|
let binary_scope =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::L7BinaryScopeMismatch {
|
|
rule_name: "selected".to_string(),
|
|
expected: vec!["/usr/bin/curl".to_string(), "/usr/bin/python3".to_string()],
|
|
declared: vec!["/usr/bin/curl".to_string()],
|
|
});
|
|
assert_eq!(binary_scope.code(), Code::FailedPrecondition);
|
|
assert!(binary_scope.message().contains("/usr/bin/python3"));
|
|
|
|
let port_scope =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::L7PortScopeMismatch {
|
|
rule_name: "selected".to_string(),
|
|
host: "api.example.com".to_string(),
|
|
expected: vec![443, 8443],
|
|
declared: vec![443],
|
|
});
|
|
assert_eq!(port_scope.code(), Code::FailedPrecondition);
|
|
assert!(port_scope.message().contains("8443"));
|
|
|
|
let undeclared_port = map_policy_merge_error(
|
|
openshell_policy::PolicyMergeError::UndeclaredPortWouldChange {
|
|
operation_index: 5,
|
|
rule_name: "existing".to_string(),
|
|
host: "api.example.com".to_string(),
|
|
ports: vec![8443],
|
|
},
|
|
);
|
|
assert_eq!(undeclared_port.code(), Code::FailedPrecondition);
|
|
// The proposer has to know which port to declare.
|
|
assert!(undeclared_port.message().contains("8443"));
|
|
|
|
let mcp_conflict = map_policy_merge_error(
|
|
openshell_policy::PolicyMergeError::ConflictingInspectionContracts {
|
|
host: "mcp.example.com".to_string(),
|
|
port: 443,
|
|
contracts: vec![
|
|
"mcp(strict_tool_names=true, allow_all_known_mcp_methods=false, max_body_bytes=65536)"
|
|
.to_string(),
|
|
"mcp(strict_tool_names=true, allow_all_known_mcp_methods=false, max_body_bytes=131072)"
|
|
.to_string(),
|
|
],
|
|
},
|
|
);
|
|
assert_eq!(mcp_conflict.code(), Code::FailedPrecondition);
|
|
assert!(mcp_conflict.message().contains("131072"));
|
|
|
|
let any_binary = map_policy_merge_error(
|
|
openshell_policy::PolicyMergeError::CannotRemoveBinaryFromAnyBinaryScope {
|
|
operation_index: 4,
|
|
rule_name: "wide".to_string(),
|
|
binary_path: "/usr/bin/untrusted".to_string(),
|
|
},
|
|
);
|
|
assert_eq!(any_binary.code(), Code::FailedPrecondition);
|
|
assert!(any_binary.message().contains("/usr/bin/untrusted"));
|
|
|
|
let invalid_input =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidInputPolicy {
|
|
violations: Vec::new(),
|
|
});
|
|
assert_eq!(invalid_input.code(), Code::FailedPrecondition);
|
|
|
|
let invalid_merged =
|
|
map_policy_merge_error(openshell_policy::PolicyMergeError::InvalidMergedPolicy {
|
|
violations: Vec::new(),
|
|
});
|
|
assert_eq!(invalid_merged.code(), Code::Internal);
|
|
}
|
|
|
|
// ---- Sandbox IDOR guard (issue #1354) ----
|
|
|
|
#[tokio::test]
|
|
async fn cross_sandbox_get_sandbox_config_denied() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
let state = test_server_state().await;
|
|
// Two sandboxes; the caller is principal of A, the request body
|
|
// references B.
|
|
for (id, name) in [("sb-a", "sandbox-a"), ("sb-b", "sandbox-b")] {
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
}
|
|
let req = with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "sb-b".to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
"sb-a",
|
|
);
|
|
let err = handle_get_sandbox_config(&state, req)
|
|
.await
|
|
.expect_err("cross-sandbox call must be denied");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_sandbox_get_sandbox_config_matches_foreign_sandbox_denial() {
|
|
let state = test_server_state().await;
|
|
let req = with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "missing-sandbox".to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
"sb-a",
|
|
);
|
|
|
|
let err = handle_get_sandbox_config(&state, req)
|
|
.await
|
|
.expect_err("missing sandbox must not be distinguishable from a foreign sandbox");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
assert_eq!(err.message(), "sandbox not found or not owned by caller");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_sandbox_get_sandbox_config_allowed() {
|
|
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-self".to_string(),
|
|
name: "self".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
let req = with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "self".to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
"sb-self",
|
|
);
|
|
handle_get_sandbox_config(&state, req)
|
|
.await
|
|
.expect("matching principal must be allowed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_get_sandbox_config_rejects_all_workspaces_selector() {
|
|
let state = test_server_state().await;
|
|
let req = with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "self".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::all_workspaces_selector()),
|
|
}),
|
|
"sb-self",
|
|
);
|
|
|
|
let err = handle_get_sandbox_config(&state, req)
|
|
.await
|
|
.expect_err("single-sandbox bootstrap must reject all_workspaces");
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert_eq!(
|
|
err.message(),
|
|
"all_workspaces is not supported by this request"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_sandbox_submit_policy_analysis_denied() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
let state = test_server_state().await;
|
|
for (id, name) in [("sb-a", "sandbox-a"), ("sb-b", "sandbox-b")] {
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
}
|
|
let req = with_sandbox(
|
|
Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: "sandbox-b".to_string(),
|
|
..Default::default()
|
|
}),
|
|
"sb-a",
|
|
);
|
|
let err = handle_submit_policy_analysis(&state, req)
|
|
.await
|
|
.expect_err("cross-sandbox submit must be denied");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_sandbox_get_draft_policy_denied() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
let state = test_server_state().await;
|
|
for (id, name) in [("sb-a", "sandbox-a"), ("sb-b", "sandbox-b")] {
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
}
|
|
let req = with_sandbox(
|
|
Request::new(GetDraftPolicyRequest {
|
|
sandbox: "sandbox-b".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
}),
|
|
"sb-a",
|
|
);
|
|
let err = handle_get_draft_policy(&state, req)
|
|
.await
|
|
.expect_err("cross-sandbox draft read must be denied");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_update_config_missing_name_matches_foreign_sandbox_denial() {
|
|
let state = test_server_state().await;
|
|
let req = with_sandbox(
|
|
Request::new(UpdateConfigRequest {
|
|
sandbox: "missing-sandbox".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(ProtoSandboxPolicy::default()),
|
|
..Default::default()
|
|
}),
|
|
"sb-a",
|
|
);
|
|
|
|
let err = handle_update_config(&state, req)
|
|
.await
|
|
.expect_err("missing name must not leak existence to sandbox callers");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_submit_policy_analysis_missing_name_returns_permission_denied() {
|
|
let state = test_server_state().await;
|
|
let req = with_sandbox(
|
|
Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: "missing-sandbox".to_string(),
|
|
..Default::default()
|
|
}),
|
|
"sb-a",
|
|
);
|
|
|
|
let err = handle_submit_policy_analysis(&state, req)
|
|
.await
|
|
.expect_err("missing name must not leak existence to sandbox callers");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_get_draft_policy_missing_name_matches_foreign_sandbox_denial() {
|
|
let state = test_server_state().await;
|
|
let req = with_sandbox(
|
|
Request::new(GetDraftPolicyRequest {
|
|
sandbox: "missing-sandbox".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
}),
|
|
"sb-a",
|
|
);
|
|
|
|
let err = handle_get_draft_policy(&state, req)
|
|
.await
|
|
.expect_err("missing name must not leak existence to sandbox callers");
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_principal_can_read_any_sandbox_config() {
|
|
// RBAC was the user gate; the IDOR guard must NOT trip for users.
|
|
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-x".to_string(),
|
|
name: "x".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
let req = with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "x".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}));
|
|
handle_get_sandbox_config(&state, req)
|
|
.await
|
|
.expect("user principal must succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn log_stream_scope_rejects_sandbox_id_change_after_validation() {
|
|
let state = test_server_state().await;
|
|
for id in ["sb-a", "sb-b"] {
|
|
let sandbox = test_sandbox(id, id, ProtoSandboxPolicy::default(), vec![]);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
}
|
|
let req = with_sandbox(Request::new(()), "sb-a");
|
|
let principal = req.extensions().get::<Principal>().unwrap().clone();
|
|
let mut validated = None;
|
|
|
|
ensure_log_stream_sandbox_scope(&state, &principal, "sb-a", &mut validated)
|
|
.await
|
|
.expect("first frame should validate");
|
|
let err = ensure_log_stream_sandbox_scope(&state, &principal, "sb-b", &mut validated)
|
|
.await
|
|
.expect_err("later frame must not switch sandbox ids");
|
|
|
|
assert_eq!(err.code(), Code::PermissionDenied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn log_stream_scope_rejects_missing_sandbox() {
|
|
let state = test_server_state().await;
|
|
let req = with_sandbox(Request::new(()), "sb-a");
|
|
let principal = req.extensions().get::<Principal>().unwrap().clone();
|
|
let mut validated = None;
|
|
|
|
let err = ensure_log_stream_sandbox_scope(&state, &principal, "sb-a", &mut validated)
|
|
.await
|
|
.expect_err("missing sandbox must not validate");
|
|
|
|
assert_eq!(err.code(), Code::NotFound);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_caller_policy_sync_does_not_emit_policy_update_telemetry() {
|
|
assert!(!should_emit_config_update_policy_telemetry(true));
|
|
assert!(should_emit_config_update_policy_telemetry(false));
|
|
}
|
|
|
|
#[test]
|
|
fn first_policy_revision_does_not_emit_policy_update_telemetry() {
|
|
assert!(!should_emit_full_policy_update_telemetry(false, 1));
|
|
assert!(!should_emit_full_policy_update_telemetry(true, 2));
|
|
assert!(should_emit_full_policy_update_telemetry(false, 2));
|
|
}
|
|
|
|
// ---- Sandbox without policy ----
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_without_policy_stores_successfully() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let store = test_store().await;
|
|
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-no-policy".to_string(),
|
|
name: "no-policy-sandbox".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
let loaded = store
|
|
.get_message::<Sandbox>("sb-no-policy")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(loaded.spec.unwrap().policy.is_none());
|
|
}
|
|
|
|
fn test_provider(name: &str, provider_type: &str) -> Provider {
|
|
Provider {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: format!("provider-{name}"),
|
|
name: name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
r#type: provider_type.to_string(),
|
|
credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string()))
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
credential_expiration_times: HashMap::new(),
|
|
profile_workspace: "default".to_string(),
|
|
credential_handles: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn test_aws_provider(name: &str, provider_type: &str) -> Provider {
|
|
let mut provider = test_provider(name, provider_type);
|
|
provider.credentials = [
|
|
("AWS_ACCESS_KEY_ID".to_string(), "AKIATEST".to_string()),
|
|
(
|
|
"AWS_SECRET_ACCESS_KEY".to_string(),
|
|
"test-secret".to_string(),
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
provider
|
|
}
|
|
|
|
fn test_policy_with_rule(rule_name: &str, host: &str) -> ProtoSandboxPolicy {
|
|
ProtoSandboxPolicy {
|
|
network_policies: std::iter::once((
|
|
rule_name.to_string(),
|
|
NetworkPolicyRule {
|
|
name: rule_name.to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: host.to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
},
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn test_policy_with_credential_binding(
|
|
rule_name: &str,
|
|
host: &str,
|
|
provider: &str,
|
|
) -> ProtoSandboxPolicy {
|
|
let mut policy = test_policy_with_rule(rule_name, host);
|
|
policy
|
|
.network_policies
|
|
.get_mut(rule_name)
|
|
.unwrap()
|
|
.endpoints[0]
|
|
.credential_binding = Some(openshell_core::proto::NetworkCredentialBinding {
|
|
provider: provider.to_string(),
|
|
});
|
|
policy
|
|
}
|
|
|
|
fn test_sigv4_policy(host: &str, provider: Option<&str>) -> ProtoSandboxPolicy {
|
|
let mut policy = test_policy_with_rule("aws", host);
|
|
let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0];
|
|
endpoint.protocol = "rest".to_string();
|
|
endpoint.access = openshell_core::proto::NetworkAccessPreset::Full as i32;
|
|
endpoint.credential_signing = "sigv4".to_string();
|
|
endpoint.signing_service = "s3".to_string();
|
|
endpoint.credential_binding =
|
|
provider.map(|provider| openshell_core::proto::NetworkCredentialBinding {
|
|
provider: provider.to_string(),
|
|
});
|
|
policy
|
|
}
|
|
|
|
fn test_ambiguous_policy() -> ProtoSandboxPolicy {
|
|
let mut left = test_policy_with_rule("left", "api.example.com");
|
|
left.network_policies.get_mut("left").unwrap().endpoints[0].tls =
|
|
openshell_core::proto::NetworkTlsMode::Skip as i32;
|
|
let right = test_policy_with_rule("right", "api.example.com");
|
|
left.network_policies.extend(right.network_policies);
|
|
left
|
|
}
|
|
|
|
pub(super) fn test_sandbox(
|
|
id: &str,
|
|
name: &str,
|
|
policy: ProtoSandboxPolicy,
|
|
providers: Vec<String>,
|
|
) -> Sandbox {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(policy),
|
|
providers,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
sandbox
|
|
}
|
|
|
|
async fn get_sandbox_policy(state: &Arc<ServerState>, sandbox_id: &str) -> ProtoSandboxPolicy {
|
|
let sandbox = state
|
|
.store
|
|
.get_message::<Sandbox>(sandbox_id)
|
|
.await
|
|
.expect("sandbox lookup")
|
|
.expect("sandbox exists");
|
|
handle_get_sandbox_config(
|
|
state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: sandbox.object_name().to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
(sandbox.object_workspace()).to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner()
|
|
.policy
|
|
.expect("sandbox config should include policy")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_paths_use_one_provider_profile_snapshot() {
|
|
use openshell_core::proto::{
|
|
ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory,
|
|
ProviderProfileCredential,
|
|
};
|
|
|
|
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,
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
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 {
|
|
name: "snapshot-consistency".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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 {
|
|
name: "snapshot-consistency".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.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 {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
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()
|
|
})),
|
|
)
|
|
.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, "default", &["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(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-generic".to_string(),
|
|
name: "generic".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(openshell_core::proto::ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: Vec::new(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "backdoor.example".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: Vec::new(),
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let layers =
|
|
profile_provider_policy_layers(&store, "default", &["custom-provider".to_string()])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(layers.len(), 1);
|
|
assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_policy_layers_prefer_exact_imported_alias_profile() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_message(&test_provider("enterprise-github", "gh"))
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-gh".to_string(),
|
|
name: "gh".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
profile: Some(openshell_core::proto::ProviderProfile {
|
|
id: "gh".to_string(),
|
|
display_name: "Enterprise GitHub".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "github.enterprise.example".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let layers =
|
|
profile_provider_policy_layers(&store, "default", &["enterprise-github".to_string()])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(layers.len(), 1);
|
|
assert_eq!(layers[0].rule.endpoints.len(), 1);
|
|
assert_eq!(
|
|
layers[0].rule.endpoints[0].host,
|
|
"github.enterprise.example"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_policy_layers_include_custom_provider_profiles() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_message(&test_provider("work-custom", "custom-api"))
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-custom-api".to_string(),
|
|
name: "custom-api".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(openshell_core::proto::ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: Vec::new(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.custom.example".to_string(),
|
|
protocol: "rest".to_string(),
|
|
ports: vec![443, 8443],
|
|
allowed_ips: vec!["10.0.0.0/24".to_string()],
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "GET".to_string(),
|
|
path: "/v1/**".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
allow_encoded_slash: true,
|
|
path: "/v1".to_string(),
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/custom".to_string(),
|
|
}],
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let layers =
|
|
profile_provider_policy_layers(&store, "default", &["work-custom".to_string()])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(layers.len(), 1);
|
|
assert_eq!(layers[0].rule_name, "_provider_work_custom");
|
|
assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example");
|
|
assert_eq!(layers[0].rule.endpoints[0].ports, vec![443, 8443]);
|
|
assert_eq!(layers[0].rule.endpoints[0].rules.len(), 1);
|
|
assert_eq!(layers[0].rule.endpoints[0].allowed_ips, vec!["10.0.0.0/24"]);
|
|
assert!(layers[0].rule.endpoints[0].allow_encoded_slash);
|
|
assert_eq!(layers[0].rule.endpoints[0].path, "/v1");
|
|
assert_eq!(layers[0].rule.binaries[0].path, "/usr/bin/custom");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_policy_layers_normalize_custom_provider_type_ids() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_message(&test_provider("work-custom", " Custom-API "))
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-custom-api".to_string(),
|
|
name: "custom-api".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(openshell_core::proto::ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: Vec::new(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.custom.example".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: Vec::new(),
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let layers =
|
|
profile_provider_policy_layers(&store, "default", &["work-custom".to_string()])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(layers.len(), 1);
|
|
assert_eq!(layers[0].rule.endpoints[0].host, "api.custom.example");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_policy_layers_include_known_provider_profiles() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let layers =
|
|
profile_provider_policy_layers(&store, "default", &["work-github".to_string()])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(layers.len(), 1);
|
|
assert_eq!(layers[0].rule_name, "_provider_work_github");
|
|
assert_eq!(layers[0].rule.endpoints.len(), 3);
|
|
assert!(
|
|
layers[0]
|
|
.rule
|
|
.endpoints
|
|
.iter()
|
|
.all(|endpoint| endpoint.provider_credentialed)
|
|
);
|
|
assert!(
|
|
layers[0]
|
|
.rule
|
|
.endpoints
|
|
.iter()
|
|
.any(|endpoint| endpoint.host == "api.github.com")
|
|
);
|
|
assert!(
|
|
layers[0].rule.endpoints.iter().any(|endpoint| {
|
|
endpoint.host == "api.github.com"
|
|
&& endpoint.protocol == "graphql"
|
|
&& endpoint.path == "/graphql"
|
|
&& endpoint.access
|
|
== openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
|
|
}),
|
|
"github provider policy should include read-only GraphQL endpoint"
|
|
);
|
|
assert!(
|
|
layers[0].rule.endpoints.iter().all(|endpoint| {
|
|
// API endpoints stay read-only; the github.com git transport
|
|
// carries explicit rules so clone/fetch works (see #1769).
|
|
if endpoint.host == "github.com" {
|
|
endpoint.access == 0
|
|
} else {
|
|
endpoint.access == openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
|
|
}
|
|
}),
|
|
"github API endpoints should be read-only; git transport uses explicit rules"
|
|
);
|
|
// Pin the exact composed rule set for the git transport. Clone/fetch
|
|
// needs GET */info/refs then POST */git-upload-pack; a broader or extra
|
|
// POST rule (e.g. POST **) would also permit push (git-receive-pack), so
|
|
// matching the whole set fails on any such regression (see #1769).
|
|
let git_transport = layers[0]
|
|
.rule
|
|
.endpoints
|
|
.iter()
|
|
.find(|endpoint| endpoint.host == "github.com")
|
|
.expect("composed policy should include the github.com git transport");
|
|
let mut allowed: Vec<(&str, &str)> = git_transport
|
|
.rules
|
|
.iter()
|
|
.map(|rule| {
|
|
let allow = rule
|
|
.allow
|
|
.as_ref()
|
|
.expect("git transport rules must be allow rules");
|
|
(allow.method.as_str(), allow.path.as_str())
|
|
})
|
|
.collect();
|
|
allowed.sort_unstable();
|
|
let mut expected = vec![
|
|
("GET", "**"),
|
|
("HEAD", "**"),
|
|
("OPTIONS", "**"),
|
|
("POST", "/**/git-upload-pack"),
|
|
];
|
|
expected.sort_unstable();
|
|
assert_eq!(
|
|
allowed, expected,
|
|
"composed git transport allow rules must be exactly the read-only \
|
|
methods plus POST */git-upload-pack; a broader POST rule would \
|
|
enable push (git-receive-pack)"
|
|
);
|
|
assert!(
|
|
git_transport.deny_rules.is_empty(),
|
|
"composed git transport should block push via its narrow allow set, not deny rules"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_policy_layers_skip_public_vendor_endpoints_for_alternate_upstreams() {
|
|
let store = test_store().await;
|
|
let mut openai = test_provider("alternate-openai", "openai");
|
|
openai.config.insert(
|
|
"OPENAI_BASE_URL".to_string(),
|
|
"https://api.example.com/v1".to_string(),
|
|
);
|
|
let mut anthropic = test_provider("alternate-anthropic", "anthropic");
|
|
anthropic.config.insert(
|
|
"ANTHROPIC_BASE_URL".to_string(),
|
|
"https://api.example.com/v1".to_string(),
|
|
);
|
|
store.put_message(&openai).await.unwrap();
|
|
store.put_message(&anthropic).await.unwrap();
|
|
|
|
let layers = profile_provider_policy_layers(
|
|
&store,
|
|
"default",
|
|
&[
|
|
"alternate-openai".to_string(),
|
|
"alternate-anthropic".to_string(),
|
|
],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(layers.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_policy_layers_respect_profile_workspace_scope() {
|
|
let store = test_store().await;
|
|
|
|
let make_stored_profile = |id: &str, workspace: &str, host: &str| StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: format!("profile-{id}-{workspace}"),
|
|
name: id.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: workspace.to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(openshell_core::proto::ProviderProfile {
|
|
id: id.to_string(),
|
|
display_name: format!("{host} profile"),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: host.to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
store
|
|
.put_message(&make_stored_profile(
|
|
"scope-api",
|
|
"",
|
|
"platform.example.com",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.put_message(&make_stored_profile(
|
|
"scope-api",
|
|
"default",
|
|
"workspace.example.com",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut global_provider = test_provider("global-prov", "scope-api");
|
|
global_provider.profile_workspace = String::new();
|
|
store.put_message(&global_provider).await.unwrap();
|
|
|
|
let normal_provider = test_provider("normal-prov", "scope-api");
|
|
store.put_message(&normal_provider).await.unwrap();
|
|
|
|
let global_layers =
|
|
profile_provider_policy_layers(&store, "default", &["global-prov".to_string()])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(global_layers.len(), 1);
|
|
assert_eq!(
|
|
global_layers[0].rule.endpoints[0].host, "platform.example.com",
|
|
"provider with profile_workspace='' should resolve the platform profile"
|
|
);
|
|
|
|
let normal_layers =
|
|
profile_provider_policy_layers(&store, "default", &["normal-prov".to_string()])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(normal_layers.len(), 1);
|
|
assert_eq!(
|
|
normal_layers[0].rule.endpoints[0].host, "workspace.example.com",
|
|
"provider with profile_workspace='default' should resolve the workspace profile"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_always_composes_provider_layers() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-v2-enabled",
|
|
"v2-enabled",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let effective_policy = get_sandbox_policy(&state, "sb-v2-enabled").await;
|
|
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("sandbox_only")
|
|
);
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.get("_provider_work_github")
|
|
.unwrap()
|
|
.endpoints
|
|
.iter()
|
|
.any(|endpoint| endpoint.host == "api.github.com")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_materializes_default_mcp_version_after_provider_composition() {
|
|
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-mcp-default".to_string(),
|
|
name: "mcp-default".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "mcp-default".to_string(),
|
|
display_name: "MCP default".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "mcp.example.com".to_string(),
|
|
port: 443,
|
|
protocol: "mcp".to_string(),
|
|
mcp: None,
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "tools/list".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.expect("store versionless MCP provider profile");
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-mcp-default", "mcp-default"))
|
|
.await
|
|
.expect("store MCP provider");
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-mcp-default-composed",
|
|
"mcp-default-composed",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-mcp-default".to_string()],
|
|
))
|
|
.await
|
|
.expect("store MCP sandbox");
|
|
|
|
let response = handle_get_sandbox_config(
|
|
&state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "mcp-default-composed".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.expect("provider-composed MCP policy must materialize")
|
|
.into_inner();
|
|
let effective_policy = response.policy.expect("effective composed policy");
|
|
let endpoint = effective_policy
|
|
.network_policies
|
|
.values()
|
|
.flat_map(|rule| &rule.endpoints)
|
|
.find(|endpoint| endpoint.host == "mcp.example.com")
|
|
.expect("composed MCP endpoint");
|
|
|
|
assert_eq!(
|
|
endpoint
|
|
.mcp
|
|
.as_ref()
|
|
.expect("canonical MCP options")
|
|
.versions,
|
|
["2025-11-25".to_string()]
|
|
);
|
|
assert_eq!(
|
|
response.policy_hash,
|
|
deterministic_policy_hash(&effective_policy)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn candidate_effective_policy_rejects_provider_endpoint_ambiguity() {
|
|
let base = test_policy_with_rule("base", "api.example.com");
|
|
let mut provider_rule = test_policy_with_rule("provider", "api.example.com")
|
|
.network_policies
|
|
.remove("provider")
|
|
.unwrap();
|
|
provider_rule.endpoints[0].tls = openshell_core::proto::NetworkTlsMode::Skip as i32;
|
|
let layers = [ProviderPolicyLayer {
|
|
rule_name: "_provider_test".to_string(),
|
|
rule: provider_rule,
|
|
}];
|
|
|
|
let error = validate_candidate_effective_policy(&base, &layers)
|
|
.expect_err("provider composition must reject endpoint ambiguity");
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("api.example.com"));
|
|
assert!(error.message().contains("tls"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_ambiguous_policy_before_persisting_revision() {
|
|
let state = test_server_state().await;
|
|
let mut sandbox = test_sandbox(
|
|
"sb-ambiguous-update",
|
|
"ambiguous-update",
|
|
ProtoSandboxPolicy::default(),
|
|
Vec::new(),
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "ambiguous-update".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_ambiguous_policy()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("ambiguous policy must fail before persistence");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("ambiguity validation failed"));
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-ambiguous-update")
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"invalid policy must not leave a revision in history"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_credential_binding_to_unattached_provider() {
|
|
let state = test_server_state().await;
|
|
let mut sandbox = test_sandbox(
|
|
"sb-unattached-binding",
|
|
"unattached-binding",
|
|
ProtoSandboxPolicy::default(),
|
|
Vec::new(),
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "unattached-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_policy_with_credential_binding(
|
|
"cloud",
|
|
"api.cloud.example",
|
|
"missing-provider",
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("unattached provider binding must fail before persistence");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("not attached"));
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-unattached-binding")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_policy_binding_for_endpointful_profile() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-double-binding",
|
|
"double-binding",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["work-github".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "double-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_policy_with_credential_binding(
|
|
"cloud",
|
|
"api.cloud.example",
|
|
"work-github",
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("profile and policy must not both define credential endpoints");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("already defines endpoints"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_gates_uninspected_endpointless_credential_binding() {
|
|
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-endpointless-gating".to_string(),
|
|
name: "endpointless-gating".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "endpointless-gating".to_string(),
|
|
display_name: "Endpointless Gating".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
endpoints: Vec::new(),
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-endpointless", "endpointless-gating"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-endpointless-gating",
|
|
"endpointless-gating",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["work-endpointless".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let l4 =
|
|
test_policy_with_credential_binding("bound", "api.bound.example", "work-endpointless");
|
|
let add_bound_rule = |policy: &ProtoSandboxPolicy| PolicyMergeOperation {
|
|
operation: Some(policy_merge_operation::Operation::AddRule(
|
|
openshell_core::proto::AddNetworkRule {
|
|
rule_name: "bound".to_string(),
|
|
rule: Some(policy.network_policies["bound"].clone()),
|
|
},
|
|
)),
|
|
};
|
|
let l4_error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "endpointless-gating".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(l4.clone()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("L4-only credential binding must be rejected");
|
|
assert_eq!(l4_error.code(), Code::FailedPrecondition);
|
|
assert!(l4_error.message().contains("L4-only"));
|
|
|
|
let mut tls_skip = l4.clone();
|
|
let tls_endpoint = &mut tls_skip
|
|
.network_policies
|
|
.get_mut("bound")
|
|
.unwrap()
|
|
.endpoints[0];
|
|
tls_endpoint.protocol = "rest".to_string();
|
|
tls_endpoint.access = openshell_core::proto::NetworkAccessPreset::Full as i32;
|
|
tls_endpoint.tls = openshell_core::proto::NetworkTlsMode::Skip as i32;
|
|
let tls_error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "endpointless-gating".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(tls_skip.clone()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("tls: skip credential binding must be rejected");
|
|
assert_eq!(tls_error.code(), Code::FailedPrecondition);
|
|
assert!(tls_error.message().contains("tls: skip"));
|
|
|
|
let merge_l4_error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "endpointless-gating".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
merge_operations: vec![add_bound_rule(&l4)],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("L4-only credential binding merge must be rejected");
|
|
assert_eq!(merge_l4_error.code(), Code::FailedPrecondition);
|
|
assert!(merge_l4_error.message().contains("L4-only"));
|
|
|
|
let merge_tls_error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "endpointless-gating".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
merge_operations: vec![add_bound_rule(&tls_skip)],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("tls: skip credential binding merge must be rejected");
|
|
assert_eq!(merge_tls_error.code(), Code::FailedPrecondition);
|
|
assert!(merge_tls_error.message().contains("tls: skip"));
|
|
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-endpointless-gating")
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"rejected policies must not leave a revision in history"
|
|
);
|
|
|
|
let mut opted_in = l4;
|
|
opted_in
|
|
.network_policies
|
|
.get_mut("bound")
|
|
.unwrap()
|
|
.endpoints[0]
|
|
.allow_uninspected_credentials = true;
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "endpointless-gating".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
merge_operations: vec![add_bound_rule(&opted_in)],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("explicit opt-in must admit the endpointless credential binding merge");
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-endpointless-gating")
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
|
|
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
|
|
state.store.close_for_test().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_sigv4_without_credential_source_before_persisting_revision() {
|
|
let state = test_server_state().await;
|
|
let mut sandbox = test_sandbox(
|
|
"sb-signing-no-source",
|
|
"signing-no-source",
|
|
ProtoSandboxPolicy::default(),
|
|
Vec::new(),
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "signing-no-source".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_sigv4_policy("s3.amazonaws.com", None)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("SigV4 policy without an AWS credential source must fail before persistence");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(
|
|
error
|
|
.message()
|
|
.contains("no resolvable AWS credential source")
|
|
);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-signing-no-source")
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"invalid policy must not leave a revision in history"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_sigv4_for_unbound_endpointless_aws_profile() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_aws_provider("aws-prod", "aws"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-signing-unbound-aws",
|
|
"signing-unbound-aws",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["aws-prod".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "signing-unbound-aws".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_sigv4_policy("s3.amazonaws.com", None)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("endpointless AWS profile must be bound explicitly");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(
|
|
error
|
|
.message()
|
|
.contains("no resolvable AWS credential source")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_accepts_sigv4_bound_to_endpointless_aws_profile() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_aws_provider("aws-prod", "aws"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-signing-bound-aws",
|
|
"signing-bound-aws",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["aws-prod".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "signing-bound-aws".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_sigv4_policy("s3.amazonaws.com", Some("aws-prod"))),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("bound endpointless AWS profile supplies SigV4 credentials");
|
|
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-signing-bound-aws")
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_accepts_sigv4_covered_by_endpointful_aws_profile() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_aws_provider("s3-prod", "aws-s3"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-signing-profile-endpoint",
|
|
"signing-profile-endpoint",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["s3-prod".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let mut policy = test_sigv4_policy("bucket.s3.amazonaws.com", None);
|
|
let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0];
|
|
endpoint.access = openshell_core::proto::NetworkAccessPreset::ReadWrite as i32;
|
|
endpoint.enforcement = openshell_core::proto::NetworkEnforcementMode::Enforce as i32;
|
|
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "signing-profile-endpoint".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(policy),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("endpoint-bearing AWS profile covers the signed endpoint");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_rejects_sigv4_outside_endpointful_aws_profile_boundary() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_aws_provider("s3-prod", "aws-s3"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-signing-profile-mismatch",
|
|
"signing-profile-mismatch",
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["s3-prod".to_string()],
|
|
);
|
|
sandbox.spec.as_mut().unwrap().policy = None;
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "signing-profile-mismatch".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_sigv4_policy("api.example.com", None)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("profile endpoint boundary must cover a signed endpoint");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(
|
|
error
|
|
.message()
|
|
.contains("no resolvable AWS credential source")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_operations_reject_ambiguity_before_persisting_revision() {
|
|
let state = test_server_state().await;
|
|
let mut policy = test_ambiguous_policy();
|
|
policy.network_policies.get_mut("left").unwrap().endpoints[0].path = "/v1/*".to_string();
|
|
policy.network_policies.get_mut("right").unwrap().endpoints[0].path =
|
|
"/v1/users".to_string();
|
|
let operations = policy
|
|
.network_policies
|
|
.into_iter()
|
|
.map(|(rule_name, rule)| PolicyMergeOp::AddRule { rule_name, rule })
|
|
.collect::<Vec<_>>();
|
|
|
|
let error = apply_merge_operations_with_retry(
|
|
state.store.as_ref(),
|
|
"sb-ambiguous-merge",
|
|
"default",
|
|
None,
|
|
&operations,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("ambiguous merge must fail before persistence");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy("sb-ambiguous-merge")
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_attachment_preflight_rejects_composed_ambiguity() {
|
|
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-ambiguous".to_string(),
|
|
name: "ambiguous".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "ambiguous".to_string(),
|
|
display_name: "Ambiguous".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_provider("candidate-provider", "ambiguous"))
|
|
.await
|
|
.unwrap();
|
|
let sandbox = test_sandbox(
|
|
"sb-provider-ambiguity",
|
|
"provider-ambiguity",
|
|
test_policy_with_rule("base", "api.example.com"),
|
|
Vec::new(),
|
|
);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let error = super::super::sandbox::handle_attach_sandbox_provider(
|
|
&state,
|
|
authed_request(openshell_core::proto::AttachSandboxProviderRequest {
|
|
request_id: String::new(),
|
|
sandbox: "provider-ambiguity".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
provider: "candidate-provider".to_string(),
|
|
expected_resource_version: 0,
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("provider attachment must validate the composed policy");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("tls"));
|
|
let stored = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "provider-ambiguity")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(stored.spec.unwrap().providers.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_rejects_invalid_provider_composed_policy() {
|
|
use openshell_core::proto::{
|
|
MiddlewareEndpointSelector, NetworkMiddlewareConfig, ProviderProfile,
|
|
ProviderProfileCategory,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
|
|
let profile = StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-tls-skip".to_string(),
|
|
name: "tls-skip".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
annotations: HashMap::new(),
|
|
resource_version: 0,
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "tls-skip".to_string(),
|
|
display_name: "TLS skip".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
state.store.put_message(&profile).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-tls-skip", "tls-skip"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let policy = ProtoSandboxPolicy {
|
|
network_middlewares: HashMap::from([(
|
|
"redactor".to_string(),
|
|
NetworkMiddlewareConfig {
|
|
middleware: "openshell/regex".to_string(),
|
|
on_error: "fail_closed".to_string(),
|
|
endpoints: Some(MiddlewareEndpointSelector {
|
|
include: vec!["api.example.com".to_string()],
|
|
exclude: Vec::new(),
|
|
}),
|
|
..Default::default()
|
|
},
|
|
)]),
|
|
..Default::default()
|
|
};
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-invalid-composed-policy",
|
|
"invalid-composed-policy",
|
|
policy,
|
|
vec!["work-tls-skip".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let error = handle_get_sandbox_config(
|
|
&state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "invalid-composed-policy".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("invalid composed policy must not be delivered");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("provider composition"));
|
|
assert!(error.message().contains("tls: skip"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_skips_profileless_provider_types() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("legacy-generic", "generic"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_provider("custom-provider", "custom"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-profileless",
|
|
"profileless",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["legacy-generic".to_string(), "custom-provider".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let effective_policy = get_sandbox_policy(&state, "sb-profileless").await;
|
|
|
|
assert_eq!(effective_policy.network_policies.len(), 1);
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("sandbox_only")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_composition_is_jit_and_does_not_persist_provider_layers() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-jit",
|
|
"jit",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let effective_policy = get_sandbox_policy(&state, "sb-jit").await;
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
|
|
let persisted = state
|
|
.store
|
|
.get_latest_policy("sb-jit")
|
|
.await
|
|
.unwrap()
|
|
.expect("sandbox policy should be lazily backfilled");
|
|
let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice())
|
|
.expect("persisted sandbox policy should decode");
|
|
assert!(
|
|
persisted_policy
|
|
.network_policies
|
|
.contains_key("sandbox_only")
|
|
);
|
|
assert!(
|
|
!persisted_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_uses_updated_custom_provider_profile_without_rewriting_provider() {
|
|
use crate::grpc::provider::handle_update_provider_profiles;
|
|
use openshell_core::proto::{
|
|
ProviderProfile, ProviderProfileCategory, ProviderProfileImportItem,
|
|
UpdateProviderProfilesRequest,
|
|
};
|
|
|
|
fn stored_profile(host: &str) -> StoredProviderProfile {
|
|
StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-custom-policy".to_string(),
|
|
name: "custom-policy".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: Vec::new(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: host.to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: Vec::new(),
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&stored_profile("api.before.example"))
|
|
.await
|
|
.unwrap();
|
|
let provider = test_provider("work-custom", "custom-policy");
|
|
state.store.put_message(&provider).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-custom-policy-update",
|
|
"custom-policy-update",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-custom".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let before_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await;
|
|
assert!(
|
|
before_policy.network_policies["_provider_work_custom"]
|
|
.endpoints
|
|
.iter()
|
|
.any(|endpoint| endpoint.host == "api.before.example")
|
|
);
|
|
|
|
let mut updated_profile = stored_profile("api.after.example").profile.unwrap();
|
|
updated_profile.resource_version = state
|
|
.store
|
|
.get_message_by_name::<StoredProviderProfile>("default", "custom-policy")
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.metadata
|
|
.as_ref()
|
|
.unwrap()
|
|
.resource_version;
|
|
let response = handle_update_provider_profiles(
|
|
&state,
|
|
with_user(Request::new(UpdateProviderProfilesRequest {
|
|
request_id: String::new(),
|
|
profile: Some(ProviderProfileImportItem {
|
|
profile: Some(updated_profile),
|
|
source: "custom-policy.yaml".to_string(),
|
|
}),
|
|
expected_resource_version: 0,
|
|
id: "custom-policy".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(response.updated);
|
|
|
|
let after_policy = get_sandbox_policy(&state, "sb-custom-policy-update").await;
|
|
let provider_rule = &after_policy.network_policies["_provider_work_custom"];
|
|
assert!(
|
|
provider_rule
|
|
.endpoints
|
|
.iter()
|
|
.any(|endpoint| endpoint.host == "api.after.example")
|
|
);
|
|
assert!(
|
|
!provider_rule
|
|
.endpoints
|
|
.iter()
|
|
.any(|endpoint| endpoint.host == "api.before.example")
|
|
);
|
|
|
|
let persisted_provider: Provider = state
|
|
.store
|
|
.get_message_by_name::<Provider>("default", "work-custom")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(persisted_provider.r#type, provider.r#type);
|
|
assert_eq!(persisted_provider.credentials, provider.credentials);
|
|
|
|
let persisted_policy = state
|
|
.store
|
|
.get_latest_policy("sb-custom-policy-update")
|
|
.await
|
|
.unwrap()
|
|
.expect("sandbox policy should be lazily backfilled");
|
|
let persisted_policy =
|
|
ProtoSandboxPolicy::decode(persisted_policy.policy_payload.as_slice())
|
|
.expect("persisted sandbox policy should decode");
|
|
assert!(
|
|
persisted_policy
|
|
.network_policies
|
|
.contains_key("sandbox_only")
|
|
);
|
|
assert!(
|
|
!persisted_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_custom")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_composes_user_and_provider_rules() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
let mut policy = test_policy_with_rule("custom_github", "api.github.com");
|
|
let endpoint = &mut policy
|
|
.network_policies
|
|
.get_mut("custom_github")
|
|
.expect("custom rule")
|
|
.endpoints[0];
|
|
endpoint.protocol = "rest".to_string();
|
|
endpoint.access = openshell_core::proto::NetworkAccessPreset::ReadOnly as i32;
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-overlap",
|
|
"overlap",
|
|
policy,
|
|
vec!["work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let effective_policy = get_sandbox_policy(&state, "sb-overlap").await;
|
|
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("custom_github")
|
|
);
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
assert_eq!(
|
|
effective_policy
|
|
.network_policies
|
|
.get("custom_github")
|
|
.unwrap()
|
|
.endpoints[0]
|
|
.host,
|
|
"api.github.com"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_environment_resolution_is_stable_across_policy_composition() {
|
|
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-provider-env",
|
|
"provider-env",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let legacy_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-provider-env".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner()
|
|
.environment;
|
|
|
|
let v2_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-provider-env".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner()
|
|
.environment;
|
|
|
|
assert_eq!(legacy_env, v2_env);
|
|
assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_files_do_not_block_legacy_provider_environment_requests() {
|
|
use openshell_core::proto::{
|
|
GetSandboxProviderEnvironmentRequest, ProviderProfile, ProviderProfileCategory,
|
|
ProviderProfileFile,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-config-only".to_string(),
|
|
name: "config-only".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "config-only".to_string(),
|
|
display_name: "Config only".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
files: vec![ProviderProfileFile {
|
|
path: "client.toml".to_string(),
|
|
content: "endpoint = '{{config.endpoint}}'".to_string(),
|
|
env_var: "CLIENT_CONFIG_FILE".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut file_provider = test_provider("work-config", "config-only");
|
|
file_provider.credentials.clear();
|
|
file_provider
|
|
.config
|
|
.insert("endpoint".to_string(), "https://config.example".to_string());
|
|
state.store.put_message(&file_provider).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-files-and-credentials",
|
|
"files-and-credentials",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-config".to_string(), "work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
// An older supervisor sends this request without a provider-file
|
|
// capability field and ignores the additive files response field.
|
|
let response = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-files-and-credentials".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(
|
|
response.environment.get("GITHUB_TOKEN"),
|
|
Some(&"ghp-test".to_string())
|
|
);
|
|
assert_eq!(
|
|
response.environment.get("CLIENT_CONFIG_FILE"),
|
|
Some(&"/run/openshell/providers/work-config/client.toml".to_string())
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.files
|
|
.get("/run/openshell/providers/work-config/client.toml"),
|
|
Some(&"endpoint = 'https://config.example'".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_readiness_snapshot_uses_baseline_static_binding_contract() {
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = test_sandbox(
|
|
"sb-static-ready",
|
|
"static-ready",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string()],
|
|
);
|
|
let attachment_epoch = uuid::Uuid::new_v4().to_string();
|
|
sandbox.spec.as_mut().unwrap().provider_attachment_epoch = attachment_epoch.clone();
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Static binding support is sufficient to install this snapshot. The
|
|
// environment and policy must describe the same attachment authority.
|
|
let response = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-static-ready".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let config = load_sandbox_config(&state, &sandbox).await.unwrap();
|
|
|
|
assert_eq!(
|
|
response.readiness_reason,
|
|
ProviderReadinessReason::Unspecified as i32
|
|
);
|
|
assert_eq!(response.provider_attachment_epoch, attachment_epoch);
|
|
assert_eq!(
|
|
response.provider_attachment_epoch,
|
|
config.provider_attachment_epoch
|
|
);
|
|
assert_eq!(response.provider_env_revision, config.provider_env_revision);
|
|
assert_eq!(response.policy_hash, config.policy_hash);
|
|
assert!(response.environment.contains_key("GITHUB_TOKEN"));
|
|
assert!(
|
|
response
|
|
.static_credential_bindings
|
|
.contains_key("GITHUB_TOKEN")
|
|
);
|
|
assert!(
|
|
!response
|
|
.non_secret_environment_keys
|
|
.iter()
|
|
.any(|key| key == "GITHUB_TOKEN")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() {
|
|
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-legacy-provider-env",
|
|
"legacy-provider-env",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-legacy-provider-env".to_string(),
|
|
supports_static_credential_bindings: false,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert!(!response.environment.contains_key("GITHUB_TOKEN"));
|
|
assert!(response.static_credential_bindings.is_empty());
|
|
assert_eq!(
|
|
response.readiness_reason,
|
|
ProviderReadinessReason::UnsupportedSupervisor as i32
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_environment_withholds_unbound_static_credentials_independently() {
|
|
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
let mut profileless_openai = test_provider("gateway-openai", "legacy-openai");
|
|
profileless_openai.credentials =
|
|
HashMap::from([("OPENAI_API_KEY".to_string(), "openai-secret".to_string())]);
|
|
state.store.put_message(&profileless_openai).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-unbound-provider-env",
|
|
"unbound-provider-env",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string(), "gateway-openai".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-unbound-provider-env".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert!(!response.environment.contains_key("OPENAI_API_KEY"));
|
|
assert_eq!(
|
|
response.readiness_reason,
|
|
ProviderReadinessReason::CredentialsWithheld as i32
|
|
);
|
|
assert!(
|
|
!response
|
|
.static_credential_bindings
|
|
.contains_key("OPENAI_API_KEY")
|
|
);
|
|
assert_eq!(
|
|
response.environment.get("GITHUB_TOKEN"),
|
|
Some(&"ghp-test".to_string())
|
|
);
|
|
assert!(
|
|
response
|
|
.static_credential_bindings
|
|
.get("GITHUB_TOKEN")
|
|
.is_some_and(|binding| !binding.endpoints.is_empty())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_environment_uses_policy_binding_for_endpointless_profile() {
|
|
use openshell_core::proto::{
|
|
GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest,
|
|
NetworkCredentialBinding, ProviderProfile, ProviderProfileCategory,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-endpointless".to_string(),
|
|
name: "endpointless".to_string(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "endpointless".to_string(),
|
|
display_name: "Endpointless".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
credentials: vec![openshell_core::proto::ProviderProfileCredential {
|
|
name: "cloud_token".to_string(),
|
|
env_vars: vec!["CLOUD_TOKEN".to_string()],
|
|
..Default::default()
|
|
}],
|
|
endpoints: Vec::new(),
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let mut provider = test_provider("work-cloud", "endpointless");
|
|
provider.credentials =
|
|
HashMap::from([("CLOUD_TOKEN".to_string(), "cloud-secret".to_string())]);
|
|
state.store.put_message(&provider).await.unwrap();
|
|
|
|
let mut policy = test_policy_with_rule("cloud_api", "api.cloud.example");
|
|
policy
|
|
.network_policies
|
|
.get_mut("cloud_api")
|
|
.unwrap()
|
|
.endpoints[0]
|
|
.credential_binding = Some(NetworkCredentialBinding {
|
|
provider: "work-cloud".to_string(),
|
|
});
|
|
let bound_endpoint = &mut policy
|
|
.network_policies
|
|
.get_mut("cloud_api")
|
|
.unwrap()
|
|
.endpoints[0];
|
|
bound_endpoint.protocol = "rest".to_string();
|
|
bound_endpoint.access = openshell_core::proto::NetworkAccessPreset::Full as i32;
|
|
openshell_policy::ensure_sandbox_process_identity(&mut policy);
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-policy-binding",
|
|
"policy-binding",
|
|
policy.clone(),
|
|
vec!["work-cloud".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let config = handle_get_sandbox_config(
|
|
&state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "policy-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let environment = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-policy-binding".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert!(
|
|
config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0]
|
|
.provider_credentialed,
|
|
"config delivery must derive provenance from the endpointless binding"
|
|
);
|
|
assert_eq!(
|
|
environment.environment.get("CLOUD_TOKEN"),
|
|
Some(&"cloud-secret".to_string())
|
|
);
|
|
assert_eq!(
|
|
environment.static_credential_bindings["CLOUD_TOKEN"].endpoints,
|
|
vec![StaticCredentialEndpointBinding {
|
|
host: "api.cloud.example".to_string(),
|
|
port: 443,
|
|
path: String::new(),
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
config.provider_env_revision, environment.provider_env_revision,
|
|
"config and provider environment must advertise one atomic revision"
|
|
);
|
|
|
|
let mut next_policy = policy;
|
|
next_policy
|
|
.network_policies
|
|
.get_mut("cloud_api")
|
|
.unwrap()
|
|
.endpoints[0]
|
|
.host = "api2.cloud.example".to_string();
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "policy-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(next_policy.clone()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("policy binding update must succeed");
|
|
let next_config = handle_get_sandbox_config(
|
|
&state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "policy-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let next_environment = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-policy-binding".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert!(
|
|
next_config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0]
|
|
.provider_credentialed
|
|
);
|
|
assert_ne!(
|
|
config.provider_env_revision, next_config.provider_env_revision,
|
|
"changing the policy binding must rotate the provider environment revision"
|
|
);
|
|
assert_eq!(
|
|
next_config.provider_env_revision,
|
|
next_environment.provider_env_revision
|
|
);
|
|
assert_eq!(
|
|
next_environment.static_credential_bindings["CLOUD_TOKEN"].endpoints[0].host,
|
|
"api2.cloud.example"
|
|
);
|
|
|
|
let mut unbound_policy = next_policy;
|
|
unbound_policy
|
|
.network_policies
|
|
.get_mut("cloud_api")
|
|
.unwrap()
|
|
.endpoints[0]
|
|
.credential_binding = None;
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "policy-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(unbound_policy),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("removing a policy binding must succeed");
|
|
let unbound_config = handle_get_sandbox_config(
|
|
&state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "policy-binding".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let unbound_environment = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-policy-binding".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert!(
|
|
!unbound_config.policy.as_ref().unwrap().network_policies["cloud_api"].endpoints[0]
|
|
.provider_credentialed,
|
|
"removing the binding must clear the derived provenance"
|
|
);
|
|
assert_ne!(
|
|
next_environment.provider_env_revision, unbound_environment.provider_env_revision,
|
|
"removing the binding must rotate the provider environment revision"
|
|
);
|
|
assert!(
|
|
!unbound_environment.environment.contains_key("CLOUD_TOKEN"),
|
|
"removing the only binding must withhold the static credential"
|
|
);
|
|
assert!(
|
|
!unbound_environment
|
|
.static_credential_bindings
|
|
.contains_key("CLOUD_TOKEN"),
|
|
"an endpointless profile must not emit incomplete binding metadata"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_static_binding_does_not_suppress_valid_dynamic_credentials() {
|
|
use openshell_core::proto::{
|
|
GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant, ProviderProfile,
|
|
ProviderProfileCategory, ProviderProfileCredential,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let mut invalid_static = test_provider("invalid-static", "profile-without-endpoints");
|
|
invalid_static.credentials =
|
|
HashMap::from([("INVALID_TOKEN".to_string(), "static-secret".to_string())]);
|
|
let dynamic = test_provider("dynamic", "custom-dynamic");
|
|
let dynamic_profile = StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-custom-dynamic".to_string(),
|
|
name: "custom-dynamic".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "custom-dynamic".to_string(),
|
|
display_name: "Custom Dynamic".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
credentials: vec![ProviderProfileCredential {
|
|
name: "access_token".to_string(),
|
|
auth_style: "bearer".to_string(),
|
|
header_name: "authorization".to_string(),
|
|
token_grant: Some(ProviderCredentialTokenGrant {
|
|
token_endpoint: "https://auth.example.test/token".to_string(),
|
|
audience: "api://default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.dynamic.example.test".to_string(),
|
|
port: 443,
|
|
path: "/**".to_string(),
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
|
|
state.store.put_message(&invalid_static).await.unwrap();
|
|
state.store.put_message(&dynamic).await.unwrap();
|
|
state.store.put_message(&dynamic_profile).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-mixed-provider-env",
|
|
"mixed-provider-env",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["invalid-static".to_string(), "dynamic".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-mixed-provider-env".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.expect("mixed snapshot must be returned")
|
|
.into_inner();
|
|
|
|
assert!(
|
|
!response.environment.contains_key("INVALID_TOKEN"),
|
|
"an unbound static credential must be withheld before the supervisor snapshot"
|
|
);
|
|
assert!(
|
|
!response
|
|
.static_credential_bindings
|
|
.contains_key("INVALID_TOKEN"),
|
|
"an unbound static credential must not emit incomplete binding metadata"
|
|
);
|
|
assert!(
|
|
!response.dynamic_credentials.is_empty(),
|
|
"valid dynamic credentials must survive an unrelated static binding failure"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_environment_withholds_token_exchange_subject_credential() {
|
|
use openshell_core::proto::{
|
|
GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant,
|
|
ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType,
|
|
ProviderProfile, ProviderProfileCategory, ProviderProfileCredential,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let profile = StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-token-exchange-subject".to_string(),
|
|
name: "token-exchange-subject".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "token-exchange-subject".to_string(),
|
|
display_name: "Token Exchange Subject".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
credentials: vec![
|
|
ProviderProfileCredential {
|
|
name: "subject_token".to_string(),
|
|
..Default::default()
|
|
},
|
|
ProviderProfileCredential {
|
|
name: "access_token".to_string(),
|
|
auth_style: "bearer".to_string(),
|
|
header_name: "authorization".to_string(),
|
|
token_grant: Some(ProviderCredentialTokenGrant {
|
|
grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32,
|
|
token_endpoint: "https://auth.example.test/token".to_string(),
|
|
audience: "api://exchange".to_string(),
|
|
subject_token: Some(ProviderCredentialTokenGrantSubjectToken {
|
|
source: "provider_credential".to_string(),
|
|
credential: "subject_token".to_string(),
|
|
subject_token_type: "urn:ietf:params:oauth:token-type:access_token"
|
|
.to_string(),
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
],
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.exchange.example.test".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
};
|
|
state.store.put_message(&profile).await.unwrap();
|
|
|
|
let mut provider = test_provider("exchange-provider", "token-exchange-subject");
|
|
provider.credentials = HashMap::from([(
|
|
"subject_token".to_string(),
|
|
"raw-gateway-oidc-token".to_string(),
|
|
)]);
|
|
provider.credential_expiration_times = HashMap::from([(
|
|
"subject_token".to_string(),
|
|
openshell_core::time::timestamp_from_millis(current_time_ms() + 60_000).unwrap(),
|
|
)]);
|
|
state.store.put_message(&provider).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-token-exchange-subject",
|
|
"token-exchange-subject",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["exchange-provider".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-token-exchange-subject".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert!(
|
|
!response.environment.contains_key("subject_token"),
|
|
"token-exchange subject credentials must not enter sandbox environment material"
|
|
);
|
|
assert!(
|
|
!response
|
|
.static_credential_bindings
|
|
.contains_key("subject_token"),
|
|
"token-exchange subject credentials must not own workload placeholders"
|
|
);
|
|
assert!(
|
|
!response
|
|
.credential_expiration_times
|
|
.contains_key("subject_token"),
|
|
"withheld subject credentials must not emit sandbox expiry metadata"
|
|
);
|
|
let dynamic_access_token = response
|
|
.dynamic_credentials
|
|
.values()
|
|
.find(|credential| credential.name == "access_token")
|
|
.expect("dynamic access_token credential should remain available");
|
|
assert_eq!(
|
|
dynamic_access_token
|
|
.token_grant
|
|
.as_ref()
|
|
.and_then(|grant| grant.subject_token.as_ref())
|
|
.map(|subject| subject.credential.as_str()),
|
|
Some("subject_token")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_env_revision_changes_on_consecutive_provider_updates_without_delay() {
|
|
use openshell_core::proto::GetSandboxProviderEnvironmentRequest;
|
|
|
|
let state = test_server_state().await;
|
|
let mut provider = test_provider("work-github", "github");
|
|
state.store.put_message(&provider).await.unwrap();
|
|
let first_resource_version = state
|
|
.store
|
|
.get_by_name(Provider::object_type(), "default", "work-github")
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.resource_version;
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-provider-revision",
|
|
"provider-revision",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
vec!["work-github".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let first = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-provider-revision".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
provider
|
|
.credentials
|
|
.insert("GITHUB_TOKEN".to_string(), "rotated".to_string());
|
|
state
|
|
.store
|
|
.put_if(
|
|
Provider::object_type(),
|
|
provider.object_id(),
|
|
provider.object_name(),
|
|
provider.object_workspace(),
|
|
&provider.encode_to_vec(),
|
|
None,
|
|
crate::persistence::WriteCondition::Unconditional,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let second_resource_version = state
|
|
.store
|
|
.get_by_name(Provider::object_type(), "default", "work-github")
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.resource_version;
|
|
assert_ne!(
|
|
first_resource_version, second_resource_version,
|
|
"consecutive writes must advance the authoritative resource version"
|
|
);
|
|
|
|
let second = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-provider-revision".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_ne!(
|
|
first.provider_env_revision, second.provider_env_revision,
|
|
"provider object updates must trigger sandbox credential refresh"
|
|
);
|
|
assert_eq!(
|
|
second.environment.get("GITHUB_TOKEN"),
|
|
Some(&"rotated".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() {
|
|
use openshell_core::proto::{
|
|
ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory,
|
|
ProviderProfileCredential,
|
|
};
|
|
|
|
fn dynamic_profile(
|
|
id: &str,
|
|
endpoint_host: &str,
|
|
token_endpoint: &str,
|
|
) -> StoredProviderProfile {
|
|
StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: format!("profile-{id}"),
|
|
name: id.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(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!["GITHUB_TOKEN".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".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: endpoint_host.to_string(),
|
|
port: 443,
|
|
path: "/**".to_string(),
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}
|
|
}
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&dynamic_profile(
|
|
"snapshot-a",
|
|
"api.snapshot-a.example",
|
|
"https://auth.snapshot-a.example/token",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&dynamic_profile(
|
|
"snapshot-b",
|
|
"api.snapshot-b.example",
|
|
"https://auth.snapshot-b.example/token",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let catalog = state
|
|
.provider_profile_sources
|
|
.snapshot_catalog(state.store.as_ref(), "default")
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut first_provider = test_provider("replaceable", "snapshot-a");
|
|
first_provider.metadata.as_mut().unwrap().id = "provider-identity-a".to_string();
|
|
first_provider.credentials =
|
|
HashMap::from([("GITHUB_TOKEN".to_string(), "secret-a".to_string())]);
|
|
state.store.put_message(&first_provider).await.unwrap();
|
|
|
|
let provider_names = vec!["replaceable".to_string()];
|
|
let first_records = crate::grpc::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
"default",
|
|
&provider_names,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let first_revision =
|
|
compute_provider_env_revision_from_records(&catalog, &first_records).unwrap();
|
|
let mut next_version_records = first_records.clone();
|
|
next_version_records[0].resource_version += 1;
|
|
assert_ne!(
|
|
first_revision,
|
|
compute_provider_env_revision_from_records(&catalog, &next_version_records).unwrap(),
|
|
"resource version alone must advance the provider environment revision"
|
|
);
|
|
|
|
state
|
|
.store
|
|
.delete_by_name(Provider::object_type(), "default", "replaceable")
|
|
.await
|
|
.unwrap();
|
|
let mut replacement = test_provider("replaceable", "snapshot-b");
|
|
replacement.metadata.as_mut().unwrap().id = "provider-identity-b".to_string();
|
|
replacement.credentials =
|
|
HashMap::from([("GITHUB_TOKEN".to_string(), "secret-b".to_string())]);
|
|
state.store.put_message(&replacement).await.unwrap();
|
|
|
|
let first_environment = crate::grpc::provider::resolve_provider_environment_from_records(
|
|
state.store.as_ref(),
|
|
&catalog,
|
|
&first_records,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
first_environment.environment.get("GITHUB_TOKEN"),
|
|
Some(&"secret-a".to_string())
|
|
);
|
|
assert_eq!(
|
|
first_environment
|
|
.static_credential_bindings
|
|
.get("GITHUB_TOKEN")
|
|
.map(|binding| binding.credential_identity.as_str()),
|
|
Some("provider-identity-a:GITHUB_TOKEN")
|
|
);
|
|
assert_eq!(first_environment.dynamic_credentials.len(), 1);
|
|
assert!(
|
|
first_environment
|
|
.dynamic_credentials
|
|
.values()
|
|
.all(|credential| {
|
|
credential.token_grant.as_ref().is_some_and(|grant| {
|
|
grant.token_endpoint == "https://auth.snapshot-a.example/token"
|
|
})
|
|
}),
|
|
"dynamic grants must come from the first loaded provider snapshot"
|
|
);
|
|
|
|
let replacement_records = crate::grpc::provider::load_provider_environment_records(
|
|
state.store.as_ref(),
|
|
"default",
|
|
&provider_names,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let replacement_revision =
|
|
compute_provider_env_revision_from_records(&catalog, &replacement_records).unwrap();
|
|
let replacement_environment =
|
|
crate::grpc::provider::resolve_provider_environment_from_records(
|
|
state.store.as_ref(),
|
|
&catalog,
|
|
&replacement_records,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_ne!(first_revision, replacement_revision);
|
|
assert_eq!(
|
|
replacement_environment.environment.get("GITHUB_TOKEN"),
|
|
Some(&"secret-b".to_string())
|
|
);
|
|
assert_eq!(
|
|
replacement_environment
|
|
.static_credential_bindings
|
|
.get("GITHUB_TOKEN")
|
|
.map(|binding| binding.credential_identity.as_str()),
|
|
Some("provider-identity-b:GITHUB_TOKEN")
|
|
);
|
|
assert_eq!(replacement_environment.dynamic_credentials.len(), 1);
|
|
assert!(
|
|
replacement_environment
|
|
.dynamic_credentials
|
|
.values()
|
|
.all(|credential| {
|
|
credential.token_grant.as_ref().is_some_and(|grant| {
|
|
grant.token_endpoint == "https://auth.snapshot-b.example/token"
|
|
})
|
|
}),
|
|
"dynamic grants must change only after loading the replacement record"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_env_revision_changes_when_custom_profile_token_grant_changes() {
|
|
use crate::grpc::provider::handle_update_provider_profiles;
|
|
use openshell_core::proto::{
|
|
ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory,
|
|
ProviderProfileCredential, ProviderProfileImportItem, UpdateProviderProfilesRequest,
|
|
};
|
|
use std::time::Duration;
|
|
|
|
fn token_grant_profile(token_endpoint: &str) -> StoredProviderProfile {
|
|
StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-custom-token".to_string(),
|
|
name: "custom-token".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: vec![ProviderProfileCredential {
|
|
name: "access_token".to_string(),
|
|
auth_style: "bearer".to_string(),
|
|
header_name: "authorization".to_string(),
|
|
token_grant: Some(ProviderCredentialTokenGrant {
|
|
token_endpoint: token_endpoint.to_string(),
|
|
audience: "api://default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.custom.example".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
..Default::default()
|
|
}],
|
|
binaries: Vec::new(),
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-custom-token", "custom-token"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&token_grant_profile("https://auth.example.com/token"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let first = compute_provider_env_revision(
|
|
state.store.as_ref(),
|
|
"default",
|
|
&["work-custom-token".to_string()],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
let mut rotated_profile = token_grant_profile("https://auth.example.com/rotated-token")
|
|
.profile
|
|
.unwrap();
|
|
rotated_profile.resource_version = state
|
|
.store
|
|
.get_message_by_name::<StoredProviderProfile>("default", "custom-token")
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.metadata
|
|
.as_ref()
|
|
.unwrap()
|
|
.resource_version;
|
|
handle_update_provider_profiles(
|
|
&state,
|
|
with_user(Request::new(UpdateProviderProfilesRequest {
|
|
request_id: String::new(),
|
|
profile: Some(ProviderProfileImportItem {
|
|
profile: Some(rotated_profile),
|
|
source: "custom-token.yaml".to_string(),
|
|
}),
|
|
expected_resource_version: 0,
|
|
id: "custom-token".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let second = compute_provider_env_revision(
|
|
state.store.as_ref(),
|
|
"default",
|
|
&["work-custom-token".to_string()],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_ne!(
|
|
first, second,
|
|
"custom provider profile updates must trigger sandbox dynamic credential refresh"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn platform_profile_narrowing_changes_platform_provider_revision_when_shadowed() {
|
|
use crate::persistence::WriteCondition;
|
|
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
|
|
|
|
fn stored_profile(workspace: &str, path: &str) -> StoredProviderProfile {
|
|
StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: format!(
|
|
"profile-scoped-revision-{}",
|
|
if workspace.is_empty() {
|
|
"platform"
|
|
} else {
|
|
workspace
|
|
}
|
|
),
|
|
name: "scoped-revision".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: workspace.to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: "scoped-revision".to_string(),
|
|
display_name: format!("{workspace} scoped revision"),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.test".to_string(),
|
|
port: 443,
|
|
path: path.to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}
|
|
}
|
|
|
|
let store = test_store().await;
|
|
store.put_message(&stored_profile("", "/**")).await.unwrap();
|
|
store
|
|
.put_message(&stored_profile("default", "/workspace/**"))
|
|
.await
|
|
.unwrap();
|
|
let mut provider = test_provider("platform-scoped", "scoped-revision");
|
|
provider.profile_workspace = String::new();
|
|
store.put_message(&provider).await.unwrap();
|
|
|
|
let first =
|
|
compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()])
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut platform = store
|
|
.get_message_by_name::<StoredProviderProfile>("", "scoped-revision")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let metadata = platform.metadata.as_ref().unwrap();
|
|
let object_id = metadata.id.clone();
|
|
let resource_version = metadata.resource_version;
|
|
platform.profile.as_mut().unwrap().endpoints[0].path = "/v1/**".to_string();
|
|
store
|
|
.put_if(
|
|
StoredProviderProfile::object_type(),
|
|
&object_id,
|
|
"scoped-revision",
|
|
"",
|
|
&platform.encode_to_vec(),
|
|
None,
|
|
WriteCondition::MatchResourceVersion(resource_version),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let second =
|
|
compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()])
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(
|
|
first, second,
|
|
"narrowing the selected platform fallback must refresh sandbox credentials"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_config_and_provider_env_follow_attached_provider_lifecycle() {
|
|
use crate::grpc::sandbox::{
|
|
handle_attach_sandbox_provider, handle_detach_sandbox_provider,
|
|
};
|
|
use openshell_core::proto::{
|
|
AttachSandboxProviderRequest, DetachSandboxProviderRequest,
|
|
GetSandboxProviderEnvironmentRequest,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-attach-lifecycle",
|
|
"attach-lifecycle",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let baseline_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
|
|
assert!(
|
|
!baseline_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
let baseline_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-attach-lifecycle".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
handle_attach_sandbox_provider(
|
|
&state,
|
|
with_user(Request::new(AttachSandboxProviderRequest {
|
|
request_id: String::new(),
|
|
sandbox: "attach-lifecycle".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
provider: "work-github".to_string(),
|
|
expected_resource_version: 0,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let attached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
|
|
assert!(
|
|
attached_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
|
|
let attached_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-attach-lifecycle".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_ne!(
|
|
baseline_env.provider_env_revision,
|
|
attached_env.provider_env_revision
|
|
);
|
|
assert_eq!(
|
|
attached_env.environment.get("GITHUB_TOKEN"),
|
|
Some(&"ghp-test".to_string())
|
|
);
|
|
|
|
handle_detach_sandbox_provider(
|
|
&state,
|
|
authed_request(DetachSandboxProviderRequest {
|
|
request_id: String::new(),
|
|
sandbox: "attach-lifecycle".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
provider: "work-github".to_string(),
|
|
expected_resource_version: 0,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let detached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
|
|
assert!(
|
|
!detached_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
|
|
let detached_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-attach-lifecycle".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_ne!(
|
|
attached_env.provider_env_revision,
|
|
detached_env.provider_env_revision
|
|
);
|
|
assert!(!detached_env.environment.contains_key("GITHUB_TOKEN"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn custom_imported_profile_policy_and_env_follow_attach_detach_lifecycle() {
|
|
use crate::grpc::provider::handle_import_provider_profiles;
|
|
use crate::grpc::sandbox::{
|
|
handle_attach_sandbox_provider, handle_detach_sandbox_provider,
|
|
};
|
|
use openshell_core::proto::{
|
|
AttachSandboxProviderRequest, DetachSandboxProviderRequest,
|
|
GetSandboxProviderEnvironmentRequest, ImportProviderProfilesRequest, NetworkBinary,
|
|
ProviderProfile, ProviderProfileCategory, ProviderProfileCredential,
|
|
ProviderProfileImportItem,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
handle_import_provider_profiles(
|
|
&state,
|
|
authed_request(ImportProviderProfilesRequest {
|
|
request_id: String::new(),
|
|
profiles: vec![ProviderProfileImportItem {
|
|
source: "custom-api.yaml".to_string(),
|
|
profile: Some(ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: vec![ProviderProfileCredential {
|
|
name: "api_key".to_string(),
|
|
env_vars: vec!["CUSTOM_API_KEY".to_string()],
|
|
auth_style: "bearer".to_string(),
|
|
header_name: "authorization".to_string(),
|
|
required: true,
|
|
..Default::default()
|
|
}],
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.custom.example".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "GET".to_string(),
|
|
path: "/v1/**".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/custom".to_string(),
|
|
}],
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
}],
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut provider = test_provider("work-custom", "custom-api");
|
|
provider.credentials =
|
|
std::iter::once(("CUSTOM_API_KEY".to_string(), "custom-secret".to_string())).collect();
|
|
state.store.put_message(&provider).await.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-attach-lifecycle",
|
|
"attach-lifecycle",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let baseline_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
|
|
assert!(
|
|
!baseline_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_custom")
|
|
);
|
|
let baseline_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-attach-lifecycle".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
handle_attach_sandbox_provider(
|
|
&state,
|
|
with_user(Request::new(AttachSandboxProviderRequest {
|
|
request_id: String::new(),
|
|
sandbox: "attach-lifecycle".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
provider: "work-custom".to_string(),
|
|
expected_resource_version: 0,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let attached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
|
|
let custom_rule = attached_policy
|
|
.network_policies
|
|
.get("_provider_work_custom")
|
|
.expect("custom provider rule should be composed after attach");
|
|
assert_eq!(custom_rule.endpoints[0].host, "api.custom.example");
|
|
assert_eq!(custom_rule.endpoints[0].protocol, "rest");
|
|
assert_eq!(custom_rule.endpoints[0].rules.len(), 1);
|
|
assert_eq!(custom_rule.binaries[0].path, "/usr/bin/custom");
|
|
|
|
let attached_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-attach-lifecycle".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_ne!(
|
|
baseline_env.provider_env_revision,
|
|
attached_env.provider_env_revision
|
|
);
|
|
assert_eq!(
|
|
attached_env.environment.get("CUSTOM_API_KEY"),
|
|
Some(&"custom-secret".to_string())
|
|
);
|
|
|
|
handle_detach_sandbox_provider(
|
|
&state,
|
|
authed_request(DetachSandboxProviderRequest {
|
|
request_id: String::new(),
|
|
sandbox: "attach-lifecycle".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
provider: "work-custom".to_string(),
|
|
expected_resource_version: 0,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let detached_policy = get_sandbox_policy(&state, "sb-attach-lifecycle").await;
|
|
assert!(
|
|
!detached_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_custom")
|
|
);
|
|
let detached_env = handle_get_sandbox_provider_environment(
|
|
&state,
|
|
with_user(Request::new(GetSandboxProviderEnvironmentRequest {
|
|
sandbox_id: "sb-attach-lifecycle".to_string(),
|
|
supports_static_credential_bindings: true,
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_ne!(
|
|
attached_env.provider_env_revision,
|
|
detached_env.provider_env_revision
|
|
);
|
|
assert!(!detached_env.environment.contains_key("CUSTOM_API_KEY"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn global_policy_suppresses_provider_profile_layers() {
|
|
use openshell_core::proto::{
|
|
GetSandboxConfigRequest, NetworkEndpoint, NetworkPolicyRule, SandboxPhase,
|
|
SandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-github", "github"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let sandbox_policy = SandboxPolicy {
|
|
network_policies: std::iter::once((
|
|
"sandbox_only".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "sandbox_only".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "sandbox.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
},
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-global-profile".to_string(),
|
|
name: "global-profile-sandbox".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(sandbox_policy),
|
|
providers: vec!["work-github".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let global_policy = SandboxPolicy {
|
|
network_policies: std::iter::once((
|
|
"global_only".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "global_only".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "global.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
},
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
let global_settings = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
POLICY_SETTING_KEY.to_string(),
|
|
StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
save_global_settings(state.store.as_ref(), &global_settings)
|
|
.await
|
|
.unwrap();
|
|
|
|
let response = handle_get_sandbox_config(
|
|
&state,
|
|
with_user(Request::new(GetSandboxConfigRequest {
|
|
name: "global-profile-sandbox".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
let effective_policy = response.policy.expect("global policy should be returned");
|
|
assert_eq!(response.policy_source, PolicySource::Global as i32);
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("global_only")
|
|
);
|
|
assert!(
|
|
!effective_policy
|
|
.network_policies
|
|
.contains_key("sandbox_only")
|
|
);
|
|
assert!(
|
|
!effective_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_policy_backfill_on_update_when_no_baseline() {
|
|
use openshell_core::proto::{FilesystemPolicy, LandlockPolicy, SandboxPhase, SandboxSpec};
|
|
|
|
let store = test_store().await;
|
|
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-backfill".to_string(),
|
|
name: "backfill-sandbox".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
let new_policy = ProtoSandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
read_only: vec!["/usr".into()],
|
|
read_write: vec!["/tmp".into()],
|
|
}),
|
|
landlock: Some(LandlockPolicy {
|
|
compatibility: "best_effort".into(),
|
|
}),
|
|
process: Some(openshell_core::proto::ProcessPolicy {
|
|
run_as_user: "sandbox".into(),
|
|
run_as_group: "sandbox".into(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut sandbox = store
|
|
.get_message::<Sandbox>("sb-backfill")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
if let Some(ref mut spec) = sandbox.spec {
|
|
spec.policy = Some(new_policy.clone());
|
|
}
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
let loaded = store
|
|
.get_message::<Sandbox>("sb-backfill")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let policy = loaded.spec.unwrap().policy.unwrap();
|
|
assert_eq!(policy.version, 1);
|
|
assert!(policy.filesystem.is_some());
|
|
assert_eq!(policy.process.unwrap().run_as_user, "sandbox");
|
|
}
|
|
|
|
/// Test helper: pin the proposal approval mode for a sandbox via the
|
|
/// settings model, mirroring what `openshell settings set <name>
|
|
/// proposal_approval_mode <mode>` would do at runtime.
|
|
async fn seed_sandbox_approval_mode(state: &Arc<ServerState>, sandbox_name: &str, mode: &str) {
|
|
let mut settings = load_sandbox_settings(state.store.as_ref(), "default", sandbox_name)
|
|
.await
|
|
.unwrap();
|
|
settings.settings.insert(
|
|
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
|
|
StoredSettingValue::String(mode.to_string()),
|
|
);
|
|
settings.revision = settings.revision.wrapping_add(1);
|
|
save_sandbox_settings(state.store.as_ref(), "default", sandbox_name, &settings)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// Test helper: pin the gateway-wide proposal approval mode, mirroring
|
|
/// `openshell settings set --global proposal_approval_mode <mode>`.
|
|
async fn seed_global_approval_mode(state: &Arc<ServerState>, mode: &str) {
|
|
let mut settings = load_global_settings(state.store.as_ref()).await.unwrap();
|
|
settings.settings.insert(
|
|
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
|
|
StoredSettingValue::String(mode.to_string()),
|
|
);
|
|
settings.revision = settings.revision.wrapping_add(1);
|
|
save_global_settings(state.store.as_ref(), &settings)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approve_all_applies_multiple_independent_chunks_and_reuses_cached_validation() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-approve-all-multiple";
|
|
let sandbox_name = "approve-all-multiple";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: [
|
|
("alpha", "alpha.example.com", "/usr/bin/curl"),
|
|
("beta", "beta.example.com", "/usr/bin/wget"),
|
|
]
|
|
.into_iter()
|
|
.map(|(name, host, binary)| PolicyChunk {
|
|
rule_name: name.to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: name.to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: host.to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: binary.to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
})
|
|
.collect(),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(submit.accepted_chunk_ids.len(), 2);
|
|
|
|
let mut chunks = Vec::new();
|
|
for (index, chunk_id) in submit.accepted_chunk_ids.iter().enumerate() {
|
|
let mut chunk = state
|
|
.store
|
|
.get_draft_chunk(chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let proposed_rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice()).unwrap();
|
|
assert!(
|
|
proposed_rule.endpoints[0].advisor_proposed,
|
|
"the gateway must stamp advisor endpoint provenance"
|
|
);
|
|
chunk.validation_result = format!("prover: cached sentinel {index}");
|
|
assert!(
|
|
state
|
|
.store
|
|
.update_draft_chunk_evaluation(&chunk)
|
|
.await
|
|
.unwrap()
|
|
);
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
let approved = handle_approve_all_draft_chunks(
|
|
&state,
|
|
with_user(Request::new(ApproveAllDraftChunksRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
approvals: chunks
|
|
.iter()
|
|
.map(|chunk| openshell_core::proto::DraftChunkApproval {
|
|
chunk_id: chunk.id.clone(),
|
|
review_token: chunk.review_token.clone(),
|
|
})
|
|
.collect(),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(approved.chunks_approved, 2);
|
|
assert_eq!(approved.chunks_skipped, 0);
|
|
assert_eq!(approved.policy_version, 1);
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
|
|
assert!(policy.network_policies.contains_key("alpha"));
|
|
assert!(policy.network_policies.contains_key("beta"));
|
|
assert!(
|
|
policy
|
|
.network_policies
|
|
.values()
|
|
.all(|rule| rule.endpoints[0].advisor_proposed)
|
|
);
|
|
for (index, chunk) in chunks.iter().enumerate() {
|
|
let stored = state
|
|
.store
|
|
.get_draft_chunk(&chunk.id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored.status, "approved");
|
|
assert_eq!(
|
|
stored.validation_result,
|
|
format!("prover: cached sentinel {index}")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approve_all_skips_later_endpoint_conflict_and_applies_compatible_prefix() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-approve-all-conflict";
|
|
let sandbox_name = "approve-all-conflict";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![
|
|
PolicyChunk {
|
|
rule_name: "inspected".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "inspected".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "shared.example.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce
|
|
as i32,
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
},
|
|
PolicyChunk {
|
|
rule_name: "conflicting".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "conflicting".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "shared.example.com".to_string(),
|
|
port: 443,
|
|
protocol: "graphql".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce
|
|
as i32,
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
|
|
advisor_proposed: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/wget".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(submit.accepted_chunk_ids.len(), 2);
|
|
let chunks = futures::future::try_join_all(
|
|
submit
|
|
.accepted_chunk_ids
|
|
.iter()
|
|
.map(|id| state.store.get_draft_chunk(id)),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(Option::unwrap)
|
|
.collect::<Vec<_>>();
|
|
|
|
let approved = handle_approve_all_draft_chunks(
|
|
&state,
|
|
with_user(Request::new(ApproveAllDraftChunksRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
approvals: chunks
|
|
.iter()
|
|
.map(|chunk| openshell_core::proto::DraftChunkApproval {
|
|
chunk_id: chunk.id.clone(),
|
|
review_token: chunk.review_token.clone(),
|
|
})
|
|
.collect(),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(approved.chunks_approved, 1);
|
|
assert_eq!(approved.chunks_skipped, 1);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.get_draft_chunk(&chunks[0].id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
"approved"
|
|
);
|
|
let skipped = state
|
|
.store
|
|
.get_draft_chunk(&chunks[1].id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(skipped.status, "pending");
|
|
assert!(!skipped.application_error.is_empty());
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let policy = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
|
|
assert!(policy.network_policies.contains_key("inspected"));
|
|
assert!(!policy.network_policies.contains_key("conflicting"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reviewed_batch_operations_stale_snapshot_apply_nothing() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-approve-all-stale";
|
|
let sandbox_name = "approve-all-stale";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let reviewed_policy = ProtoSandboxPolicy::default();
|
|
let reviewed_hash = deterministic_policy_hash(&reviewed_policy);
|
|
let changed_policy = test_policy_with_rule("concurrent", "concurrent.example.com");
|
|
let changed_hash = deterministic_policy_hash(&changed_policy);
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"concurrent-policy",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&changed_policy.encode_to_vec(),
|
|
&changed_hash,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let operations = [
|
|
PolicyMergeOp::AddRule {
|
|
rule_name: "alpha".to_string(),
|
|
rule: NetworkPolicyRule {
|
|
name: "alpha".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "alpha.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
PolicyMergeOp::AddRule {
|
|
rule_name: "beta".to_string(),
|
|
rule: NetworkPolicyRule {
|
|
name: "beta".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "beta.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/wget".to_string(),
|
|
}],
|
|
},
|
|
},
|
|
];
|
|
let error = apply_merge_operations_with_retry(
|
|
state.store.as_ref(),
|
|
sandbox_id,
|
|
"default",
|
|
Some(&reviewed_policy),
|
|
&operations,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
Some(&reviewed_hash),
|
|
None,
|
|
)
|
|
.await
|
|
.expect_err("a stale reviewed snapshot must reject the complete batch");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(latest.policy_hash, changed_hash);
|
|
let policy = ProtoSandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
|
|
assert!(policy.network_policies.contains_key("concurrent"));
|
|
assert!(!policy.network_policies.contains_key("alpha"));
|
|
assert!(!policy.network_policies.contains_key("beta"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approve_all_skips_private_allowed_ips_unless_included() {
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "private-allowed-ips";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-private-allowed-ips",
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "private_service".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "private_service".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["10.0.0.0/8".to_string()],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = submit.accepted_chunk_ids[0].clone();
|
|
let chunk = state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(
|
|
chunk
|
|
.security_notes
|
|
.contains("allowed_ips includes private/internal range '10.0.0.0/8'.")
|
|
);
|
|
|
|
let skipped = handle_approve_all_draft_chunks(
|
|
&state,
|
|
with_user(Request::new(ApproveAllDraftChunksRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
include_security_flagged: false,
|
|
approvals: vec![openshell_core::proto::DraftChunkApproval {
|
|
chunk_id: chunk_id.clone(),
|
|
review_token: chunk.review_token.clone(),
|
|
}],
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(skipped.chunks_approved, 0);
|
|
assert_eq!(skipped.chunks_skipped, 1);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
"pending"
|
|
);
|
|
|
|
let approved = handle_approve_all_draft_chunks(
|
|
&state,
|
|
with_user(Request::new(ApproveAllDraftChunksRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
include_security_flagged: true,
|
|
approvals: vec![openshell_core::proto::DraftChunkApproval {
|
|
chunk_id: chunk_id.clone(),
|
|
review_token: chunk.review_token.clone(),
|
|
}],
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(approved.chunks_approved, 1);
|
|
assert_eq!(approved.chunks_skipped, 0);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
"approved"
|
|
);
|
|
|
|
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
|
|
state.store.close_for_test().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edit_recomputes_security_notes_before_read_and_approve_all() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-edit-security-notes";
|
|
let sandbox_name = "edit-security-notes";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let safe_rule = NetworkPolicyRule {
|
|
name: "edited_service".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "edited_service".to_string(),
|
|
proposed_rule: Some(safe_rule.clone()),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = submit.accepted_chunk_ids[0].clone();
|
|
|
|
let mut private_rule = safe_rule;
|
|
private_rule.endpoints[0].allowed_ips = vec!["10.0.0.0/8".to_string()];
|
|
handle_edit_draft_chunk(
|
|
&state,
|
|
with_user(Request::new(EditDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
proposed_rule: Some(private_rule),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(stored.security_notes.is_empty());
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(
|
|
draft.chunks[0]
|
|
.security_notes
|
|
.contains("allowed_ips includes private/internal range '10.0.0.0/8'.")
|
|
);
|
|
|
|
let skipped = handle_approve_all_draft_chunks(
|
|
&state,
|
|
with_user(Request::new(ApproveAllDraftChunksRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
include_security_flagged: false,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(skipped.chunks_approved, 0);
|
|
assert_eq!(skipped.chunks_skipped, 1);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
"pending"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approve_all_recomputes_empty_legacy_security_notes() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-stale-security-notes";
|
|
let sandbox_name = "stale-security-notes";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "legacy_private".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["10.0.0.0/8".to_string()],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let mut chunk = pending_draft_chunk("legacy-private", sandbox_id);
|
|
chunk.rule_name = rule.name.clone();
|
|
chunk.proposed_rule = rule.encode_to_vec();
|
|
chunk.host = "service.example.com".to_string();
|
|
chunk.port = 443;
|
|
state
|
|
.store
|
|
.put_draft_chunk(&chunk, None, "default")
|
|
.await
|
|
.unwrap();
|
|
|
|
let skipped = handle_approve_all_draft_chunks(
|
|
&state,
|
|
with_user(Request::new(ApproveAllDraftChunksRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
include_security_flagged: false,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(skipped.chunks_approved, 0);
|
|
assert_eq!(skipped.chunks_skipped, 1);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.get_draft_chunk(&chunk.id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
"pending"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn security_flagged_empty_delta_does_not_auto_approve() {
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "security-flagged-auto";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-security-flagged-auto",
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
seed_sandbox_approval_mode(&state, sandbox_name, "auto").await;
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "private_service".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "private_service".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["10.0.0.0/8".to_string()],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(draft.chunks[0].validation_result, "prover: no new findings");
|
|
assert_eq!(draft.chunks[0].status, "pending");
|
|
assert!(
|
|
draft.chunks[0]
|
|
.security_notes
|
|
.contains("allowed_ips includes private/internal range '10.0.0.0/8'.")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mechanistic_dedup_auto_approval_rechecks_edited_stored_rule() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-stored-prover-verdict";
|
|
let sandbox_name = "stored-prover-verdict";
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec!["github-pat".to_string()],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
// The first observation is clean and remains pending in the default
|
|
// manual mode. Its normalized endpoint columns become the dedup key.
|
|
let safe_rule = NetworkPolicyRule {
|
|
name: "safe_service".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "service.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let first = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: safe_rule.name.clone(),
|
|
proposed_rule: Some(safe_rule.clone()),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = first.accepted_chunk_ids[0].clone();
|
|
let original = state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(original.status, "pending");
|
|
assert_eq!(original.validation_result, "prover: no new findings");
|
|
|
|
// Editing only replaces the stored protobuf payload; the normalized
|
|
// dedup columns remain service.example.com:443 + /usr/bin/curl. This
|
|
// replacement has no advisory notes but does add credentialed reach.
|
|
let finding_rule = NetworkPolicyRule {
|
|
name: safe_rule.name.clone(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: safe_rule.binaries.clone(),
|
|
};
|
|
assert!(generate_security_notes(&finding_rule).is_empty());
|
|
let credential_set = CredentialSet {
|
|
credentials: vec![Credential {
|
|
name: "github-pat".to_string(),
|
|
cred_type: "github".to_string(),
|
|
scopes: Vec::new(),
|
|
injected_via: String::new(),
|
|
target_hosts: vec!["api.github.com".to_string()],
|
|
}],
|
|
api_registries: HashMap::new(),
|
|
};
|
|
assert!(
|
|
validation_result_for_agent_proposal(
|
|
ProtoSandboxPolicy::default(),
|
|
&finding_rule.name,
|
|
&finding_rule,
|
|
&credential_set,
|
|
)
|
|
.contains("credential_reach_expansion")
|
|
);
|
|
handle_edit_draft_chunk(
|
|
&state,
|
|
with_user(Request::new(EditDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
proposed_rule: Some(finding_rule),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
seed_sandbox_approval_mode(&state, sandbox_name, "auto").await;
|
|
|
|
// The duplicate incoming proposal is clean. Dedup returns the edited
|
|
// row, so auto-approval must evaluate that stored payload instead.
|
|
let duplicate = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: safe_rule.name.clone(),
|
|
proposed_rule: Some(safe_rule),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(duplicate.accepted_chunk_ids, vec![chunk_id.clone()]);
|
|
|
|
let stored = state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(stored.status, "pending");
|
|
let stored_rule = NetworkPolicyRule::decode(stored.proposed_rule.as_slice()).unwrap();
|
|
assert_eq!(stored_rule.endpoints[0].host, "api.github.com");
|
|
assert!(generate_security_notes(&stored_rule).is_empty());
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn draft_chunk_handler_lifecycle_round_trip() {
|
|
use openshell_core::proto::{
|
|
GetDraftPolicyRequest, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
// Attach a github provider so the proposal below has a credential in
|
|
// scope for api.github.com. This causes the prover to emit a HIGH
|
|
// finding (L4 + credential in scope), keeping the chunk pending so
|
|
// the manual approve/reject lifecycle this test exercises is
|
|
// reachable. Without a provider, the proposal would auto-approve and
|
|
// the lifecycle assertions would no longer apply.
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-draft-flow".to_string(),
|
|
name: "draft-flow".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
providers: vec!["github-pat".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
let sandbox_name = sandbox.object_name().to_string();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "allow_github".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
allow_uninspected_credentials: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_github".to_string(),
|
|
proposed_rule: Some(proposed_rule.clone()),
|
|
rationale: "observed denied request".to_string(),
|
|
confidence: 0.85,
|
|
hit_count: 3,
|
|
first_seen_time: openshell_core::time::timestamp_from_millis(100).ok(),
|
|
last_seen_time: openshell_core::time::timestamp_from_millis(200).ok(),
|
|
binary: "/usr/bin/curl".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(submit.accepted_chunks, 1);
|
|
assert_eq!(submit.rejected_chunks, 0);
|
|
assert_eq!(submit.accepted_chunk_ids.len(), 1);
|
|
assert!(!submit.accepted_chunk_ids[0].is_empty());
|
|
|
|
let draft_policy = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(draft_policy.draft_version, 1);
|
|
assert_eq!(draft_policy.chunks.len(), 1);
|
|
// The proposal explicitly opts in to L4 credentials. The prover emits
|
|
// a HIGH finding and the security note keeps the chunk pending for the
|
|
// manual approve path this test exercises.
|
|
assert_eq!(draft_policy.chunks[0].status, "pending");
|
|
let chunk_id = draft_policy.chunks[0].id.clone();
|
|
let review_token = draft_policy.chunks[0].review_token.clone();
|
|
|
|
let approve = handle_approve_draft_chunk(
|
|
&state,
|
|
authed_request(ApproveDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
review_token,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(approve.policy_version, 1);
|
|
assert!(!approve.policy_hash.is_empty());
|
|
|
|
let history_after_approve = handle_get_draft_history(
|
|
&state,
|
|
authed_request(GetDraftHistoryRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(history_after_approve.entries.len(), 2);
|
|
assert_eq!(history_after_approve.entries[0].event_type, "proposed");
|
|
assert_eq!(history_after_approve.entries[1].event_type, "approved");
|
|
assert_eq!(history_after_approve.entries[1].chunk_id, chunk_id);
|
|
|
|
let policies_after_approve = handle_list_sandbox_policies(
|
|
&state,
|
|
authed_request(ListSandboxPoliciesRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
page_size: 10,
|
|
page_token: String::new(),
|
|
global: false,
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(policies_after_approve.revisions.len(), 1);
|
|
assert_eq!(policies_after_approve.revisions[0].version, 1);
|
|
|
|
let undo = handle_undo_draft_chunk(
|
|
&state,
|
|
authed_request(UndoDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(undo.policy_version, 2);
|
|
assert!(!undo.policy_hash.is_empty());
|
|
|
|
let draft_policy_after_undo = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(draft_policy_after_undo.chunks.len(), 1);
|
|
assert_eq!(draft_policy_after_undo.chunks[0].status, "pending");
|
|
|
|
let history_after_undo = handle_get_draft_history(
|
|
&state,
|
|
authed_request(GetDraftHistoryRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(history_after_undo.entries.len(), 1);
|
|
assert_eq!(history_after_undo.entries[0].event_type, "proposed");
|
|
|
|
let policies_after_undo = handle_list_sandbox_policies(
|
|
&state,
|
|
authed_request(ListSandboxPoliciesRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
page_size: 10,
|
|
page_token: String::new(),
|
|
global: false,
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(policies_after_undo.revisions.len(), 2);
|
|
assert_eq!(policies_after_undo.revisions[0].version, 2);
|
|
assert_eq!(policies_after_undo.revisions[1].version, 1);
|
|
|
|
let cleared = handle_clear_draft_chunks(
|
|
&state,
|
|
authed_request(ClearDraftChunksRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(cleared.chunks_cleared, 1);
|
|
|
|
let draft_policy_after_clear = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(draft_policy_after_clear.chunks.is_empty());
|
|
|
|
let history_after_clear = handle_get_draft_history(
|
|
&state,
|
|
authed_request(GetDraftHistoryRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(history_after_clear.entries.is_empty());
|
|
}
|
|
|
|
/// A reviewer's free-form rejection reason must round-trip through
|
|
/// persistence and surface on the chunk via `GetDraftPolicy`, so the
|
|
/// in-sandbox agent can read the guidance and redraft. The MVP-v2 agent
|
|
/// feedback loop hangs off this guarantee.
|
|
#[tokio::test]
|
|
async fn reject_with_reason_persists_into_chunk_for_agent_readback() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "agent-feedback-loop".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-feedback".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "allow_example".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_example".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "agent intent".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = submit.accepted_chunk_ids[0].clone();
|
|
|
|
let guidance = "scope to docs/ paths only, not all repo contents";
|
|
handle_reject_draft_chunk(
|
|
&state,
|
|
authed_request(RejectDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
reason: guidance.to_string(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let rejected = draft
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == chunk_id)
|
|
.expect("rejected chunk should still be visible");
|
|
assert_eq!(rejected.status, "rejected");
|
|
assert_eq!(
|
|
rejected.rejection_reason, guidance,
|
|
"reviewer's free-form reason must round-trip into the chunk for agent readback"
|
|
);
|
|
// The prover now runs on every proposal regardless of analysis_mode.
|
|
// For this rule (L4 to api.example.com, no provider attached, no
|
|
// credential in scope), v1 calibration emits no finding — so the
|
|
// verdict is the clean "no new findings" string, not empty.
|
|
assert_eq!(rejected.validation_result, "prover: no new findings");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_authored_exact_l7_proposal_gets_prover_pass_verdict() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase,
|
|
SandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "agent-l7-verdict".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-agent-l7-verdict".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
// Opt this sandbox into auto-approval via the settings model — same
|
|
// path the CLI's `--approval-mode auto` exercises — to test the
|
|
// empty-delta → approved path.
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "github_contents_write".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "PUT".to_string(),
|
|
path: "/repos/org/repo/contents/demo/file.md".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_contents_write".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "write one demo file".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
assert_eq!(
|
|
verdict, "prover: no new findings",
|
|
"exact L7 PUT against an inspected endpoint should not introduce \
|
|
any new findings over baseline; got: {verdict}"
|
|
);
|
|
// Auto-approval gate: empty delta + sandbox opted into auto mode →
|
|
// status flips to approved without human action. The canonical
|
|
// happy path for agent speed.
|
|
assert_eq!(
|
|
draft.chunks[0].status, "approved",
|
|
"empty-delta agent-authored proposal under auto mode must auto-approve; \
|
|
got status: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Implicit supersede: when a refined agent-authored proposal lands for
|
|
/// the same `(host, port, binary)` as a pending mechanistic chunk, the
|
|
/// older mechanistic chunk is auto-rejected with a "superseded by
|
|
/// chunk X" reason. This is the refinement loop without a
|
|
/// `supersedes_chunk_id` field — structural overlap is enough.
|
|
#[tokio::test]
|
|
async fn agent_authored_submission_supersedes_pending_mechanistic_for_same_endpoint() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase,
|
|
SandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
// github provider attached so the mechanistic L4 lands a HIGH
|
|
// finding and stays pending.
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
let sandbox_name = "supersede-flow".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-supersede-flow".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
providers: vec!["github-pat".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Step 1: mechanistic submits a broad L4 grant; the prover flags it
|
|
// HIGH, so it lands in pending.
|
|
let mechanistic_rule = NetworkPolicyRule {
|
|
name: "allow_api_github_com_443".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
allow_uninspected_credentials: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let mechanistic_submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_api_github_com_443".to_string(),
|
|
proposed_rule: Some(mechanistic_rule),
|
|
rationale: "Allow /usr/bin/curl to connect to api.github.com:443.".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let mechanistic_chunk_id = mechanistic_submit.accepted_chunk_ids[0].clone();
|
|
|
|
// Sanity-check: the mechanistic chunk is pending and carries a HIGH
|
|
// finding.
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let mech = draft
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == mechanistic_chunk_id)
|
|
.expect("mechanistic chunk present");
|
|
assert_eq!(mech.status, "pending");
|
|
// The attached GitHub profile already grants credentialed reach for
|
|
// this host, so the mechanistic proposal does not expand reach.
|
|
assert!(
|
|
!mech
|
|
.validation_result
|
|
.contains("credential_reach_expansion"),
|
|
"profile-composed reach should prevent a duplicate expansion finding; got: {}",
|
|
mech.validation_result
|
|
);
|
|
|
|
// Step 2: the agent refines into a narrow L7 proposal for the SAME
|
|
// (host, port, binary). Under the v1 calibration, an L7 PUT on a
|
|
// host where the binary already had credentialed reach (read-only)
|
|
// emits a capability_expansion finding (new method on already-
|
|
// reached host) rather than a fresh reach expansion. The agent
|
|
// chunk stays pending for human review. The mechanistic chunk gets
|
|
// auto-rejected as superseded regardless of the agent chunk's own
|
|
// validation verdict — supersede is unconditional on `(host, port,
|
|
// binary)` overlap.
|
|
let agent_rule = NetworkPolicyRule {
|
|
name: "github_contents_put".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "PUT".to_string(),
|
|
path: "/repos/owner/name/contents/path/file.md".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let agent_submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_contents_put".to_string(),
|
|
proposed_rule: Some(agent_rule),
|
|
rationale: "refined L7 scope for the demo write".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let agent_chunk_id = agent_submit.accepted_chunk_ids[0].clone();
|
|
|
|
let draft_after = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
let agent = draft_after
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == agent_chunk_id)
|
|
.expect("agent chunk present");
|
|
let mech_after = draft_after
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == mechanistic_chunk_id)
|
|
.expect("mechanistic chunk should still be visible (with new status)");
|
|
|
|
assert_eq!(
|
|
agent.status, "pending",
|
|
"agent-authored L7 PUT with credential in scope must land in pending; \
|
|
the baseline policy has no pre-existing rule for curl on api.github.com \
|
|
so the agent's chunk grants brand-new credentialed reach. got: {}",
|
|
agent.status
|
|
);
|
|
assert!(
|
|
agent
|
|
.validation_result
|
|
.contains("credential_reach_expansion"),
|
|
"agent chunk should carry credential_reach_expansion (new credentialed reach \
|
|
on api.github.com); got: {}",
|
|
agent.validation_result
|
|
);
|
|
assert_eq!(
|
|
mech_after.status, "rejected",
|
|
"older mechanistic chunk for same (host, port, binary) should be superseded; \
|
|
got: {}",
|
|
mech_after.status
|
|
);
|
|
assert!(
|
|
mech_after.rejection_reason.contains(&agent_chunk_id),
|
|
"rejection reason should cite the superseding chunk id; got: {}",
|
|
mech_after.rejection_reason
|
|
);
|
|
assert!(
|
|
mech_after.rejection_reason.contains("superseded"),
|
|
"rejection reason should explain the supersede; got: {}",
|
|
mech_after.rejection_reason
|
|
);
|
|
}
|
|
|
|
/// Auto-approval is **proposer-agnostic**: a mechanistic proposal whose
|
|
/// prover delta is empty auto-approves the same way an agent-authored one
|
|
/// does. Source provenance is preserved in the audit trail (OCSF event
|
|
/// `source=mechanistic`) but does not change the safety decision.
|
|
#[tokio::test]
|
|
async fn mechanistic_proposal_with_empty_delta_also_auto_approves() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "mechanistic-clean".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-mechanistic-clean".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
// No providers → no credential in scope for the proposed host.
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
// Opt into auto mode via the settings model to test the
|
|
// proposer-agnostic gate.
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "Allow /usr/bin/curl to connect to example.com:443.".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
assert_eq!(verdict, "prover: no new findings");
|
|
assert_eq!(
|
|
draft.chunks[0].status, "approved",
|
|
"empty-delta mechanistic proposal under auto mode must auto-approve \
|
|
(proposer-agnostic); got status: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mechanistic_existing_multi_port_rest_endpoint_auto_approves_narrow_overlay() {
|
|
use openshell_core::proto::{
|
|
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "mechanistic-existing-rest".to_string();
|
|
let mut base_policy = SandboxPolicy::default();
|
|
base_policy.network_policies.insert(
|
|
"cargo_registry".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "cargo-registry".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "index.crates.io".to_string(),
|
|
port: 80,
|
|
ports: vec![80, 443],
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/cargo".to_string(),
|
|
}],
|
|
},
|
|
);
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-mechanistic-existing-rest".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(base_policy),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
let advisor_binary = NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
};
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_index_crates_io_443".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "allow_index_crates_io_443".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "index.crates.io".to_string(),
|
|
port: 443,
|
|
advisor_proposed: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![advisor_binary],
|
|
}),
|
|
hit_count: 1,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk = &draft.chunks[0];
|
|
assert_eq!(
|
|
chunk.status, "approved",
|
|
"application error: {}; prover: {}",
|
|
chunk.application_error, chunk.validation_result
|
|
);
|
|
assert_eq!(chunk.rule_name, "allow_index_crates_io_443");
|
|
assert_eq!(chunk.validation_result, "prover: no new findings");
|
|
assert!(chunk.application_error.is_empty());
|
|
assert!(!chunk.review_token.is_empty());
|
|
let canonical = chunk.proposed_rule.as_ref().unwrap();
|
|
assert_eq!(canonical.endpoints[0].protocol, "rest");
|
|
assert_eq!(
|
|
canonical.endpoints[0].access,
|
|
openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
|
|
);
|
|
assert!(
|
|
canonical.endpoints[0].advisor_proposed,
|
|
"a new advisor overlay must retain proposal provenance"
|
|
);
|
|
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy("sb-mechanistic-existing-rest")
|
|
.await
|
|
.unwrap()
|
|
.expect("auto approval persisted a policy revision");
|
|
let applied = ProtoSandboxPolicy::decode(revision.policy_payload.as_slice()).unwrap();
|
|
let cargo_rule = &applied.network_policies["cargo_registry"];
|
|
assert_eq!(cargo_rule.endpoints.len(), 1);
|
|
assert_eq!(cargo_rule.endpoints[0].ports, vec![80, 443]);
|
|
assert_eq!(cargo_rule.endpoints[0].protocol, "rest");
|
|
assert_eq!(
|
|
cargo_rule.endpoints[0].access,
|
|
openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
|
|
);
|
|
assert_eq!(cargo_rule.binaries.len(), 1);
|
|
assert_eq!(cargo_rule.binaries[0].path, "/usr/bin/cargo");
|
|
let curl_rule = &applied.network_policies["allow_index_crates_io_443"];
|
|
assert_eq!(curl_rule.endpoints.len(), 1);
|
|
assert_eq!(curl_rule.endpoints[0].ports, vec![443]);
|
|
assert_eq!(curl_rule.endpoints[0].protocol, "rest");
|
|
assert_eq!(
|
|
curl_rule.endpoints[0].access,
|
|
openshell_core::proto::NetworkAccessPreset::ReadOnly as i32
|
|
);
|
|
assert!(
|
|
curl_rule.endpoints[0].advisor_proposed,
|
|
"the persisted advisor overlay must retain proposal provenance"
|
|
);
|
|
assert_eq!(curl_rule.binaries.len(), 1);
|
|
assert_eq!(curl_rule.binaries[0].path, "/usr/bin/curl");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_graphql_candidate_is_rejected_before_reviewer_inbox() {
|
|
use openshell_core::proto::{
|
|
L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase,
|
|
SandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "invalid-graphql-preflight".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-invalid-graphql-preflight".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy::default()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let response = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "bad_graphql".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "bad-graphql".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
protocol: "graphql".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce
|
|
as i32,
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
// Runtime requires an operation type for
|
|
// GraphQL rules; this intentionally omits it.
|
|
fields: vec!["viewer".to_string()],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(response.accepted_chunks, 0);
|
|
assert_eq!(response.rejected_chunks, 1);
|
|
assert!(response.rejection_reasons[0].contains("operation_type"));
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(draft.chunks.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn changed_policy_inputs_refresh_token_and_require_fresh_review() {
|
|
use openshell_core::proto::{
|
|
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "stale-review-token".to_string();
|
|
let sandbox_id = "sb-stale-review-token";
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sandbox_id.to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
workspace: "default".to_string(),
|
|
..Default::default()
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy::default()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "example".to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: "example".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = &submit.accepted_chunk_ids[0];
|
|
let before = state
|
|
.store
|
|
.get_draft_chunk(chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(!before.review_token.is_empty());
|
|
|
|
// The cached verdict is deliberately replaced with a sentinel while
|
|
// retaining its token. With unchanged inputs, evaluation must carry
|
|
// that value through instead of invoking the prover again.
|
|
let mut cached = before.clone();
|
|
cached.validation_result = "prover: cached sentinel".to_string();
|
|
assert!(
|
|
state
|
|
.store
|
|
.update_draft_chunk_evaluation(&cached)
|
|
.await
|
|
.unwrap()
|
|
);
|
|
let unchanged = require_current_proposal_evaluation(
|
|
&state,
|
|
"default",
|
|
&sandbox,
|
|
&cached,
|
|
Some(&cached.review_token),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(unchanged.validation_result, "prover: cached sentinel");
|
|
|
|
let mut changed_base = SandboxPolicy::default();
|
|
changed_base.network_policies.insert(
|
|
"unrelated".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "unrelated".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "unrelated.example".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/wget".to_string(),
|
|
}],
|
|
},
|
|
);
|
|
let changed_hash = deterministic_policy_hash(&changed_base);
|
|
state
|
|
.store
|
|
.put_policy_revision(
|
|
"stale-token-policy",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&changed_base.encode_to_vec(),
|
|
&changed_hash,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let error = handle_approve_draft_chunk(
|
|
&state,
|
|
with_user(Request::new(ApproveDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
review_token: before.review_token.clone(),
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("changed base policy must invalidate the reviewed token");
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("inputs changed"));
|
|
let refreshed = state
|
|
.store
|
|
.get_draft_chunk(chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(refreshed.status, "pending");
|
|
assert_ne!(refreshed.review_token, before.review_token);
|
|
assert_eq!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.policy_hash,
|
|
changed_hash
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_change_reconciles_pending_chunk_already_covered_by_live_policy() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-covered-pending";
|
|
let sandbox_name = "covered-pending";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let rule = NetworkPolicyRule {
|
|
name: "example".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "example".to_string(),
|
|
proposed_rule: Some(rule.clone()),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = &submit.accepted_chunk_ids[0];
|
|
|
|
let mut live = ProtoSandboxPolicy::default();
|
|
live.network_policies.insert("example".to_string(), rule);
|
|
assert_eq!(
|
|
reconcile_pending_chunks_covered_by_policy(&state, sandbox_id, &live, 7)
|
|
.await
|
|
.unwrap(),
|
|
1
|
|
);
|
|
let reconciled = state
|
|
.store
|
|
.get_draft_chunk(chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(reconciled.status, "rejected");
|
|
assert_eq!(
|
|
reconciled.rejection_reason,
|
|
"covered by active policy revision 7"
|
|
);
|
|
}
|
|
|
|
/// `protocol: rest, access: full` on a host where the binary had no
|
|
/// prior credentialed reach: the prover emits
|
|
/// `credential_reach_expansion`. (The per-method `capability_expansion`
|
|
/// paths are suppressed by the gateway delta because the reach is
|
|
/// new; one finding describes the change, not eight.)
|
|
#[tokio::test]
|
|
async fn agent_authored_l7_full_with_credential_emits_reach_expansion() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
let sandbox_name = "l7-full-with-cred".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-l7-full-with-cred".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
providers: vec!["github-pat".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
// L7-annotated (protocol: rest, enforce) but access: full — no
|
|
// method/path bound. Credential in scope.
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "github_l7_full".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_l7_full".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "broad L7 dressing".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
assert!(
|
|
verdict.contains("credential_reach_expansion"),
|
|
"L7 `access: full` on a host the binary did not previously reach must emit \
|
|
credential_reach_expansion; got: {verdict}"
|
|
);
|
|
// Capability_expansion paths for the same (binary, host:port) are
|
|
// suppressed when the reach itself is new — one finding, not many.
|
|
assert!(
|
|
!verdict.contains("capability_expansion"),
|
|
"capability_expansion must be suppressed when reach itself is new; got: {verdict}"
|
|
);
|
|
assert_eq!(
|
|
draft.chunks[0].status, "pending",
|
|
"any prover finding must keep the chunk in pending despite auto mode; got: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Acceptance criterion #7: default approval mode is manual. A sandbox
|
|
/// with no `proposal_approval_mode` setting at either scope must NOT
|
|
/// auto-approve empty-delta proposals; the chunk lands in `pending` for
|
|
/// human review. This is the default-deny safeguard: auto-approval is
|
|
/// an explicit opt-in, not a global behavior change shipped under a
|
|
/// feature.
|
|
#[tokio::test]
|
|
async fn empty_delta_does_not_auto_approve_when_mode_unset() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "default-manual-mode".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-default-manual-mode".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
// No approval-mode setting seeded at sandbox or gateway
|
|
// scope — the resolver must treat absence as "manual".
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "un-credentialed L4 — prover sees no finding".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
assert_eq!(
|
|
verdict, "prover: no new findings",
|
|
"prover should still emit no findings; gate is downstream",
|
|
);
|
|
assert_eq!(
|
|
draft.chunks[0].status, "pending",
|
|
"default (unset) proposal_approval_mode must not auto-approve; \
|
|
chunk should wait for human review. got status: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Unknown `proposal_approval_mode` strings (typos, future-mode values
|
|
/// the gateway doesn't yet know about) fall back to manual. This locks
|
|
/// in forward-compat: a future CLI that learns about `"auto_on_low_risk"`
|
|
/// can never accidentally bypass an older gateway's review gate just by
|
|
/// virtue of an unrecognized value defaulting to "auto."
|
|
#[tokio::test]
|
|
async fn empty_delta_does_not_auto_approve_when_mode_unknown_string() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "unknown-mode".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-unknown-mode".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
// A future-CLI value the current gateway doesn't recognize.
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto_on_low_risk").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "un-credentialed L4".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(
|
|
draft.chunks[0].status, "pending",
|
|
"unknown approval-mode strings must fall back to manual; \
|
|
only the literal \"auto\" opts in. got: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Explicit `"manual"` is equivalent to the unset default — chunk lands
|
|
/// in pending even with empty delta.
|
|
#[tokio::test]
|
|
async fn empty_delta_does_not_auto_approve_when_mode_explicit_manual() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "explicit-manual-mode".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-explicit-manual-mode".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "manual").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "un-credentialed L4 — prover sees no finding".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(
|
|
draft.chunks[0].status, "pending",
|
|
"explicit manual mode must equal default mode — no auto-approval; \
|
|
got: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Gateway-scope `proposal_approval_mode = "auto"` enables auto-approval
|
|
/// for any sandbox under that gateway, with no per-sandbox setting
|
|
/// required. This is the fleet-wide opt-in path — a reviewer flips the
|
|
/// gateway setting once and every sandbox without an explicit override
|
|
/// gets prover-gated auto-approval.
|
|
#[tokio::test]
|
|
async fn empty_delta_auto_approves_from_gateway_scope_setting() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "gateway-auto-mode".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-gateway-auto-mode".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
// Fleet-wide opt-in — no sandbox-scope setting.
|
|
seed_global_approval_mode(&state, "auto").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "un-credentialed L4 — empty delta".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(
|
|
draft.chunks[0].status, "approved",
|
|
"empty-delta proposal must auto-approve when the gateway-scope \
|
|
setting is \"auto\" and no sandbox-scope override exists. got: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Gateway scope wins over sandbox scope. A reviewer can pin manual mode
|
|
/// fleet-wide; a per-sandbox `"auto"` value is silently ignored. Matches
|
|
/// the existing settings precedence convention (global wins, sandbox is
|
|
/// the per-sandbox override only when no global is set).
|
|
#[tokio::test]
|
|
async fn gateway_manual_overrides_sandbox_auto() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "gateway-pinned-manual".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-gateway-pinned-manual".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
// Gateway pins manual; the sandbox-scope override is supplied (test
|
|
// helper bypasses the UpdateConfig precondition, simulating the
|
|
// before-pin state) to prove the resolver still picks the gateway
|
|
// value.
|
|
seed_global_approval_mode(&state, "manual").await;
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "un-credentialed L4 — empty delta".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(
|
|
draft.chunks[0].status, "pending",
|
|
"gateway-scope \"manual\" must win over sandbox-scope \"auto\"; \
|
|
got: {}",
|
|
draft.chunks[0].status
|
|
);
|
|
}
|
|
|
|
/// Agent submissions targeting a `_provider_*` rule name are rejected at
|
|
/// the submit boundary. Provider-synthesized rules are a reserved
|
|
/// namespace; an agent that addresses one by name could otherwise
|
|
/// circumvent the merge guard that splits agent contributions into their
|
|
/// own rule (so the prover sees them honestly).
|
|
#[tokio::test]
|
|
async fn submit_rejects_reserved_provider_rule_name_prefix() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "reject-provider-prefix".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-reject-provider-prefix".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "github".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
allow_uninspected_credentials: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let response = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "_provider_work_github".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "should be rejected — addresses provider rule by name".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(response.accepted_chunks, 0, "chunk must be rejected");
|
|
assert_eq!(response.rejected_chunks, 1);
|
|
assert!(
|
|
response
|
|
.rejection_reasons
|
|
.iter()
|
|
.any(|r| r.contains("_provider_")),
|
|
"rejection reason must cite the reserved-prefix rule. got: {:?}",
|
|
response.rejection_reasons,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_authored_submit_rejects_native_tcp_and_tls_skip_but_allows_explicit_proxy() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "reject-agent-raw-transports";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-reject-agent-raw-transports",
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let endpoint = |protocol: &str, tls: &str| NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
protocol: protocol.to_string(),
|
|
tls: openshell_policy::network_tls_mode_from_str(tls)
|
|
.expect("test TLS mode must be valid") as i32,
|
|
..Default::default()
|
|
};
|
|
let chunk = |name: &str, endpoint: NetworkEndpoint| PolicyChunk {
|
|
rule_name: name.to_string(),
|
|
proposed_rule: Some(NetworkPolicyRule {
|
|
name: name.to_string(),
|
|
endpoints: vec![endpoint],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let response = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.to_string(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![
|
|
chunk("native_tcp", endpoint("tcp", "")),
|
|
chunk("raw_tls", endpoint("", "skip")),
|
|
chunk("explicit_proxy", endpoint("", "")),
|
|
],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(response.accepted_chunks, 1);
|
|
assert_eq!(response.rejected_chunks, 2);
|
|
assert_eq!(response.rejection_reasons.len(), 2);
|
|
assert!(
|
|
response
|
|
.rejection_reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("protocol tcp"))
|
|
);
|
|
assert!(
|
|
response
|
|
.rejection_reasons
|
|
.iter()
|
|
.any(|reason| reason.contains("tls: skip"))
|
|
);
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(draft.chunks.len(), 1);
|
|
assert_eq!(draft.chunks[0].rule_name, "explicit_proxy");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn approve_draft_chunk_rejects_stored_reserved_provider_rule_name() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-approve-provider-prefix";
|
|
let sandbox_name = "approve-provider-prefix";
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
ProtoSandboxPolicy::default(),
|
|
vec![],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "_provider_work_github".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let chunk = DraftChunkRecord {
|
|
id: "chunk-provider-prefix".to_string(),
|
|
sandbox_id: sandbox_id.to_string(),
|
|
draft_version: 1,
|
|
status: "pending".to_string(),
|
|
rule_name: "_provider_work_github".to_string(),
|
|
proposed_rule: proposed_rule.encode_to_vec(),
|
|
rationale: "stored legacy/proposal chunk should not approve".to_string(),
|
|
security_notes: String::new(),
|
|
confidence: 1.0,
|
|
created_at_ms: 0,
|
|
decided_at_ms: None,
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
binary: "/usr/bin/curl".to_string(),
|
|
hit_count: 1,
|
|
first_seen_ms: 0,
|
|
last_seen_ms: 0,
|
|
validation_result: String::new(),
|
|
rejection_reason: String::new(),
|
|
..Default::default()
|
|
};
|
|
state
|
|
.store
|
|
.put_draft_chunk(&chunk, None, "default")
|
|
.await
|
|
.expect("draft chunk should persist");
|
|
|
|
let err = handle_approve_draft_chunk(
|
|
&state,
|
|
with_user(Request::new(ApproveDraftChunkRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk.id.clone(),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("reserved provider rule names must be rejected at approval");
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("_provider_work_github"));
|
|
assert!(err.message().contains("reserved '_provider_' prefix"));
|
|
let stored_chunk = state
|
|
.store
|
|
.get_draft_chunk(&chunk.id)
|
|
.await
|
|
.unwrap()
|
|
.expect("chunk should still exist");
|
|
assert_eq!(stored_chunk.status, "pending");
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.is_none(),
|
|
"failed approval must not persist a policy revision"
|
|
);
|
|
}
|
|
|
|
/// v1 calibration row: **L4 with a credential in scope → HIGH finding.**
|
|
/// The sandbox has a github provider attached, so a credential is in
|
|
/// scope for api.github.com. A broad L4 proposal therefore lands in
|
|
/// pending with a HIGH finding.
|
|
#[tokio::test]
|
|
async fn agent_authored_l4_proposal_with_credential_records_high_finding() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
// Attach a github provider so a credential is in scope for api.github.com.
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
let sandbox_name = "agent-l4-with-cred".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-agent-l4-with-cred".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
providers: vec!["github-pat".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "github_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
allow_uninspected_credentials: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "broad fallback".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
let first_line = verdict.lines().next().unwrap_or("");
|
|
assert!(
|
|
first_line.starts_with("prover: ") && first_line.contains("new finding"),
|
|
"expected first line like `prover: N new finding(s)`, got: {verdict}"
|
|
);
|
|
assert!(
|
|
verdict.contains("credential_reach_expansion"),
|
|
"L4 + credential in scope emits credential_reach_expansion (the binary gains \
|
|
credentialed reach to a new host:port); got: {verdict}"
|
|
);
|
|
assert!(
|
|
verdict.contains("api.github.com:443"),
|
|
"expected the finding line to cite the proposed endpoint, got: {verdict}"
|
|
);
|
|
}
|
|
|
|
/// v1 calibration row: **L4 with NO credential in scope → no finding.**
|
|
/// Without an attached provider, no credential targets api.github.com,
|
|
/// so the prover treats the L4 grant as bounded (no privileged action
|
|
/// available) and emits nothing. The proposal verdict reads
|
|
/// `prover: no new findings`, eligible for auto-approval.
|
|
#[tokio::test]
|
|
async fn agent_authored_l4_proposal_without_credential_emits_no_finding() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "agent-l4-no-cred".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-agent-l4-no-cred".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
// No providers — credential set will be empty.
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "anon_l4".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "anon_l4".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "no privileged access available".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
assert_eq!(
|
|
verdict, "prover: no new findings",
|
|
"L4 grant with no credential in scope is bounded in v1; got: {verdict}"
|
|
);
|
|
}
|
|
|
|
/// v1 calibration row: **link-local host → HIGH finding regardless of
|
|
/// credentials.** Even with no provider attached, a proposal targeting
|
|
/// `169.254.169.254` (AWS IMDS / cloud metadata) emits a HIGH finding.
|
|
/// This is the one categorical safety floor v1 ships.
|
|
#[tokio::test]
|
|
async fn agent_authored_link_local_proposal_records_high_finding() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "agent-link-local".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-agent-link-local".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
// Deliberately no provider — link-local should still fire.
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "metadata_endpoint".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "169.254.169.254".to_string(),
|
|
port: 80,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "metadata_endpoint".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "agent is curious about IMDS".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let verdict = &draft.chunks[0].validation_result;
|
|
assert!(
|
|
verdict.contains("link_local_reach"),
|
|
"link-local proposal must emit link_local_reach regardless of credentials; \
|
|
got: {verdict}"
|
|
);
|
|
assert!(
|
|
verdict.contains("169.254.169.254"),
|
|
"finding line must cite the link-local host; got: {verdict}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_authored_validation_uses_profile_composed_effective_policy() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint,
|
|
ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
state
|
|
.store
|
|
.put_message(&test_provider("work-custom", "custom-api"))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "profile-custom-api".to_string(),
|
|
name: "custom-api".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
files: Vec::new(),
|
|
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,
|
|
credentials: Vec::new(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
|
|
deny_rules: vec![L7DenyRule {
|
|
method: "DELETE".to_string(),
|
|
path: "/repos/*".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
inference_capable: false,
|
|
discovery: None,
|
|
source: String::new(),
|
|
scope: String::new(),
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let sandbox_id = "sb-agent-provider-effective-policy";
|
|
let sandbox_name = "agent-provider-effective-policy".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sandbox_id.to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
providers: vec!["work-custom".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "github_contents_write".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
// Match the provider-owned endpoint contract so this test
|
|
// exercises prover composition rather than a deterministic
|
|
// application failure.
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Audit as i32,
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "PUT".to_string(),
|
|
path: "/repos/org/repo/contents/demo/file.md".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
advisor_proposed: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_contents_write".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
rationale: "write one demo file".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(submit.accepted_chunks, 1);
|
|
assert_eq!(submit.rejected_chunks, 0);
|
|
let chunk_id = submit.accepted_chunk_ids[0].clone();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk = draft
|
|
.chunks
|
|
.iter()
|
|
.find(|chunk| chunk.id == chunk_id)
|
|
.expect("provider-overlap proposal should reach the draft inbox");
|
|
assert_eq!(chunk.status, "pending");
|
|
let verdict = &chunk.validation_result;
|
|
let first_line = verdict.lines().next().unwrap_or("");
|
|
assert!(
|
|
first_line.starts_with("prover: "),
|
|
"validation should run end-to-end against the providers-v2 composed \
|
|
effective policy and produce a prover verdict; got: {verdict}"
|
|
);
|
|
assert!(
|
|
!verdict.contains("validation unavailable"),
|
|
"providers-v2 composition must not break the prover pipeline; \
|
|
got: {verdict}"
|
|
);
|
|
|
|
handle_approve_draft_chunk(
|
|
&state,
|
|
authed_request(ApproveDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id,
|
|
review_token: chunk.review_token.clone(),
|
|
}),
|
|
)
|
|
.await
|
|
.expect("provider-overlap proposal should approve");
|
|
|
|
let stored = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("approval should persist a base-policy revision");
|
|
let base_policy = ProtoSandboxPolicy::decode(stored.policy_payload.as_slice()).unwrap();
|
|
assert!(
|
|
base_policy
|
|
.network_policies
|
|
.contains_key("github_contents_write")
|
|
);
|
|
assert!(
|
|
!base_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_custom"),
|
|
"provider-composed rules must not be copied into the mutable base policy"
|
|
);
|
|
|
|
let effective_policy = get_sandbox_policy(&state, sandbox_id).await;
|
|
let provider_rule = &effective_policy.network_policies["_provider_work_custom"];
|
|
assert_eq!(
|
|
provider_rule.endpoints[0].access,
|
|
openshell_core::proto::NetworkAccessPreset::Full as i32
|
|
);
|
|
assert_eq!(provider_rule.endpoints[0].deny_rules.len(), 1);
|
|
assert!(!provider_rule.endpoints[0].advisor_proposed);
|
|
assert!(
|
|
effective_policy
|
|
.network_policies
|
|
.contains_key("github_contents_write")
|
|
);
|
|
}
|
|
|
|
/// End-to-end loop test against the v1 calibration and the auto-approval
|
|
/// gate. Mirrors the two-path flow in `examples/agent-driven-policy-management`:
|
|
///
|
|
/// 1. Un-credentialed L7 proposal (raw.githubusercontent.com GET) →
|
|
/// prover sees no findings → sandbox in `auto` mode → chunk
|
|
/// auto-approves without human action.
|
|
///
|
|
/// 2. Credentialed L7 proposal (api.github.com PUT) → prover sees
|
|
/// `github_token` in scope, emits MEDIUM → chunk lands in pending
|
|
/// for human review even under `auto` mode.
|
|
///
|
|
/// This is the deterministic counterpart of the demo's product UX
|
|
/// claim: "narrow safe = free, narrow credentialed = one approval."
|
|
#[tokio::test]
|
|
async fn full_loop_under_v2_auto_mode_splits_credentialed_and_uncredentialed() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, L7Allow, L7Rule, NetworkBinary, NetworkEndpoint, SandboxPhase,
|
|
SandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
|
|
// Github provider attached: a credential ends up in scope for
|
|
// api.github.com (PUT proposal flags MEDIUM). raw.githubusercontent.com
|
|
// is not declared by any provider, so the bootstrap fetch is
|
|
// un-credentialed and auto-approves.
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let sandbox_name = "full-loop-v2".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-full-loop-v2".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
providers: vec!["github-pat".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
// ── Step 1: un-credentialed GET → expected auto-approve ──
|
|
let uncredentialed_rule = NetworkPolicyRule {
|
|
name: "github_raw_openapi_get".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "raw.githubusercontent.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "GET".to_string(),
|
|
path: "/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json"
|
|
.to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let step1 = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_raw_openapi_get".to_string(),
|
|
proposed_rule: Some(uncredentialed_rule),
|
|
rationale: "fetch the public github openapi description".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let step1_chunk_id = step1.accepted_chunk_ids[0].clone();
|
|
|
|
// ── Step 2: credentialed PUT → expected MEDIUM, pending ──
|
|
let credentialed_rule = NetworkPolicyRule {
|
|
name: "github_contents_put".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "PUT".to_string(),
|
|
path: "/repos/owner/name/contents/path/file.md".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let step2 = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "github_contents_put".to_string(),
|
|
proposed_rule: Some(credentialed_rule),
|
|
rationale: "write the demo file via the GitHub Contents API".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let step2_chunk_id = step2.accepted_chunk_ids[0].clone();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
let step1_chunk = draft
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == step1_chunk_id)
|
|
.expect("step1 chunk present");
|
|
let step2_chunk = draft
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == step2_chunk_id)
|
|
.expect("step2 chunk present");
|
|
|
|
assert_eq!(
|
|
step1_chunk.status, "approved",
|
|
"un-credentialed L7 proposal under v2 + auto mode must auto-approve; got: {}",
|
|
step1_chunk.status
|
|
);
|
|
assert_eq!(
|
|
step1_chunk.validation_result, "prover: no new findings",
|
|
"un-credentialed L7 verdict should be `no new findings`; got: {}",
|
|
step1_chunk.validation_result
|
|
);
|
|
|
|
assert_eq!(
|
|
step2_chunk.status, "pending",
|
|
"credentialed L7 PUT under v2 + auto mode must stay pending; got: {}",
|
|
step2_chunk.status
|
|
);
|
|
// This test's spec policy has no pre-existing rule for curl on
|
|
// api.github.com, so the agent's chunk grants brand-new
|
|
// credentialed reach: the finding is credential_reach_expansion,
|
|
// not capability_expansion. (The capability_expansion path is
|
|
// suppressed by the delta because the reach is new — one finding
|
|
// per change, not two.) The demo's policy.template.yaml has
|
|
// github_api_readonly which exercises the capability_expansion
|
|
// path; that's covered by the supersede test above.
|
|
assert!(
|
|
step2_chunk
|
|
.validation_result
|
|
.contains("credential_reach_expansion"),
|
|
"credentialed PUT on a host the binary did not previously reach must carry \
|
|
credential_reach_expansion; got: {}",
|
|
step2_chunk.validation_result
|
|
);
|
|
assert!(
|
|
!step2_chunk
|
|
.validation_result
|
|
.contains("capability_expansion"),
|
|
"capability_expansion must be suppressed when reach itself is new; got: {}",
|
|
step2_chunk.validation_result
|
|
);
|
|
}
|
|
|
|
/// Two agent-authored proposals targeting the same host/port/binary must
|
|
/// each persist as a distinct chunk. The mechanistic-mode dedup
|
|
/// (`host|port|binary`) is wrong for agent intent: the redraft loop
|
|
/// relies on the second submission landing as its own chunk so the
|
|
/// reviewer can decide on it independently. Regression test for the bug
|
|
/// where Flow B of `e2e/policy-advisor/wait-smoke.sh` saw a fresh
|
|
/// `chunk_id` returned from submit but `RejectDraftChunk` could not
|
|
/// find it because the SQL ON CONFLICT had silently kept the prior row.
|
|
#[tokio::test]
|
|
async fn agent_authored_submits_for_same_endpoint_do_not_dedup() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "redraft-loop".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-redraft".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Two proposals with the same host|port|binary (so the mechanistic
|
|
// dedup_key would collide) but distinct rule names and L7 paths —
|
|
// proves the gateway distinguishes them by intentional act and not
|
|
// by payload hash. If a future dedup-by-payload-hash regression
|
|
// landed, this test would still fail because the chunk_ids would
|
|
// still need to be distinct.
|
|
let make_rule = |rule_name: &str| NetworkPolicyRule {
|
|
name: rule_name.to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let submit_one = |rule_name: &str, rule: NetworkPolicyRule| {
|
|
let state = state.clone();
|
|
let sandbox_name = sandbox_name.clone();
|
|
let rule_name = rule_name.to_string();
|
|
async move {
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name,
|
|
analysis_mode: "agent_authored".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name,
|
|
proposed_rule: Some(rule),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner()
|
|
}
|
|
};
|
|
|
|
let first = submit_one("allow_first", make_rule("allow_first")).await;
|
|
let second = submit_one("allow_second", make_rule("allow_second")).await;
|
|
|
|
assert_eq!(first.accepted_chunk_ids.len(), 1);
|
|
assert_eq!(second.accepted_chunk_ids.len(), 1);
|
|
assert_ne!(
|
|
first.accepted_chunk_ids[0], second.accepted_chunk_ids[0],
|
|
"second agent-authored proposal for the same endpoint must get its own chunk_id, not dedup"
|
|
);
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let ids: Vec<_> = draft.chunks.iter().map(|c| c.id.as_str()).collect();
|
|
assert!(
|
|
ids.contains(&first.accepted_chunk_ids[0].as_str())
|
|
&& ids.contains(&second.accepted_chunk_ids[0].as_str()),
|
|
"both reported chunk_ids must be persisted; got: {ids:?}"
|
|
);
|
|
|
|
// Reject the second by id to prove the gateway can actually find
|
|
// what the submit response claimed to have created — this is the
|
|
// exact path the smoke test exercises end-to-end.
|
|
handle_reject_draft_chunk(
|
|
&state,
|
|
authed_request(RejectDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: second.accepted_chunk_ids[0].clone(),
|
|
reason: "redraft test".to_string(),
|
|
}),
|
|
)
|
|
.await
|
|
.expect("reject must find the chunk_id the submit response just promised");
|
|
}
|
|
|
|
/// Complement to the agent-authored test above: mechanistic-mode
|
|
/// submissions for the same endpoint must STILL dedup. The
|
|
/// observation-driven path relies on N denials folding into one chunk
|
|
/// instead of N near-identical chunks. Lock the behavior in so a future
|
|
/// change to the dedup branch doesn't accidentally also turn off
|
|
/// mechanistic dedup.
|
|
#[tokio::test]
|
|
async fn mechanistic_submits_for_same_endpoint_dedup_into_one_chunk() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "mechanistic-dedup".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-mech-dedup".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "allow_example".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let submit_one = || {
|
|
let state = state.clone();
|
|
let sandbox_name = sandbox_name.clone();
|
|
let rule = proposed_rule.clone();
|
|
async move {
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name,
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_example".to_string(),
|
|
proposed_rule: Some(rule),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner()
|
|
}
|
|
};
|
|
let first = submit_one().await;
|
|
let second = submit_one().await;
|
|
assert_eq!(first.accepted_chunk_ids.len(), 1);
|
|
assert_eq!(second.accepted_chunk_ids.len(), 1);
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(
|
|
draft.chunks.len(),
|
|
1,
|
|
"two mechanistic submits for the same host|port|binary must dedup; got {} chunks",
|
|
draft.chunks.len()
|
|
);
|
|
// Both submits must report the same effective id — the id of the
|
|
// one row that actually exists in the DB. Before the dedup fix the
|
|
// second submit would return a freshly-generated UUID that was
|
|
// never persisted; this assertion locks the contract down.
|
|
let stored_id = &draft.chunks[0].id;
|
|
assert_eq!(
|
|
&first.accepted_chunk_ids[0], stored_id,
|
|
"first submit's reported id must match the stored chunk"
|
|
);
|
|
assert_eq!(
|
|
&second.accepted_chunk_ids[0], stored_id,
|
|
"second submit must report the same id as the first (dedup fold-in), not a fresh UUID"
|
|
);
|
|
}
|
|
|
|
/// Regression: a mechanistic denial flush for an endpoint already covered
|
|
/// by an auto-approved mechanistic chunk must NOT flip that chunk to
|
|
/// `rejected`. The dedup upsert returns the approved row's own id; before
|
|
/// the self-exclusion + pending guard the self-reject scan matched the row
|
|
/// against itself and rejected it, corrupting the governance ledger (status
|
|
/// read `rejected`) while the merged rule stayed enforced.
|
|
#[tokio::test]
|
|
async fn resubmitted_mechanistic_endpoint_keeps_approved_chunk() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxPolicy,
|
|
SandboxSpec,
|
|
};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "mechanistic-reapprove".to_string();
|
|
let sandbox_id = "sb-mechanistic-reapprove";
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sandbox_id.to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: std::collections::HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: Some(SandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_write: vec!["/sandbox".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
// No providers in scope + auto mode → empty-delta prover verdict → the
|
|
// first mechanistic submit auto-approves and merges the rule.
|
|
seed_sandbox_approval_mode(&state, &sandbox_name, "auto").await;
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "allow_example_8080".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 8080,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let submit_one = || {
|
|
let state = state.clone();
|
|
let sandbox_name = sandbox_name.clone();
|
|
let rule = proposed_rule.clone();
|
|
async move {
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name,
|
|
analysis_mode: "mechanistic".to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_example_8080".to_string(),
|
|
proposed_rule: Some(rule),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner()
|
|
}
|
|
};
|
|
|
|
// First submit: auto-approves the endpoint.
|
|
submit_one().await;
|
|
let after_first = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(after_first.chunks.len(), 1);
|
|
assert_eq!(
|
|
after_first.chunks[0].status, "approved",
|
|
"first mechanistic submit under auto mode must auto-approve; got {}",
|
|
after_first.chunks[0].status
|
|
);
|
|
|
|
// Second submit for the SAME endpoint: the dedup upsert returns the
|
|
// approved row's own id. The chunk must stay approved, not self-reject.
|
|
submit_one().await;
|
|
let after_second = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(
|
|
after_second.chunks.len(),
|
|
1,
|
|
"resubmit must dedup into the one existing row"
|
|
);
|
|
let chunk = &after_second.chunks[0];
|
|
assert_eq!(
|
|
chunk.status, "approved",
|
|
"resubmitting a mechanistic denial for an already-approved endpoint must not flip \
|
|
the approved chunk to rejected; got status {}",
|
|
chunk.status
|
|
);
|
|
assert!(
|
|
chunk.rejection_reason.is_empty(),
|
|
"approved chunk must carry no rejection reason; got {:?}",
|
|
chunk.rejection_reason
|
|
);
|
|
assert_eq!(
|
|
chunk.hit_count, 2,
|
|
"the redundant denial must fold into hit_count, not a status flip"
|
|
);
|
|
|
|
// The rule must remain merged in the active policy — the ledger and the
|
|
// enforced policy must agree.
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("auto-approve must have persisted a policy revision");
|
|
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
|
|
assert!(
|
|
policy.network_policies.contains_key("allow_example_8080"),
|
|
"approved rule must stay merged after resubmit; keys: {:?}",
|
|
policy.network_policies.keys().collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
fn pending_draft_chunk(id: &str, sandbox_id: &str) -> DraftChunkRecord {
|
|
DraftChunkRecord {
|
|
id: id.to_string(),
|
|
sandbox_id: sandbox_id.to_string(),
|
|
draft_version: 1,
|
|
status: "pending".to_string(),
|
|
rule_name: "allow_endpoint".to_string(),
|
|
proposed_rule: Vec::new(),
|
|
rationale: String::new(),
|
|
security_notes: String::new(),
|
|
confidence: 0.5,
|
|
created_at_ms: 1_000,
|
|
decided_at_ms: None,
|
|
host: "10.0.0.5".to_string(),
|
|
port: 8080,
|
|
binary: "/usr/bin/curl".to_string(),
|
|
hit_count: 1,
|
|
first_seen_ms: 1_000,
|
|
last_seen_ms: 1_000,
|
|
validation_result: String::new(),
|
|
rejection_reason: String::new(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn conditionally_reject_transitions_pending_chunk() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_draft_chunk(
|
|
&pending_draft_chunk("cas-pending", "sb-cas-pending"),
|
|
None,
|
|
"default",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let changed = store
|
|
.conditionally_reject_draft_chunk("cas-pending", 5, "covered by approved chunk cover-1")
|
|
.await
|
|
.unwrap();
|
|
assert!(changed, "a pending chunk must transition to rejected");
|
|
|
|
let stored = store.get_draft_chunk("cas-pending").await.unwrap().unwrap();
|
|
assert_eq!(stored.status, "rejected");
|
|
assert_eq!(stored.decided_at_ms, Some(5));
|
|
assert_eq!(stored.rejection_reason, "covered by approved chunk cover-1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn conditionally_reject_leaves_approved_chunk_untouched() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_draft_chunk(
|
|
&pending_draft_chunk("cas-approved", "sb-cas-approved"),
|
|
None,
|
|
"default",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
store
|
|
.update_draft_chunk_status("cas-approved", "approved", Some(1), None)
|
|
.await
|
|
.unwrap()
|
|
);
|
|
|
|
let changed = store
|
|
.conditionally_reject_draft_chunk("cas-approved", 2, "covered")
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!changed,
|
|
"an approved chunk must not be conditionally rejected"
|
|
);
|
|
|
|
let stored = store
|
|
.get_draft_chunk("cas-approved")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
stored.status, "approved",
|
|
"the approved status must survive a losing conditional reject"
|
|
);
|
|
assert!(stored.rejection_reason.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn conditionally_reject_loses_race_to_approval() {
|
|
let store = test_store().await;
|
|
store
|
|
.put_draft_chunk(
|
|
&pending_draft_chunk("cas-race", "sb-cas-race"),
|
|
None,
|
|
"default",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let observed = store.get_draft_chunk("cas-race").await.unwrap().unwrap();
|
|
assert_eq!(observed.status, "pending");
|
|
|
|
assert!(
|
|
store
|
|
.update_draft_chunk_status("cas-race", "approved", Some(10), None)
|
|
.await
|
|
.unwrap()
|
|
);
|
|
|
|
let changed = store
|
|
.conditionally_reject_draft_chunk("cas-race", 11, "covered")
|
|
.await
|
|
.unwrap();
|
|
assert!(!changed);
|
|
|
|
let stored = store.get_draft_chunk("cas-race").await.unwrap().unwrap();
|
|
assert_eq!(
|
|
stored.status, "approved",
|
|
"an approval that commits before the conditional reject must win; the ledger must \
|
|
never read rejected while the merged rule stays enforced"
|
|
);
|
|
}
|
|
|
|
/// Undo of an approve must clear any `rejection_reason` left over from a
|
|
/// prior reject. Without this, the in-sandbox agent reading chunks via
|
|
/// `policy.local` cannot tell "pending and never rejected" from "pending
|
|
/// but previously rejected with this stale guidance." The only path that
|
|
/// lands a non-empty reason on a pending chunk is reject → re-approve →
|
|
/// undo, so the test walks that sequence.
|
|
#[tokio::test]
|
|
async fn undo_after_reject_clears_stale_rejection_reason() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_name = "undo-clears-reason".to_string();
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-undo-clears".to_string(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "allow_example".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
let submit = handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_name.clone(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_example".to_string(),
|
|
proposed_rule: Some(proposed_rule),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = submit.accepted_chunk_ids[0].clone();
|
|
let review_token = state
|
|
.store
|
|
.get_draft_chunk(&chunk_id)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.review_token;
|
|
|
|
handle_reject_draft_chunk(
|
|
&state,
|
|
authed_request(RejectDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
reason: "scope too broad".to_string(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
handle_approve_draft_chunk(
|
|
&state,
|
|
authed_request(ApproveDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
review_token,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
handle_undo_draft_chunk(
|
|
&state,
|
|
authed_request(UndoDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let restored = draft
|
|
.chunks
|
|
.iter()
|
|
.find(|c| c.id == chunk_id)
|
|
.expect("chunk should still be present after undo");
|
|
assert_eq!(restored.status, "pending");
|
|
assert!(
|
|
restored.rejection_reason.is_empty(),
|
|
"undo must clear stale rejection_reason; got: {:?}",
|
|
restored.rejection_reason
|
|
);
|
|
|
|
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
|
|
state.store.close_for_test().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn draft_chunk_handlers_reject_cross_sandbox_chunk_ids() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
// Attach a github provider so the explicitly opted-in L4 proposal
|
|
// below has a credential in scope and stays pending, keeping this
|
|
// cross-sandbox approve check reachable.
|
|
state
|
|
.store
|
|
.put_message(&test_provider("github-pat", "github"))
|
|
.await
|
|
.unwrap();
|
|
let mut sandbox_a = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-draft-owner".to_string(),
|
|
name: "draft-owner".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
providers: vec!["github-pat".to_string()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox_a.set_phase(SandboxPhase::Ready as i32);
|
|
let mut sandbox_b = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-draft-other".to_string(),
|
|
name: "draft-other".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_001).ok(),
|
|
labels: std::collections::HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox_b.set_phase(SandboxPhase::Ready as i32);
|
|
state.store.put_message(&sandbox_a).await.unwrap();
|
|
state.store.put_message(&sandbox_b).await.unwrap();
|
|
|
|
let proposed_rule = NetworkPolicyRule {
|
|
name: "allow_github".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
allow_uninspected_credentials: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
|
|
handle_submit_policy_analysis(
|
|
&state,
|
|
with_user(Request::new(SubmitPolicyAnalysisRequest {
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
|
name: sandbox_a.object_name().to_string(),
|
|
proposed_chunks: vec![PolicyChunk {
|
|
rule_name: "allow_example".to_string(),
|
|
proposed_rule: Some(proposed_rule.clone()),
|
|
rationale: "observed denied request".to_string(),
|
|
confidence: 0.85,
|
|
hit_count: 3,
|
|
first_seen_time: openshell_core::time::timestamp_from_millis(100).ok(),
|
|
last_seen_time: openshell_core::time::timestamp_from_millis(200).ok(),
|
|
binary: "/usr/bin/curl".to_string(),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let draft_policy = handle_get_draft_policy(
|
|
&state,
|
|
with_user(Request::new(GetDraftPolicyRequest {
|
|
sandbox: sandbox_a.object_name().to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
status_filter: String::new(),
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
let chunk_id = draft_policy.chunks[0].id.clone();
|
|
let review_token = draft_policy.chunks[0].review_token.clone();
|
|
let other_name = sandbox_b.object_name().to_string();
|
|
|
|
let approve_err = handle_approve_draft_chunk(
|
|
&state,
|
|
authed_request(ApproveDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: other_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
review_token: String::new(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(approve_err.code(), Code::NotFound);
|
|
|
|
let reject_err = handle_reject_draft_chunk(
|
|
&state,
|
|
authed_request(RejectDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: other_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
reason: "wrong sandbox".to_string(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(reject_err.code(), Code::NotFound);
|
|
|
|
let edit_err = handle_edit_draft_chunk(
|
|
&state,
|
|
authed_request(EditDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: other_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
proposed_rule: Some(proposed_rule.clone()),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(edit_err.code(), Code::NotFound);
|
|
|
|
handle_approve_draft_chunk(
|
|
&state,
|
|
authed_request(ApproveDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: sandbox_a.object_name().to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id: chunk_id.clone(),
|
|
review_token,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let undo_err = handle_undo_draft_chunk(
|
|
&state,
|
|
authed_request(UndoDraftChunkRequest {
|
|
request_id: String::new(),
|
|
sandbox: other_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
chunk_id,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(undo_err.code(), Code::NotFound);
|
|
|
|
// Wait for SQLx's SQLite worker threads to stop before the test process exits;
|
|
state.store.close_for_test().await;
|
|
}
|
|
|
|
#[test]
|
|
fn build_gateway_policy_audit_event_formats_ocsf_config_line() {
|
|
let message = build_gateway_policy_audit_event(
|
|
"sb-123",
|
|
"demo-sandbox",
|
|
"merged",
|
|
"gateway merged incremental policy op: add-allow api.github.com:443 [POST /repos/*/issues]",
|
|
7,
|
|
"sha256:testhash",
|
|
&[],
|
|
)
|
|
.format_shorthand();
|
|
|
|
assert_eq!(
|
|
message,
|
|
"CONFIG:MERGED [INFO] gateway merged incremental policy op: add-allow api.github.com:443 [POST /repos/*/issues] [version:v7 hash:sha256:testhash]"
|
|
);
|
|
}
|
|
|
|
/// Auto-approval audit messages carry `auto=true`, `source=<mode>`, and
|
|
/// `prover_delta=empty` as extra unmapped fields so a reviewer can
|
|
/// reconstruct the safety reasoning without needing to grep the chunk
|
|
/// table. The message text itself says "auto-approved: no new prover
|
|
/// findings" — never "safe" — because the claim is about the prover's
|
|
/// reasoning, not the world.
|
|
#[test]
|
|
fn build_gateway_policy_audit_event_carries_auto_approve_provenance() {
|
|
let extra = [
|
|
("auto", "true".to_string()),
|
|
("source", "agent_authored".to_string()),
|
|
("prover_delta", "empty".to_string()),
|
|
];
|
|
let message = build_gateway_policy_audit_event(
|
|
"sb-123",
|
|
"demo-sandbox",
|
|
"approved",
|
|
"auto-approved: no new prover findings (source=agent_authored) — chunk abc: add-rule x",
|
|
12,
|
|
"sha256:autohash",
|
|
&extra,
|
|
)
|
|
.format_shorthand();
|
|
assert!(
|
|
message.contains("CONFIG:APPROVED"),
|
|
"auto-approval reuses CONFIG:APPROVED; got: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("auto-approved: no new prover findings"),
|
|
"audit copy must say `no new prover findings`, not `safe`; got: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("auto:true"),
|
|
"missing auto field: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("source:agent_authored"),
|
|
"missing source field: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("prover_delta:empty"),
|
|
"missing prover_delta field: {message}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_policy_audit_events_reach_ocsf_jsonl() {
|
|
use tracing_subscriber::prelude::*;
|
|
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let path = directory.path().join("events.jsonl");
|
|
let config = toml::from_str(&format!(
|
|
"path = {:?}\nrotation = 'never'\n",
|
|
path.display().to_string()
|
|
))
|
|
.unwrap();
|
|
let log = crate::ocsf_log::OcsfLog::start(config).unwrap();
|
|
let subscriber = tracing_subscriber::registry().with(log.layer());
|
|
tracing::subscriber::with_default(subscriber, || {
|
|
emit_gateway_policy_audit_log(
|
|
"sb-123",
|
|
"demo-sandbox",
|
|
"approved",
|
|
"approved chunk abc",
|
|
7,
|
|
"sha256:manual",
|
|
);
|
|
emit_gateway_policy_auto_approve_audit_log(
|
|
"sb-123",
|
|
"demo-sandbox",
|
|
"auto-approved: no new prover findings",
|
|
8,
|
|
"sha256:auto",
|
|
"mechanistic",
|
|
"gateway",
|
|
);
|
|
});
|
|
log.shutdown().await;
|
|
|
|
let events: Vec<serde_json::Value> = std::fs::read_to_string(path)
|
|
.unwrap()
|
|
.lines()
|
|
.map(|line| serde_json::from_str(line).unwrap())
|
|
.collect();
|
|
assert_eq!(events.len(), 2);
|
|
for event in &events {
|
|
assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway");
|
|
assert_eq!(event["container"]["uid"], "sb-123");
|
|
assert!(event["container"].get("image").is_none());
|
|
}
|
|
assert_eq!(events[0]["unmapped"]["policy_hash"], "sha256:manual");
|
|
assert!(events[0]["unmapped"].get("auto").is_none());
|
|
assert_eq!(events[1]["unmapped"]["policy_hash"], "sha256:auto");
|
|
assert_eq!(events[1]["unmapped"]["auto"], "true");
|
|
assert_eq!(events[1]["unmapped"]["resolved_from"], "gateway");
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_cli_policy_merge_op_formats_rest_allow_rules() {
|
|
let operation = PolicyMergeOp::AddAllowRules {
|
|
target: L7RuleTarget {
|
|
rule_name: "github".to_string(),
|
|
host: "api.github.com".to_string(),
|
|
ports: vec![443],
|
|
path: Some(String::new()),
|
|
binaries: L7BinaryScope::Restricted(vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}]),
|
|
},
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "POST".to_string(),
|
|
path: "/repos/*/issues".to_string(),
|
|
command: String::new(),
|
|
query: HashMap::new(),
|
|
operation_type: String::new(),
|
|
operation_name: String::new(),
|
|
fields: Vec::new(),
|
|
params: HashMap::default(),
|
|
}),
|
|
}],
|
|
};
|
|
|
|
assert_eq!(
|
|
summarize_cli_policy_merge_op(&operation),
|
|
"add-allow api.github.com:443 rule=github endpoint-path=Some(\"\") binaries=[/usr/bin/curl] [POST /repos/*/issues]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_cli_policy_merge_op_formats_endpoint_additions() {
|
|
let operation = PolicyMergeOp::AddRule {
|
|
rule_name: "github_api".to_string(),
|
|
rule: NetworkPolicyRule {
|
|
name: "github_api".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
summarize_cli_policy_merge_op(&operation),
|
|
"add-endpoint github_api endpoints=[api.github.com:443 protocol=rest access=read-only enforcement=enforce] binaries=[/usr/bin/curl]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_cli_policy_merge_op_formats_websocket_credential_rewrite() {
|
|
let operation = PolicyMergeOp::AddRule {
|
|
rule_name: "realtime_api".to_string(),
|
|
rule: NetworkPolicyRule {
|
|
name: "realtime_api".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "realtime.example.com".to_string(),
|
|
port: 443,
|
|
protocol: "websocket".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadWrite as i32,
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
websocket_credential_rewrite: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/node".to_string(),
|
|
}],
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
summarize_cli_policy_merge_op(&operation),
|
|
"add-endpoint realtime_api endpoints=[realtime.example.com:443 protocol=websocket access=read-write enforcement=enforce websocket_credential_rewrite=true] binaries=[/usr/bin/node]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_cli_policy_merge_op_formats_request_body_credential_rewrite() {
|
|
let operation = PolicyMergeOp::AddRule {
|
|
rule_name: "slack_api".to_string(),
|
|
rule: NetworkPolicyRule {
|
|
name: "slack_api".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "slack.com".to_string(),
|
|
port: 443,
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadWrite as i32,
|
|
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
|
|
request_body_credential_rewrite: true,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/node".to_string(),
|
|
}],
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
summarize_cli_policy_merge_op(&operation),
|
|
"add-endpoint slack_api endpoints=[slack.com:443 protocol=rest access=read-write enforcement=enforce request_body_credential_rewrite=true] binaries=[/usr/bin/node]"
|
|
);
|
|
}
|
|
|
|
// ---- merge_chunk_into_policy ----
|
|
|
|
#[tokio::test]
|
|
async fn merge_chunk_into_policy_adds_first_network_rule_to_empty_policy() {
|
|
use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let store = test_store().await;
|
|
let rule = NetworkPolicyRule {
|
|
name: "google".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "google.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let chunk = DraftChunkRecord {
|
|
id: "chunk-1".to_string(),
|
|
sandbox_id: "sb-empty".to_string(),
|
|
draft_version: 1,
|
|
status: "pending".to_string(),
|
|
rule_name: "google".to_string(),
|
|
proposed_rule: rule.encode_to_vec(),
|
|
rationale: String::new(),
|
|
security_notes: String::new(),
|
|
confidence: 1.0,
|
|
created_at_ms: 0,
|
|
decided_at_ms: None,
|
|
host: "google.com".to_string(),
|
|
port: 443,
|
|
binary: "/usr/bin/curl".to_string(),
|
|
hit_count: 1,
|
|
first_seen_ms: 0,
|
|
last_seen_ms: 0,
|
|
validation_result: String::new(),
|
|
rejection_reason: String::new(),
|
|
..Default::default()
|
|
};
|
|
|
|
let (version, _) =
|
|
merge_chunk_into_policy(&store, &chunk.sandbox_id, "default", &chunk, &[])
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(version, 1);
|
|
|
|
let latest = store
|
|
.get_latest_policy(&chunk.sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("policy revision should be persisted");
|
|
let policy = openshell_core::proto::SandboxPolicy::decode(latest.policy_payload.as_slice())
|
|
.expect("policy payload should decode");
|
|
let stored_rule = policy
|
|
.network_policies
|
|
.get("google")
|
|
.expect("merged rule should be present");
|
|
assert_eq!(stored_rule.endpoints[0].host, "google.com");
|
|
assert_eq!(stored_rule.endpoints[0].port, 443);
|
|
assert_eq!(stored_rule.binaries[0].path, "/usr/bin/curl");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_chunk_merges_into_existing_rule_by_host_port() {
|
|
use openshell_core::proto::{
|
|
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy,
|
|
};
|
|
|
|
let store = test_store().await;
|
|
let sandbox_id = "sb-merge";
|
|
|
|
let initial_policy = SandboxPolicy {
|
|
network_policies: std::iter::once((
|
|
"test_server".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "test_server".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "192.168.1.100".to_string(),
|
|
port: 8567,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
},
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
store
|
|
.put_policy_revision(
|
|
"p-seed",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&initial_policy.encode_to_vec(),
|
|
"seed-hash",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let proposed = NetworkPolicyRule {
|
|
name: "allow_192_168_1_100_8567".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "192.168.1.100".to_string(),
|
|
port: 8567,
|
|
allowed_ips: vec!["192.168.1.100".to_string()],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let chunk = DraftChunkRecord {
|
|
id: "chunk-merge".to_string(),
|
|
sandbox_id: sandbox_id.to_string(),
|
|
draft_version: 1,
|
|
status: "pending".to_string(),
|
|
rule_name: "allow_192_168_1_100_8567".to_string(),
|
|
proposed_rule: proposed.encode_to_vec(),
|
|
rationale: String::new(),
|
|
security_notes: String::new(),
|
|
confidence: 0.3,
|
|
created_at_ms: 0,
|
|
decided_at_ms: None,
|
|
host: "192.168.1.100".to_string(),
|
|
port: 8567,
|
|
binary: "/usr/bin/curl".to_string(),
|
|
hit_count: 1,
|
|
first_seen_ms: 0,
|
|
last_seen_ms: 0,
|
|
validation_result: String::new(),
|
|
rejection_reason: String::new(),
|
|
..Default::default()
|
|
};
|
|
|
|
let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk, &[])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(version, 2);
|
|
|
|
let latest = store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("policy revision should be persisted");
|
|
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
|
|
|
|
assert_eq!(
|
|
policy.network_policies.len(),
|
|
1,
|
|
"expected 1 rule, got {}: {:?}",
|
|
policy.network_policies.len(),
|
|
policy.network_policies.keys().collect::<Vec<_>>()
|
|
);
|
|
let rule = policy
|
|
.network_policies
|
|
.get("test_server")
|
|
.expect("original rule name 'test_server' should be preserved");
|
|
assert_eq!(rule.endpoints[0].host, "192.168.1.100");
|
|
assert_eq!(rule.endpoints[0].allowed_ips, vec!["192.168.1.100"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn merge_chunk_new_host_port_inserts_new_entry() {
|
|
use openshell_core::proto::{
|
|
NetworkBinary, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy,
|
|
};
|
|
|
|
let store = test_store().await;
|
|
let sandbox_id = "sb-new";
|
|
|
|
let initial_policy = SandboxPolicy {
|
|
network_policies: std::iter::once((
|
|
"existing_rule".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "existing_rule".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
},
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
store
|
|
.put_policy_revision(
|
|
"p-seed",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&initial_policy.encode_to_vec(),
|
|
"seed-hash",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let proposed = NetworkPolicyRule {
|
|
name: "allow_10_0_0_5_8080".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "10.0.0.5".to_string(),
|
|
port: 8080,
|
|
allowed_ips: vec!["10.0.0.5".to_string()],
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
};
|
|
let chunk = DraftChunkRecord {
|
|
id: "chunk-new".to_string(),
|
|
sandbox_id: sandbox_id.to_string(),
|
|
draft_version: 1,
|
|
status: "pending".to_string(),
|
|
rule_name: "allow_10_0_0_5_8080".to_string(),
|
|
proposed_rule: proposed.encode_to_vec(),
|
|
rationale: String::new(),
|
|
security_notes: String::new(),
|
|
confidence: 0.3,
|
|
created_at_ms: 0,
|
|
decided_at_ms: None,
|
|
host: "10.0.0.5".to_string(),
|
|
port: 8080,
|
|
binary: "/usr/bin/curl".to_string(),
|
|
hit_count: 1,
|
|
first_seen_ms: 0,
|
|
last_seen_ms: 0,
|
|
validation_result: String::new(),
|
|
rejection_reason: String::new(),
|
|
..Default::default()
|
|
};
|
|
|
|
let (version, _) = merge_chunk_into_policy(&store, sandbox_id, "default", &chunk, &[])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(version, 2);
|
|
|
|
let latest = store.get_latest_policy(sandbox_id).await.unwrap().unwrap();
|
|
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
|
|
|
|
assert_eq!(policy.network_policies.len(), 2);
|
|
assert!(policy.network_policies.contains_key("existing_rule"));
|
|
assert!(policy.network_policies.contains_key("allow_10_0_0_5_8080"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_merge_batches_preserve_both_updates() {
|
|
use openshell_core::proto::{
|
|
L7Allow, L7DenyRule, L7Rule, NetworkEndpoint, NetworkPolicyRule, SandboxPolicy,
|
|
};
|
|
|
|
let store = test_store().await;
|
|
let sandbox_id = "sb-concurrent-merge";
|
|
|
|
let initial_policy = SandboxPolicy {
|
|
network_policies: std::iter::once((
|
|
"github".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "github".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
ports: vec![443],
|
|
protocol: "rest".to_string(),
|
|
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
},
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
store
|
|
.put_policy_revision(
|
|
"p-seed",
|
|
sandbox_id,
|
|
"default",
|
|
1,
|
|
&initial_policy.encode_to_vec(),
|
|
"seed-hash",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let add_allow = [PolicyMergeOp::AddAllowRules {
|
|
target: L7RuleTarget {
|
|
rule_name: "github".to_string(),
|
|
host: "api.github.com".to_string(),
|
|
ports: vec![443],
|
|
path: None,
|
|
binaries: L7BinaryScope::Any,
|
|
},
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "POST".to_string(),
|
|
path: "/repos/*/issues".to_string(),
|
|
command: String::new(),
|
|
query: HashMap::new(),
|
|
operation_type: String::new(),
|
|
operation_name: String::new(),
|
|
fields: Vec::new(),
|
|
params: HashMap::default(),
|
|
}),
|
|
}],
|
|
}];
|
|
let add_deny = [PolicyMergeOp::AddDenyRules {
|
|
target: L7RuleTarget {
|
|
rule_name: "github".to_string(),
|
|
host: "api.github.com".to_string(),
|
|
ports: vec![443],
|
|
path: None,
|
|
binaries: L7BinaryScope::Any,
|
|
},
|
|
deny_rules: vec![L7DenyRule {
|
|
method: "POST".to_string(),
|
|
path: "/admin".to_string(),
|
|
query: HashMap::new(),
|
|
..Default::default()
|
|
}],
|
|
}];
|
|
|
|
let (left, right) = tokio::join!(
|
|
apply_merge_operations_with_retry(
|
|
&store,
|
|
sandbox_id,
|
|
"default",
|
|
None,
|
|
&add_allow,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
None,
|
|
None
|
|
),
|
|
apply_merge_operations_with_retry(
|
|
&store,
|
|
sandbox_id,
|
|
"default",
|
|
None,
|
|
&add_deny,
|
|
PolicyMergeValidationContext {
|
|
provider_layers: &[],
|
|
credential_binding: None,
|
|
},
|
|
None,
|
|
None
|
|
),
|
|
);
|
|
|
|
let mut versions = vec![left.unwrap().0, right.unwrap().0];
|
|
versions.sort_unstable();
|
|
assert_eq!(versions, vec![2, 3]);
|
|
|
|
let latest = store.get_latest_policy(sandbox_id).await.unwrap().unwrap();
|
|
assert_eq!(latest.version, 3);
|
|
|
|
let policy = SandboxPolicy::decode(latest.policy_payload.as_slice()).unwrap();
|
|
let endpoint = &policy.network_policies["github"].endpoints[0];
|
|
assert!(endpoint.access == 0);
|
|
assert_eq!(endpoint.rules.len(), 4);
|
|
assert_eq!(endpoint.deny_rules.len(), 1);
|
|
assert_eq!(endpoint.deny_rules[0].path, "/admin");
|
|
}
|
|
|
|
// ---- validate_rule_not_always_blocked ----
|
|
|
|
#[test]
|
|
fn validate_rule_rejects_loopback_allowed_ips() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "bad".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 80,
|
|
allowed_ips: vec!["127.0.0.1".to_string()],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_err());
|
|
let status = result.unwrap_err();
|
|
assert_eq!(status.code(), Code::InvalidArgument);
|
|
assert!(status.message().contains("always-blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rule_rejects_link_local_allowed_ips() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "bad".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "example.com".to_string(),
|
|
port: 80,
|
|
allowed_ips: vec!["169.254.169.254".to_string()],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().message().contains("always-blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rule_rejects_always_blocked_host() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "bad".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 80,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().message().contains("always-blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rule_rejects_localhost_host() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "bad".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "localhost".to_string(),
|
|
port: 8080,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().message().contains("always blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rule_rejects_known_metadata_hostname() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "bad".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "METADATA.GOOGLE.INTERNAL.".to_string(),
|
|
port: 80,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_err());
|
|
let status = result.unwrap_err();
|
|
assert_eq!(status.code(), Code::InvalidArgument);
|
|
assert!(status.message().contains("cloud metadata hostname"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_merge_operations_rejects_add_allow_for_known_metadata_hostname() {
|
|
let operation = PolicyMergeOp::AddAllowRules {
|
|
target: L7RuleTarget {
|
|
rule_name: "metadata".to_string(),
|
|
host: "metadata.google.internal".to_string(),
|
|
ports: vec![80],
|
|
path: None,
|
|
binaries: L7BinaryScope::Any,
|
|
},
|
|
rules: vec![L7Rule {
|
|
allow: Some(openshell_core::proto::L7Allow {
|
|
method: "GET".to_string(),
|
|
path: "/computeMetadata/v1/**".to_string(),
|
|
command: String::new(),
|
|
query: HashMap::new(),
|
|
operation_type: String::new(),
|
|
operation_name: String::new(),
|
|
fields: Vec::new(),
|
|
params: HashMap::default(),
|
|
}),
|
|
}],
|
|
};
|
|
|
|
let result = validate_merge_operations_for_server(&[operation]);
|
|
|
|
assert!(result.is_err());
|
|
let status = result.unwrap_err();
|
|
assert_eq!(status.code(), Code::InvalidArgument);
|
|
assert!(status.message().contains("cloud metadata hostname"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rule_accepts_rfc1918_allowed_ips() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "good".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "internal.corp".to_string(),
|
|
port: 443,
|
|
allowed_ips: vec!["10.0.5.0/24".to_string()],
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rule_accepts_public_host() {
|
|
use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule};
|
|
|
|
let rule = NetworkPolicyRule {
|
|
name: "good".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
};
|
|
let result = validate_rule_not_always_blocked(&rule);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// ---- Settings tests ----
|
|
|
|
#[test]
|
|
fn merge_effective_settings_includes_unset_registered_keys() {
|
|
let global = StoredSettings::default();
|
|
let sandbox = StoredSettings::default();
|
|
let merged = merge_effective_settings(&global, &sandbox).unwrap();
|
|
for registered in settings::REGISTERED_SETTINGS {
|
|
let setting = merged
|
|
.get(registered.key)
|
|
.unwrap_or_else(|| panic!("missing registered key {}", registered.key));
|
|
assert!(
|
|
setting.value.is_none(),
|
|
"expected unset value for {}",
|
|
registered.key
|
|
);
|
|
assert_eq!(setting.scope, SettingScope::Unspecified as i32);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_global_settings_includes_unset_registered_keys() {
|
|
let global = StoredSettings::default();
|
|
let materialized = materialize_global_settings(&global).unwrap();
|
|
for registered in settings::REGISTERED_SETTINGS {
|
|
let setting = materialized
|
|
.get(registered.key)
|
|
.unwrap_or_else(|| panic!("missing registered key {}", registered.key));
|
|
assert!(
|
|
setting.value.is_none(),
|
|
"expected unset value for {}",
|
|
registered.key
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decode_policy_from_global_settings_round_trip() {
|
|
let policy = openshell_core::proto::SandboxPolicy {
|
|
version: 7,
|
|
..Default::default()
|
|
};
|
|
let encoded = hex::encode(policy.encode_to_vec());
|
|
let global = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once(("policy".to_string(), StoredSettingValue::Bytes(encoded)))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
let decoded = decode_policy_from_global_settings(&global)
|
|
.unwrap()
|
|
.expect("policy present");
|
|
assert_eq!(decoded.version, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_policy_from_global_settings_validates_and_canonicalizes_stored_policy() {
|
|
let invalid = mcp_policy_with_versions(&["latest"]);
|
|
let invalid_global = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
POLICY_SETTING_KEY.to_string(),
|
|
StoredSettingValue::Bytes(hex::encode(invalid.encode_to_vec())),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
let error = decode_policy_from_global_settings(&invalid_global)
|
|
.expect_err("invalid global policy must fail closed");
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains(STORED_POLICY_SOURCE_GLOBAL));
|
|
|
|
let reversed = mcp_policy_with_versions(&["2025-11-25", "2025-06-18", "2025-03-26"]);
|
|
let canonical = validate_and_canonicalize_policy(reversed.clone())
|
|
.expect("supported global policy must canonicalize");
|
|
let valid_global = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
POLICY_SETTING_KEY.to_string(),
|
|
StoredSettingValue::Bytes(hex::encode(reversed.encode_to_vec())),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
decode_policy_from_global_settings(&valid_global)
|
|
.expect("valid global policy")
|
|
.expect("global policy present"),
|
|
canonical
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_changes_when_effective_setting_changes() {
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let mut settings = HashMap::new();
|
|
settings.insert(
|
|
"mode".to_string(),
|
|
EffectiveSetting {
|
|
value: Some(SettingValue {
|
|
value: Some(setting_value::Value::StringValue("strict".to_string())),
|
|
}),
|
|
scope: SettingScope::Sandbox.into(),
|
|
},
|
|
);
|
|
|
|
let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
|
|
settings.insert(
|
|
"mode".to_string(),
|
|
EffectiveSetting {
|
|
value: Some(SettingValue {
|
|
value: Some(setting_value::Value::StringValue("relaxed".to_string())),
|
|
}),
|
|
scope: SettingScope::Sandbox.into(),
|
|
},
|
|
);
|
|
let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
|
|
|
|
assert_ne!(rev_a, rev_b);
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_rejects_unknown_key() {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::StringValue("hello".to_string())),
|
|
};
|
|
let err = proto_setting_to_stored("unknown_key", &value).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("unknown setting key"));
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_rejects_type_mismatch() {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::StringValue("true".to_string())),
|
|
};
|
|
let err = proto_setting_to_stored("ocsf_json_enabled", &value).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("expects bool value"));
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_accepts_bool_for_registered_bool_key() {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::BoolValue(true)),
|
|
};
|
|
let stored = proto_setting_to_stored("ocsf_json_enabled", &value).unwrap();
|
|
assert_eq!(stored, StoredSettingValue::Bool(true));
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_accepts_allowed_proposal_approval_mode_values() {
|
|
for raw in ["manual", "auto"] {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::StringValue(raw.to_string())),
|
|
};
|
|
let stored = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value)
|
|
.unwrap_or_else(|e| panic!("expected '{raw}' to be accepted, got: {e}"));
|
|
assert_eq!(stored, StoredSettingValue::String(raw.to_string()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_rejects_invalid_proposal_approval_mode_value() {
|
|
// Typos and future-reserved modes must be rejected at configure time
|
|
// — without this, the value silently resolves to manual at runtime
|
|
// (fail-closed) and the operator never finds out they fat-fingered
|
|
// the setting.
|
|
for raw in ["autom", "AUTO", "Manual", "auto_on_low_risk", "", " auto"] {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::StringValue(raw.to_string())),
|
|
};
|
|
let res = proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value);
|
|
assert!(
|
|
res.is_err(),
|
|
"expected '{raw}' to be rejected, got: {res:?}"
|
|
);
|
|
let err = res.unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_rejection_message_lists_allowed_proposal_approval_mode_values() {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::StringValue("autom".to_string())),
|
|
};
|
|
let err =
|
|
proto_setting_to_stored(settings::PROPOSAL_APPROVAL_MODE_KEY, &value).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
let msg = err.message();
|
|
assert!(msg.contains("manual"), "missing 'manual' in {msg}");
|
|
assert!(msg.contains("auto"), "missing 'auto' in {msg}");
|
|
assert!(msg.contains("autom"), "missing offending value in {msg}");
|
|
}
|
|
|
|
/// Locks in that invalid `proposal_approval_mode` is rejected at the
|
|
/// `UpdateConfig` RPC boundary — not just in the `proto_setting_to_stored`
|
|
/// helper. Prevents a future refactor from accidentally routing setting
|
|
/// writes around the validation chokepoint.
|
|
#[tokio::test]
|
|
async fn update_config_global_rejects_invalid_proposal_approval_mode() {
|
|
let state = test_server_state().await;
|
|
let req = 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("autom".to_string())),
|
|
}),
|
|
..Default::default()
|
|
}));
|
|
let err = handle_update_config(&state, req)
|
|
.await
|
|
.expect_err("invalid proposal_approval_mode must be rejected at UpdateConfig");
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(
|
|
err.message().contains("autom") && err.message().contains("manual"),
|
|
"expected rejection message to echo the bad value and list allowed values; got: {}",
|
|
err.message()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_global_policy_rejects_reserved_provider_key() {
|
|
let state = test_server_state().await;
|
|
|
|
let err = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(test_policy_with_rule(
|
|
"_provider_work_github",
|
|
"api.github.com",
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("_provider_work_github"));
|
|
assert!(err.message().contains("reserved '_provider_' prefix"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_global_policy_rejects_ambiguity_before_persisting() {
|
|
let state = test_server_state().await;
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(test_ambiguous_policy()),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("ambiguous global policy must fail before persistence");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
let settings = load_global_settings(state.store.as_ref()).await.unwrap();
|
|
assert!(!settings.settings.contains_key(POLICY_SETTING_KEY));
|
|
}
|
|
|
|
async fn install_ambiguous_provider_binding(state: &Arc<ServerState>, suffix: &str) {
|
|
use openshell_core::proto::{ProviderProfile, ProviderProfileCategory};
|
|
|
|
let profile_name = format!("ambiguous-{suffix}");
|
|
let provider_name = format!("provider-{suffix}");
|
|
state
|
|
.store
|
|
.put_message(&StoredProviderProfile {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: format!("profile-{suffix}"),
|
|
name: profile_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
profile: Some(ProviderProfile {
|
|
id: profile_name.clone(),
|
|
display_name: "Ambiguous".to_string(),
|
|
category: ProviderProfileCategory::Other as i32,
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_provider(&provider_name, &profile_name))
|
|
.await
|
|
.unwrap();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
&format!("sandbox-{suffix}"),
|
|
&format!("sandbox-{suffix}"),
|
|
test_policy_with_rule("base", "api.example.com"),
|
|
vec![provider_name],
|
|
))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deleting_global_policy_rejects_reactivated_ambiguous_provider_binding() {
|
|
let state = test_server_state().await;
|
|
install_ambiguous_provider_binding(&state, "delete-policy").await;
|
|
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
policy: Some(test_policy_with_rule("global", "global.example.com")),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("global policy should suppress provider composition");
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
global: true,
|
|
setting_key: POLICY_SETTING_KEY.to_string(),
|
|
delete_setting: true,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("global policy deletion must validate reactivated provider composition");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("sandbox-delete-policy"));
|
|
let settings = load_global_settings(state.store.as_ref()).await.unwrap();
|
|
assert!(settings.settings.contains_key(POLICY_SETTING_KEY));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_preflight_rejects_persisted_ambiguous_provider_binding() {
|
|
let state = test_server_state().await;
|
|
install_ambiguous_provider_binding(&state, "upgrade").await;
|
|
|
|
let error = validate_provider_composition_startup_preflight(&state)
|
|
.await
|
|
.expect_err("startup must reject policy that unconditional composition would activate");
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("sandbox-upgrade"));
|
|
assert!(error.message().contains("invalid effective policy"));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_effective_settings_global_overrides_sandbox_key() {
|
|
let global = StoredSettings {
|
|
revision: 2,
|
|
settings: std::iter::once((
|
|
settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(),
|
|
StoredSettingValue::Bool(false),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
let sandbox = StoredSettings {
|
|
revision: 1,
|
|
settings: [
|
|
(
|
|
settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(),
|
|
StoredSettingValue::Bool(true),
|
|
),
|
|
(
|
|
"ocsf_json_enabled".to_string(),
|
|
StoredSettingValue::Bool(true),
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
let merged = merge_effective_settings(&global, &sandbox).unwrap();
|
|
let ocsf_json = merged
|
|
.get("ocsf_json_enabled")
|
|
.expect("ocsf_json_enabled present");
|
|
assert_eq!(ocsf_json.scope, SettingScope::Sandbox as i32);
|
|
|
|
let proposals = merged
|
|
.get(settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY)
|
|
.expect("agent_policy_proposals_enabled present");
|
|
assert_eq!(proposals.scope, SettingScope::Global as i32);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_effective_settings_sandbox_scoped_value_has_sandbox_scope() {
|
|
let global = StoredSettings::default();
|
|
let sandbox = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
"ocsf_json_enabled".to_string(),
|
|
StoredSettingValue::Bool(true),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
let merged = merge_effective_settings(&global, &sandbox).unwrap();
|
|
let ocsf_json = merged
|
|
.get("ocsf_json_enabled")
|
|
.expect("ocsf_json_enabled present");
|
|
assert_eq!(ocsf_json.scope, SettingScope::Sandbox as i32);
|
|
assert!(ocsf_json.value.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_effective_settings_unset_key_has_unspecified_scope_and_no_value() {
|
|
let global = StoredSettings::default();
|
|
let sandbox = StoredSettings::default();
|
|
let merged = merge_effective_settings(&global, &sandbox).unwrap();
|
|
for registered in settings::REGISTERED_SETTINGS {
|
|
let setting = merged.get(registered.key).unwrap();
|
|
assert_eq!(setting.scope, SettingScope::Unspecified as i32);
|
|
assert!(setting.value.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn merge_effective_settings_policy_key_is_excluded() {
|
|
let global = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
"policy".to_string(),
|
|
StoredSettingValue::Bytes("deadbeef".to_string()),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
let sandbox = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
"policy".to_string(),
|
|
StoredSettingValue::Bytes("cafebabe".to_string()),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
|
|
let merged = merge_effective_settings(&global, &sandbox).unwrap();
|
|
assert!(!merged.contains_key("policy"));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_settings_names_match_sandbox_names() {
|
|
let sandbox_name = "my-sandbox";
|
|
assert_eq!(sandbox_name, "my-sandbox");
|
|
}
|
|
|
|
// ---- compute_config_revision ----
|
|
|
|
#[test]
|
|
fn config_revision_stable_when_nothing_changes() {
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let mut settings = HashMap::new();
|
|
settings.insert(
|
|
"log_level".to_string(),
|
|
EffectiveSetting {
|
|
value: Some(SettingValue {
|
|
value: Some(setting_value::Value::StringValue("info".to_string())),
|
|
}),
|
|
scope: SettingScope::Sandbox.into(),
|
|
},
|
|
);
|
|
|
|
let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
|
|
let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
|
|
assert_eq!(rev_a, rev_b);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_changes_when_policy_changes() {
|
|
let policy_a = ProtoSandboxPolicy {
|
|
version: 1,
|
|
..Default::default()
|
|
};
|
|
let policy_b = ProtoSandboxPolicy {
|
|
version: 2,
|
|
..Default::default()
|
|
};
|
|
let settings = HashMap::new();
|
|
|
|
let rev_a = compute_config_revision(Some(&policy_a), &settings, PolicySource::Sandbox, &[]);
|
|
let rev_b = compute_config_revision(Some(&policy_b), &settings, PolicySource::Sandbox, &[]);
|
|
assert_ne!(rev_a, rev_b);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_hash_changes_when_network_middlewares_change() {
|
|
let policy_a = ProtoSandboxPolicy::default();
|
|
let policy_b = ProtoSandboxPolicy {
|
|
network_middlewares: HashMap::from([(
|
|
"regex-redactor".into(),
|
|
openshell_core::proto::NetworkMiddlewareConfig {
|
|
middleware: "openshell/regex".into(),
|
|
on_error: "fail_closed".into(),
|
|
..Default::default()
|
|
},
|
|
)]),
|
|
..Default::default()
|
|
};
|
|
|
|
assert_ne!(
|
|
deterministic_policy_hash(&policy_a),
|
|
deterministic_policy_hash(&policy_b),
|
|
"middleware-only policy changes must produce a new policy hash"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_hash_is_stable_across_middleware_config_field_insertion_order() {
|
|
use prost_types::{Struct, Value, value::Kind};
|
|
use std::collections::BTreeMap;
|
|
|
|
fn string_value(value: &str) -> Value {
|
|
Value {
|
|
kind: Some(Kind::StringValue(value.into())),
|
|
}
|
|
}
|
|
|
|
fn middleware_config(reverse: bool) -> Struct {
|
|
let mut nested = BTreeMap::new();
|
|
let mut fields = BTreeMap::new();
|
|
if reverse {
|
|
nested.insert("second".into(), string_value("two"));
|
|
nested.insert("first".into(), string_value("one"));
|
|
fields.insert(
|
|
"nested".into(),
|
|
Value {
|
|
kind: Some(Kind::StructValue(Struct { fields: nested })),
|
|
},
|
|
);
|
|
fields.insert("mode".into(), string_value("redact"));
|
|
} else {
|
|
nested.insert("first".into(), string_value("one"));
|
|
nested.insert("second".into(), string_value("two"));
|
|
fields.insert("mode".into(), string_value("redact"));
|
|
fields.insert(
|
|
"nested".into(),
|
|
Value {
|
|
kind: Some(Kind::StructValue(Struct { fields: nested })),
|
|
},
|
|
);
|
|
}
|
|
Struct { fields }
|
|
}
|
|
|
|
let policy = |reverse| ProtoSandboxPolicy {
|
|
network_middlewares: HashMap::from([(
|
|
"regex-redactor".into(),
|
|
openshell_core::proto::NetworkMiddlewareConfig {
|
|
middleware: "openshell/regex".into(),
|
|
config: Some(middleware_config(reverse)),
|
|
..Default::default()
|
|
},
|
|
)]),
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
deterministic_policy_hash(&policy(false)),
|
|
deterministic_policy_hash(&policy(true)),
|
|
"equivalent middleware configs must hash identically regardless of field insertion order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn review_token_and_policy_hash_are_stable_across_nested_proto_map_order() {
|
|
use openshell_core::proto::{GraphqlOperation, L7Allow, L7QueryMatcher};
|
|
|
|
fn matcher(value: &str) -> L7QueryMatcher {
|
|
L7QueryMatcher {
|
|
glob: value.to_string(),
|
|
any: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn rule(reverse: bool) -> NetworkPolicyRule {
|
|
let mut query = HashMap::new();
|
|
let mut params = HashMap::new();
|
|
let mut persisted = HashMap::new();
|
|
let entries = if reverse {
|
|
[("zeta", "two"), ("alpha", "one")]
|
|
} else {
|
|
[("alpha", "one"), ("zeta", "two")]
|
|
};
|
|
for (key, value) in entries {
|
|
query.insert(key.to_string(), matcher(value));
|
|
params.insert(key.to_string(), matcher(value));
|
|
persisted.insert(
|
|
key.to_string(),
|
|
GraphqlOperation {
|
|
operation_type: "query".to_string(),
|
|
operation_name: value.to_string(),
|
|
fields: vec![value.to_string()],
|
|
},
|
|
);
|
|
}
|
|
NetworkPolicyRule {
|
|
name: "mapped".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.example.com".to_string(),
|
|
port: 443,
|
|
ports: vec![443],
|
|
protocol: "graphql".to_string(),
|
|
rules: vec![L7Rule {
|
|
allow: Some(L7Allow {
|
|
method: "POST".to_string(),
|
|
path: "/graphql".to_string(),
|
|
query,
|
|
params,
|
|
operation_type: "query".to_string(),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
graphql_persisted_queries: persisted,
|
|
..Default::default()
|
|
}],
|
|
binaries: vec![NetworkBinary {
|
|
path: "/usr/bin/curl".to_string(),
|
|
}],
|
|
}
|
|
}
|
|
|
|
let left_rule = rule(false);
|
|
let right_rule = rule(true);
|
|
assert_eq!(left_rule, right_rule);
|
|
let left_policy = ProtoSandboxPolicy {
|
|
network_policies: HashMap::from([("mapped".to_string(), left_rule.clone())]),
|
|
..Default::default()
|
|
};
|
|
let right_policy = ProtoSandboxPolicy {
|
|
network_policies: HashMap::from([("mapped".to_string(), right_rule.clone())]),
|
|
..Default::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
deterministic_policy_hash(&left_policy),
|
|
deterministic_policy_hash(&right_policy)
|
|
);
|
|
assert_eq!(
|
|
compute_proposal_review_token(
|
|
"mapped",
|
|
&left_rule,
|
|
&left_policy,
|
|
&left_policy,
|
|
&CredentialSet::default(),
|
|
),
|
|
compute_proposal_review_token(
|
|
"mapped",
|
|
&right_rule,
|
|
&right_policy,
|
|
&right_policy,
|
|
&CredentialSet::default(),
|
|
)
|
|
);
|
|
assert_eq!(
|
|
compute_failed_proposal_evaluation_hash("mapped", &left_rule, &left_policy, "failed",),
|
|
compute_failed_proposal_evaluation_hash("mapped", &right_rule, &right_policy, "failed",)
|
|
);
|
|
|
|
let mut changed_rule = right_rule;
|
|
changed_rule.endpoints[0].rules[0]
|
|
.allow
|
|
.as_mut()
|
|
.unwrap()
|
|
.query
|
|
.get_mut("alpha")
|
|
.unwrap()
|
|
.glob = "changed".to_string();
|
|
let changed_policy = ProtoSandboxPolicy {
|
|
network_policies: HashMap::from([("mapped".to_string(), changed_rule.clone())]),
|
|
..Default::default()
|
|
};
|
|
assert_ne!(
|
|
deterministic_policy_hash(&left_policy),
|
|
deterministic_policy_hash(&changed_policy),
|
|
"map values remain decision-relevant after canonical ordering"
|
|
);
|
|
assert_ne!(
|
|
compute_proposal_review_token(
|
|
"mapped",
|
|
&left_rule,
|
|
&left_policy,
|
|
&left_policy,
|
|
&CredentialSet::default(),
|
|
),
|
|
compute_proposal_review_token(
|
|
"mapped",
|
|
&changed_rule,
|
|
&changed_policy,
|
|
&changed_policy,
|
|
&CredentialSet::default(),
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_changes_when_policy_source_changes() {
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let settings = HashMap::new();
|
|
|
|
let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
|
|
let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Global, &[]);
|
|
assert_ne!(rev_a, rev_b);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_changes_when_validation_failure_mode_changes() {
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let settings = HashMap::new();
|
|
|
|
let fail_closed = compute_config_revision_with_validation_mode(
|
|
Some(&policy),
|
|
&settings,
|
|
PolicySource::Sandbox,
|
|
&[],
|
|
openshell_core::PolicyValidationFailureMode::FailClosed,
|
|
false,
|
|
);
|
|
let retain_last_valid = compute_config_revision_with_validation_mode(
|
|
Some(&policy),
|
|
&settings,
|
|
PolicySource::Sandbox,
|
|
&[],
|
|
openshell_core::PolicyValidationFailureMode::RetainLastValid,
|
|
false,
|
|
);
|
|
assert_ne!(fail_closed, retain_last_valid);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_changes_when_extension_authentication_capability_changes() {
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let settings = HashMap::new();
|
|
|
|
let disabled = compute_config_revision_with_validation_mode(
|
|
Some(&policy),
|
|
&settings,
|
|
PolicySource::Sandbox,
|
|
&[],
|
|
openshell_core::PolicyValidationFailureMode::FailClosed,
|
|
false,
|
|
);
|
|
let enabled = compute_config_revision_with_validation_mode(
|
|
Some(&policy),
|
|
&settings,
|
|
PolicySource::Sandbox,
|
|
&[],
|
|
openshell_core::PolicyValidationFailureMode::FailClosed,
|
|
true,
|
|
);
|
|
assert_ne!(disabled, enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_changes_when_supervisor_middleware_services_change() {
|
|
let policy = ProtoSandboxPolicy::default();
|
|
let settings = HashMap::new();
|
|
let service = openshell_core::proto::SupervisorMiddlewareService {
|
|
name: "local-guard".into(),
|
|
grpc_endpoint: "http://127.0.0.1:50051".into(),
|
|
max_payload_bytes: 1024,
|
|
..Default::default()
|
|
};
|
|
|
|
let without = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]);
|
|
let with =
|
|
compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[service]);
|
|
assert_ne!(without, with);
|
|
}
|
|
|
|
#[test]
|
|
fn config_revision_without_policy_still_hashes_settings() {
|
|
let mut settings = HashMap::new();
|
|
settings.insert(
|
|
"log_level".to_string(),
|
|
EffectiveSetting {
|
|
value: Some(SettingValue {
|
|
value: Some(setting_value::Value::StringValue("debug".to_string())),
|
|
}),
|
|
scope: SettingScope::Sandbox.into(),
|
|
},
|
|
);
|
|
|
|
let rev_a = compute_config_revision(None, &settings, PolicySource::Sandbox, &[]);
|
|
|
|
settings.insert(
|
|
"log_level".to_string(),
|
|
EffectiveSetting {
|
|
value: Some(SettingValue {
|
|
value: Some(setting_value::Value::StringValue("warn".to_string())),
|
|
}),
|
|
scope: SettingScope::Sandbox.into(),
|
|
},
|
|
);
|
|
|
|
let rev_b = compute_config_revision(None, &settings, PolicySource::Sandbox, &[]);
|
|
assert_ne!(rev_a, rev_b);
|
|
}
|
|
|
|
// ---- stored <-> proto round-trip ----
|
|
|
|
#[test]
|
|
fn stored_setting_to_proto_string_round_trip() {
|
|
let stored = StoredSettingValue::String("hello".to_string());
|
|
let proto = stored_setting_to_proto(&stored).unwrap();
|
|
assert_eq!(
|
|
proto.value,
|
|
Some(setting_value::Value::StringValue("hello".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_setting_to_proto_int_round_trip() {
|
|
let stored = StoredSettingValue::Int(42);
|
|
let proto = stored_setting_to_proto(&stored).unwrap();
|
|
assert_eq!(proto.value, Some(setting_value::Value::IntValue(42)));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_setting_to_proto_bool_round_trip() {
|
|
let stored = StoredSettingValue::Bool(false);
|
|
let proto = stored_setting_to_proto(&stored).unwrap();
|
|
assert_eq!(proto.value, Some(setting_value::Value::BoolValue(false)));
|
|
}
|
|
|
|
// ---- upsert_setting_value ----
|
|
|
|
#[test]
|
|
fn upsert_setting_value_returns_true_on_insert() {
|
|
let mut map = BTreeMap::new();
|
|
let changed = upsert_setting_value(
|
|
&mut map,
|
|
"log_level",
|
|
StoredSettingValue::String("debug".to_string()),
|
|
);
|
|
assert!(changed);
|
|
assert_eq!(
|
|
map.get("log_level"),
|
|
Some(&StoredSettingValue::String("debug".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_setting_value_returns_false_when_unchanged() {
|
|
let mut map = BTreeMap::new();
|
|
map.insert(
|
|
"log_level".to_string(),
|
|
StoredSettingValue::String("debug".to_string()),
|
|
);
|
|
let changed = upsert_setting_value(
|
|
&mut map,
|
|
"log_level",
|
|
StoredSettingValue::String("debug".to_string()),
|
|
);
|
|
assert!(!changed);
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_setting_value_returns_true_on_update() {
|
|
let mut map = BTreeMap::new();
|
|
map.insert(
|
|
"log_level".to_string(),
|
|
StoredSettingValue::String("debug".to_string()),
|
|
);
|
|
let changed = upsert_setting_value(
|
|
&mut map,
|
|
"log_level",
|
|
StoredSettingValue::String("warn".to_string()),
|
|
);
|
|
assert!(changed);
|
|
}
|
|
|
|
// ---- Settings persistence ----
|
|
|
|
#[tokio::test]
|
|
async fn global_settings_load_returns_default_when_empty() {
|
|
let store = test_store().await;
|
|
let settings = load_global_settings(&store).await.unwrap();
|
|
assert!(settings.settings.is_empty());
|
|
assert_eq!(settings.revision, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_settings_load_returns_default_when_empty() {
|
|
let store = test_store().await;
|
|
let settings = load_sandbox_settings(&store, "default", "nonexistent")
|
|
.await
|
|
.unwrap();
|
|
assert!(settings.settings.is_empty());
|
|
assert_eq!(settings.revision, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn global_settings_save_and_load_round_trip() {
|
|
let store = test_store().await;
|
|
|
|
let mut settings = StoredSettings::default();
|
|
settings.settings.insert(
|
|
"log_level".to_string(),
|
|
StoredSettingValue::String("error".to_string()),
|
|
);
|
|
settings.settings.insert(
|
|
"ocsf_json_enabled".to_string(),
|
|
StoredSettingValue::Bool(true),
|
|
);
|
|
settings.revision = 5;
|
|
save_global_settings(&store, &settings).await.unwrap();
|
|
|
|
let loaded = load_global_settings(&store).await.unwrap();
|
|
assert_eq!(loaded.revision, 5);
|
|
assert_eq!(
|
|
loaded.settings.get("log_level"),
|
|
Some(&StoredSettingValue::String("error".to_string()))
|
|
);
|
|
assert_eq!(
|
|
loaded.settings.get("ocsf_json_enabled"),
|
|
Some(&StoredSettingValue::Bool(true))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_settings_save_and_load_round_trip() {
|
|
let store = test_store().await;
|
|
|
|
let sandbox_name = "my-sandbox";
|
|
let mut settings = StoredSettings::default();
|
|
settings.settings.insert(
|
|
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
|
|
StoredSettingValue::String("auto".to_string()),
|
|
);
|
|
settings.revision = 3;
|
|
save_sandbox_settings(&store, "default", sandbox_name, &settings)
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = load_sandbox_settings(&store, "default", sandbox_name)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(loaded.revision, 3);
|
|
assert_eq!(
|
|
loaded.settings.get(settings::PROPOSAL_APPROVAL_MODE_KEY),
|
|
Some(&StoredSettingValue::String("auto".to_string()))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_global_setting_mutations_are_serialized() {
|
|
let store = Arc::new(test_store().await);
|
|
let mutex = Arc::new(tokio::sync::Mutex::new(()));
|
|
|
|
let n = 50;
|
|
let mut handles = Vec::with_capacity(n);
|
|
|
|
for i in 0..n {
|
|
let store = store.clone();
|
|
let mutex = mutex.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let _guard = mutex.lock().await;
|
|
let mut settings = load_global_settings(&store).await.unwrap();
|
|
settings
|
|
.settings
|
|
.insert(format!("key_{i}"), StoredSettingValue::Int(i as i64));
|
|
settings.revision = settings.revision.wrapping_add(1);
|
|
save_global_settings(&store, &settings).await.unwrap();
|
|
}));
|
|
}
|
|
|
|
for h in handles {
|
|
h.await.unwrap();
|
|
}
|
|
|
|
let final_settings = load_global_settings(&store).await.unwrap();
|
|
assert_eq!(final_settings.revision, n as u64);
|
|
assert_eq!(final_settings.settings.len(), n);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_global_setting_mutations_without_lock_can_lose_writes() {
|
|
let store = Arc::new(test_store().await);
|
|
|
|
let n = 50;
|
|
let mut handles = Vec::with_capacity(n);
|
|
|
|
for i in 0..n {
|
|
let store = store.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let mut settings = load_global_settings(&store).await.unwrap();
|
|
tokio::task::yield_now().await;
|
|
settings
|
|
.settings
|
|
.insert(format!("key_{i}"), StoredSettingValue::Int(i as i64));
|
|
settings.revision = settings.revision.wrapping_add(1);
|
|
save_global_settings(&store, &settings).await
|
|
}));
|
|
}
|
|
|
|
let mut succeeded = 0;
|
|
let mut cas_conflicts = 0;
|
|
for h in handles {
|
|
match h.await.unwrap() {
|
|
Ok(()) => succeeded += 1,
|
|
Err(e) if e.code() == Code::Aborted => cas_conflicts += 1,
|
|
Err(e) => panic!("unexpected error: {e}"),
|
|
}
|
|
}
|
|
|
|
let final_settings = load_global_settings(&store).await.unwrap();
|
|
|
|
// With single-attempt CAS (no retry), concurrent modifications are properly detected:
|
|
// - All tasks read initial state (revision=0, resource_version=0)
|
|
// - First write succeeds with resource_version=1
|
|
// - Subsequent writes fail with ABORTED (CAS conflict) because they all have stale resource_version=0
|
|
// - Only the first write succeeds; all others are rejected
|
|
//
|
|
// This demonstrates that single-attempt CAS prevents lost writes by rejecting stale updates.
|
|
// The caller must retry from a fresh read to incorporate concurrent changes.
|
|
assert!(
|
|
cas_conflicts > 0,
|
|
"most concurrent writes should fail with CAS conflict (succeeded={succeeded}, conflicts={cas_conflicts})"
|
|
);
|
|
assert!(
|
|
succeeded < n,
|
|
"not all writes should succeed due to conflicts (succeeded={succeeded}, total={n})"
|
|
);
|
|
assert_eq!(
|
|
final_settings.revision as usize, succeeded,
|
|
"final revision should match number of successful writes"
|
|
);
|
|
assert_eq!(
|
|
final_settings.settings.len(),
|
|
succeeded,
|
|
"final settings should contain exactly the keys from successful writes"
|
|
);
|
|
|
|
eprintln!(
|
|
"unlocked CAS test: {succeeded} succeeded, {cas_conflicts} CAS conflicts, \
|
|
final revision={} (matches succeeded count, demonstrating proper conflict detection)",
|
|
final_settings.revision
|
|
);
|
|
}
|
|
|
|
// ---- Conflict guard tests ----
|
|
|
|
#[tokio::test]
|
|
async fn conflict_guard_sandbox_set_blocked_when_global_exists() {
|
|
let store = test_store().await;
|
|
|
|
let mut global = StoredSettings::default();
|
|
global.settings.insert(
|
|
"log_level".to_string(),
|
|
StoredSettingValue::String("warn".to_string()),
|
|
);
|
|
global.revision = 1;
|
|
save_global_settings(&store, &global).await.unwrap();
|
|
|
|
let loaded_global = load_global_settings(&store).await.unwrap();
|
|
let globally_managed = loaded_global.settings.contains_key("log_level");
|
|
assert!(globally_managed);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn conflict_guard_sandbox_delete_blocked_when_global_exists() {
|
|
let store = test_store().await;
|
|
|
|
let mut global = StoredSettings::default();
|
|
global.settings.insert(
|
|
"ocsf_json_enabled".to_string(),
|
|
StoredSettingValue::Bool(true),
|
|
);
|
|
global.revision = 1;
|
|
save_global_settings(&store, &global).await.unwrap();
|
|
|
|
let loaded_global = load_global_settings(&store).await.unwrap();
|
|
assert!(loaded_global.settings.contains_key("ocsf_json_enabled"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_unlock_sandbox_set_succeeds_after_global_delete() {
|
|
let store = test_store().await;
|
|
|
|
// Create initial global settings
|
|
let mut global = StoredSettings::default();
|
|
global.settings.insert(
|
|
"log_level".to_string(),
|
|
StoredSettingValue::String("warn".to_string()),
|
|
);
|
|
global.revision = 1;
|
|
save_global_settings(&store, &global).await.unwrap();
|
|
|
|
let loaded = load_global_settings(&store).await.unwrap();
|
|
assert!(loaded.settings.contains_key("log_level"));
|
|
|
|
// Load fresh to get current resource_version before updating
|
|
let mut global = load_global_settings(&store).await.unwrap();
|
|
global.settings.remove("log_level");
|
|
global.revision = 2;
|
|
save_global_settings(&store, &global).await.unwrap();
|
|
|
|
let loaded = load_global_settings(&store).await.unwrap();
|
|
assert!(!loaded.settings.contains_key("log_level"));
|
|
|
|
let sandbox_name = "test-sandbox";
|
|
let mut sandbox_settings = load_sandbox_settings(&store, "default", sandbox_name)
|
|
.await
|
|
.unwrap();
|
|
let changed = upsert_setting_value(
|
|
&mut sandbox_settings.settings,
|
|
"log_level",
|
|
StoredSettingValue::String("debug".to_string()),
|
|
);
|
|
assert!(changed);
|
|
sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1);
|
|
save_sandbox_settings(&store, "default", sandbox_name, &sandbox_settings)
|
|
.await
|
|
.unwrap();
|
|
|
|
let reloaded = load_sandbox_settings(&store, "default", sandbox_name)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
reloaded.settings.get("log_level"),
|
|
Some(&StoredSettingValue::String("debug".to_string())),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_settings_are_workspace_isolated() {
|
|
let store = test_store().await;
|
|
let sandbox_name = "work";
|
|
|
|
let mut alpha_settings = StoredSettings::default();
|
|
alpha_settings.settings.insert(
|
|
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
|
|
StoredSettingValue::String("auto".to_string()),
|
|
);
|
|
alpha_settings.revision = 1;
|
|
save_sandbox_settings(&store, "alpha", sandbox_name, &alpha_settings)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut beta_settings = StoredSettings::default();
|
|
beta_settings.settings.insert(
|
|
settings::PROPOSAL_APPROVAL_MODE_KEY.to_string(),
|
|
StoredSettingValue::String("manual".to_string()),
|
|
);
|
|
beta_settings.revision = 1;
|
|
save_sandbox_settings(&store, "beta", sandbox_name, &beta_settings)
|
|
.await
|
|
.unwrap();
|
|
|
|
let alpha_loaded = load_sandbox_settings(&store, "alpha", sandbox_name)
|
|
.await
|
|
.unwrap();
|
|
let beta_loaded = load_sandbox_settings(&store, "beta", sandbox_name)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
alpha_loaded
|
|
.settings
|
|
.get(settings::PROPOSAL_APPROVAL_MODE_KEY),
|
|
Some(&StoredSettingValue::String("auto".to_string())),
|
|
);
|
|
assert_eq!(
|
|
beta_loaded
|
|
.settings
|
|
.get(settings::PROPOSAL_APPROVAL_MODE_KEY),
|
|
Some(&StoredSettingValue::String("manual".to_string())),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_sandbox_config_returns_workspace() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
let state = test_server_state().await;
|
|
|
|
for (ws, sb_id, sb_name) in [("alpha", "sb-alpha", "work"), ("beta", "sb-beta", "work")] {
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sb_id.to_string(),
|
|
name: sb_name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
annotations: HashMap::new(),
|
|
resource_version: 0,
|
|
workspace: ws.to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
}
|
|
|
|
let alpha_req = with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "work".to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
"sb-alpha",
|
|
);
|
|
let alpha_resp = handle_get_sandbox_config(&state, alpha_req)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(alpha_resp.workspace, "alpha");
|
|
|
|
let beta_req = with_sandbox(
|
|
Request::new(GetSandboxConfigRequest {
|
|
name: "work".to_string(),
|
|
workspace_scope: None,
|
|
}),
|
|
"sb-beta",
|
|
);
|
|
let beta_resp = handle_get_sandbox_config(&state, beta_req)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert_eq!(beta_resp.workspace, "beta");
|
|
}
|
|
|
|
#[test]
|
|
fn validate_registered_setting_key_rejects_policy() {
|
|
let err = validate_registered_setting_key("policy").unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("unknown setting key"));
|
|
}
|
|
|
|
#[test]
|
|
fn proto_setting_to_stored_rejects_policy_key() {
|
|
let value = SettingValue {
|
|
value: Some(setting_value::Value::StringValue("anything".to_string())),
|
|
};
|
|
let err = proto_setting_to_stored("policy", &value).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("unknown setting key"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_settings_detects_concurrent_modification() {
|
|
let store = test_store().await;
|
|
|
|
// Create initial settings
|
|
let mut settings = StoredSettings {
|
|
revision: 1,
|
|
settings: std::iter::once((
|
|
"initial_key".to_string(),
|
|
StoredSettingValue::String("initial_value".to_string()),
|
|
))
|
|
.collect(),
|
|
..Default::default()
|
|
};
|
|
save_global_settings(&store, &settings).await.unwrap();
|
|
|
|
// Load settings (simulating first client read)
|
|
let loaded = load_global_settings(&store).await.unwrap();
|
|
assert_eq!(loaded.revision, 1);
|
|
|
|
// Simulate concurrent modification: another client updates the settings
|
|
let mut concurrent_update = loaded.clone();
|
|
concurrent_update.settings.insert(
|
|
"concurrent_key".to_string(),
|
|
StoredSettingValue::String("concurrent_value".to_string()),
|
|
);
|
|
concurrent_update.revision = 2;
|
|
save_global_settings(&store, &concurrent_update)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Now attempt to save our original modification (which is based on stale revision 1)
|
|
settings.settings.insert(
|
|
"our_key".to_string(),
|
|
StoredSettingValue::String("our_value".to_string()),
|
|
);
|
|
settings.revision = 2; // We think we're updating to revision 2
|
|
|
|
let result = save_global_settings(&store, &settings).await;
|
|
|
|
// Should fail with ABORTED due to concurrent modification
|
|
assert!(result.is_err(), "save with stale revision should fail");
|
|
let err = result.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::Aborted,
|
|
"should fail with ABORTED due to version mismatch"
|
|
);
|
|
assert!(
|
|
err.message().contains("concurrently"),
|
|
"error should mention concurrent modification: {}",
|
|
err.message()
|
|
);
|
|
|
|
// Verify the database contains the concurrent update, not our stale update
|
|
let final_settings = load_global_settings(&store).await.unwrap();
|
|
assert_eq!(final_settings.revision, 2);
|
|
assert!(
|
|
final_settings.settings.contains_key("concurrent_key"),
|
|
"concurrent update should be preserved"
|
|
);
|
|
assert!(
|
|
!final_settings.settings.contains_key("our_key"),
|
|
"stale update should NOT be in database"
|
|
);
|
|
}
|
|
|
|
// ---- CAS (Client-driven optimistic concurrency) tests for UpdateConfig ----
|
|
// These test the policy backfill path where spec.policy is None and UpdateConfig
|
|
// uses update_message_cas to atomically set it.
|
|
|
|
#[tokio::test]
|
|
async fn update_config_policy_backfill_cas_succeeds_with_correct_version() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
|
|
// Create a sandbox WITHOUT a policy (spec.policy = None)
|
|
// This simulates a sandbox before the supervisor has discovered and synced a policy
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-1".to_string(),
|
|
name: "test-sandbox".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
spec: Some(SandboxSpec {
|
|
policy: None, // No policy yet - will be backfilled
|
|
providers: Vec::new(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
sandbox.set_phase(SandboxPhase::Provisioning as i32);
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Fetch the sandbox to get its current resource_version
|
|
let current = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "test-sandbox")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
|
|
// Backfill the policy with correct expected_resource_version
|
|
let new_policy = ProtoSandboxPolicy {
|
|
process: Some(openshell_core::proto::ProcessPolicy {
|
|
run_as_user: "1234".to_string(),
|
|
run_as_group: String::new(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
authed_request(UpdateConfigRequest {
|
|
request_id: String::new(),
|
|
sandbox: "test-sandbox".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(new_policy),
|
|
setting_key: String::new(),
|
|
setting_value: None,
|
|
delete_setting: false,
|
|
global: false,
|
|
merge_operations: vec![],
|
|
expected_resource_version: current_version,
|
|
annotations: HashMap::new(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
// UpdateConfigResponse contains the policy version
|
|
assert_eq!(response.version, 1);
|
|
|
|
// Verify the resource_version incremented and policy was backfilled
|
|
// without replacing an omitted process identity component.
|
|
let updated_sandbox = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "test-sandbox")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let process = updated_sandbox
|
|
.spec
|
|
.as_ref()
|
|
.and_then(|spec| spec.policy.as_ref())
|
|
.and_then(|policy| policy.process.as_ref())
|
|
.expect("partial process identity should be persisted");
|
|
assert_eq!(process.run_as_user, "1234");
|
|
assert!(process.run_as_group.is_empty());
|
|
assert_eq!(
|
|
updated_sandbox.metadata.as_ref().unwrap().resource_version,
|
|
current_version + 1,
|
|
"resource_version should increment during CAS backfill"
|
|
);
|
|
assert!(
|
|
updated_sandbox.spec.as_ref().unwrap().policy.is_some(),
|
|
"policy should be backfilled"
|
|
);
|
|
}
|
|
|
|
#[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_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::from([(
|
|
"openshell.nvidia.com/existing".to_string(),
|
|
"keep".to_string(),
|
|
)]),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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::<Sandbox>("default", "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,
|
|
authed_request(UpdateConfigRequest {
|
|
request_id: String::new(),
|
|
sandbox: "annotated-backfill".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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::<Sandbox>("default", "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 policy = test_policy_with_rule("sandbox_only", "sandbox.example.com");
|
|
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",
|
|
"default",
|
|
1,
|
|
&policy.encode_to_vec(),
|
|
&hash,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut watch_rx = state.sandbox_watch_bus.subscribe("sb-same-hash");
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
authed_request(UpdateConfigRequest {
|
|
sandbox: "same-hash".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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);
|
|
watch_rx
|
|
.try_recv()
|
|
.expect("new provenance revision must notify the sandbox watcher");
|
|
assert!(
|
|
matches!(
|
|
watch_rx.try_recv(),
|
|
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
|
|
),
|
|
"one committed revision must wake the sandbox watcher exactly once"
|
|
);
|
|
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::<Sandbox>("default", "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 policy = test_policy_with_rule("sandbox_only", "sandbox.example.com");
|
|
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 {
|
|
sandbox: "idempotent-provenance".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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 {
|
|
sandbox: "idempotent-provenance".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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 baseline = test_policy_with_rule("sandbox_only", "old.example.com");
|
|
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",
|
|
"default",
|
|
1,
|
|
&baseline.encode_to_vec(),
|
|
&deterministic_policy_hash(&baseline),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let updated = test_policy_with_rule("sandbox_only", "new.example.com");
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "preserve-full".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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::<Sandbox>("default", "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 baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com");
|
|
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 {
|
|
sandbox: ("preserve-merge").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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::<Sandbox>("default", "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 baseline = test_policy_with_rule("sandbox_only", "sandbox.example.com");
|
|
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 {
|
|
sandbox: ("merge-provenance").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::from([(
|
|
"openshell.nvidia.com/policy-signature".to_string(),
|
|
"keep".to_string(),
|
|
)]),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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::<Sandbox>("default", "preserve-backfill")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
authed_request(UpdateConfigRequest {
|
|
sandbox: "preserve-backfill".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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::<Sandbox>("default", "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_reports_immutable_removal_before_policy_safety_errors() {
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-static-error-priority";
|
|
let sandbox_name = "static-error-priority";
|
|
let baseline = openshell_policy::restrictive_default_policy();
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
sandbox_id,
|
|
sandbox_name,
|
|
baseline.clone(),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let current = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
|
|
// The replacement removes baseline paths and adds an unsafe traversal.
|
|
// Live-policy immutability is the earlier contract, so it must remain
|
|
// the stable failure even when later whole-policy validation would fail.
|
|
let mut unsafe_replacement = baseline;
|
|
unsafe_replacement.filesystem.as_mut().unwrap().read_only =
|
|
vec!["/usr/../etc/shadow".to_string()];
|
|
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(unsafe_replacement),
|
|
expected_resource_version: current_version,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("immutable removal must fail before whole-policy validation");
|
|
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
assert!(error.message().contains("cannot be removed"));
|
|
let unchanged = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
unchanged
|
|
.spec
|
|
.as_ref()
|
|
.and_then(|spec| spec.policy.as_ref()),
|
|
Some(&openshell_policy::restrictive_default_policy())
|
|
);
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_policy_backfill_validates_before_persistence() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-invalid-first-sync";
|
|
let sandbox_name = "invalid-first-sync";
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sandbox_id.to_string(),
|
|
name: sandbox_name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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::<Sandbox>("default", sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
|
|
let invalid_version_sets: &[&[&str]] = &[&["latest"], &["2025-11-25", "2025-11-25"]];
|
|
for versions in invalid_version_sets {
|
|
let error = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(mcp_policy_with_versions(versions)),
|
|
expected_resource_version: current_version,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect_err("invalid first-sync policy must fail before backfill");
|
|
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
let unchanged = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(unchanged.spec.as_ref().unwrap().policy.is_none());
|
|
assert!(
|
|
state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_policy_backfill_persists_defaulted_mcp_versions_identically() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let canonical_policy = mcp_policy_with_versions(&["2025-11-25"]);
|
|
let canonical_policy = validate_and_canonicalize_policy(canonical_policy)
|
|
.expect("explicit default MCP policy must canonicalize");
|
|
let canonical_payload = canonical_policy.encode_to_vec();
|
|
let canonical_hash = deterministic_policy_hash(&canonical_policy);
|
|
let cases = [
|
|
("omitted-options", mcp_policy_without_options()),
|
|
("empty-versions", mcp_policy_with_versions(&[])),
|
|
(
|
|
"explicit-default",
|
|
mcp_policy_with_versions(&["2025-11-25"]),
|
|
),
|
|
];
|
|
|
|
for (case, policy) in cases {
|
|
let sandbox_id = format!("sb-default-first-sync-{case}");
|
|
let sandbox_name = format!("default-first-sync-{case}");
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sandbox_id.clone(),
|
|
name: sandbox_name.clone(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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::<Sandbox>("default", &sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.clone(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(policy),
|
|
expected_resource_version: current_version,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("defaulted first-sync policy must persist")
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 1, "{case}");
|
|
assert_eq!(response.policy_hash, canonical_hash, "{case}");
|
|
let stored = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", &sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let stored_policy = stored
|
|
.spec
|
|
.as_ref()
|
|
.and_then(|spec| spec.policy.as_ref())
|
|
.expect("backfilled sandbox policy");
|
|
assert_eq!(stored_policy, &canonical_policy, "{case}");
|
|
assert_eq!(
|
|
mcp_versions(stored_policy),
|
|
&["2025-11-25".to_string()],
|
|
"{case}"
|
|
);
|
|
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("first-sync policy revision must exist");
|
|
assert_eq!(revision.policy_payload, canonical_payload, "{case}");
|
|
assert_eq!(revision.policy_hash, canonical_hash, "{case}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_policy_backfill_canonicalizes_mcp_versions_before_persistence() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
let sandbox_id = "sb-canonical-first-sync";
|
|
let sandbox_name = "canonical-first-sync";
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: sandbox_id.to_string(),
|
|
name: sandbox_name.to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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::<Sandbox>("default", sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
let canonical_policy =
|
|
mcp_policy_with_versions(&["2025-03-26", "2025-06-18", "2025-11-25"]);
|
|
let canonical_policy = validate_and_canonicalize_policy(canonical_policy)
|
|
.expect("canonical MCP policy must validate");
|
|
|
|
handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: sandbox_name.to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(mcp_policy_with_versions(&[
|
|
"2025-11-25",
|
|
"2025-06-18",
|
|
"2025-03-26",
|
|
])),
|
|
expected_resource_version: current_version,
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.expect("valid first-sync policy must persist");
|
|
|
|
let stored = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", sandbox_name)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
stored.spec.as_ref().and_then(|spec| spec.policy.as_ref()),
|
|
Some(&canonical_policy)
|
|
);
|
|
let revision = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.unwrap()
|
|
.expect("first-sync policy revision must exist");
|
|
assert_eq!(revision.policy_payload, canonical_policy.encode_to_vec());
|
|
assert_eq!(
|
|
revision.policy_hash,
|
|
deterministic_policy_hash(&canonical_policy)
|
|
);
|
|
}
|
|
|
|
#[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 {
|
|
sandbox: "invalid-annotation".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".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;
|
|
state
|
|
.store
|
|
.put_message(&test_sandbox(
|
|
"sb-user-reserved-key",
|
|
"user-reserved-key",
|
|
test_policy_with_rule("sandbox_only", "sandbox.example.com"),
|
|
Vec::new(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = handle_update_config(
|
|
&state,
|
|
with_user(Request::new(UpdateConfigRequest {
|
|
sandbox: "user-reserved-key".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(test_policy_with_rule(
|
|
"_provider_work_github",
|
|
"api.github.com",
|
|
)),
|
|
..Default::default()
|
|
})),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("_provider_work_github"));
|
|
assert!(err.message().contains("reserved '_provider_' prefix"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_sandbox_sync_strips_reserved_provider_keys_before_persisting() {
|
|
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-sync-strip".to_string(),
|
|
name: "sync-strip".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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::<Sandbox>("default", "sync-strip")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
|
|
let mut synced_policy = test_policy_with_rule("sandbox_only", "sandbox.example.com");
|
|
synced_policy.network_policies.insert(
|
|
"_provider_work_github".to_string(),
|
|
NetworkPolicyRule {
|
|
name: "_provider_work_github".to_string(),
|
|
endpoints: vec![NetworkEndpoint {
|
|
host: "api.github.com".to_string(),
|
|
port: 443,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
let response = handle_update_config(
|
|
&state,
|
|
with_sandbox(
|
|
Request::new(UpdateConfigRequest {
|
|
sandbox: "sync-strip".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(synced_policy),
|
|
expected_resource_version: current_version,
|
|
..Default::default()
|
|
}),
|
|
"sb-sync-strip",
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
|
|
assert_eq!(response.version, 1);
|
|
|
|
let updated_sandbox = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "sync-strip")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let spec_policy = updated_sandbox
|
|
.spec
|
|
.as_ref()
|
|
.and_then(|spec| spec.policy.as_ref())
|
|
.expect("spec.policy should be backfilled");
|
|
assert!(spec_policy.network_policies.contains_key("sandbox_only"));
|
|
assert!(
|
|
!spec_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
|
|
let persisted = state
|
|
.store
|
|
.get_latest_policy("sb-sync-strip")
|
|
.await
|
|
.unwrap()
|
|
.expect("policy revision should be persisted");
|
|
let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice())
|
|
.expect("persisted policy should decode");
|
|
assert!(
|
|
persisted_policy
|
|
.network_policies
|
|
.contains_key("sandbox_only")
|
|
);
|
|
assert!(
|
|
!persisted_policy
|
|
.network_policies
|
|
.contains_key("_provider_work_github")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_config_policy_backfill_cas_rejects_stale_version() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
|
|
let state = test_server_state().await;
|
|
|
|
// Create a sandbox WITHOUT a policy
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-1".to_string(),
|
|
name: "test-sandbox".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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();
|
|
|
|
// Get current version
|
|
let current = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "test-sandbox")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let current_version = current.metadata.as_ref().unwrap().resource_version;
|
|
|
|
// Try to backfill with a stale version
|
|
let new_policy = ProtoSandboxPolicy::default();
|
|
|
|
let err = handle_update_config(
|
|
&state,
|
|
authed_request(UpdateConfigRequest {
|
|
request_id: String::new(),
|
|
sandbox: "test-sandbox".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(new_policy),
|
|
setting_key: String::new(),
|
|
setting_value: None,
|
|
delete_setting: false,
|
|
global: false,
|
|
merge_operations: vec![],
|
|
expected_resource_version: 99, // stale version
|
|
annotations: HashMap::new(),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
// Should get ABORTED status for CAS conflict
|
|
assert_eq!(err.code(), Code::Aborted);
|
|
assert!(
|
|
err.message().contains("modified concurrently")
|
|
|| err.message().contains("resource_version"),
|
|
"error message should mention concurrency conflict: {}",
|
|
err.message()
|
|
);
|
|
|
|
// Verify the sandbox was not modified (policy still None)
|
|
let unchanged = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "test-sandbox")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
unchanged.metadata.as_ref().unwrap().resource_version,
|
|
current_version,
|
|
"resource_version should not change when CAS fails"
|
|
);
|
|
assert!(
|
|
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]
|
|
async fn update_config_policy_backfill_concurrent_with_stale_versions() {
|
|
use openshell_core::proto::{SandboxPhase, SandboxSpec};
|
|
use std::sync::Arc;
|
|
|
|
let state = Arc::new(test_server_state().await);
|
|
|
|
// Create a sandbox WITHOUT a policy
|
|
let mut sandbox = Sandbox {
|
|
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
|
|
id: "sb-1".to_string(),
|
|
name: "test-sandbox".to_string(),
|
|
created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(),
|
|
labels: HashMap::new(),
|
|
resource_version: 0,
|
|
annotations: HashMap::new(),
|
|
workspace: "default".to_string(),
|
|
deletion_time: None,
|
|
}),
|
|
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();
|
|
|
|
// All three clients fetch the sandbox and see the same version
|
|
let initial = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "test-sandbox")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let initial_version = initial.metadata.as_ref().unwrap().resource_version;
|
|
|
|
// Launch 3 concurrent policy backfill attempts, all using the same initial version
|
|
let mut handles = vec![];
|
|
for _i in 0..3 {
|
|
let state_clone = Arc::clone(&state);
|
|
let new_policy = ProtoSandboxPolicy::default();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
handle_update_config(
|
|
&state_clone,
|
|
authed_request(UpdateConfigRequest {
|
|
request_id: String::new(),
|
|
sandbox: "test-sandbox".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"default".to_string(),
|
|
)),
|
|
policy: Some(new_policy),
|
|
setting_key: String::new(),
|
|
setting_value: None,
|
|
delete_setting: false,
|
|
global: false,
|
|
merge_operations: vec![],
|
|
expected_resource_version: initial_version,
|
|
annotations: HashMap::new(),
|
|
}),
|
|
)
|
|
.await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles)
|
|
.await
|
|
.into_iter()
|
|
.map(|r| r.unwrap())
|
|
.collect();
|
|
|
|
// Only one should succeed; others should get ABORTED
|
|
let successes = results.iter().filter(|r| r.is_ok()).count();
|
|
let aborted_conflicts = results
|
|
.iter()
|
|
.filter(|r| r.as_ref().err().is_some_and(|e| e.code() == Code::Aborted))
|
|
.count();
|
|
|
|
assert_eq!(
|
|
successes, 1,
|
|
"exactly one backfill should succeed with client-driven CAS"
|
|
);
|
|
assert_eq!(
|
|
aborted_conflicts, 2,
|
|
"two backfills should fail with ABORTED due to stale version"
|
|
);
|
|
|
|
// Final sandbox should have resource_version = initial_version + 1 and policy backfilled
|
|
let final_sandbox = state
|
|
.store
|
|
.get_message_by_name::<Sandbox>("default", "test-sandbox")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(
|
|
final_sandbox.metadata.as_ref().unwrap().resource_version,
|
|
initial_version + 1
|
|
);
|
|
assert!(
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// Non-member callers must receive `PERMISSION_DENIED` — not `NOT_FOUND` —
|
|
/// when targeting a workspace that does not exist. Returning `NOT_FOUND`
|
|
/// would create a CWE-203 workspace-name oracle.
|
|
#[tokio::test]
|
|
async fn non_member_gets_gets_not_found_without_sandbox_oracle() {
|
|
let mut state = test_server_state().await;
|
|
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
|
|
|
|
fn non_member_request<T>(inner: T) -> Request<T> {
|
|
let mut req = Request::new(inner);
|
|
req.extensions_mut().insert(Principal::User(UserPrincipal {
|
|
identity: Identity {
|
|
subject: "non-member".to_string(),
|
|
display_name: None,
|
|
roles: vec![],
|
|
scopes: vec![],
|
|
provider: IdentityProvider::Oidc,
|
|
},
|
|
}));
|
|
req
|
|
}
|
|
|
|
let err = handle_get_sandbox_policy_status(
|
|
&state,
|
|
non_member_request(GetSandboxPolicyStatusRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_get_sandbox_policy_status should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_list_sandbox_policies(
|
|
&state,
|
|
non_member_request(ListSandboxPoliciesRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_list_sandbox_policies should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_update_config(
|
|
&state,
|
|
non_member_request(UpdateConfigRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_update_config should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_get_draft_policy(
|
|
&state,
|
|
non_member_request(GetDraftPolicyRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_get_draft_policy should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_approve_draft_chunk(
|
|
&state,
|
|
non_member_request(ApproveDraftChunkRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_approve_draft_chunk should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_reject_draft_chunk(
|
|
&state,
|
|
non_member_request(RejectDraftChunkRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_reject_draft_chunk should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_approve_all_draft_chunks(
|
|
&state,
|
|
non_member_request(ApproveAllDraftChunksRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_approve_all_draft_chunks should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_edit_draft_chunk(
|
|
&state,
|
|
non_member_request(EditDraftChunkRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_edit_draft_chunk should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_undo_draft_chunk(
|
|
&state,
|
|
non_member_request(UndoDraftChunkRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_undo_draft_chunk should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_clear_draft_chunks(
|
|
&state,
|
|
non_member_request(ClearDraftChunksRequest {
|
|
request_id: String::new(),
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_clear_draft_chunks should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
|
|
let err = handle_get_draft_history(
|
|
&state,
|
|
non_member_request(GetDraftHistoryRequest {
|
|
sandbox: ("any").to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"no-such-ws".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_get_draft_history should return NotFound, got {:?}",
|
|
err.code()
|
|
);
|
|
}
|
|
|
|
/// Name-based policy handlers hide cross-workspace resources after
|
|
/// authorizing the explicit workspace selector.
|
|
#[tokio::test]
|
|
async fn name_based_policy_handlers_hide_cross_workspace_sandboxes() {
|
|
let mut state = test_server_state().await;
|
|
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
|
|
|
|
fn non_member_request<T>(inner: T) -> Request<T> {
|
|
let mut req = Request::new(inner);
|
|
req.extensions_mut().insert(Principal::User(UserPrincipal {
|
|
identity: Identity {
|
|
subject: "non-member".to_string(),
|
|
display_name: None,
|
|
roles: vec![],
|
|
scopes: vec![],
|
|
provider: IdentityProvider::Oidc,
|
|
},
|
|
}));
|
|
req
|
|
}
|
|
|
|
let mut sandbox = test_sandbox(
|
|
"sandbox-other",
|
|
"other",
|
|
ProtoSandboxPolicy::default(),
|
|
Vec::new(),
|
|
);
|
|
sandbox.metadata.as_mut().unwrap().workspace = "other-workspace".to_string();
|
|
state.store.put_message(&sandbox).await.unwrap();
|
|
|
|
// --- handle_get_sandbox_config ---
|
|
let err = handle_get_sandbox_config(
|
|
&state,
|
|
non_member_request(GetSandboxConfigRequest {
|
|
name: "other".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"other-workspace".to_string(),
|
|
)),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_get_sandbox_config must return NotFound, not PermissionDenied"
|
|
);
|
|
|
|
// --- handle_get_sandbox_logs ---
|
|
let err = handle_get_sandbox_logs(
|
|
&state,
|
|
non_member_request(GetSandboxLogsRequest {
|
|
sandbox: "other".to_string(),
|
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
|
"other-workspace".to_string(),
|
|
)),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.code(),
|
|
Code::NotFound,
|
|
"handle_get_sandbox_logs must hide unauthorized sandbox existence"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_gateway_config_accessible_without_platform_admin() {
|
|
let mut state = test_server_state().await;
|
|
Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".to_string();
|
|
|
|
let mut req = Request::new(GetGatewayConfigRequest {});
|
|
req.extensions_mut().insert(Principal::User(UserPrincipal {
|
|
identity: Identity {
|
|
subject: "workspace-user".to_string(),
|
|
display_name: None,
|
|
roles: vec![],
|
|
scopes: vec![],
|
|
provider: IdentityProvider::Oidc,
|
|
},
|
|
}));
|
|
|
|
let response = handle_get_gateway_config(&state, req).await;
|
|
assert!(
|
|
response.is_ok(),
|
|
"GetGatewayConfig must not require Platform Admin; got {:?}",
|
|
response.unwrap_err()
|
|
);
|
|
}
|
|
}
|