operations: enforce community capability limits

This commit is contained in:
a.tolmachev
2026-05-03 17:55:35 +00:00
parent 1f0818d660
commit 82b7b7278e
22 changed files with 612 additions and 324 deletions
+286 -3
View File
@@ -233,9 +233,9 @@ mod tests {
use crank_core::{
AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionConfig, ExecutionMode,
GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, JobStatus, MembershipRole,
OperationId, Protocol, RestTarget, SecretKind, SoapBindingStyle, SoapOperationMetadata,
SoapTarget, SoapVersion, StreamSession, StreamStatus, Target, ToolDescription,
TransportBehavior, WorkspaceId,
OperationId, OperationSecurityLevel, Protocol, RestTarget, SecretKind, SoapBindingStyle,
SoapOperationMetadata, SoapTarget, SoapVersion, StreamSession, StreamStatus, Target,
ToolDescription, TransportBehavior, WebsocketTarget, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{CreateAsyncJobRequest, CreateStreamSessionRequest, PostgresRegistry};
@@ -1067,6 +1067,173 @@ mod tests {
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_unsupported_protocol_for_community_operation_create() {
let registry = test_registry().await;
let storage_root = test_storage_root("community_protocol_reject");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let response = client
.post(format!("{base_url}/operations"))
.json(&test_websocket_operation_payload(
"wss://example.com/stream",
"telemetry_ws",
))
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "validation_error");
assert_eq!(
body["error"]["message"],
"protocol websocket is not supported in Community"
);
assert_eq!(
body["error"]["context"],
json!({
"protocol": "websocket",
"edition": "community",
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_premium_security_level_for_community_operation_create() {
let registry = test_registry().await;
let storage_root = test_storage_root("community_security_reject_create");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let mut payload = test_operation_payload(&upstream_base_url, "crm_elevated");
payload.security_level = OperationSecurityLevel::Elevated;
let response = client
.post(format!("{base_url}/operations"))
.json(&payload)
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "validation_error");
assert_eq!(
body["error"]["message"],
"security level elevated is not supported in Community"
);
assert_eq!(
body["error"]["context"],
json!({
"security_level": "elevated",
"edition": "community",
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_premium_security_level_for_community_operation_update() {
let registry = test_registry().await;
let storage_root = test_storage_root("community_security_reject_update");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_update_elevated",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let response = client
.patch(format!("{base_url}/operations/{operation_id}"))
.json(&json!({
"display_name": "Create Lead",
"category": "sales",
"security_level": "elevated",
"target": {
"kind": "rest",
"base_url": upstream_base_url,
"method": "POST",
"path_template": "/crm/leads",
"static_headers": {}
},
"input_schema": object_schema("email"),
"output_schema": object_schema("id"),
"input_mapping": {
"rules": [{
"source": "$.mcp.email",
"target": "$.request.body.email",
"required": true,
"default_value": null,
"transform": null,
"condition": null,
"notes": null
}]
},
"output_mapping": {
"rules": [{
"source": "$.response.body.id",
"target": "$.output.id",
"required": true,
"default_value": null,
"transform": null,
"condition": null,
"notes": null
}]
},
"execution_config": {
"timeout_ms": 1000,
"retry_policy": null,
"auth_profile_ref": null,
"headers": {},
"protocol_options": null,
"streaming": null
},
"tool_description": {
"title": "Create Lead",
"description": "Creates a CRM lead",
"tags": ["crm"],
"examples": []
}
}))
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "validation_error");
assert_eq!(
body["error"]["message"],
"security level elevated is not supported in Community"
);
assert_eq!(
body["error"]["context"],
json!({
"security_level": "elevated",
"edition": "community",
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_workspace_access_lifecycle() {
@@ -2375,6 +2542,63 @@ mod tests {
assert_eq!(imported["import_mode"], "upsert");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn rejects_premium_security_level_for_community_yaml_upsert() {
let registry = test_registry().await;
let storage_root = test_storage_root("community_yaml_security_reject");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_yaml_standard",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let mut yaml = client
.get(format!("{base_url}/operations/{operation_id}/export"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
yaml = yaml.replace("security_level: standard", "security_level: elevated");
let response = client
.post(format!("{base_url}/operations/import?mode=upsert"))
.body(yaml)
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "validation_error");
assert_eq!(
body["error"]["message"],
"security level elevated is not supported in Community"
);
assert_eq!(
body["error"]["context"],
json!({
"security_level": "elevated",
"edition": "community",
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_workspace_secrets_without_exposing_plaintext() {
@@ -3069,6 +3293,7 @@ mod tests {
display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
security_level: OperationSecurityLevel::Standard,
target: Target::Rest(RestTarget {
base_url: base_url.to_owned(),
method: HttpMethod::Post,
@@ -3122,6 +3347,7 @@ mod tests {
display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql,
security_level: OperationSecurityLevel::Standard,
target: Target::Graphql(GraphqlTarget {
endpoint: endpoint.to_owned(),
operation_type: GraphqlOperationType::Mutation,
@@ -3178,6 +3404,7 @@ mod tests {
display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc,
security_level: OperationSecurityLevel::Standard,
target: Target::Grpc(GrpcTarget {
server_addr: server_addr.to_owned(),
package: "echo".to_owned(),
@@ -3325,6 +3552,7 @@ mod tests {
display_name: "Create Lead SOAP".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Soap,
security_level: OperationSecurityLevel::Standard,
target: Target::Soap(SoapTarget {
wsdl_ref: "sample_wsdl".into(),
service_name: "LeadService".to_owned(),
@@ -3383,6 +3611,61 @@ mod tests {
}
}
fn test_websocket_operation_payload(url: &str, name: &str) -> OperationPayload {
OperationPayload {
name: name.to_owned(),
display_name: "Telemetry WebSocket".to_owned(),
category: "telemetry".to_owned(),
protocol: Protocol::Websocket,
security_level: OperationSecurityLevel::Standard,
target: Target::Websocket(WebsocketTarget {
url: url.to_owned(),
subprotocols: Vec::new(),
subscribe_message_template: Some(json!({ "subscribe": true })),
unsubscribe_message_template: None,
static_headers: BTreeMap::new(),
}),
input_schema: object_schema("channel"),
output_schema: object_schema("event"),
input_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.mcp.channel".to_owned(),
target: "$.request.body.channel".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
output_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.response.body.event".to_owned(),
target: "$.output.event".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
execution_config: ExecutionConfig {
timeout_ms: 1_000,
retry_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
protocol_options: None,
streaming: None,
},
tool_description: ToolDescription {
title: "Telemetry WebSocket".to_owned(),
description: "Streams telemetry events".to_owned(),
tags: vec!["telemetry".to_owned(), "websocket".to_owned()],
examples: Vec::new(),
},
}
}
const SOAP_TEST_WSDL: &str = r#"<?xml version="1.0"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
+57
View File
@@ -101,6 +101,8 @@ pub struct OperationPayload {
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol,
#[serde(default)]
pub security_level: OperationSecurityLevel,
pub target: Target,
pub input_schema: Schema,
pub output_schema: Schema,
@@ -570,6 +572,8 @@ pub struct UpdateOperationPayload {
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
#[serde(default)]
pub security_level: OperationSecurityLevel,
pub target: Target,
pub input_schema: Schema,
pub output_schema: Schema,
@@ -617,6 +621,7 @@ pub struct OperationSummaryView {
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub security_level: OperationSecurityLevel,
pub target_url: String,
pub target_action: String,
pub status: OperationStatus,
@@ -637,6 +642,7 @@ pub struct OperationDetailView {
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub security_level: OperationSecurityLevel,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
@@ -1966,6 +1972,7 @@ impl AdminService {
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
security_level: summary.security_level,
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
@@ -2036,6 +2043,7 @@ impl AdminService {
display_name: payload.display_name,
category: payload.category,
protocol: payload.protocol,
security_level: payload.security_level,
status: OperationStatus::Draft,
version: 1,
target: payload.target,
@@ -2097,6 +2105,7 @@ impl AdminService {
display_name: payload.operation.display_name,
category: payload.operation.category,
protocol: payload.operation.protocol,
security_level: payload.operation.security_level,
status: OperationStatus::Draft,
version,
target: payload.operation.target,
@@ -2160,6 +2169,7 @@ impl AdminService {
display_name: payload.display_name,
category: payload.category,
protocol: existing.snapshot.protocol,
security_level: payload.security_level,
status: OperationStatus::Draft,
version: existing.version,
target: payload.target,
@@ -3661,6 +3671,7 @@ impl AdminService {
display_name: document.operation.display_name.clone(),
category: document.operation.category.clone(),
protocol: document.operation.protocol,
security_level: document.operation.security_level,
target: document.operation.target.clone(),
input_schema: document.operation.input_schema.clone(),
output_schema: document.operation.output_schema.clone(),
@@ -3841,6 +3852,7 @@ impl AdminService {
}
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
self.validate_operation_capabilities(payload.protocol, payload.security_level)?;
validate_protocol_target(payload.protocol, &payload.target)?;
payload.input_mapping.validate_paths()?;
payload.output_mapping.validate_paths()?;
@@ -3848,12 +3860,52 @@ impl AdminService {
}
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
self.validate_operation_capabilities(operation.protocol, operation.security_level)?;
validate_protocol_target(operation.protocol, &operation.target)?;
operation.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?;
Ok(())
}
fn validate_operation_capabilities(
&self,
protocol: Protocol,
security_level: OperationSecurityLevel,
) -> Result<(), ApiError> {
let supported_protocols = [Protocol::Rest, Protocol::Graphql, Protocol::Grpc];
if !supported_protocols.contains(&protocol) {
return Err(ApiError::validation_with_context(
format!(
"protocol {} is not supported in Community",
serde_json::to_string(&protocol)
.unwrap_or_else(|_| "\"unknown\"".to_owned())
.trim_matches('"')
),
json!({
"protocol": protocol,
"edition": "community",
}),
));
}
if security_level != OperationSecurityLevel::Standard {
return Err(ApiError::validation_with_context(
format!(
"security level {} is not supported in Community",
serde_json::to_string(&security_level)
.unwrap_or_else(|_| "\"unknown\"".to_owned())
.trim_matches('"')
),
json!({
"security_level": security_level,
"edition": "community",
}),
));
}
Ok(())
}
async fn find_operation_by_name(
&self,
workspace_id: &WorkspaceId,
@@ -4665,6 +4717,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
display_name: "Create CRM Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
security_level: OperationSecurityLevel::Standard,
target: Target::Rest(crank_core::RestTarget {
base_url: "https://crm.demo.internal".to_owned(),
method: crank_core::HttpMethod::Post,
@@ -4718,6 +4771,7 @@ fn demo_graphql_operation_payload() -> OperationPayload {
display_name: "Get Invoice Status".to_owned(),
category: "finance".to_owned(),
protocol: Protocol::Graphql,
security_level: OperationSecurityLevel::Standard,
target: Target::Graphql(crank_core::GraphqlTarget {
endpoint: "https://billing.demo.internal/graphql".to_owned(),
operation_type: crank_core::GraphqlOperationType::Query,
@@ -4769,6 +4823,7 @@ fn demo_grpc_operation_payload() -> OperationPayload {
display_name: "Lookup Support Ticket".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc,
security_level: OperationSecurityLevel::Standard,
target: Target::Grpc(crank_core::GrpcTarget {
server_addr: "http://grpc.demo.internal".to_owned(),
package: "echo".to_owned(),
@@ -4833,6 +4888,7 @@ fn demo_archived_operation_payload() -> OperationPayload {
display_name: "Archive Marketing Contact".to_owned(),
category: "marketing".to_owned(),
protocol: Protocol::Rest,
security_level: OperationSecurityLevel::Standard,
target: Target::Rest(crank_core::RestTarget {
base_url: "https://marketing.demo.internal".to_owned(),
method: crank_core::HttpMethod::Patch,
@@ -5341,6 +5397,7 @@ fn enrich_operation_summary(
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
security_level: summary.security_level,
target_url: summary.target_url,
target_action: summary.target_action,
status: summary.status,