Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ jobs:
-Dtest=S3ClientUtilMinioPolicyCompatibilityIT test

- name: Verify rendered sandbox credential contract
run: >-
python3 docker/astronAgent/scripts/verify_security_contract.py
--ci-placeholder-required-env
run: python3 docker/astronAgent/scripts/verify_security_contract.py

- name: Verify Helm internal credential contract
run: python3 helm/astron-agent/tests/verify_tenant_bootstrap.py

# ============================================================================
# Stage 2: Build astron Agent Docker Images (Parallel Jobs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.iflytek.astron.console.commons.security;

import org.apache.commons.lang3.StringUtils;

import java.util.regex.Pattern;

/** Shared validation and header naming for trusted calls to the core tenant service. */
public final class TenantInternalApiKey {

public static final String HEADER = "X-Tenant-Internal-Key";

private static final int MIN_LENGTH = 32;
private static final int MAX_LENGTH = 50;
private static final String LEGACY_PUBLIC_SECRET = "NjhmY2NmM2NkZDE4MDFlNmM5ZjcyZjMy";
private static final Pattern SAFE_VALUE = Pattern.compile("[A-Za-z0-9._~-]+");

private TenantInternalApiKey() {}

/** Return a normalized credential or fail closed before issuing an internal request. */
public static String requireConfigured(String configuredValue) {
String apiKey = StringUtils.trimToEmpty(configuredValue);
if (apiKey.length() < MIN_LENGTH
|| apiKey.length() > MAX_LENGTH
|| !SAFE_VALUE.matcher(apiKey).matches()
|| LEGACY_PUBLIC_SECRET.equals(apiKey)) {
throw new IllegalStateException(
"TENANT_SECRET must contain 32-50 safe characters and must not use the published legacy value");
}
return apiKey;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.iflytek.astron.console.commons.security;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.HexFormat;
import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.lang3.StringUtils;

/** Creates short-lived signed workflow identities without disclosing the shared internal key. */
public final class WorkflowGatewayIdentity {

public static final String TIMESTAMP_HEADER = "X-Workflow-Gateway-Timestamp";
public static final String SIGNATURE_HEADER = "X-Workflow-Gateway-Signature";

private static final String POST = "POST";
private static final String HMAC_SHA_256 = "HmacSHA256";
private static final Set<String> PUBLIC_WORKFLOW_PATHS = Set.of(
"/workflow/v1/chat/completions", "/workflow/v1/resume");

private WorkflowGatewayIdentity() {}

/**
* Validate the original public request metadata and return the exact path bound into the signature.
* Query parameters are deliberately excluded; no decoding or path normalization is performed, so
* encoded or alternate paths fail closed.
*/
public static String requireAuthorizedPath(String originalMethod, String originalUri) {
if (!POST.equals(originalMethod) || StringUtils.isEmpty(originalUri)) {
throw new IllegalArgumentException("unsupported workflow gateway request");
}
int queryStart = originalUri.indexOf('?');
String path = queryStart < 0 ? originalUri : originalUri.substring(0, queryStart);
if (!PUBLIC_WORKFLOW_PATHS.contains(path) || originalUri.indexOf('#') >= 0) {
throw new IllegalArgumentException("unsupported workflow gateway request");
}
return path;
}

/** Sign {@code method + newline + path + newline + appId + newline + epochSeconds}. */
public static String sign(
String configuredKey,
String method,
String path,
String appId,
long epochSeconds) {
String internalKey = WorkflowInternalApiKey.requireConfigured(configuredKey);
if (!POST.equals(method)
|| !PUBLIC_WORKFLOW_PATHS.contains(path)
|| StringUtils.isBlank(appId)
|| appId.indexOf('\r') >= 0
|| appId.indexOf('\n') >= 0
|| epochSeconds < 0) {
throw new IllegalArgumentException("invalid workflow gateway identity");
}
String payload = method + '\n' + path + '\n' + appId + '\n' + epochSeconds;
try {
Mac mac = Mac.getInstance(HMAC_SHA_256);
mac.init(new SecretKeySpec(
internalKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA_256));
return HexFormat.of()
.formatHex(
mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException(
"Unable to sign workflow gateway identity", exception);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.iflytek.astron.console.commons.security;

import org.apache.commons.lang3.StringUtils;

/** Shared validation and header naming for trusted calls to the core workflow service. */
public final class WorkflowInternalApiKey {

public static final String HEADER = "X-Workflow-Internal-Key";

private static final String PLACEHOLDER = "CHANGE_ME_WORKFLOW_INTERNAL_API_KEY";
private static final int MIN_LENGTH = 32;

private WorkflowInternalApiKey() {}

/** Return a normalized credential or fail closed before issuing an internal request. */
public static String requireConfigured(String configuredValue) {
String apiKey = StringUtils.trimToEmpty(configuredValue);
if (apiKey.length() < MIN_LENGTH
|| PLACEHOLDER.equals(apiKey)
|| apiKey.indexOf('\r') >= 0
|| apiKey.indexOf('\n') >= 0) {
throw new IllegalStateException(
"WORKFLOW_INTERNAL_API_KEY must contain a non-default value of at least 32 characters");
}
return apiKey;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ public class WorkflowBotChatServiceImpl implements WorkflowBotChatService {
@Value("${common.apiSecret}")
private String appSecret;

@Value("${workflow.internal-api-key:}")
private String workflowInternalApiKey;

/**
* Handle chatbot workflow requests
*
Expand Down Expand Up @@ -187,7 +190,8 @@ public void chatWorkflowBot(ChatBotReqDto chatBotReqDto, SseEmitter sseEmitter,
body = RequestBody.create(JSON.toJSONString(build), MediaType.parse("application/json; charset=utf-8"));
apiUsedUrl = resumeUrl;
}
WorkflowClient client = new WorkflowClient(apiUsedUrl, appId, appKey, appSecret, body);
WorkflowClient client = new WorkflowClient(
apiUsedUrl, appId, appKey, appSecret, body, workflowInternalApiKey);
WorkflowListener listener = new WorkflowListener(client, chatReqRecords, sseId, wssListenerService, isDebug, sseEmitter);
client.createWebSocketConnect(listener);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.iflytek.astron.console.commons.enums.bot.BotUploadEnum;
import com.iflytek.astron.console.commons.exception.BusinessException;
import com.iflytek.astron.console.commons.mapper.bot.ChatBotBaseMapper;
import com.iflytek.astron.console.commons.security.WorkflowInternalApiKey;
import com.iflytek.astron.console.commons.service.bot.ChatBotTagService;
import com.iflytek.astron.console.commons.service.data.UserLangChainDataService;
import com.iflytek.astron.console.commons.service.workflow.impl.WorkflowBotParamServiceImpl;
Expand All @@ -33,6 +34,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -78,6 +80,9 @@ public class MaasUtil {
@Value("${maas.authApi}")
private String authApi;

@Value("${workflow.internal-api-key:}")
private String workflowInternalApiKey;

@Value("${maas.mcpHost}")
private String mcpHost;

Expand Down Expand Up @@ -204,11 +209,15 @@ public JSONObject synchronizeWorkFlow(UserLangChainInfo userLangChainInfo, BotCr
// If it's newly created, then it's empty, use POST request
httpMethod = "POST";
}
log.info("----- maas synchronization request body: {}", JSONObject.toJSONString(param));
String requestJson = JSONObject.toJSONString(param);
log.info(
"MaaS workflow synchronization request prepared, method={}, bodyBytes={}",
httpMethod,
requestJson.getBytes(StandardCharsets.UTF_8).length);

// Build request body
RequestBody requestBody = RequestBody.create(
JSONObject.toJSONString(param),
requestJson,
MediaType.parse("application/json; charset=utf-8"));

// Build request
Expand Down Expand Up @@ -242,7 +251,9 @@ public JSONObject synchronizeWorkFlow(UserLangChainInfo userLangChainInfo, BotCr

JSONObject res = JSONObject.parseObject(response);
if (res.getInteger("code") != 0) {
log.error("------ Synchronize maas workflow failed, reason: {}", res);
log.error(
"MaaS workflow synchronization was rejected, code={}",
res.getInteger("code"));
return new JSONObject();
}
return res;
Expand Down Expand Up @@ -434,18 +445,25 @@ private JSONObject createApiInternal(String flowId, String appid, String version
* @return String representation of response content
*/
private String executeRequest(String url, MaasApi bodyData) {
String serializedBody = JSONObject.toJSONString(bodyData);
RequestBody requestBody = RequestBody.create(
JSONObject.toJSONString(bodyData),
serializedBody,
MediaType.parse("application/json; charset=utf-8"));
Request request = new Request.Builder()
.url(url)
.post(requestBody)
.addHeader("X-Consumer-Username", consumerId)
.addHeader(
WorkflowInternalApiKey.HEADER,
WorkflowInternalApiKey.requireConfigured(workflowInternalApiKey))
.addHeader("Lang-Code", I18nUtil.getLanguage())
.addHeader("Authorization", "Bearer %s:%s".formatted(consumerKey, consumerSecret))
.addHeader(X_AUTH_SOURCE_HEADER, X_AUTH_SOURCE_VALUE)
.build();
log.info("MaasUtil executeRequest url: {} request: {}, header: {}, body: {}", request.url(), request, request.headers(), bodyData);
log.info(
"MaaS workflow API request, url={}, bodyBytes={}",
request.url(),
serializedBody.getBytes(StandardCharsets.UTF_8).length);
try (Response httpResponse = HTTP_CLIENT.newCall(request).execute()) {
ResponseBody responseBody = httpResponse.body();
if (responseBody != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.iflytek.astron.console.commons.workflow;

import com.iflytek.astron.console.commons.security.WorkflowInternalApiKey;
import lombok.extern.slf4j.Slf4j;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
Expand All @@ -25,6 +26,8 @@ public class WorkflowClient {

private String appSecret;

private String workflowInternalApiKey;

private Request request;

private RequestBody requestBody;
Expand All @@ -39,12 +42,20 @@ public class WorkflowClient {
.connectionPool(new ConnectionPool(1000, 10, TimeUnit.MINUTES))
.build();

public WorkflowClient(String chatUrl, String appId, String appKey, String appSecret, RequestBody requestBody) {
public WorkflowClient(
String chatUrl,
String appId,
String appKey,
String appSecret,
RequestBody requestBody,
String workflowInternalApiKey) {
this.chatUrl = chatUrl;
this.appId = appId;
this.appKey = appKey;
this.appSecret = appSecret;
this.requestBody = requestBody;
this.workflowInternalApiKey =
WorkflowInternalApiKey.requireConfigured(workflowInternalApiKey);
}

/**
Expand All @@ -57,6 +68,7 @@ public void createWebSocketConnect(EventSourceListener sseListener) {
String wsURL = chatUrl;
this.request = new Request.Builder()
.header("X-Consumer-Username", appId)
.header(WorkflowInternalApiKey.HEADER, workflowInternalApiKey)
.header("Authorization", genAuthorization())
.url(wsURL)
.post(requestBody)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ workflow.not.public=Workflow is not public, cannot copy
workflow.not.publish=Workflow not published
workflow.import.failed=Import failed
workflow.no.workflow=Flow not found
workflow.node.debug.failed=Workflow node debugging failed. Please check the node configuration.
workflow.code.execution.failed=Code node execution failed. Check the code or execution environment.
workflow.code.execution.timeout=Code node execution timed out. Shorten the code or adjust the timeout.
workflow.code.executor.unavailable=No code execution environment is configured. Enable E2B or the built-in isolated executor.
parse.input.param.type.failed=Parse flow input parameter type failed
workflow.protocol.empty=Workflow protocol is empty
bot.not.exist=Bot does not exist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ workflow.not.public=工作流不是公共的,不可复制
workflow.not.publish=工作流未发布
workflow.import.failed=导入失败
workflow.no.workflow=未找到flow
workflow.node.debug.failed=工作流节点调试失败,请检查节点配置
workflow.code.execution.failed=代码节点执行失败,请检查代码或执行环境
workflow.code.execution.timeout=代码节点执行超时,请缩短代码或调整超时设置
workflow.code.executor.unavailable=未配置代码执行环境,请启用 E2B 或内置隔离执行器
parse.input.param.type.failed=解析flow输入参数类型失败
workflow.protocol.empty=工作流协议为空
bot.not.exist=bot 不存在
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.iflytek.astron.console.commons.security;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;

class TenantInternalApiKeyTest {

@Test
void requireConfiguredAcceptsAndTrimsStrongCredential() {
String credential = "0123456789abcdef._~-0123456789abcdef";

assertThat(TenantInternalApiKey.requireConfigured(" " + credential + " "))
.isEqualTo(credential);
}

@ParameterizedTest
@NullSource
@ValueSource(strings = {
"",
"short-tenant-secret",
"0123456789abcdef\r0123456789abcdef",
"0123456789abcdef\n0123456789abcdef",
"0123456789abcdef!0123456789abcdef",
"0123456789abcdef中文0123456789abcdef",
"012345678901234567890123456789012345678901234567890",
"NjhmY2NmM2NkZDE4MDFlNmM5ZjcyZjMy"
})
void requireConfiguredRejectsValuesOutsideTenantBootstrapContract(String credential) {
assertThatThrownBy(() -> TenantInternalApiKey.requireConfigured(credential))
.isInstanceOf(IllegalStateException.class)
.hasMessage(
"TENANT_SECRET must contain 32-50 safe characters and must not use the published legacy value");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.iflytek.astron.console.commons.security;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class WorkflowGatewayIdentityTest {

@Test
void signatureMatchesCrossLanguageFixedVector() {
assertThat(WorkflowGatewayIdentity.sign(
"g".repeat(32),
"POST",
"/workflow/v1/chat/completions",
"gateway-app",
1_700_000_000L))
.isEqualTo(
"6261d5595286ac9407951e6c4c690bd1c50c2fbfe4f29addc50e3f437ef6a871");
}

@Test
void authorizedPathExcludesQueryWithoutDecodingThePath() {
assertThat(WorkflowGatewayIdentity.requireAuthorizedPath(
"POST", "/workflow/v1/resume?trace_id=123"))
.isEqualTo("/workflow/v1/resume");
}

@ParameterizedTest
@CsvSource({
"GET,/workflow/v1/chat/completions",
"POST,/workflow/v1/run",
"POST,/workflow/v1/chat%2Fcompletions",
"POST,/workflow/v1/chat/completions#fragment"
})
void unsupportedMethodOrAlternatePathFailsClosed(String method, String uri) {
assertThatThrownBy(() -> WorkflowGatewayIdentity.requireAuthorizedPath(method, uri))
.isInstanceOf(IllegalArgumentException.class);
}
}
Loading
Loading