feat(ai): harden gateway persistence and authorization

This commit is contained in:
Logic
2026-08-13 20:31:02 +08:00
parent 82f2440fd0
commit 7d18c85dc4
33 changed files with 742 additions and 39 deletions
@@ -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();
}
}
@@ -167,6 +167,7 @@ public class QueryController {
}
private GatewayEnvelope alertAnalysisEnvelope() {
ActorSupport.requireCurrentAdminSurenessActor();
return GatewayEnvelope.builder()
.channelId(ChannelId.ALERT.id())
.receivedAt(System.currentTimeMillis())
@@ -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. */
@@ -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);
@@ -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();
}
@@ -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);
}
}
@@ -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);
}
@@ -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.
*/
@@ -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");
@@ -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");
}
}
@@ -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.
@@ -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)
@@ -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,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);
}
@@ -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();
@@ -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(
@@ -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);
}
@@ -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));
}
}
@@ -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
@@ -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());
@@ -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]
@@ -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");
@@ -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]
@@ -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]
+11
View File
@@ -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]