This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 2724513d fix: avoid exposing LLM API keys (#536)
2724513d is described below
commit 2724513d3afd33b6bc67aca6efc0ddc63d746c25
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 05:26:06 2026 -0700
fix: avoid exposing LLM API keys (#536)
* fix: align frontend API success response handling (#429)
* ci: add build workflow and PR review skill (#430)
* fix: connect K8s certificate page to APIs (#440)
* feat: extend translation keys & add useLanguage compatible alias in
LangContext (#433)
* fix: validate audit query and cleanup parameters (#442)
* chore: fix Dockerfile missing style/ copy and standardize pr-review skill
(#444)
- Add COPY style ./style to server/Dockerfile so Maven checkstyle can
find style/rmq_checkstyle.xml during docker compose build
- Rewrite pr-review SKILL.md with an 8-stage pipeline checklist,
Dockerfile pre-flight fix step, and [Studio] title format spec
* feat: add centralized ThemeContext and useTheme hook for dark mode
management (#435)
* fix: preserve SSE framing for AI chat (#438)
* feat: add Prometheus range query adapter (#432)
Add a real Prometheus /api/v1/query_range adapter as the foundation for
observability (#431): configurable base URL, timeouts, Basic/Bearer auth, error
mapping, and tests.
* feat: implement login page, auth & AI modules, simplify theme management
(#473)
Add login page with full auth workflow, Auth/AI API modules, Zustand state,
and simplified theme management.
* feat: add Ops page (NameServer management, VIPChannel, TLS) (#474)
Add Ops management page for NameServer address management, VIP channel
toggle, and TLS switch.
* feat: add Producer page (#475)
Add a page to query producer client connections by Topic and Producer Group.
* feat: add AlertManagement page for alert rule operations (#476)
Add alert rule management page: parse/render Prometheus AlertManager YAML
rules with search, toggle, and CRUD.
* feat: add SslSettings page for SSL/TLS configuration management (#477)
Add SSL/TLS configuration page: SSL toggle, TLS version,
KeyStore/TrustStore, client auth mode.
* feat: add broker cluster overview page (#479)
Add Broker Cluster overview page with tab-based NameServer/Broker/Proxy
management and cluster stats.
* feat: add consumer group management page (#480)
Add consumer group management page.
* feat: add lite topic management page (#481)
Add lite topic management page.
* feat: add Proxy management page (#482)
Add Proxy management page (address list, status, TPS, memory, CPU, uptime).
* feat: add LLM Settings configuration page (#483)
Add LLM settings configuration page (provider, apiKey, model, temperature,
etc.).
* feat: connect frontend pages to backend APIs and align contracts (#445)
Consolidated API-integration work
(#445,#446,#447,#448,#449,#450,#451,#452,#453,#454,#458,#459,#460,#461,#463).
* feat: add service capabilities and interaction enhancements (#462)
Consolidated service/interaction work
(#462,#468,#469,#464,#470,#471,#472,#488).
* feat: add preferences persistence, accessibility and env config (#465)
Consolidated preferences/a11y/config work (#465,#466,#485,#486,#487,#484).
* fix: include remaining consolidated frontend changes (#490)
topicService topic API contract, authStore persisted session, vite env
config.
* fix: align login response contract (#492)
Align frontend LoginResponse type with backend LoginVO contract: change
flat username/role to nested user.username/admin structure.
* fix: use runtime DNS resolver for Nginx API proxy (#494)
Fix Nginx API proxy failure caused by hardcoded DNS resolver by switching
to container runtime dynamic resolution.
* feat: enhance i18n support with labelKey-based translations (#495)
Migrate hardcoded Chinese UI text to i18n labelKey pattern, add ~280
translation entries (zh+en), fix duplicate key compilation errors.
* test: fix frontend baseline assertions (#498)
Fix test assertions to align with backend PageResult contract and current
i18n labels.
* fix: initialize deploy helpers before validation (#499)
Move logging/error helper initialization before config validation in
deploy.sh to fix err() undefined when REMOTE_HOST is missing.
* ci: build frontend Docker image (#500)
Add frontend Docker image build to CI workflow to validate Dockerfile/nginx
deployment path.
* fix: load producer topics from Studio API response (#501)
Fix Producer page topic list to read from Studio API new format
(data[].name) with backward compatibility for legacy topicList format.
* fix: generate data source keys on creation (#509)
Fix ConcurrentHashMap NPE when creating data sources with null key by
auto-generating UUID in service layer.
* feat: add producer connection endpoint (#510)
Add producer connection query endpoint with topic/producerGroup filtering,
returning backward-compatible connectionSet format.
* fix: avoid returning General Settings API key (#512)
Fix security issue where General Settings endpoint returned API key in
plaintext. Replace with apiKeyConfigured boolean flag and support explicit key
clearing.
* feat: add catalog-driven read-only tools (#514)
Introduce versioned YAML tool catalog with JSON Schema validation, SHA-256
digest, deep immutability, and two read-only L1 tool handlers (rmq.cluster.list
/ rmq.capabilities).
* feat: add LiteTopic backend endpoints (#502)
Add backend stub endpoints for LiteTopic page: list, session details, TTL
extension, quota, and capability check.
* feat: add ops backend endpoints (#504)
Add ops page backend endpoints: NameServer address management, VIP Channel,
and TLS switches.
* feat: add proxy address endpoints (#505)
Add proxy address list and add-address endpoints supporting Studio frontend
form submission.
* feat: add LLM settings endpoints (#506)
Add LLM settings backend endpoints: config read/write, connectivity test,
and model listing.
* feat: add alert rules YAML endpoint (#507)
Add alert rules Prometheus YAML export endpoint with default RocketMQ alert
template fallback.
* fix: ACL page API integration (#516)
Wire ACL management page to backend API, add update endpoints for ACL rules
and users with proper validation.
* fix: sort queried messages by store time (#519)
Sort message query results by storeTime in descending order so latest
messages appear first.
* feat: add consumer stack diagnostics API (#521)
Add consumer thread stack diagnostics endpoint (GET
/api/groups/{name}/instances/{clientId}/stack) with Provider/Stub/Service
layering.
* fix: avoid exposing LLM API keys
---------
Co-authored-by: btlqql <[email protected]>
Co-authored-by: lizhimins <[email protected]>
Co-authored-by: zhaohai <[email protected]>
Co-authored-by: terrancesli <[email protected]>
Co-authored-by: wizcraft_kris
<[email protected]>
Co-authored-by: Loyal-Young <[email protected]>
Co-authored-by: majialong <[email protected]>
Co-authored-by: yx9o <[email protected]>
Co-authored-by: PiliLily <[email protected]>
---
.../rocketmq/studio/ops/ai/LlmConfigService.java | 26 +++++++++-
.../apache/rocketmq/studio/ops/ai/LlmConfigVO.java | 10 ++++
.../studio/ops/ai/LlmConfigServiceTest.java | 54 +++++++++++++++++++-
.../rocketmq/studio/ops/ai/LlmControllerTest.java | 2 +
web/src/api/llm.test.ts | 16 +++++-
web/src/api/llm.ts | 3 +-
web/src/pages/studio/LlmSettings.tsx | 57 ++++++++++++++--------
7 files changed, 141 insertions(+), 27 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
index 0ffc590f..240d1027 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
@@ -67,8 +67,15 @@ public class LlmConfigService {
public synchronized void saveConfig(LlmConfigVO config) {
LlmConfigVO normalized = normalize(config);
- overrides = copy(normalized);
GeneralSettingsVO current = settingsService.getGeneralSettings();
+ boolean sameProvider = current != null
+ &&
normalizeProvider(current.getLlmProvider()).equals(normalized.getProvider());
+ boolean apiKeyOmitted = isBlank(normalized.getApiKey());
+ LlmConfigVO effective = copy(normalized);
+ if (apiKeyOmitted && sameProvider) {
+ effective.setApiKey(defaultString(current.getApiKey(), ""));
+ }
+ overrides = effective;
settingsService.saveGeneralSettings(GeneralSettingsVO.builder()
.theme(current.getTheme())
.compact(current.isCompact())
@@ -78,13 +85,14 @@ public class LlmConfigService {
.requireLogin(current.isRequireLogin())
.llmProvider(normalized.getProvider())
.apiKey(normalized.getApiKey())
+ .clearApiKey(apiKeyOmitted && !sameProvider)
.model(normalized.getModel())
.baseUrl(normalized.getApiBase())
.build());
}
public LlmOperationResultVO testConfig(LlmConfigVO config) {
- LlmConfigVO normalized = normalize(config);
+ LlmConfigVO normalized =
withStoredApiKeyIfSameProvider(normalize(config));
String provider = normalized.getProvider();
boolean keyRequired = !"ollama".equals(provider);
if (keyRequired && isBlank(normalized.getApiKey())) {
@@ -136,6 +144,20 @@ public class LlmConfigService {
.build();
}
+ private LlmConfigVO withStoredApiKeyIfSameProvider(LlmConfigVO config) {
+ if (!isBlank(config.getApiKey())) {
+ return config;
+ }
+ GeneralSettingsVO current = settingsService.getGeneralSettings();
+ if (current == null || !normalizeProvider(current.getLlmProvider())
+ .equals(config.getProvider())) {
+ return config;
+ }
+ LlmConfigVO effective = copy(config);
+ effective.setApiKey(defaultString(current.getApiKey(), ""));
+ return effective;
+ }
+
private LlmConfigVO copy(LlmConfigVO config) {
return normalize(config);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
index d354cde5..8981022e 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
@@ -17,10 +17,13 @@
package org.apache.rocketmq.studio.ops.ai;
+import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.springframework.util.StringUtils;
@Data
@Builder
@@ -28,6 +31,8 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
public class LlmConfigVO {
private String provider;
+ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+ @ToString.Exclude
private String apiKey;
private String apiBase;
private String model;
@@ -37,4 +42,9 @@ public class LlmConfigVO {
private String deploymentName;
private String apiVersion;
private String awsRegion;
+
+ @JsonProperty(value = "apiKeyConfigured", access =
JsonProperty.Access.READ_ONLY)
+ public boolean isApiKeyConfigured() {
+ return StringUtils.hasText(apiKey);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
index 3b40024a..3d250356 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
@@ -57,6 +57,7 @@ class LlmConfigServiceTest {
assertThat(config.getProvider()).isEqualTo("openai");
assertThat(config.getApiKey()).isEqualTo("sk-test");
+ assertThat(config.isApiKeyConfigured()).isTrue();
assertThat(config.getApiBase()).isEqualTo("https://api.openai.com/v1");
assertThat(config.getModel()).isEqualTo("gpt-4o");
assertThat(config.isEnabled()).isTrue();
@@ -89,17 +90,66 @@ class LlmConfigServiceTest {
}
@Test
- void testConfigShouldRejectMissingRequiredApiKey() {
- LlmOperationResultVO result =
llmConfigService.testConfig(LlmConfigVO.builder()
+ void saveConfigShouldKeepStoredApiKeyWhenSameProviderRequestOmitsIt() {
+ LlmConfigVO config = LlmConfigVO.builder()
.provider("openai")
.apiKey("")
+ .apiBase("https://api.openai.com/v1")
.model("gpt-4o")
+ .enabled(true)
+ .build();
+
+ llmConfigService.saveConfig(config);
+
+
assertThat(llmConfigService.getConfig().getApiKey()).isEqualTo("sk-test");
+ ArgumentCaptor<GeneralSettingsVO> captor =
ArgumentCaptor.forClass(GeneralSettingsVO.class);
+ verify(settingsService).saveGeneralSettings(captor.capture());
+ assertThat(captor.getValue().getApiKey()).isEmpty();
+ assertThat(captor.getValue().isClearApiKey()).isFalse();
+ }
+
+ @Test
+ void
saveConfigShouldClearStoredApiKeyWhenProviderChangesWithoutReplacement() {
+ LlmConfigVO config = LlmConfigVO.builder()
+ .provider("deepseek")
+ .apiKey("")
+ .apiBase("https://api.deepseek.com/v1")
+ .model("deepseek-chat")
+ .enabled(true)
+ .build();
+
+ llmConfigService.saveConfig(config);
+
+
assertThat(llmConfigService.getConfig().isApiKeyConfigured()).isFalse();
+ ArgumentCaptor<GeneralSettingsVO> captor =
ArgumentCaptor.forClass(GeneralSettingsVO.class);
+ verify(settingsService).saveGeneralSettings(captor.capture());
+ assertThat(captor.getValue().isClearApiKey()).isTrue();
+ }
+
+ @Test
+ void testConfigShouldRejectMissingRequiredApiKey() {
+ LlmOperationResultVO result =
llmConfigService.testConfig(LlmConfigVO.builder()
+ .provider("deepseek")
+ .apiKey("")
+ .model("deepseek-chat")
.build());
assertThat(result.getStatus()).isEqualTo(1);
assertThat(result.getErrMsg()).isEqualTo("API Key is required");
}
+ @Test
+ void testConfigShouldReuseStoredApiKeyForSameProvider() {
+ LlmOperationResultVO result =
llmConfigService.testConfig(LlmConfigVO.builder()
+ .provider("openai")
+ .apiKey("")
+ .model("gpt-4o")
+ .build());
+
+ assertThat(result.getStatus()).isZero();
+ assertThat(result.getMsg()).isEqualTo("Configuration accepted");
+ }
+
@Test
void testConfigShouldAllowOllamaWithoutApiKey() {
LlmOperationResultVO result =
llmConfigService.testConfig(LlmConfigVO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmControllerTest.java
index 1a957046..0b6a0a3e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmControllerTest.java
@@ -64,6 +64,8 @@ class LlmControllerTest {
mockMvc.perform(get("/api/llm/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.provider").value("openai"))
+ .andExpect(jsonPath("$.apiKey").doesNotExist())
+ .andExpect(jsonPath("$.apiKeyConfigured").value(true))
.andExpect(jsonPath("$.apiBase").value("https://api.openai.com/v1"))
.andExpect(jsonPath("$.model").value("gpt-4o"))
.andExpect(jsonPath("$.enabled").value(true));
diff --git a/web/src/api/llm.test.ts b/web/src/api/llm.test.ts
index 63bcaf50..feb1e3c1 100644
--- a/web/src/api/llm.test.ts
+++ b/web/src/api/llm.test.ts
@@ -38,6 +38,16 @@ const sampleConfig: LlmConfig = {
enabled: true,
};
+const storedConfig: LlmConfig = {
+ provider: 'openai',
+ apiKeyConfigured: true,
+ apiBase: 'https://api.openai.com/v1',
+ model: 'gpt-4o',
+ maxTokens: 4096,
+ temperature: 0.7,
+ enabled: true,
+};
+
describe('LLM API', () => {
beforeEach(() => {
mock.reset();
@@ -50,11 +60,13 @@ describe('LLM API', () => {
});
it('fetches LLM config', async () => {
- mock.onGet('/llm/config').reply(200, sampleConfig);
+ mock.onGet('/llm/config').reply(200, storedConfig);
const result = await getLlmConfig();
- expect(result).toEqual(sampleConfig);
+ expect(result).toEqual(storedConfig);
expect(result.provider).toBe('openai');
+ expect(result.apiKey).toBeUndefined();
+ expect(result.apiKeyConfigured).toBe(true);
expect(result.enabled).toBe(true);
});
diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts
index 871366ac..5c832f72 100644
--- a/web/src/api/llm.ts
+++ b/web/src/api/llm.ts
@@ -19,7 +19,8 @@ import client from './client';
export interface LlmConfig {
provider: string;
- apiKey: string;
+ apiKey?: string;
+ apiKeyConfigured?: boolean;
apiBase: string;
model: string;
maxTokens: number;
diff --git a/web/src/pages/studio/LlmSettings.tsx
b/web/src/pages/studio/LlmSettings.tsx
index 74f34edf..bf236175 100644
--- a/web/src/pages/studio/LlmSettings.tsx
+++ b/web/src/pages/studio/LlmSettings.tsx
@@ -153,6 +153,8 @@ interface TestResult {
msg: string;
}
+const MASKED_API_KEY = '••••••••';
+
const LlmSettingsPage: React.FC = () => {
const { t } = useLang();
const { message } = App.useApp();
@@ -164,7 +166,7 @@ const LlmSettingsPage: React.FC = () => {
const [enabled, setEnabled] = useState(false);
const [selectedProvider, setSelectedProvider] = useState('openai');
const [apiKeyMasked, setApiKeyMasked] = useState(true);
- const [savedApiKey, setSavedApiKey] = useState('');
+ const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
const [modelOptions, setModelOptions] = useState<{ value: string; label:
string }[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
@@ -183,9 +185,14 @@ const LlmSettingsPage: React.FC = () => {
const provider = config.provider || 'openai';
setSelectedProvider(provider);
setEnabled(config.enabled || false);
- if (config.apiKey) {
- setSavedApiKey(config.apiKey);
- form.setFieldsValue({ apiKey: maskApiKey(config.apiKey) });
+ if (config.apiKeyConfigured) {
+ setApiKeyConfigured(true);
+ setApiKeyMasked(true);
+ form.setFieldsValue({ apiKey: MASKED_API_KEY });
+ } else {
+ setApiKeyConfigured(false);
+ setApiKeyMasked(false);
+ form.setFieldsValue({ apiKey: '' });
}
form.setFieldsValue({
provider,
@@ -241,11 +248,6 @@ const LlmSettingsPage: React.FC = () => {
});
}
- const maskApiKey = (key: string) => {
- if (!key || key.length < 8) return key ? '••••••••' : '';
- return key.slice(0, 4) + '••••••••' + key.slice(-4);
- };
-
const currentProvider = PROVIDERS.find((p) => p.key === selectedProvider) ||
PROVIDERS[0];
const handleProviderChange = useCallback(
@@ -258,17 +260,23 @@ const LlmSettingsPage: React.FC = () => {
apiBase: provider.defaultBaseUrl,
model: provider.defaultModel,
});
+ if (value !== selectedProvider) {
+ form.setFieldsValue({ apiKey: '' });
+ setApiKeyConfigured(false);
+ setApiKeyMasked(false);
+ }
if (!provider.requireApiKey) {
form.setFieldsValue({ apiKey: '' });
- setSavedApiKey('');
+ setApiKeyConfigured(false);
+ setApiKeyMasked(false);
}
}
},
- [form],
+ [form, selectedProvider],
);
const handleApiKeyFocus = () => {
- if (apiKeyMasked && savedApiKey) {
+ if (apiKeyMasked && apiKeyConfigured) {
form.setFieldsValue({ apiKey: '' });
setApiKeyMasked(false);
}
@@ -276,12 +284,15 @@ const LlmSettingsPage: React.FC = () => {
const handleApiKeyBlur = () => {
const val = form.getFieldValue('apiKey');
- if (!val && savedApiKey) {
- form.setFieldsValue({ apiKey: maskApiKey(savedApiKey) });
+ if (!val && apiKeyConfigured) {
+ form.setFieldsValue({ apiKey: MASKED_API_KEY });
setApiKeyMasked(true);
}
};
+ const effectiveApiKey = (value: unknown) =>
+ apiKeyMasked && apiKeyConfigured ? '' : String(value || '');
+
const handleTestConnection = () => {
setTestLoading(true);
setTestResult(null);
@@ -290,7 +301,7 @@ const LlmSettingsPage: React.FC = () => {
.then((values) => {
const testConfig: LlmConfig = {
...values,
- apiKey: apiKeyMasked && savedApiKey ? savedApiKey : values.apiKey ||
'',
+ apiKey: effectiveApiKey(values.apiKey),
enabled: true,
};
testLlmConnection(testConfig)
@@ -302,8 +313,11 @@ const LlmSettingsPage: React.FC = () => {
saveLlmConfig(testConfig)
.then(() => {
if (testConfig.apiKey) {
- setSavedApiKey(testConfig.apiKey);
- form.setFieldsValue({ apiKey:
maskApiKey(testConfig.apiKey) });
+ setApiKeyConfigured(true);
+ form.setFieldsValue({ apiKey: MASKED_API_KEY });
+ setApiKeyMasked(true);
+ } else if (apiKeyConfigured) {
+ form.setFieldsValue({ apiKey: MASKED_API_KEY });
setApiKeyMasked(true);
}
})
@@ -341,7 +355,7 @@ const LlmSettingsPage: React.FC = () => {
.then((values) => {
const config: LlmConfig = {
...values,
- apiKey: apiKeyMasked && savedApiKey ? savedApiKey : values.apiKey ||
'',
+ apiKey: effectiveApiKey(values.apiKey),
enabled,
};
saveLlmConfig(config)
@@ -349,8 +363,11 @@ const LlmSettingsPage: React.FC = () => {
if (result && result.status === 0) {
message.success(t('llm.saveSuccess'));
if (config.apiKey) {
- setSavedApiKey(config.apiKey);
- form.setFieldsValue({ apiKey: maskApiKey(config.apiKey) });
+ setApiKeyConfigured(true);
+ form.setFieldsValue({ apiKey: MASKED_API_KEY });
+ setApiKeyMasked(true);
+ } else if (apiKeyConfigured) {
+ form.setFieldsValue({ apiKey: MASKED_API_KEY });
setApiKeyMasked(true);
}
} else {