mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 09:40:58 +00:00
feat(ai): harden gateway persistence and authorization
This commit is contained in:
+1
-1
@@ -166,7 +166,7 @@ public class ModelProviderConfigController {
|
||||
return GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.WEB_UI.id())
|
||||
.receivedAt(System.currentTimeMillis())
|
||||
.actor(ActorSupport.requireCurrentSurenessActor())
|
||||
.actor(ActorSupport.requireCurrentAdminSurenessActor())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ public class QueryController {
|
||||
}
|
||||
|
||||
private GatewayEnvelope alertAnalysisEnvelope() {
|
||||
ActorSupport.requireCurrentAdminSurenessActor();
|
||||
return GatewayEnvelope.builder()
|
||||
.channelId(ChannelId.ALERT.id())
|
||||
.receivedAt(System.currentTimeMillis())
|
||||
|
||||
+3
-7
@@ -50,7 +50,6 @@ import org.apache.hertzbeat.ai.gateway.tool.interaction.AgentInteractionInputSer
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -93,7 +92,7 @@ public class WebUiController {
|
||||
return ((GatewayStreamResponse) commandRouter.handle(
|
||||
chatCommand(request, ReplyMode.STREAM, acceptLanguage))).events()
|
||||
.map(this::toServerSentEvent)
|
||||
.onErrorResume(exception -> Flux.just(toServerSentEvent(errorEvent(exception))));
|
||||
.onErrorResume(exception -> Flux.just(toServerSentEvent(errorEvent())));
|
||||
}
|
||||
|
||||
@PostMapping("/runs/{runUid}/stop")
|
||||
@@ -188,12 +187,9 @@ public class WebUiController {
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayEvent errorEvent(Throwable exception) {
|
||||
String message = StringUtils.hasText(exception.getMessage())
|
||||
? exception.getMessage()
|
||||
: "Agent Gateway stream failed";
|
||||
private GatewayEvent errorEvent() {
|
||||
return new GatewayEvent(GatewayEventType.ERROR, "webui:error", null, null, null, null,
|
||||
new ErrorPayload(null, message), System.currentTimeMillis());
|
||||
new ErrorPayload(null, "Agent Gateway stream failed"), System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/** Values supplied to a pending interaction request. */
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ public class AgentSessionService {
|
||||
throw new IllegalArgumentException("Transcript message role must not be blank");
|
||||
}
|
||||
entry.setSessionSequence(nextSessionSequence(entry.getSessionId()));
|
||||
entry.setPayloadJson(rawPayload);
|
||||
entry.setPayloadJson(GatewayText.redactSecrets(rawPayload));
|
||||
entry.setMessageRole(GatewayText.requireBounded(
|
||||
entry.getMessageRole(), TRANSCRIPT_ROLE_LIMIT, "Transcript message role"));
|
||||
return transcriptEntryDao.save(entry);
|
||||
|
||||
+8
-2
@@ -22,8 +22,10 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeHistoryWindow;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeTextSanitizer;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptContent;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewaySecretRedactor;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
@@ -89,7 +91,9 @@ public class AgentTranscriptRecorder {
|
||||
.toolName(GatewayText.requireBounded(
|
||||
message.getToolName(), TRANSCRIPT_TOOL_NAME_LIMIT, "Transcript tool name"))
|
||||
.errorMessage(GatewayText.requireBounded(
|
||||
message.getErrorMessage(), TRANSCRIPT_TOOL_ERROR_LIMIT,
|
||||
AgentRuntimeTextSanitizer.sanitizeAndLimit(
|
||||
message.getErrorMessage(), TRANSCRIPT_TOOL_ERROR_LIMIT),
|
||||
TRANSCRIPT_TOOL_ERROR_LIMIT,
|
||||
"Transcript tool error"))
|
||||
.content(validateTranscriptContent(message.getContent()))
|
||||
.build();
|
||||
@@ -106,7 +110,9 @@ public class AgentTranscriptRecorder {
|
||||
block.getId(), TRANSCRIPT_TOOL_CALL_ID_LIMIT, "Transcript tool-call id"))
|
||||
.name(GatewayText.requireBounded(
|
||||
block.getName(), TRANSCRIPT_TOOL_NAME_LIMIT, "Transcript tool name"))
|
||||
.input(block.getInput() == null ? Map.of() : block.getInput())
|
||||
.text(GatewayText.redactSecrets(block.getText()))
|
||||
.input(GatewaySecretRedactor.redactMap(
|
||||
block.getInput() == null ? Map.of() : block.getInput()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentTranscriptEntryDao;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeProperties;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Deletes durable Agent Gateway transcript entries after their configured retention period.
|
||||
*/
|
||||
@Service
|
||||
public class AgentTranscriptRetentionService {
|
||||
|
||||
private final AgentTranscriptEntryDao transcriptEntryDao;
|
||||
private final AgentRuntimeProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
public AgentTranscriptRetentionService(AgentTranscriptEntryDao transcriptEntryDao,
|
||||
AgentRuntimeProperties properties) {
|
||||
this(transcriptEntryDao, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
AgentTranscriptRetentionService(AgentTranscriptEntryDao transcriptEntryDao,
|
||||
AgentRuntimeProperties properties,
|
||||
Clock clock) {
|
||||
this.transcriptEntryDao = Objects.requireNonNull(
|
||||
transcriptEntryDao, "transcriptEntryDao must not be null");
|
||||
this.properties = Objects.requireNonNull(properties, "properties must not be null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge transcript entries whose creation timestamp is older than the retention boundary.
|
||||
*
|
||||
* @return number of deleted transcript entries
|
||||
*/
|
||||
@Scheduled(cron = "${hertzbeat.agent.runtime.transcript-retention-cron:0 30 2 * * *}")
|
||||
@Transactional
|
||||
public long purgeExpiredTranscripts() {
|
||||
LocalDateTime cutoff = LocalDateTime.ofInstant(
|
||||
clock.instant().minus(properties.getTranscriptRetention()), clock.getZone());
|
||||
return transcriptEntryDao.deleteByGmtCreateBefore(cutoff);
|
||||
}
|
||||
}
|
||||
+6
@@ -17,6 +17,7 @@
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation.persistence;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentTranscriptEntry;
|
||||
@@ -52,4 +53,9 @@ public interface AgentTranscriptEntryDao extends JpaRepository<AgentTranscriptEn
|
||||
*/
|
||||
List<AgentTranscriptEntry> findBySessionIdAndSessionSequenceGreaterThanEqualOrderBySessionSequenceAsc(
|
||||
Long sessionId, Long sessionSequence, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Delete transcript entries older than the configured retention boundary.
|
||||
*/
|
||||
long deleteByGmtCreateBefore(LocalDateTime cutoff);
|
||||
}
|
||||
|
||||
+9
@@ -60,6 +60,15 @@ public final class ActorSupport {
|
||||
return requireSurenessActor(SurenessContextHolder.getBindSubject());
|
||||
}
|
||||
|
||||
/** Build an authenticated administrator actor from the current Sureness context. */
|
||||
public static AgentActor requireCurrentAdminSurenessActor() {
|
||||
AgentActor actor = requireCurrentSurenessActor();
|
||||
if (!actor.getRoles().contains(ROLE_ADMIN)) {
|
||||
throw new IllegalStateException("Administrator role is required");
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a trusted user actor from a Sureness subject and validate principal and roles.
|
||||
*/
|
||||
|
||||
+6
@@ -47,6 +47,8 @@ public class AgentRuntimeProperties {
|
||||
|
||||
private Duration toolTimeout = Duration.ofSeconds(180);
|
||||
|
||||
private Duration transcriptRetention = Duration.ofDays(30);
|
||||
|
||||
private ContextProperties context = new ContextProperties();
|
||||
|
||||
private RetryProperties retry = new RetryProperties();
|
||||
@@ -80,6 +82,10 @@ public class AgentRuntimeProperties {
|
||||
this.toolTimeout = requirePositive("toolTimeout", toolTimeout);
|
||||
}
|
||||
|
||||
public void setTranscriptRetention(Duration transcriptRetention) {
|
||||
this.transcriptRetention = requirePositive("transcriptRetention", transcriptRetention);
|
||||
}
|
||||
|
||||
public void setContext(ContextProperties context) {
|
||||
// The runtime loop needs a complete context policy before calculating every model input window.
|
||||
this.context = Objects.requireNonNull(context, "context must not be null");
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.text;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Recursively removes credentials from transcript and ledger payloads. */
|
||||
public final class GatewaySecretRedactor {
|
||||
|
||||
public static final String REDACTED = "[REDACTED]";
|
||||
|
||||
private static final Set<String> SECRET_KEYS = Set.of(
|
||||
"password", "passwd", "pwd", "pass",
|
||||
"token", "apitoken", "apikey", "accesstoken",
|
||||
"secret", "secretkey", "clientsecret", "privatekey",
|
||||
"authorization", "bearer");
|
||||
|
||||
private GatewaySecretRedactor() {
|
||||
}
|
||||
|
||||
public static Map<String, Object> redactMap(Map<String, Object> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> redacted = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
redacted.put(key, isSecretKey(key) ? REDACTED : redactValue(entry.getValue()));
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
private static Object redactValue(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
String key = String.valueOf(entry.getKey());
|
||||
normalized.put(key, isSecretKey(key) ? REDACTED : redactValue(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> redacted = new ArrayList<>(list.size());
|
||||
for (Object item : list) {
|
||||
redacted.add(redactValue(item));
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return GatewayText.redactSecrets(text);
|
||||
}
|
||||
if (value == null || value instanceof Number || value instanceof Boolean) {
|
||||
return value;
|
||||
}
|
||||
return GatewayText.redactSecrets(String.valueOf(value));
|
||||
}
|
||||
|
||||
private static boolean isSecretKey(String key) {
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
String normalized = key.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
|
||||
return SECRET_KEYS.contains(normalized)
|
||||
|| normalized.endsWith("password")
|
||||
|| normalized.endsWith("apikey")
|
||||
|| normalized.endsWith("secretkey")
|
||||
|| normalized.endsWith("accesstoken")
|
||||
|| normalized.endsWith("clientsecret");
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -21,11 +21,12 @@ import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.persistence.AgentToolCallDao;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.AgentActor;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeTextSanitizer;
|
||||
import org.apache.hertzbeat.ai.gateway.identity.ActorSupport;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeTextSanitizer;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewaySecretRedactor;
|
||||
import org.apache.hertzbeat.ai.gateway.text.GatewayText;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.core.persistence.AgentToolCallDao;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentToolCall;
|
||||
import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -53,7 +54,8 @@ public class AgentToolCallLedgerService {
|
||||
toolCall.setStatus(AgentToolStatus.DENIED.name());
|
||||
toolCall.setApprovalStatus(AgentApprovalStatus.NOT_REQUIRED.name());
|
||||
toolCall.setErrorMessage(GatewayText.requireBounded(
|
||||
policy.getReason(), ERROR_LIMIT, "tool policy reason"));
|
||||
AgentRuntimeTextSanitizer.sanitizeAndLimit(policy.getReason(), ERROR_LIMIT),
|
||||
ERROR_LIMIT, "tool policy reason"));
|
||||
return toolCallDao.save(toolCall);
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ public class AgentToolCallLedgerService {
|
||||
public AgentToolCall recordApprovedToolResumed(AgentToolExecutionRequest request, AgentToolDescriptor descriptor,
|
||||
AgentPolicyResult policy) {
|
||||
AgentToolCall toolCall = approvedPendingToolCall(request, descriptor);
|
||||
String canonicalArgs = AgentToolPayloadHasher.canonicalArgumentsJson(request.getArguments());
|
||||
String canonicalArgs = safeArgumentsJson(request);
|
||||
toolCall.setStatus(AgentToolStatus.RUNNING.name());
|
||||
toolCall.setRisk(policy.getRisk().name());
|
||||
toolCall.setPolicyDecision(policy.getDecision().name());
|
||||
@@ -130,7 +132,7 @@ public class AgentToolCallLedgerService {
|
||||
@Transactional
|
||||
public AgentToolCall completeToolCall(AgentToolCall toolCall, AgentToolOutput output, long elapsedMs) {
|
||||
toolCall.setStatus(output.getStatus().name());
|
||||
toolCall.setResultOutput(output.getModelContent());
|
||||
toolCall.setResultOutput(AgentRuntimeTextSanitizer.redact(output.getModelContent()));
|
||||
if (AgentToolStatus.SUCCEEDED.equals(output.getStatus())) {
|
||||
toolCall.setErrorMessage(null);
|
||||
} else {
|
||||
@@ -158,7 +160,7 @@ public class AgentToolCallLedgerService {
|
||||
}
|
||||
|
||||
private AgentToolCall baseToolCall(AgentToolExecutionRequest request, AgentToolDescriptor descriptor, AgentPolicyResult policy) {
|
||||
String canonicalArgs = AgentToolPayloadHasher.canonicalArgumentsJson(request.getArguments());
|
||||
String canonicalArgs = safeArgumentsJson(request);
|
||||
return AgentToolCall.builder()
|
||||
.toolCallId(GatewayText.requireBounded(request.getToolCallId(), 128, "tool call id"))
|
||||
.runId(request.getRunId())
|
||||
@@ -174,6 +176,11 @@ public class AgentToolCallLedgerService {
|
||||
.build();
|
||||
}
|
||||
|
||||
private String safeArgumentsJson(AgentToolExecutionRequest request) {
|
||||
return AgentToolPayloadHasher.canonicalArgumentsJson(
|
||||
GatewaySecretRedactor.redactMap(request.getArguments()));
|
||||
}
|
||||
|
||||
private AgentToolCall approvedPendingToolCall(AgentToolExecutionRequest request, AgentToolDescriptor descriptor) {
|
||||
String approvalId = request.getApprovalId();
|
||||
// Approval resume must target one existing approval row before mutating it to RUNNING.
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ class AgentAlertAnalysisEventHandlerTest {
|
||||
"alertname", alertName,
|
||||
"defineid", defineId,
|
||||
"instance", "8.137.157.93:22",
|
||||
"instancename", "我的阿里云服务器",
|
||||
"instancename", "production-server",
|
||||
"provider", "aliyun",
|
||||
"severity", "emergency"))
|
||||
.content(alertName)
|
||||
|
||||
+18
-3
@@ -21,6 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.usthe.sureness.subject.SubjectSum;
|
||||
@@ -126,6 +128,15 @@ class ModelProviderConfigControllerTest {
|
||||
assertWebUiActor(command);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotReadOrMutateProviderConfiguration() {
|
||||
bindSubject("user");
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> controller().getConfigurations());
|
||||
assertThrows(IllegalStateException.class, () -> controller().createConfiguration(new ModelProviderConfig()));
|
||||
verifyNoInteractions(commandRouter);
|
||||
}
|
||||
|
||||
private ModelProviderConfigController controller() {
|
||||
return new ModelProviderConfigController(commandRouter);
|
||||
}
|
||||
@@ -143,10 +154,14 @@ class ModelProviderConfigControllerTest {
|
||||
}
|
||||
|
||||
private void bindSubject() {
|
||||
bindSubject("admin");
|
||||
}
|
||||
|
||||
private void bindSubject(String role) {
|
||||
when(subject.getPrincipal()).thenReturn("trusted-user");
|
||||
when(subject.getRoles()).thenReturn(List.of("user"));
|
||||
when(subject.hasRole("admin")).thenReturn(false);
|
||||
when(subject.hasRole("user")).thenReturn(true);
|
||||
when(subject.getRoles()).thenReturn(List.of(role));
|
||||
when(subject.hasRole("admin")).thenReturn("admin".equals(role));
|
||||
when(subject.hasRole("user")).thenReturn("user".equals(role));
|
||||
when(subject.hasRole("guest")).thenReturn(false);
|
||||
SurenessContextHolder.bindSubject(subject);
|
||||
}
|
||||
|
||||
+21
-3
@@ -21,6 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.usthe.sureness.subject.SubjectSum;
|
||||
@@ -107,6 +109,7 @@ class QueryControllerTest {
|
||||
|
||||
@Test
|
||||
void alertAnalysisListShouldRouteSystemEnvelopeAndSearchThroughGatewayCommandRouter() {
|
||||
bindSubject();
|
||||
Page<AgentSession> page = Page.empty(PageRequest.of(0, 50));
|
||||
when(commandRouter.handle(commandCaptor.capture()))
|
||||
.thenReturn(response("list-alert-analysis-sessions:0", "sessions", page));
|
||||
@@ -125,6 +128,7 @@ class QueryControllerTest {
|
||||
|
||||
@Test
|
||||
void alertAnalysisSessionShouldRouteSystemEnvelopeThroughGatewayCommandRouter() {
|
||||
bindSubject();
|
||||
GatewaySingleResponse sessionResponse = response("get-alert-analysis-session:ags-1", "session");
|
||||
when(commandRouter.handle(commandCaptor.capture())).thenReturn(sessionResponse);
|
||||
|
||||
@@ -164,6 +168,7 @@ class QueryControllerTest {
|
||||
|
||||
@Test
|
||||
void alertAnalysisTranscriptShouldRouteSystemEnvelopeThroughGatewayCommandRouter() {
|
||||
bindSubject();
|
||||
PageRequest defaultPage = PageRequest.of(0, 50);
|
||||
Page<AgentTranscriptEntry> transcript = Page.empty(defaultPage);
|
||||
when(commandRouter.handle(commandCaptor.capture()))
|
||||
@@ -181,6 +186,15 @@ class QueryControllerTest {
|
||||
assertEquals("ags-1", command.sessionUid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotReadAutomaticAlertAnalysisSessions() {
|
||||
bindSubject("user");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> controller().listAlertAnalysisSessions(0, 50, null));
|
||||
verifyNoInteractions(commandRouter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removedRunLedgerPathsShouldNotBeMapped() {
|
||||
String removedSegment = "/ta" + "sks";
|
||||
@@ -212,10 +226,14 @@ class QueryControllerTest {
|
||||
}
|
||||
|
||||
private void bindSubject() {
|
||||
bindSubject("admin");
|
||||
}
|
||||
|
||||
private void bindSubject(String role) {
|
||||
when(subject.getPrincipal()).thenReturn("trusted-user");
|
||||
when(subject.getRoles()).thenReturn(List.of("admin"));
|
||||
when(subject.hasRole("admin")).thenReturn(true);
|
||||
when(subject.hasRole("user")).thenReturn(false);
|
||||
when(subject.getRoles()).thenReturn(List.of(role));
|
||||
when(subject.hasRole("admin")).thenReturn("admin".equals(role));
|
||||
when(subject.hasRole("user")).thenReturn("user".equals(role));
|
||||
when(subject.hasRole("guest")).thenReturn(false);
|
||||
SurenessContextHolder.bindSubject(subject);
|
||||
}
|
||||
|
||||
+17
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.ai.gateway.channel.webui;
|
||||
|
||||
import static org.apache.hertzbeat.common.constants.CommonConstants.SUCCESS_CODE;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -43,6 +44,7 @@ import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.Meta;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewaySingleResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayResponse.GatewayStreamResponse;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.RunCompletedPayload;
|
||||
import org.apache.hertzbeat.ai.gateway.application.GatewayEvent.ErrorPayload;
|
||||
import org.apache.hertzbeat.common.entity.dto.Message;
|
||||
import org.apache.hertzbeat.ai.gateway.tool.interaction.AgentInteractionInputService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -128,6 +130,21 @@ class WebUiControllerTest {
|
||||
assertEquals("ja-JP", command.envelope().getPreferredLanguage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamChatShouldNotExposeRuntimeExceptionDetails() {
|
||||
bindSubject();
|
||||
when(commandRouter.handle(commandCaptor.capture())).thenReturn(new GatewayStreamResponse(
|
||||
new Meta("msg-1", "conv-1", "ags-1", "run-1", false, "streaming"),
|
||||
Flux.error(new IllegalStateException("provider apiKey=private-value"))));
|
||||
|
||||
List<ServerSentEvent<GatewayEvent>> events = controller().streamChat(chatRequest(), null)
|
||||
.collectList().block();
|
||||
|
||||
ErrorPayload error = (ErrorPayload) events.getFirst().data().payload();
|
||||
assertFalse(error.errorMessage().contains("private-value"));
|
||||
assertEquals("Agent Gateway stream failed", error.errorMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopRunShouldRouteAuthenticatedCancellationCommand() {
|
||||
bindSubject();
|
||||
|
||||
+19
@@ -206,6 +206,25 @@ class AgentSessionServiceTest {
|
||||
assertTrue(entry.getPayloadJson().contains("alertId=1001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordTranscriptEntryShouldRedactSecretsAtThePersistenceBoundary() {
|
||||
AgentSessionService service = new AgentSessionService(
|
||||
sessionDao, transcriptEntryDao, sessionKeyBuilder, entityManager);
|
||||
AgentSession session = AgentSession.builder().id(1L).transcriptSequence(0L).build();
|
||||
when(sessionDao.findFirstById(1L)).thenReturn(Optional.of(session));
|
||||
when(transcriptEntryDao.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
AgentTranscriptEntry entry = service.recordTranscriptEntry(AgentTranscriptEntry.builder()
|
||||
.sessionId(1L)
|
||||
.payloadJson("{\"password\":\"raw-secret\",\"message\":\"keep this\"}")
|
||||
.messageRole("assistant")
|
||||
.build());
|
||||
|
||||
assertFalse(entry.getPayloadJson().contains("raw-secret"));
|
||||
assertTrue(entry.getPayloadJson().contains("[REDACTED]"));
|
||||
assertTrue(entry.getPayloadJson().contains("keep this"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordTranscriptEntryShouldRejectMissingRole() {
|
||||
AgentSessionService service = new AgentSessionService(
|
||||
|
||||
+31
-4
@@ -18,11 +18,16 @@
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput.Message;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput;
|
||||
import org.apache.hertzbeat.ai.gateway.contract.UserInput.Message;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptContent;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.TranscriptMessage;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentRun;
|
||||
import org.apache.hertzbeat.common.entity.agent.AgentSession;
|
||||
@@ -43,7 +48,7 @@ class AgentTranscriptRecorderTest {
|
||||
private AgentSessionService sessionService;
|
||||
|
||||
@Test
|
||||
void shouldPersistUserAndToolTextWithoutSanitizingOrTruncating() {
|
||||
void shouldPersistFullTranscriptTextWithSecretsRedacted() {
|
||||
when(sessionService.recordTranscriptEntry(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
AgentTranscriptRecorder recorder = new AgentTranscriptRecorder(sessionService);
|
||||
AgentSession session = AgentSession.builder().id(1L).sessionUid("session-1").build();
|
||||
@@ -61,12 +66,34 @@ class AgentTranscriptRecorderTest {
|
||||
AgentTranscriptEntry toolEntry = recorder.recordRunMessage(
|
||||
session, run, TranscriptMessage.toolResult("call-1", "monitor.get", toolText, toolError));
|
||||
|
||||
assertEquals(userText, message(userEntry).text());
|
||||
assertEquals(toolText, message(toolEntry).text());
|
||||
assertTrue(message(userEntry).text().contains("detail ".repeat(500)));
|
||||
assertFalse(message(userEntry).text().contains("user-secret"));
|
||||
assertTrue(message(userEntry).text().contains("token=[REDACTED]"));
|
||||
assertFalse(message(toolEntry).text().contains("tool-secret"));
|
||||
assertTrue(message(toolEntry).text().contains("\"token\":\"[REDACTED]\""));
|
||||
assertEquals(toolError, message(toolEntry).getErrorMessage());
|
||||
assertEquals("call-1", message(toolEntry).getToolCallId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRedactNestedToolArgumentsBeforePersistence() {
|
||||
when(sessionService.recordTranscriptEntry(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
AgentTranscriptRecorder recorder = new AgentTranscriptRecorder(sessionService);
|
||||
AgentSession session = AgentSession.builder().id(1L).sessionUid("session-1").build();
|
||||
AgentRun run = AgentRun.builder().id(2L).runUid("run-1").sessionId(1L).build();
|
||||
TranscriptMessage message = TranscriptMessage.assistantToolCalls(null, List.of(
|
||||
TranscriptContent.toolCall("call-1", "database.connect", Map.of(
|
||||
"username", "operator",
|
||||
"credentials", Map.of("password", "nested-secret")))), null);
|
||||
|
||||
AgentTranscriptEntry entry = recorder.recordRunMessage(session, run, message);
|
||||
Map<String, Object> input = message(entry).toolCalls().getFirst().getInput();
|
||||
|
||||
assertEquals("operator", input.get("username"));
|
||||
assertFalse(entry.getPayloadJson().contains("nested-secret"));
|
||||
assertTrue(entry.getPayloadJson().contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
private TranscriptMessage message(AgentTranscriptEntry entry) {
|
||||
return JsonUtil.fromJson(entry.getPayloadJson(), TranscriptMessage.class);
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.ai.gateway.conversation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import org.apache.hertzbeat.ai.gateway.conversation.persistence.AgentTranscriptEntryDao;
|
||||
import org.apache.hertzbeat.ai.gateway.runtime.AgentRuntimeProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
/** Retention contracts for durable Agent Gateway transcripts. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AgentTranscriptRetentionServiceTest {
|
||||
|
||||
@Mock
|
||||
private AgentTranscriptEntryDao transcriptEntryDao;
|
||||
|
||||
@Test
|
||||
void purgeUsesTheConfiguredAbsoluteRetentionCutoff() {
|
||||
AgentRuntimeProperties properties = new AgentRuntimeProperties();
|
||||
properties.setTranscriptRetention(Duration.ofDays(7));
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-08-13T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
new AgentTranscriptRetentionService(transcriptEntryDao, properties, clock)
|
||||
.purgeExpiredTranscripts();
|
||||
|
||||
verify(transcriptEntryDao).deleteByGmtCreateBefore(
|
||||
LocalDateTime.of(2026, 8, 6, 12, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retentionCannotBeDisabledAccidentally() {
|
||||
AgentRuntimeProperties properties = new AgentRuntimeProperties();
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> properties.setTranscriptRetention(Duration.ZERO));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -34,7 +34,7 @@ class AgentRuntimeTokenEstimatorTest {
|
||||
void estimateTextShouldUseCeilingUtf8BytesDividedByFour() {
|
||||
assertEquals(1L, estimator.estimateText("test"));
|
||||
assertEquals(2L, estimator.estimateText("tests"));
|
||||
assertEquals(3L, estimator.estimateText("告警分析"));
|
||||
assertEquals(3L, estimator.estimateText("alert now"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+4
-3
@@ -66,9 +66,9 @@ class AgentToolCallLedgerServiceTest {
|
||||
assertEquals("RUNNING", toolCall.getStatus());
|
||||
assertEquals(AgentApprovalStatus.NOT_REQUIRED.name(), toolCall.getApprovalStatus());
|
||||
assertTrue(toolCall.getInputJson().contains("\"pageSize\":1"));
|
||||
assertTrue(toolCall.getInputJson().contains("\"password\":\"hunter2\""));
|
||||
assertFalse(toolCall.getInputJson().contains("hunter2"));
|
||||
assertEquals(AgentToolPayloadHasher.normalizedArgumentsHash(request.getArguments()), toolCall.getInputHash());
|
||||
assertFalse(toolCall.getInputJson().contains("[REDACTED]"));
|
||||
assertTrue(toolCall.getInputJson().contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,7 +159,8 @@ class AgentToolCallLedgerServiceTest {
|
||||
assertEquals(2L, completed.getRunId());
|
||||
assertEquals("run_1", completed.getRunUid());
|
||||
assertEquals(1L, completed.getSessionId());
|
||||
assertEquals("ok password=hunter2", completed.getResultOutput());
|
||||
assertFalse(completed.getResultOutput().contains("hunter2"));
|
||||
assertTrue(completed.getResultOutput().contains("[REDACTED]"));
|
||||
assertEquals(AgentToolStatus.FAILED.name(), failedOutput.getStatus());
|
||||
assertFalse(failedOutput.getErrorMessage().contains("abc123"));
|
||||
assertEquals(2L, failed.getRunId());
|
||||
|
||||
+2
@@ -21,6 +21,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* Model Provider Configuration
|
||||
@@ -47,5 +48,6 @@ public class ModelProviderConfig {
|
||||
private String model;
|
||||
|
||||
@Schema(title = "API Key", description = "API key", example = "sk-...")
|
||||
@ToString.Exclude
|
||||
private String apiKey;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,17 @@ resourceRole:
|
||||
- /api/bulletin/**===delete===[admin]
|
||||
- /api/sse/**===get===[admin,user]
|
||||
- /api/sse/**===post===[admin,user]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin,user]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
+59
-7
@@ -29,6 +29,7 @@ import org.apache.hertzbeat.common.constants.GeneralConfigTypeEnum;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfigState;
|
||||
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
|
||||
import org.apache.hertzbeat.common.util.AesUtil;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.apache.hertzbeat.manager.service.ModelProviderConfigurationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -54,18 +55,18 @@ public class ModelProviderConfigServiceImpl extends AbstractGeneralConfigService
|
||||
|
||||
@Override
|
||||
public ModelProviderConfigState getState() {
|
||||
return getConfig();
|
||||
return copyState(getConfig());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProviderConfig getConfiguration(String uid) {
|
||||
return copyConfiguration(findConfiguration(getConfig(), uid));
|
||||
return decryptedConfiguration(findConfiguration(getConfig(), uid));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProviderConfig getActiveConfiguration() {
|
||||
ModelProviderConfig activeConfiguration = activeConfiguration(getConfig());
|
||||
return activeConfiguration == null ? null : copyConfiguration(activeConfiguration);
|
||||
return activeConfiguration == null ? null : decryptedConfiguration(activeConfiguration);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -77,7 +78,7 @@ public class ModelProviderConfigServiceImpl extends AbstractGeneralConfigService
|
||||
savedConfig.setUid(UUID.randomUUID().toString());
|
||||
state.getProviders().add(savedConfig);
|
||||
saveConfig(state);
|
||||
return getConfig();
|
||||
return getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -99,7 +100,7 @@ public class ModelProviderConfigServiceImpl extends AbstractGeneralConfigService
|
||||
int index = state.getProviders().indexOf(previous);
|
||||
state.getProviders().set(index, replacement);
|
||||
saveConfig(state);
|
||||
return getConfig();
|
||||
return getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -112,7 +113,7 @@ public class ModelProviderConfigServiceImpl extends AbstractGeneralConfigService
|
||||
state.setActiveProviderUid(null);
|
||||
}
|
||||
saveConfig(state);
|
||||
return getConfig();
|
||||
return getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -127,7 +128,13 @@ public class ModelProviderConfigServiceImpl extends AbstractGeneralConfigService
|
||||
}
|
||||
state.setActiveProviderUid(uid);
|
||||
saveConfig(state);
|
||||
return getConfig();
|
||||
return getState();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void saveConfig(ModelProviderConfigState state) {
|
||||
super.saveConfig(encryptedState(state));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -183,6 +190,51 @@ public class ModelProviderConfigServiceImpl extends AbstractGeneralConfigService
|
||||
return new ModelProviderConfigState(null, new ArrayList<>());
|
||||
}
|
||||
|
||||
private ModelProviderConfigState encryptedState(ModelProviderConfigState state) {
|
||||
Objects.requireNonNull(state, "model provider configuration state is required");
|
||||
List<ModelProviderConfig> providers = state.getProviders() == null
|
||||
? List.of()
|
||||
: state.getProviders();
|
||||
List<ModelProviderConfig> encryptedProviders = new ArrayList<>(providers.size());
|
||||
for (ModelProviderConfig provider : providers) {
|
||||
ModelProviderConfig encrypted = copyConfiguration(provider);
|
||||
encrypted.setApiKey(encryptSecret(encrypted.getApiKey()));
|
||||
encryptedProviders.add(encrypted);
|
||||
}
|
||||
return new ModelProviderConfigState(state.getActiveProviderUid(), encryptedProviders);
|
||||
}
|
||||
|
||||
private ModelProviderConfigState copyState(ModelProviderConfigState state) {
|
||||
List<ModelProviderConfig> providers = state.getProviders().stream()
|
||||
.map(this::copyConfiguration)
|
||||
.toList();
|
||||
return new ModelProviderConfigState(state.getActiveProviderUid(), new ArrayList<>(providers));
|
||||
}
|
||||
|
||||
private String encryptSecret(String secret) {
|
||||
if (!StringUtils.hasText(secret) || AesUtil.isCiphertext(secret)) {
|
||||
return secret;
|
||||
}
|
||||
String encrypted = AesUtil.aesEncode(secret);
|
||||
if (!AesUtil.isCiphertext(encrypted)) {
|
||||
throw new IllegalStateException("Model provider secret encryption failed");
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
private ModelProviderConfig decryptedConfiguration(ModelProviderConfig persisted) {
|
||||
ModelProviderConfig copy = copyConfiguration(persisted);
|
||||
if (StringUtils.hasText(copy.getApiKey()) && AesUtil.isCiphertext(copy.getApiKey())) {
|
||||
String ciphertext = copy.getApiKey();
|
||||
String plaintext = AesUtil.aesDecode(ciphertext);
|
||||
if (Objects.equals(ciphertext, plaintext)) {
|
||||
throw new IllegalStateException("Model provider secret decryption failed");
|
||||
}
|
||||
copy.setApiKey(plaintext);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private ModelProviderConfig findConfiguration(ModelProviderConfigState state, String uid) {
|
||||
if (!StringUtils.hasText(uid)) {
|
||||
throw new IllegalArgumentException("Model provider configuration UID is required");
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.manager.service.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
|
||||
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfigState;
|
||||
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
|
||||
import org.apache.hertzbeat.common.util.AesUtil;
|
||||
import org.apache.hertzbeat.common.util.JsonUtil;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
|
||||
/** Secret-at-rest contracts for model provider configurations. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ModelProviderConfigServiceImplTest {
|
||||
|
||||
private static final String API_KEY = "provider-secret-value";
|
||||
|
||||
@Mock
|
||||
private GeneralConfigDao generalConfigDao;
|
||||
|
||||
private AtomicReference<GeneralConfig> stored;
|
||||
private ModelProviderConfigServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
stored = new AtomicReference<>();
|
||||
when(generalConfigDao.findByType("provider")).thenAnswer(invocation -> stored.get());
|
||||
lenient().when(generalConfigDao.save(any(GeneralConfig.class))).thenAnswer(invocation -> {
|
||||
GeneralConfig config = invocation.getArgument(0);
|
||||
stored.set(config);
|
||||
return config;
|
||||
});
|
||||
service = new ModelProviderConfigServiceImpl(generalConfigDao);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreSecretKey() {
|
||||
AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEncryptsApiKeyAtRestAndDecryptsOnlyForRuntimeUse() {
|
||||
ModelProviderConfig request = provider(API_KEY);
|
||||
assertFalse(request.toString().contains(API_KEY));
|
||||
|
||||
ModelProviderConfigState state = service.createConfiguration(request);
|
||||
String content = stored.get().getContent();
|
||||
assertFalse(content.contains(API_KEY));
|
||||
|
||||
ModelProviderConfig persisted = persistedState(content).getProviders().getFirst();
|
||||
assertTrue(AesUtil.isCiphertext(persisted.getApiKey()));
|
||||
assertEquals(API_KEY, AesUtil.aesDecode(persisted.getApiKey()));
|
||||
assertTrue(AesUtil.isCiphertext(state.getProviders().getFirst().getApiKey()));
|
||||
assertEquals(API_KEY, service.getConfiguration(persisted.getUid()).getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankUpdatePreservesExistingCiphertextWithoutExposingIt() {
|
||||
ModelProviderConfigState created = service.createConfiguration(provider(API_KEY));
|
||||
String uid = created.getProviders().getFirst().getUid();
|
||||
String ciphertext = persistedState(stored.get().getContent()).getProviders().getFirst().getApiKey();
|
||||
|
||||
service.updateConfiguration(uid, provider(""));
|
||||
|
||||
assertEquals(ciphertext, persistedState(stored.get().getContent()).getProviders().getFirst().getApiKey());
|
||||
assertEquals(API_KEY, service.getConfiguration(uid).getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptionFailureNeverPersistsPlaintext() {
|
||||
AesUtil.setDefaultSecretKey("invalid-key");
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.createConfiguration(provider(API_KEY)));
|
||||
|
||||
verify(generalConfigDao, never()).save(any(GeneralConfig.class));
|
||||
}
|
||||
|
||||
private ModelProviderConfig provider(String apiKey) {
|
||||
ModelProviderConfig config = new ModelProviderConfig();
|
||||
config.setType("openai-compatible");
|
||||
config.setCode("openai");
|
||||
config.setBaseUrl("https://provider.invalid");
|
||||
config.setModel("model");
|
||||
config.setApiKey(apiKey);
|
||||
return config;
|
||||
}
|
||||
|
||||
private ModelProviderConfigState persistedState(String content) {
|
||||
return JsonUtil.fromJson(content, new TypeReference<>() { });
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,17 @@ resourceRole:
|
||||
- /api/actions/catalog===post===[admin]
|
||||
- /api/sse/**===get===[admin,user]
|
||||
- /api/sse/**===post===[admin,user]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin,user]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
@@ -157,6 +157,17 @@ resourceRole:
|
||||
- /api/actions/catalog===post===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.startup;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Authorization matrix for every shipped Agent Gateway route. */
|
||||
class AgentGatewayAuthorizationConfigTest {
|
||||
|
||||
private static final List<String> SURENESS_CONFIGS = List.of(
|
||||
"hertzbeat-startup/src/main/resources/sureness.yml",
|
||||
"hertzbeat-manager/src/test/resources/sureness.yml",
|
||||
"hertzbeat-e2e/hertzbeat-observability-e2e/src/test/resources/sureness.yml",
|
||||
"script/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml",
|
||||
"script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml");
|
||||
|
||||
private static final List<String> RULES = List.of(
|
||||
" - /api/agent/model-providers/**===get===[admin]",
|
||||
" - /api/agent/model-providers/**===post===[admin]",
|
||||
" - /api/agent/model-providers/**===put===[admin]",
|
||||
" - /api/agent/model-providers/**===delete===[admin]",
|
||||
" - /api/agent/alert-analysis/**===get===[admin]",
|
||||
" - /api/agent/sessions===get===[admin,user]",
|
||||
" - /api/agent/sessions/**===get===[admin,user]",
|
||||
" - /api/agent/webui/**===post===[admin,user]",
|
||||
" - /api/agent/runs/**===post===[admin,user]",
|
||||
" - /api/agent/approvals/**===post===[admin]",
|
||||
" - /api/agent/interactions/**===post===[admin,user]");
|
||||
|
||||
@Test
|
||||
void shippedConfigsUseTheExactAgentGatewayRoleMatrix() throws IOException {
|
||||
for (String config : SURENESS_CONFIGS) {
|
||||
List<String> lines = Files.readAllLines(repoRoot().resolve(config));
|
||||
List<String> agentRules = lines.stream()
|
||||
.filter(line -> line.startsWith(" - /api/agent/"))
|
||||
.toList();
|
||||
assertEquals(RULES, agentRules, config);
|
||||
assertFalse(lines.stream().anyMatch(line -> line.startsWith(" - /api/agent/**===")), config);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path repoRoot() {
|
||||
Path current = Paths.get("").toAbsolutePath();
|
||||
while (current != null && !Files.exists(current.resolve("hertzbeat-startup/pom.xml"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Cannot locate HertzBeat repository root");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,17 @@ resourceRole:
|
||||
- /api/bulletin/**===delete===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
@@ -142,6 +142,17 @@ resourceRole:
|
||||
- /api/bulletin/**===delete===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
@@ -142,6 +142,17 @@ resourceRole:
|
||||
- /api/bulletin/**===delete===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
@@ -142,6 +142,17 @@ resourceRole:
|
||||
- /api/bulletin/**===delete===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
@@ -142,6 +142,17 @@ resourceRole:
|
||||
- /api/bulletin/**===delete===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
@@ -147,6 +147,17 @@ resourceRole:
|
||||
- /api/actions/catalog===post===[admin]
|
||||
- /api/mcp/**===get===[admin]
|
||||
- /api/mcp/**===post===[admin]
|
||||
- /api/agent/model-providers/**===get===[admin]
|
||||
- /api/agent/model-providers/**===post===[admin]
|
||||
- /api/agent/model-providers/**===put===[admin]
|
||||
- /api/agent/model-providers/**===delete===[admin]
|
||||
- /api/agent/alert-analysis/**===get===[admin]
|
||||
- /api/agent/sessions===get===[admin,user]
|
||||
- /api/agent/sessions/**===get===[admin,user]
|
||||
- /api/agent/webui/**===post===[admin,user]
|
||||
- /api/agent/runs/**===post===[admin,user]
|
||||
- /api/agent/approvals/**===post===[admin]
|
||||
- /api/agent/interactions/**===post===[admin,user]
|
||||
- /api/chat/**===get===[admin,user]
|
||||
- /api/chat/**===post===[admin]
|
||||
- /api/logs/ingest/**===post===[admin,user]
|
||||
|
||||
Reference in New Issue
Block a user