mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-21 06:11:31 +08:00
* feat(extensions)!: normalize protocol negotiation Closes #3057 Introduce a shared extension handshake, enforce protocol and capability compatibility across extension families, and expose immutable negotiated snapshots through gateway info and the Go SDK. Signed-off-by: Seth Jennings <sjenning@redhat.com> * fix(credentials): fail fast on negotiation errors Signed-off-by: Seth Jennings <sjenning@redhat.com> * fix(extensions): validate gateway handshake metadata Signed-off-by: Seth Jennings <sjenning@redhat.com> * fix(go-sdk): re-export extension kind constants Signed-off-by: Seth Jennings <sjenning@redhat.com> * fix(extensions): fail fast on credential handshake rejection Signed-off-by: Seth Jennings <sjenning@redhat.com> --------- Signed-off-by: Seth Jennings <sjenning@redhat.com>
991 lines
29 KiB
Rust
991 lines
29 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use super::*;
|
|
use openshell_core::proto::gateway_interceptor::v1::{
|
|
ModifyOperationEvaluation, PostCommitEvaluation, ValidateEvaluation,
|
|
};
|
|
use serde_json::json;
|
|
|
|
fn service() -> GovernanceInterceptorService {
|
|
let profiles = load_provider_profiles(&default_profiles_path()).unwrap();
|
|
GovernanceInterceptorService::from_profiles(profiles).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn describe_rejects_incompatible_gateway_metadata() {
|
|
let service = service();
|
|
let mut gateway = openshell_core::extension_protocol::gateway_metadata(
|
|
openshell_core::extension_protocol::ExtensionFamily::GatewayInterceptor,
|
|
);
|
|
gateway.protocol_version.as_mut().unwrap().major = 2;
|
|
|
|
let error = GatewayInterceptor::describe(
|
|
&service,
|
|
Request::new(DescribeRequest {
|
|
gateway: Some(gateway),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error.code(), Code::FailedPrecondition);
|
|
assert!(error.message().contains("unsupported protocol"));
|
|
}
|
|
|
|
fn evaluation(
|
|
method: &str,
|
|
phase: GatewayInterceptorPhase,
|
|
operation: Value,
|
|
) -> InterceptorEvaluation {
|
|
let proposed_operation = Some(json_to_struct(&operation).unwrap());
|
|
let phase = match phase {
|
|
GatewayInterceptorPhase::ModifyOperation => {
|
|
interceptor_evaluation::Phase::ModifyOperation(ModifyOperationEvaluation {
|
|
proposed_operation,
|
|
})
|
|
}
|
|
GatewayInterceptorPhase::Validate => {
|
|
interceptor_evaluation::Phase::Validate(ValidateEvaluation {
|
|
proposed_operation,
|
|
current_state: None,
|
|
})
|
|
}
|
|
GatewayInterceptorPhase::PostCommit => {
|
|
interceptor_evaluation::Phase::PostCommit(PostCommitEvaluation {
|
|
committed_response: proposed_operation,
|
|
})
|
|
}
|
|
GatewayInterceptorPhase::Unspecified => panic!("test evaluation phase must be specified"),
|
|
};
|
|
InterceptorEvaluation {
|
|
interceptor_name: "test".to_string(),
|
|
binding_id: "binding".to_string(),
|
|
service: SERVICE.to_string(),
|
|
method: method.to_string(),
|
|
principal: HashMap::new(),
|
|
phase: Some(phase),
|
|
}
|
|
}
|
|
|
|
fn sandbox_evaluation(
|
|
method: &str,
|
|
phase: GatewayInterceptorPhase,
|
|
operation: Value,
|
|
) -> InterceptorEvaluation {
|
|
let mut evaluation = evaluation(method, phase, operation);
|
|
evaluation
|
|
.principal
|
|
.insert("kind".to_string(), "sandbox".to_string());
|
|
evaluation
|
|
.principal
|
|
.insert("sandbox_id".to_string(), "demo-id".to_string());
|
|
evaluation
|
|
}
|
|
|
|
fn managed_profile_ids(service: &GovernanceInterceptorService) -> Vec<String> {
|
|
service.current_profile_state().ids
|
|
}
|
|
|
|
fn policy_state(service: &GovernanceInterceptorService) -> PolicyState {
|
|
service.current_policy_state()
|
|
}
|
|
|
|
fn jwt_header(service: &GovernanceInterceptorService) -> Header {
|
|
let mut header = Header::new(Algorithm::EdDSA);
|
|
header.kid = Some(service.policy_signer.kid().to_string());
|
|
header
|
|
}
|
|
|
|
#[test]
|
|
fn evaluation_requires_a_phase_payload() {
|
|
let service = service();
|
|
let mut request = evaluation(
|
|
"CreateProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({}),
|
|
);
|
|
request.phase = None;
|
|
|
|
let error = service.evaluate_inner(&request).unwrap_err();
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
assert_eq!(error.message(), "interceptor phase is required");
|
|
}
|
|
|
|
#[test]
|
|
fn evaluation_requires_a_proposed_operation() {
|
|
let service = service();
|
|
let mut request = evaluation(
|
|
"CreateProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({}),
|
|
);
|
|
let Some(interceptor_evaluation::Phase::Validate(payload)) = request.phase.as_mut() else {
|
|
panic!("expected validate payload");
|
|
};
|
|
payload.proposed_operation = None;
|
|
|
|
let error = service.evaluate_inner(&request).unwrap_err();
|
|
assert_eq!(error.code(), Code::InvalidArgument);
|
|
assert_eq!(error.message(), "phase payload is required");
|
|
}
|
|
|
|
fn assert_signed_profile(service: &GovernanceInterceptorService, profile: &ProviderProfile) {
|
|
let profile_hash = profile
|
|
.annotations
|
|
.get(PROFILE_HASH_ANNOTATION)
|
|
.expect("profile hash annotation");
|
|
assert_eq!(
|
|
profile_hash,
|
|
&deterministic_profile_hash(profile).expect("profile hash")
|
|
);
|
|
assert_eq!(
|
|
profile
|
|
.annotations
|
|
.get(PROFILE_SIGNATURE_KID_ANNOTATION)
|
|
.map(String::as_str),
|
|
Some(service.policy_signer.kid())
|
|
);
|
|
let profile_signature = profile
|
|
.annotations
|
|
.get(PROFILE_SIGNATURE_ANNOTATION)
|
|
.expect("profile signature annotation");
|
|
service
|
|
.policy_signer
|
|
.verify_profile_signature(profile_signature, &profile.id, profile_hash)
|
|
.expect("profile signature verifies");
|
|
}
|
|
|
|
fn governed_create_operation(
|
|
service: &GovernanceInterceptorService,
|
|
policy: Value,
|
|
signature: String,
|
|
) -> Value {
|
|
governed_create_operation_with_providers(policy, signature, managed_profile_ids(service))
|
|
}
|
|
|
|
fn governed_create_operation_with_providers(
|
|
policy: Value,
|
|
signature: String,
|
|
providers: Vec<String>,
|
|
) -> Value {
|
|
let mut operation = json!({
|
|
"spec": {
|
|
"policy": policy,
|
|
"providers": providers,
|
|
},
|
|
"annotations": {},
|
|
});
|
|
operation
|
|
.pointer_mut("/annotations")
|
|
.and_then(Value::as_object_mut)
|
|
.unwrap()
|
|
.insert(
|
|
POLICY_SIGNATURE_ANNOTATION.to_string(),
|
|
Value::String(signature),
|
|
);
|
|
operation
|
|
}
|
|
|
|
fn signature_patch_token(result: &InterceptorResult) -> String {
|
|
result
|
|
.patches
|
|
.iter()
|
|
.find(|patch| {
|
|
patch.path == "/annotations/openshell.nvidia.com~1policy-signature"
|
|
|| patch.path == "/annotations"
|
|
})
|
|
.and_then(|patch| patch.value.as_ref())
|
|
.map(proto_value_to_json)
|
|
.and_then(|value| {
|
|
value.as_str().map(ToString::to_string).or_else(|| {
|
|
value
|
|
.pointer(&format!(
|
|
"/{}",
|
|
json_pointer_escape(POLICY_SIGNATURE_ANNOTATION)
|
|
))
|
|
.and_then(Value::as_str)
|
|
.map(ToString::to_string)
|
|
})
|
|
})
|
|
.expect("signature patch value")
|
|
}
|
|
|
|
fn policy_yaml_with_dynamic_rule() -> String {
|
|
let policy = include_str!("../policy.yaml");
|
|
let changed = policy
|
|
.replace("api-1.example.com", "api-2.example.com")
|
|
.replace("api.example.com", "api.changed.example.com");
|
|
if changed != policy {
|
|
return changed;
|
|
}
|
|
|
|
policy.replace(
|
|
"network_policies: {}",
|
|
r#"network_policies:
|
|
example_api:
|
|
name: example-api
|
|
endpoints:
|
|
- host: example.com
|
|
port: 443
|
|
protocol: rest
|
|
enforcement: enforce
|
|
access: read-only"#,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_declares_governance_bindings() {
|
|
let service = service();
|
|
let manifest = service.manifest();
|
|
let ids: Vec<_> = manifest
|
|
.bindings
|
|
.iter()
|
|
.map(|binding| binding.id.as_str())
|
|
.collect();
|
|
assert!(ids.contains(&"govern-import-provider-profiles"));
|
|
assert!(ids.contains(&"govern-update-provider-profiles"));
|
|
assert!(ids.contains(&"govern-delete-provider-profile"));
|
|
assert!(ids.contains(&"govern-update-config"));
|
|
assert!(ids.contains(&"govern-submit-policy-analysis"));
|
|
assert!(ids.contains(&"govern-create-sandbox"));
|
|
assert!(!ids.contains(&"govern-attach-provider"));
|
|
assert!(!ids.contains(&"govern-detach-provider"));
|
|
assert!(!ids.contains(&"govern-update-provider"));
|
|
assert!(!ids.contains(&"govern-delete-provider"));
|
|
assert_eq!(manifest.failure_policy, "fail_closed");
|
|
assert!(manifest.provider_profiles);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn snapshot_provider_profiles_returns_current_profiles() {
|
|
let service = service();
|
|
let snapshot = service
|
|
.snapshot_provider_profiles(Request::new(ProviderProfileSnapshotRequest {}))
|
|
.await
|
|
.unwrap()
|
|
.into_inner();
|
|
assert!(!snapshot.revision.is_empty());
|
|
let profile_ids = snapshot
|
|
.profiles
|
|
.iter()
|
|
.map(|profile| profile.id.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(profile_ids, vec!["github", "slack"]);
|
|
for profile in &snapshot.profiles {
|
|
assert_signed_profile(&service, profile);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn profile_loader_uses_file_name_as_profile_id() {
|
|
let loaded = load_provider_profile_source(
|
|
"profiles/example-api.yaml",
|
|
r#"
|
|
id: ignored
|
|
display_name: Example API
|
|
description: Example profile
|
|
credentials: []
|
|
endpoints: []
|
|
binaries: []
|
|
"#,
|
|
"example-api",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(loaded.profile.id, "example-api");
|
|
|
|
let loaded = load_provider_profile_source(
|
|
"profiles/no-id.yaml",
|
|
r#"
|
|
display_name: No ID
|
|
description: Filename supplies the profile id
|
|
credentials: []
|
|
endpoints: []
|
|
binaries: []
|
|
"#,
|
|
"no-id",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(loaded.profile.id, "no-id");
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_modify_adds_policy_and_signature_without_replacing_providers() {
|
|
let service = service();
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::ModifyOperation,
|
|
json!({
|
|
"name": "demo",
|
|
"spec": {"providers": ["github"]},
|
|
"labels": {"team": "platform"},
|
|
}),
|
|
))
|
|
.unwrap();
|
|
|
|
assert!(result.allowed);
|
|
let paths: Vec<_> = result
|
|
.patches
|
|
.iter()
|
|
.map(|patch| patch.path.as_str())
|
|
.collect();
|
|
assert!(paths.contains(&"/spec/policy"));
|
|
assert!(!paths.contains(&"/spec/providers"));
|
|
assert!(
|
|
paths.contains(&"/annotations")
|
|
|| paths.contains(&"/annotations/openshell.nvidia.com~1policy-signature")
|
|
);
|
|
let token = signature_patch_token(&result);
|
|
assert_eq!(token.split('.').count(), 3);
|
|
assert_eq!(
|
|
result
|
|
.log_annotations
|
|
.get("correlation_id")
|
|
.map(String::as_str),
|
|
Some("governance:create-sandbox:demo")
|
|
);
|
|
assert!(result.log_annotations.contains_key("policy_hash"));
|
|
assert!(result.log_annotations.contains_key("policy_signature_kid"));
|
|
assert!(!result.log_annotations.contains_key("policy_signature"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_accepts_selected_provider_subset() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
governed_create_operation_with_providers(
|
|
state.policy.clone(),
|
|
state.policy_signature.clone(),
|
|
vec!["github".to_string()],
|
|
),
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_accepts_missing_provider_list() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let mut operation = json!({
|
|
"spec": {
|
|
"policy": state.policy.clone(),
|
|
},
|
|
"annotations": {},
|
|
});
|
|
operation
|
|
.pointer_mut("/annotations")
|
|
.and_then(Value::as_object_mut)
|
|
.unwrap()
|
|
.insert(
|
|
POLICY_SIGNATURE_ANNOTATION.to_string(),
|
|
Value::String(state.policy_signature.clone()),
|
|
);
|
|
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
operation,
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_denies_unmanaged_provider() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
governed_create_operation_with_providers(
|
|
state.policy.clone(),
|
|
state.policy_signature.clone(),
|
|
vec!["github".to_string(), "teams".to_string()],
|
|
),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(
|
|
result
|
|
.reason
|
|
.contains("sandbox providers may only use vended provider profiles")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_denies_missing_signature() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"spec": {
|
|
"policy": state.policy,
|
|
"providers": managed_profile_ids(&service),
|
|
},
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_denies_malformed_signature() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
governed_create_operation(&service, state.policy.clone(), "not-a-jwt".to_string()),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("signature"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_denies_signature_from_other_key() {
|
|
let governance = service();
|
|
let other = service();
|
|
let governance_state = policy_state(&governance);
|
|
let other_state = policy_state(&other);
|
|
let result = governance
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
governed_create_operation(
|
|
&governance,
|
|
governance_state.policy.clone(),
|
|
other_state.policy_signature,
|
|
),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("signature"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_sandbox_validate_denies_signed_policy_mismatch() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let mut tampered_policy = state.policy.clone();
|
|
tampered_policy
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.insert("version".to_string(), json!(999));
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateSandbox",
|
|
GatewayInterceptorPhase::Validate,
|
|
governed_create_operation(&service, tampered_policy, state.policy_signature.clone()),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("signature"));
|
|
}
|
|
|
|
#[test]
|
|
fn policy_signature_rejects_legacy_hash_algorithm() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let claims = PolicySignatureClaims {
|
|
sub: POLICY_JWT_SUBJECT.to_string(),
|
|
iss: POLICY_JWT_ISSUER.to_string(),
|
|
aud: POLICY_JWT_AUDIENCE.to_string(),
|
|
iat: now_secs(),
|
|
exp: 0,
|
|
hash_algorithm: "openshell-governance-protobuf-sha256-v1".to_string(),
|
|
policy_sha256: state.policy_hash.clone(),
|
|
};
|
|
let token = encode(
|
|
&jwt_header(&service),
|
|
&claims,
|
|
&service.policy_signer.encoding_key,
|
|
)
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.policy_signer
|
|
.verify_policy_signature(&token, &state.policy_hash)
|
|
.unwrap_err();
|
|
assert_eq!(error, "unexpected policy hash algorithm");
|
|
}
|
|
|
|
#[test]
|
|
fn profile_signature_rejects_missing_hash_algorithm() {
|
|
#[derive(serde::Serialize)]
|
|
struct LegacyProfileSignatureClaims {
|
|
sub: String,
|
|
iss: String,
|
|
aud: String,
|
|
iat: i64,
|
|
exp: i64,
|
|
profile_id: String,
|
|
profile_sha256: String,
|
|
}
|
|
|
|
let service = service();
|
|
let profile = &service.current_profile_state().profiles[0];
|
|
let profile_hash = profile.annotations.get(PROFILE_HASH_ANNOTATION).unwrap();
|
|
let claims = LegacyProfileSignatureClaims {
|
|
sub: format!("{PROFILE_JWT_SUBJECT_PREFIX}{}", profile.id),
|
|
iss: POLICY_JWT_ISSUER.to_string(),
|
|
aud: PROFILE_JWT_AUDIENCE.to_string(),
|
|
iat: 0,
|
|
exp: 0,
|
|
profile_id: profile.id.clone(),
|
|
profile_sha256: profile_hash.clone(),
|
|
};
|
|
let token = encode(
|
|
&jwt_header(&service),
|
|
&claims,
|
|
&service.policy_signer.encoding_key,
|
|
)
|
|
.unwrap();
|
|
|
|
let error = service
|
|
.policy_signer
|
|
.verify_profile_signature(&token, &profile.id, profile_hash)
|
|
.unwrap_err();
|
|
assert!(error.contains("missing field `hash_algorithm`"));
|
|
}
|
|
|
|
#[test]
|
|
fn policy_patch_uses_protobuf_json_names() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
assert!(state.policy.get("filesystem").is_some());
|
|
assert!(state.policy.get("networkPolicies").is_some());
|
|
assert!(state.policy.get("filesystem_policy").is_none());
|
|
assert!(state.policy.get("network_policies").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn policy_reload_updates_hash_and_preserves_last_valid_state_on_error() {
|
|
let service = service();
|
|
let before = policy_state(&service);
|
|
let changed = service
|
|
.reload_policy_from_yaml(&policy_yaml_with_dynamic_rule())
|
|
.unwrap()
|
|
.expect("policy hash should change");
|
|
assert_ne!(before.policy_hash, changed.policy_hash);
|
|
assert_eq!(policy_state(&service).policy_hash, changed.policy_hash);
|
|
|
|
let err = service
|
|
.reload_policy_from_yaml("version: not-a-number")
|
|
.expect_err("invalid policy should be rejected");
|
|
assert!(err.contains("failed to parse policy YAML"));
|
|
assert_eq!(policy_state(&service).policy_hash, changed.policy_hash);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_governance_policy_update_is_allowed() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"name": "demo",
|
|
"policy": state.policy.clone(),
|
|
"annotations": policy_update_annotations(&state, "governance:reload-policy:test"),
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_sync_requires_current_signed_governance_policy() {
|
|
let service = service();
|
|
let state = policy_state(&service);
|
|
|
|
let unsigned = service
|
|
.evaluate_inner(&sandbox_evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"name": "demo", "policy": state.policy.clone()}),
|
|
))
|
|
.unwrap();
|
|
assert!(!unsigned.allowed);
|
|
assert!(unsigned.reason.contains("governance annotations"));
|
|
|
|
let signed = service
|
|
.evaluate_inner(&sandbox_evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"name": "demo",
|
|
"policy": state.policy.clone(),
|
|
"annotations": policy_update_annotations(
|
|
&state,
|
|
"governance:sandbox-sync:test",
|
|
),
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(signed.allowed);
|
|
|
|
let mut widened = state.policy.clone();
|
|
widened["networkPolicies"]["sandbox_added"] = json!({
|
|
"name": "sandbox-added",
|
|
"endpoints": [{"host": "sandbox-added.example", "port": 443}],
|
|
});
|
|
let copied_annotations = service
|
|
.evaluate_inner(&sandbox_evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"name": "demo",
|
|
"policy": widened,
|
|
"annotations": policy_update_annotations(
|
|
&state,
|
|
"governance:sandbox-sync:copied",
|
|
),
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(!copied_annotations.allowed);
|
|
assert!(copied_annotations.reason.contains("invalid"));
|
|
}
|
|
|
|
#[test]
|
|
fn stale_governance_policy_update_is_denied_after_reload() {
|
|
let service = service();
|
|
let stale = policy_state(&service);
|
|
let changed = service
|
|
.reload_policy_from_yaml(&policy_yaml_with_dynamic_rule())
|
|
.unwrap()
|
|
.expect("policy hash should change");
|
|
assert_ne!(stale.policy_hash, changed.policy_hash);
|
|
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"name": "demo",
|
|
"policy": stale.policy.clone(),
|
|
"annotations": policy_update_annotations(&stale, "governance:reload-policy:stale"),
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("stale"));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_creation_is_limited_to_vended_profiles() {
|
|
let service = service();
|
|
let github = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"provider": {"metadata": {"name": "work-github"}, "type": "github"}}),
|
|
))
|
|
.unwrap();
|
|
assert!(github.allowed);
|
|
|
|
let slack = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"provider": {"metadata": {"name": "team-chat"}, "type": "slack"}}),
|
|
))
|
|
.unwrap();
|
|
assert!(slack.allowed);
|
|
|
|
let teams = service
|
|
.evaluate_inner(&evaluation(
|
|
"CreateProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"provider": {"metadata": {"name": "teams"}, "type": "teams"}}),
|
|
))
|
|
.unwrap();
|
|
assert!(!teams.allowed);
|
|
assert!(
|
|
teams
|
|
.reason
|
|
.contains("providers may only use vended provider profiles")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_import_is_limited_to_governed_profiles() {
|
|
let service = service();
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"ImportProviderProfiles",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"profiles": [
|
|
{"profile": {"id": "github"}},
|
|
{"profile": {"id": "slack"}}
|
|
]
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"ImportProviderProfiles",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"profiles": [{"profile": {"id": "custom-slack"}}]}),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_update_is_limited_to_matching_governed_profiles() {
|
|
let service = service();
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateProviderProfiles",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"id": "slack",
|
|
"profile": {"profile": {"id": "slack"}}
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateProviderProfiles",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"id": "slack",
|
|
"profile": {"profile": {"id": "github"}}
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateProviderProfiles",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"id": "custom-slack",
|
|
"profile": {"profile": {"id": "custom-slack"}}
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_profile_delete_is_denied() {
|
|
let service = service();
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"DeleteProviderProfile",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"id": "github"}),
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("deletes are blocked"));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_update_and_delete_are_not_governed() {
|
|
let service = service();
|
|
let update = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"provider": {"metadata": {"name": "slack"}}}),
|
|
))
|
|
.unwrap();
|
|
assert!(update.allowed);
|
|
|
|
let delete = service
|
|
.evaluate_inner(&evaluation(
|
|
"DeleteProvider",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"name": "github"}),
|
|
))
|
|
.unwrap();
|
|
assert!(delete.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_update_and_merge_are_denied() {
|
|
let service = service();
|
|
for operation in [
|
|
json!({"name": "demo", "policy": {"version": 1}}),
|
|
json!({"name": "demo", "mergeOperations": [{"op": "add"}]}),
|
|
json!({"name": "demo", "merge_operations": [{"op": "add"}]}),
|
|
] {
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
operation,
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
}
|
|
|
|
let settings_update = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"global": true, "settingKey": "agent_policy_proposals_enabled"}),
|
|
))
|
|
.unwrap();
|
|
assert!(settings_update.allowed);
|
|
|
|
let global_policy_update = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"global": true, "policy": {"version": 1}}),
|
|
))
|
|
.unwrap();
|
|
assert!(global_policy_update.allowed);
|
|
|
|
let sandbox_policy_sync = service
|
|
.evaluate_inner(&sandbox_evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"name": "demo", "policy": {"version": 1}}),
|
|
))
|
|
.unwrap();
|
|
assert!(!sandbox_policy_sync.allowed);
|
|
}
|
|
|
|
#[test]
|
|
fn automatic_proposal_approval_settings_are_denied() {
|
|
let service = service();
|
|
|
|
for operation in [
|
|
json!({
|
|
"global": true,
|
|
"settingKey": "proposal_approval_mode",
|
|
"settingValue": {"stringValue": "auto"},
|
|
}),
|
|
json!({
|
|
"name": "demo",
|
|
"settingKey": "proposal_approval_mode",
|
|
"settingValue": {"stringValue": "auto"},
|
|
}),
|
|
] {
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
operation,
|
|
))
|
|
.unwrap();
|
|
assert!(!result.allowed);
|
|
assert!(result.reason.contains("automatic policy proposal approval"));
|
|
}
|
|
|
|
for operation in [
|
|
json!({
|
|
"global": true,
|
|
"settingKey": "proposal_approval_mode",
|
|
"settingValue": {"stringValue": "manual"},
|
|
}),
|
|
json!({
|
|
"global": true,
|
|
"settingKey": "proposal_approval_mode",
|
|
"deleteSetting": true,
|
|
}),
|
|
] {
|
|
let result = service
|
|
.evaluate_inner(&evaluation(
|
|
"UpdateConfig",
|
|
GatewayInterceptorPhase::Validate,
|
|
operation,
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_analysis_allows_telemetry_but_denies_proposals() {
|
|
let service = service();
|
|
|
|
for operation in [
|
|
json!({
|
|
"name": "demo",
|
|
"networkActivitySummaries": [{"networkActivityCount": 1}],
|
|
}),
|
|
json!({
|
|
"name": "demo",
|
|
"summaries": [{"host": "denied.example", "port": 443}],
|
|
}),
|
|
json!({"name": "demo", "proposedChunks": []}),
|
|
] {
|
|
let result = service
|
|
.evaluate_inner(&sandbox_evaluation(
|
|
"SubmitPolicyAnalysis",
|
|
GatewayInterceptorPhase::Validate,
|
|
operation,
|
|
))
|
|
.unwrap();
|
|
assert!(result.allowed);
|
|
}
|
|
|
|
let proposal = service
|
|
.evaluate_inner(&sandbox_evaluation(
|
|
"SubmitPolicyAnalysis",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({
|
|
"name": "demo",
|
|
"proposedChunks": [{"ruleName": "sandbox-added"}],
|
|
}),
|
|
))
|
|
.unwrap();
|
|
assert!(!proposal.allowed);
|
|
assert!(
|
|
proposal
|
|
.reason
|
|
.contains("sandbox-authored policy proposals")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn policy_analysis_fails_closed_without_a_sandbox_principal() {
|
|
let service = service();
|
|
|
|
let missing = service
|
|
.evaluate_inner(&evaluation(
|
|
"SubmitPolicyAnalysis",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"name": "demo"}),
|
|
))
|
|
.unwrap();
|
|
assert!(!missing.allowed);
|
|
|
|
let mut user = evaluation(
|
|
"SubmitPolicyAnalysis",
|
|
GatewayInterceptorPhase::Validate,
|
|
json!({"name": "demo"}),
|
|
);
|
|
user.principal
|
|
.insert("kind".to_string(), "user".to_string());
|
|
let user = service.evaluate_inner(&user).unwrap();
|
|
assert!(!user.allowed);
|
|
}
|