This is an automated email from the ASF dual-hosted git repository.
Duansg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new dc10bcef97 maintenance: scope AI conversations by creator (#4280)
dc10bcef97 is described below
commit dc10bcef976f7aa425fe699537e1f3ffde44ef44
Author: Logic <[email protected]>
AuthorDate: Tue Aug 25 23:05:12 2026 +0800
maintenance: scope AI conversations by creator (#4280)
Co-authored-by: Duansg <[email protected]>
---
.../hertzbeat/ai/dao/ChatConversationDao.java | 6 +
.../apache/hertzbeat/ai/dao/SopScheduleDao.java | 14 +-
.../hertzbeat/ai/schedule/SopScheduleExecutor.java | 31 +++-
.../hertzbeat/ai/service/SopScheduleService.java | 9 ++
.../ai/service/impl/ConversationServiceImpl.java | 41 +++--
.../ai/service/impl/SopScheduleServiceImpl.java | 100 +++++++++----
.../hertzbeat/ai/tools/impl/MonitorToolsImpl.java | 12 +-
.../ai/schedule/SopScheduleExecutorTest.java | 41 +++++
.../service/impl/ConversationServiceImplTest.java | 122 +++++++++++++--
.../service/impl/SopScheduleServiceImplTest.java | 166 ++++++++++++++++++---
.../ai/tools/impl/MonitorToolsImplTest.java | 77 ++++++++++
.../common/entity/ai/ChatConversation.java | 7 +-
.../hertzbeat/common/entity/ai/SopSchedule.java | 1 +
.../common/entity/ai/ChatConversationTest.java | 30 +++-
.../h2/V182__scope_sop_schedule_owners.sql | 24 +++
.../mysql/V182__scope_sop_schedule_owners.sql | 24 +++
.../postgresql/V182__scope_sop_schedule_owners.sql | 24 +++
home/docs/start/upgrade.md | 26 ++++
18 files changed, 672 insertions(+), 83 deletions(-)
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
index 971b51f7cb..852f7d4f03 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
@@ -17,6 +17,8 @@
package org.apache.hertzbeat.ai.dao;
+import java.util.List;
+import java.util.Optional;
import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -26,4 +28,8 @@ import org.springframework.stereotype.Repository;
*/
@Repository
public interface ChatConversationDao extends JpaRepository<ChatConversation,
Long> {
+
+ Optional<ChatConversation> findByIdAndCreator(Long id, String creator);
+
+ List<ChatConversation> findAllByCreatorOrderByIdDesc(String creator);
}
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java
index 08861591e6..1b82536c1c 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.ai.dao;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Optional;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -37,14 +38,23 @@ public interface SopScheduleDao extends
JpaRepository<SopSchedule, Long>, JpaSpe
* @param conversationId The conversation ID
* @return List of schedules
*/
- List<SopSchedule> findByConversationId(Long conversationId);
+ List<SopSchedule> findByConversationIdAndCreator(Long conversationId,
String creator);
+
+ /**
+ * Find a schedule only when it belongs to the supplied creator.
+ * @param id schedule identity
+ * @param creator authenticated creator
+ * @return matching schedule
+ */
+ Optional<SopSchedule> findByIdAndCreator(Long id, String creator);
/**
* Find all enabled schedules that are due for execution.
* @param currentTime The current time to compare against
* @return List of due schedules
*/
- @Query("SELECT s FROM SopSchedule s WHERE s.enabled = true AND
s.nextRunTime <= :currentTime")
+ @Query("SELECT s FROM SopSchedule s "
+ + "WHERE s.enabled = true AND s.creator IS NOT NULL AND
s.nextRunTime <= :currentTime")
List<SopSchedule> findDueSchedules(@Param("currentTime") LocalDateTime
currentTime);
/**
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java
index 2466b84625..710da0e0d5 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java
@@ -21,6 +21,7 @@ import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.dao.ChatMessageDao;
import org.apache.hertzbeat.ai.service.SopScheduleService;
@@ -98,6 +99,12 @@ public class SopScheduleExecutor {
* Execute a single scheduled SOP and push result to conversation.
*/
private void executeSchedule(SopSchedule schedule) {
+ schedule =
sopScheduleService.getScheduleForExecution(schedule.getId());
+ if (schedule == null) {
+ return;
+ }
+ String executionCreator = schedule.getCreator();
+ Long executionConversationId = schedule.getConversationId();
log.info("Executing scheduled SOP {} for conversation {}",
schedule.getSopName(), schedule.getConversationId());
@@ -122,7 +129,13 @@ public class SopScheduleExecutor {
// Execute SOP
SopResult result = sopEngine.executeSync(definition, params);
-
+
+ SopSchedule deliverySchedule =
sopScheduleService.getScheduleForExecution(schedule.getId());
+ if (!hasSameExecutionTarget(deliverySchedule, executionCreator,
executionConversationId)) {
+ log.warn("Schedule {} lost its execution owner before result
delivery", schedule.getId());
+ return;
+ }
+
// Create push message
String messageContent = formatPushMessage(schedule, result);
@@ -131,6 +144,7 @@ public class SopScheduleExecutor {
.conversationId(schedule.getConversationId())
.role(ROLE_SYSTEM_PUSH)
.content(messageContent)
+ .creator(schedule.getCreator())
.build();
chatMessageDao.save(pushMessage);
@@ -141,7 +155,13 @@ public class SopScheduleExecutor {
} catch (Exception e) {
log.error("Failed to execute scheduled SOP {} for conversation
{}",
schedule.getSopName(), schedule.getConversationId(), e);
-
+
+ SopSchedule deliverySchedule =
sopScheduleService.getScheduleForExecution(schedule.getId());
+ if (!hasSameExecutionTarget(deliverySchedule, executionCreator,
executionConversationId)) {
+ log.warn("Schedule {} lost its execution owner before error
delivery", schedule.getId());
+ return;
+ }
+
// Still save an error message
String errorContent =
SopMessageUtil.getMessage("schedule.push.error.prefix") + " " +
schedule.getSopName()
+ "\n\n" +
SopMessageUtil.getMessage("schedule.push.error.label") + " " + e.getMessage();
@@ -149,6 +169,7 @@ public class SopScheduleExecutor {
.conversationId(schedule.getConversationId())
.role(ROLE_SYSTEM_PUSH)
.content(errorContent)
+ .creator(schedule.getCreator())
.build();
chatMessageDao.save(errorMessage);
@@ -158,6 +179,12 @@ public class SopScheduleExecutor {
}
}
+ private boolean hasSameExecutionTarget(SopSchedule schedule, String
creator, Long conversationId) {
+ return schedule != null
+ && Objects.equals(creator, schedule.getCreator())
+ && Objects.equals(conversationId,
schedule.getConversationId());
+ }
+
/**
* Format the push message content with SOP result.
*/
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java
index 3f04938bf6..fa69c23699 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java
@@ -73,6 +73,15 @@ public interface SopScheduleService {
*/
List<SopSchedule> getDueSchedules();
+ /**
+ * Re-read a schedule for background execution and verify that its
persisted
+ * creator still owns the target conversation. This method does not depend
+ * on a request-thread subject.
+ * @param id schedule ID
+ * @return validated schedule, or {@code null} when it must not execute
+ */
+ SopSchedule getScheduleForExecution(Long id);
+
/**
* Update the execution times after a schedule runs.
* @param id The schedule ID
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
index b66b839e08..577a81d19f 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
@@ -33,7 +33,6 @@ import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
import org.apache.hertzbeat.common.util.AesUtil;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.domain.Sort;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -66,6 +65,10 @@ public class ConversationServiceImpl implements
ConversationService {
@Override
public Flux<ServerSentEvent<ChatResponseChunk>> streamChat(String message,
Long conversationId) {
+ String creator = requireCurrentUserId();
+ ChatConversation conversation = conversationId == null
+ ? null
+ : requireOwnedConversation(conversationId, creator);
// Check if provider is properly configured
if (!chatClientProviderService.isConfigured()) {
@@ -78,15 +81,12 @@ public class ConversationServiceImpl implements
ConversationService {
.build());
}
- ChatConversation conversation;
- if (conversationId == null) {
+ if (conversation == null) {
// The API contract makes conversationId optional, so create a
conversation for the first message.
conversation = new ChatConversation();
conversation.setTitle(buildConversationTitle(message));
+ conversation.setCreator(creator);
conversation = conversationDao.save(conversation);
- } else {
- conversation = conversationDao.findById(conversationId)
- .orElseThrow(() -> new IllegalArgumentException("Conversation
not found: " + conversationId));
}
Long currentConversationId = conversation.getId();
log.info("Starting streaming conversation: {}", currentConversationId);
@@ -170,6 +170,7 @@ public class ConversationServiceImpl implements
ConversationService {
public ChatConversation createConversation() {
ChatConversation conversation = new ChatConversation();
conversation.setTitle("conversation-" +
UUID.randomUUID().toString().substring(0, 4));
+ conversation.setCreator(requireCurrentUserId());
return conversationDao.save(conversation);
}
@@ -182,17 +183,16 @@ public class ConversationServiceImpl implements
ConversationService {
if (conversationId == null) {
return null;
}
- ChatConversation conversation =
conversationDao.findById(conversationId).orElse(null);
- if (conversation != null) {
- List<ChatMessage> messages =
messageDao.findByConversationIdOrderByGmtCreateAsc(conversationId);
- conversation.setMessages(messages);
- }
+ ChatConversation conversation =
requireOwnedConversation(conversationId, requireCurrentUserId());
+ List<ChatMessage> messages =
messageDao.findByConversationIdOrderByGmtCreateAsc(conversationId);
+ conversation.setMessages(messages);
return conversation;
}
@Override
public List<ChatConversation> getAllConversations() {
- List<ChatConversation> conversations =
conversationDao.findAll(Sort.by(Sort.Direction.DESC, "id"));
+ List<ChatConversation> conversations =
+
conversationDao.findAllByCreatorOrderByIdDesc(requireCurrentUserId());
if (conversations.isEmpty()) {
return conversations;
}
@@ -213,6 +213,7 @@ public class ConversationServiceImpl implements
ConversationService {
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteConversation(Long conversationId) {
+ requireOwnedConversation(conversationId, requireCurrentUserId());
// Delete associated schedules first to prevent tasks from writing
orphaned messages.
sopScheduleDao.deleteByConversationId(conversationId);
List<ChatMessage> messages =
messageDao.findByConversationIdOrderByGmtCreateAsc(conversationId);
@@ -224,7 +225,8 @@ public class ConversationServiceImpl implements
ConversationService {
@Override
public Boolean saveSecurityData(SecurityData securityData) {
- Optional<ChatConversation> chatConversation =
conversationDao.findById(securityData.getConversationId());
+ Optional<ChatConversation> chatConversation =
conversationDao.findByIdAndCreator(
+ securityData.getConversationId(), requireCurrentUserId());
if (chatConversation.isPresent()) {
ChatConversation conversation = chatConversation.get();
conversation.setSecurityData(AesUtil.aesEncode(securityData.getSecurityData()));
@@ -234,4 +236,17 @@ public class ConversationServiceImpl implements
ConversationService {
return false;
}
+ private String requireCurrentUserId() {
+ SubjectSum subject = SurenessContextHolder.getBindSubject();
+ if (subject == null || subject.getPrincipal() == null) {
+ throw new IllegalStateException("No authenticated user");
+ }
+ return String.valueOf(subject.getPrincipal());
+ }
+
+ private ChatConversation requireOwnedConversation(Long conversationId,
String creator) {
+ return conversationDao.findByIdAndCreator(conversationId, creator)
+ .orElseThrow(() -> new IllegalArgumentException("Conversation not
found: " + conversationId));
+ }
+
}
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java
index 7e6f0f0850..232f331c12 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java
@@ -17,11 +17,16 @@
package org.apache.hertzbeat.ai.service.impl;
+import com.usthe.sureness.subject.SubjectSum;
+import com.usthe.sureness.util.SurenessContextHolder;
import java.time.LocalDateTime;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.ai.dao.ChatConversationDao;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.support.CronExpression;
@@ -36,23 +41,32 @@ import
org.springframework.transaction.annotation.Transactional;
public class SopScheduleServiceImpl implements SopScheduleService {
private final SopScheduleDao sopScheduleDao;
+ private final ChatConversationDao conversationDao;
@Autowired
- public SopScheduleServiceImpl(SopScheduleDao sopScheduleDao) {
+ public SopScheduleServiceImpl(SopScheduleDao sopScheduleDao,
+ ChatConversationDao conversationDao) {
this.sopScheduleDao = sopScheduleDao;
+ this.conversationDao = conversationDao;
}
@Override
@Transactional
public SopSchedule createSchedule(SopSchedule schedule) {
- // Validate cron expression
+ String creator = requireCurrentUserId();
+ requireOwnedConversation(schedule.getConversationId(), creator);
validateCronExpression(schedule.getCronExpression());
-
- // Calculate next run time
-
schedule.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
- schedule.setEnabled(schedule.getEnabled() != null ?
schedule.getEnabled() : true);
-
- SopSchedule saved = sopScheduleDao.save(schedule);
+
+ SopSchedule persisted = SopSchedule.builder()
+ .conversationId(schedule.getConversationId())
+ .sopName(schedule.getSopName())
+ .sopParams(schedule.getSopParams())
+ .cronExpression(schedule.getCronExpression())
+ .enabled(schedule.getEnabled() != null ? schedule.getEnabled()
: true)
+
.nextRunTime(calculateNextRunTime(schedule.getCronExpression()))
+ .creator(creator)
+ .build();
+ SopSchedule saved = sopScheduleDao.save(persisted);
log.info("Created schedule {} for conversation {} with SOP {}",
saved.getId(), saved.getConversationId(), saved.getSopName());
return saved;
@@ -61,57 +75,59 @@ public class SopScheduleServiceImpl implements
SopScheduleService {
@Override
@Transactional
public SopSchedule updateSchedule(SopSchedule schedule) {
- SopSchedule existing = sopScheduleDao.findById(schedule.getId())
+ SopSchedule existing = sopScheduleDao.findByIdAndCreator(
+ schedule.getId(), requireCurrentUserId())
.orElseThrow(() -> new IllegalArgumentException("Schedule not
found: " + schedule.getId()));
-
- // Update fields
+
existing.setSopName(schedule.getSopName());
existing.setSopParams(schedule.getSopParams());
-
- // If cron expression changed, recalculate next run time
+
if
(!existing.getCronExpression().equals(schedule.getCronExpression())) {
validateCronExpression(schedule.getCronExpression());
existing.setCronExpression(schedule.getCronExpression());
existing.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
}
-
+
if (schedule.getEnabled() != null) {
existing.setEnabled(schedule.getEnabled());
}
-
+
return sopScheduleDao.save(existing);
}
@Override
@Transactional
public void deleteSchedule(Long id) {
+ SopSchedule schedule = sopScheduleDao.findByIdAndCreator(id,
requireCurrentUserId())
+ .orElseThrow(() -> new IllegalArgumentException("Schedule not
found: " + id));
log.info("Deleting schedule {}", id);
- sopScheduleDao.deleteById(id);
+ sopScheduleDao.delete(schedule);
}
@Override
public SopSchedule getSchedule(Long id) {
- return sopScheduleDao.findById(id).orElse(null);
+ return sopScheduleDao.findByIdAndCreator(id,
requireCurrentUserId()).orElse(null);
}
@Override
public List<SopSchedule> getSchedulesByConversation(Long conversationId) {
- return sopScheduleDao.findByConversationId(conversationId);
+ String creator = requireCurrentUserId();
+ requireOwnedConversation(conversationId, creator);
+ return sopScheduleDao.findByConversationIdAndCreator(conversationId,
creator);
}
@Override
@Transactional
public SopSchedule toggleSchedule(Long id, boolean enabled) {
- SopSchedule schedule = sopScheduleDao.findById(id)
+ SopSchedule schedule = sopScheduleDao.findByIdAndCreator(id,
requireCurrentUserId())
.orElseThrow(() -> new IllegalArgumentException("Schedule not
found: " + id));
-
+
schedule.setEnabled(enabled);
-
- // If enabling, recalculate next run time
+
if (enabled) {
schedule.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
}
-
+
log.info("Schedule {} {} ", id, enabled ? "enabled" : "disabled");
return sopScheduleDao.save(schedule);
}
@@ -123,16 +139,34 @@ public class SopScheduleServiceImpl implements
SopScheduleService {
@Override
@Transactional
- public void updateAfterExecution(Long id) {
+ public SopSchedule getScheduleForExecution(Long id) {
SopSchedule schedule = sopScheduleDao.findById(id).orElse(null);
+ if (schedule == null || !Boolean.TRUE.equals(schedule.getEnabled())) {
+ return null;
+ }
+ if (StringUtils.isBlank(schedule.getCreator())
+ || conversationDao.findByIdAndCreator(
+ schedule.getConversationId(),
schedule.getCreator()).isEmpty()) {
+ schedule.setEnabled(false);
+ sopScheduleDao.save(schedule);
+ log.warn("Disabled schedule {} because its execution owner is
missing", id);
+ return null;
+ }
+ return schedule;
+ }
+
+ @Override
+ @Transactional
+ public void updateAfterExecution(Long id) {
+ SopSchedule schedule = getScheduleForExecution(id);
if (schedule == null) {
return;
}
-
+
schedule.setLastRunTime(LocalDateTime.now());
schedule.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
sopScheduleDao.save(schedule);
-
+
log.debug("Updated schedule {} - Last run: {}, Next run: {}",
id, schedule.getLastRunTime(), schedule.getNextRunTime());
}
@@ -161,4 +195,18 @@ public class SopScheduleServiceImpl implements
SopScheduleService {
throw new IllegalArgumentException("Failed to calculate next run
time: " + cronExpression, e);
}
}
+
+ private String requireCurrentUserId() {
+ SubjectSum subject = SurenessContextHolder.getBindSubject();
+ if (subject == null || subject.getPrincipal() == null) {
+ throw new IllegalStateException("No authenticated user");
+ }
+ return String.valueOf(subject.getPrincipal());
+ }
+
+ private ChatConversation requireOwnedConversation(Long conversationId,
String creator) {
+ return conversationDao.findByIdAndCreator(conversationId, creator)
+ .orElseThrow(() ->
+ new IllegalArgumentException("Conversation not found:
" + conversationId));
+ }
}
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImpl.java
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImpl.java
index 7886cf97b6..1eb0ffd596 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImpl.java
+++
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImpl.java
@@ -312,8 +312,16 @@ public class MonitorToolsImpl implements MonitorTools {
// Query and add sensitive parameters
if (conversationId != null) {
- Optional<ChatConversation> chatConversation =
conversationDao.findById(conversationId);
- if (chatConversation.isPresent() &&
StringUtils.isNotEmpty(chatConversation.get().getSecurityData())) {
+ SubjectSum subject = McpContextHolder.getSubject();
+ if (subject == null || subject.getPrincipal() == null) {
+ return "Error: Authenticated conversation context is
required";
+ }
+ Optional<ChatConversation> chatConversation =
conversationDao.findByIdAndCreator(
+ conversationId,
String.valueOf(subject.getPrincipal()));
+ if (chatConversation.isEmpty()) {
+ return "Error: Conversation not found or inaccessible";
+ }
+ if
(StringUtils.isNotEmpty(chatConversation.get().getSecurityData())) {
List<Param> securityParams = JsonUtil.fromJson(
AesUtil.aesDecode(chatConversation.get().getSecurityData()),
new TypeReference<List<Param>>() {
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutorTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutorTest.java
index 77c7d18843..fbca5bd15e 100644
---
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutorTest.java
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutorTest.java
@@ -20,6 +20,7 @@ package org.apache.hertzbeat.ai.schedule;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -77,6 +78,8 @@ class SopScheduleExecutorTest {
.content("ok")
.build();
when(scheduleService.getDueSchedules()).thenReturn(List.of(first,
second));
+ when(scheduleService.getScheduleForExecution(1L)).thenReturn(first,
first);
+ when(scheduleService.getScheduleForExecution(2L)).thenReturn(second,
second);
when(skillRegistry.getSkill("daily_inspection")).thenReturn(definition);
when(sopEngine.executeSync(any(SopDefinition.class),
anyMap())).thenReturn(result);
doThrow(new IllegalStateException("database unavailable"))
@@ -85,6 +88,8 @@ class SopScheduleExecutorTest {
executor.checkAndExecuteDueSchedules();
verify(sopEngine, times(2)).executeSync(any(SopDefinition.class),
anyMap());
+ verify(chatMessageDao, times(2)).save(argThat(
+ message -> "alice".equals(message.getCreator())));
verify(scheduleService).updateAfterExecution(2L);
}
@@ -92,6 +97,7 @@ class SopScheduleExecutorTest {
void checkShouldRejectInvalidScheduleParameters() {
SopSchedule schedule = schedule(1L, "not-json");
when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
+ when(scheduleService.getScheduleForExecution(1L)).thenReturn(schedule,
schedule);
when(skillRegistry.getSkill("daily_inspection"))
.thenReturn(SopDefinition.builder().name("daily_inspection").build());
@@ -102,10 +108,44 @@ class SopScheduleExecutorTest {
verify(scheduleService).updateAfterExecution(1L);
}
+ @Test
+ void checkShouldSkipScheduleWithoutValidatedOwner() {
+ SopSchedule schedule = schedule(1L, null);
+ when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
+ when(scheduleService.getScheduleForExecution(1L)).thenReturn(null);
+
+ executor.checkAndExecuteDueSchedules();
+
+ verifyNoInteractions(sopEngine, chatMessageDao);
+ verify(scheduleService, times(0)).updateAfterExecution(1L);
+ }
+
+ @Test
+ void checkShouldNotDeliverWhenOwnerChangesDuringExecution() {
+ SopSchedule schedule = schedule(1L, null);
+ SopSchedule changedOwner = schedule(1L, null);
+ changedOwner.setCreator("bob");
+ changedOwner.setConversationId(20L);
+ when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
+ when(scheduleService.getScheduleForExecution(1L))
+ .thenReturn(schedule, changedOwner, changedOwner);
+ when(skillRegistry.getSkill("daily_inspection"))
+
.thenReturn(SopDefinition.builder().name("daily_inspection").build());
+ when(sopEngine.executeSync(any(SopDefinition.class), anyMap()))
+
.thenReturn(SopResult.builder().status("SUCCESS").content("ok").build());
+
+ executor.checkAndExecuteDueSchedules();
+
+ verify(sopEngine).executeSync(any(SopDefinition.class), anyMap());
+ verifyNoInteractions(chatMessageDao);
+ verify(scheduleService).updateAfterExecution(1L);
+ }
+
@Test
void checkShouldPushErrorWhenScheduledSkillNoLongerExists() {
SopSchedule schedule = schedule(1L, null);
when(scheduleService.getDueSchedules()).thenReturn(List.of(schedule));
+ when(scheduleService.getScheduleForExecution(1L)).thenReturn(schedule,
schedule);
when(skillRegistry.getSkill("daily_inspection")).thenReturn(null);
executor.checkAndExecuteDueSchedules();
@@ -123,6 +163,7 @@ class SopScheduleExecutorTest {
.conversationId(10L)
.sopName("daily_inspection")
.sopParams(params)
+ .creator("alice")
.build();
}
}
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java
index 43cdf88d8f..601fe14cec 100644
---
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java
@@ -18,9 +18,13 @@
package org.apache.hertzbeat.ai.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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@@ -35,6 +39,7 @@ import org.apache.hertzbeat.ai.dao.ChatMessageDao;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.pojo.dto.ChatResponseChunk;
+import org.apache.hertzbeat.ai.pojo.dto.SecurityData;
import org.apache.hertzbeat.ai.service.ChatClientProviderService;
import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
@@ -79,29 +84,30 @@ class ConversationServiceImplTest {
@Test
void streamChatShouldKeepCompleteConversationHistory() {
- SubjectSum subject = org.mockito.Mockito.mock(SubjectSum.class);
- SurenessContextHolder.bindSubject(subject);
+ SubjectSum subject = bindSubject("alice");
ChatConversation conversation = ChatConversation.builder()
.id(CONVERSATION_ID)
- .title("已命名会话")
+ .title("Named conversation")
+ .creator("alice")
.build();
List<ChatMessage> history = List.of(
ChatMessage.builder()
.id(11L)
.conversationId(CONVERSATION_ID)
.role("user")
- .content("上一轮问题")
+ .content("Previous question")
.build(),
ChatMessage.builder()
.id(12L)
.conversationId(CONVERSATION_ID)
.role("assistant")
- .content("上一轮回答")
+ .content("Previous answer")
.build());
AtomicLong messageId = new AtomicLong(20L);
when(chatClientProviderService.isConfigured()).thenReturn(true);
-
when(conversationDao.findById(CONVERSATION_ID)).thenReturn(Optional.of(conversation));
+ when(conversationDao.findByIdAndCreator(CONVERSATION_ID, "alice"))
+ .thenReturn(Optional.of(conversation));
when(messageDao.findByConversationIdOrderByGmtCreateAsc(CONVERSATION_ID)).thenReturn(history);
when(messageDao.save(any(ChatMessage.class))).thenAnswer(invocation ->
{
ChatMessage savedMessage = invocation.getArgument(0);
@@ -109,10 +115,10 @@ class ConversationServiceImplTest {
return savedMessage;
});
when(chatClientProviderService.streamChat(any(ChatRequestContext.class)))
- .thenReturn(Flux.just("本轮回答"));
+ .thenReturn(Flux.just("Current answer"));
List<ServerSentEvent<ChatResponseChunk>> events = conversationService
- .streamChat("本轮问题", CONVERSATION_ID)
+ .streamChat("Current question", CONVERSATION_ID)
.collectList()
.block();
@@ -129,6 +135,7 @@ class ConversationServiceImplTest {
*/
@Test
void streamChatShouldCreateConversationWhenConversationIdIsMissing() {
+ bindSubject("alice");
AtomicLong messageId = new AtomicLong(20L);
when(chatClientProviderService.isConfigured()).thenReturn(true);
when(conversationDao.save(any(ChatConversation.class))).thenAnswer(invocation
-> {
@@ -143,10 +150,10 @@ class ConversationServiceImplTest {
return savedMessage;
});
when(chatClientProviderService.streamChat(any(ChatRequestContext.class)))
- .thenReturn(Flux.just("本轮回答"));
+ .thenReturn(Flux.just("Current answer"));
List<ServerSentEvent<ChatResponseChunk>> events = conversationService
- .streamChat("本轮问题", null)
+ .streamChat("Initial question", null)
.collectList()
.block();
@@ -161,7 +168,8 @@ class ConversationServiceImplTest {
assertEquals(List.of(),
contextCaptor.getValue().getConversationHistory());
ArgumentCaptor<ChatConversation> conversationCaptor =
ArgumentCaptor.forClass(ChatConversation.class);
verify(conversationDao).save(conversationCaptor.capture());
- assertEquals("本轮问题", conversationCaptor.getValue().getTitle());
+ assertEquals("Initial question",
conversationCaptor.getValue().getTitle());
+ assertEquals("alice", conversationCaptor.getValue().getCreator());
verifyNoMoreInteractions(conversationDao);
}
@@ -170,12 +178,20 @@ class ConversationServiceImplTest {
*/
@Test
void
deleteConversationShouldRemoveSchedulesMessagesAndConversationInOrder() {
+ bindSubject("alice");
+ ChatConversation conversation = ChatConversation.builder()
+ .id(CONVERSATION_ID)
+ .title("Owned conversation")
+ .creator("alice")
+ .build();
ChatMessage message = ChatMessage.builder()
.id(11L)
.conversationId(CONVERSATION_ID)
.role("user")
.content("message to delete")
.build();
+ when(conversationDao.findByIdAndCreator(CONVERSATION_ID, "alice"))
+ .thenReturn(Optional.of(conversation));
when(messageDao.findByConversationIdOrderByGmtCreateAsc(CONVERSATION_ID))
.thenReturn(List.of(message));
@@ -186,4 +202,88 @@ class ConversationServiceImplTest {
deletionOrder.verify(messageDao).deleteAll(List.of(message));
deletionOrder.verify(conversationDao).deleteById(CONVERSATION_ID);
}
+
+ @Test
+ void listConversationsShouldExcludeOtherCreators() {
+ bindSubject("alice");
+ ChatConversation ownedConversation = ChatConversation.builder()
+ .id(CONVERSATION_ID)
+ .title("Owned conversation")
+ .creator("alice")
+ .build();
+ when(conversationDao.findAllByCreatorOrderByIdDesc("alice"))
+ .thenReturn(List.of(ownedConversation));
+
when(messageDao.findByConversationIdInOrderByGmtCreateAsc(List.of(CONVERSATION_ID)))
+ .thenReturn(List.of());
+
+ List<ChatConversation> result =
conversationService.getAllConversations();
+
+ assertEquals(List.of(ownedConversation), result);
+ verify(conversationDao).findAllByCreatorOrderByIdDesc("alice");
+ }
+
+ @Test
+ void getConversationShouldRejectAnotherCreator() {
+ bindSubject("alice");
+ when(conversationDao.findByIdAndCreator(CONVERSATION_ID, "alice"))
+ .thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> conversationService.getConversation(CONVERSATION_ID));
+ verify(messageDao,
never()).findByConversationIdOrderByGmtCreateAsc(CONVERSATION_ID);
+ }
+
+ @Test
+ void deleteConversationShouldRejectAnotherCreator() {
+ bindSubject("alice");
+ when(conversationDao.findByIdAndCreator(CONVERSATION_ID, "alice"))
+ .thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> conversationService.deleteConversation(CONVERSATION_ID));
+ verify(sopScheduleDao,
never()).deleteByConversationId(CONVERSATION_ID);
+ verify(conversationDao, never()).deleteById(CONVERSATION_ID);
+ }
+
+ @Test
+ void streamChatShouldRejectAnotherCreator() {
+ bindSubject("alice");
+ when(conversationDao.findByIdAndCreator(CONVERSATION_ID, "alice"))
+ .thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> conversationService.streamChat("Current question",
CONVERSATION_ID));
+ verify(messageDao, never()).save(any(ChatMessage.class));
+ }
+
+ @Test
+ void createConversationShouldRecordCurrentCreator() {
+ bindSubject("alice");
+ when(conversationDao.save(any(ChatConversation.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ ChatConversation conversation =
conversationService.createConversation();
+
+ assertEquals("alice", conversation.getCreator());
+ }
+
+ @Test
+ void saveSecurityDataShouldRejectAnotherCreator() {
+ bindSubject("alice");
+ SecurityData securityData = new SecurityData();
+ securityData.setConversationId(CONVERSATION_ID);
+ securityData.setSecurityData("sensitive-value");
+ when(conversationDao.findByIdAndCreator(CONVERSATION_ID, "alice"))
+ .thenReturn(Optional.empty());
+
+ assertFalse(conversationService.saveSecurityData(securityData));
+ verify(conversationDao, never()).save(any(ChatConversation.class));
+ }
+
+ private SubjectSum bindSubject(String principal) {
+ SubjectSum subject = mock(SubjectSum.class);
+ when(subject.getPrincipal()).thenReturn(principal);
+ SurenessContextHolder.bindSubject(subject);
+ return subject;
+ }
}
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImplTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImplTest.java
index 5eb588b872..446f2fabab 100644
---
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImplTest.java
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImplTest.java
@@ -1,58 +1,186 @@
/*
- * 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
+ * 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.
+ * 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.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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
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.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import com.usthe.sureness.subject.SubjectSum;
+import com.usthe.sureness.util.SurenessContextHolder;
+import java.util.Optional;
+import org.apache.hertzbeat.ai.dao.ChatConversationDao;
import org.apache.hertzbeat.ai.dao.SopScheduleDao;
+import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
- * Verifies that SOP schedules with no future execution time are not persisted.
+ * Ownership and scheduling contracts for user-facing SOP schedule operations.
*/
@ExtendWith(MockitoExtension.class)
class SopScheduleServiceImplTest {
@Mock
- private SopScheduleDao sopScheduleDao;
+ private SopScheduleDao scheduleDao;
- @InjectMocks
- private SopScheduleServiceImpl scheduleService;
+ @Mock
+ private ChatConversationDao conversationDao;
+
+ private SopScheduleServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SopScheduleServiceImpl(scheduleDao, conversationDao);
+ SubjectSum subject = mock(SubjectSum.class);
+ lenient().when(subject.getPrincipal()).thenReturn("alice");
+ SurenessContextHolder.bindSubject(subject);
+ }
+
+ @AfterEach
+ void clearSubject() {
+ SurenessContextHolder.clear();
+ }
+
+ @Test
+ void createShouldNotTrustRequestCreator() {
+ SopSchedule request = schedule(1L, "bob");
+ when(conversationDao.findByIdAndCreator(10L, "alice"))
+ .thenReturn(Optional.of(ChatConversation.builder()
+ .id(10L)
+ .creator("alice")
+ .build()));
+ when(scheduleDao.save(any(SopSchedule.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ SopSchedule created = service.createSchedule(request);
+
+ assertEquals("alice", created.getCreator());
+ }
+
+ @Test
+ void getShouldHideAnotherCreatorsSchedule() {
+ when(scheduleDao.findByIdAndCreator(1L,
"alice")).thenReturn(Optional.empty());
+
+ assertNull(service.getSchedule(1L));
+ }
+
+ @Test
+ void listShouldRejectAnotherCreatorsConversation() {
+ when(conversationDao.findByIdAndCreator(10L,
"alice")).thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> service.getSchedulesByConversation(10L));
+ verify(scheduleDao, never()).findByConversationIdAndCreator(10L,
"alice");
+ }
+
+ @Test
+ void deleteShouldNotRemoveAnotherCreatorsSchedule() {
+ when(scheduleDao.findByIdAndCreator(1L,
"alice")).thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class, () ->
service.deleteSchedule(1L));
+ verify(scheduleDao, never()).delete(any(SopSchedule.class));
+ }
+
+ @Test
+ void updateAndToggleShouldNotModifyAnotherCreatorsSchedule() {
+ when(scheduleDao.findByIdAndCreator(1L,
"alice")).thenReturn(Optional.empty());
+
+ assertThrows(IllegalArgumentException.class,
+ () -> service.updateSchedule(schedule(1L, "bob")));
+ assertThrows(IllegalArgumentException.class,
+ () -> service.toggleSchedule(1L, true));
+ verify(scheduleDao, never()).save(any(SopSchedule.class));
+ }
+
+ @Test
+ void backgroundExecutionShouldDisableMissingOwner() {
+ SopSchedule schedule = schedule(1L, "legacy-owner");
+ schedule.setEnabled(true);
+ when(scheduleDao.findById(1L)).thenReturn(Optional.of(schedule));
+ when(conversationDao.findByIdAndCreator(10L, "legacy-owner"))
+ .thenReturn(Optional.empty());
+ when(scheduleDao.save(schedule)).thenReturn(schedule);
+
+ assertNull(service.getScheduleForExecution(1L));
+ assertFalse(schedule.getEnabled());
+ verify(scheduleDao).save(schedule);
+ }
+
+ @Test
+ void backgroundExecutionUsesPersistedOwnerWithoutRequestSubject() {
+ SurenessContextHolder.clear();
+ SopSchedule schedule = schedule(1L, "alice");
+ schedule.setEnabled(true);
+ when(scheduleDao.findById(1L)).thenReturn(Optional.of(schedule));
+ when(conversationDao.findByIdAndCreator(10L, "alice"))
+ .thenReturn(Optional.of(ChatConversation.builder()
+ .id(10L)
+ .creator("alice")
+ .build()));
+
+ assertSame(schedule, service.getScheduleForExecution(1L));
+ }
@Test
void createScheduleShouldRejectCronWithoutFutureExecutionTime() {
SopSchedule schedule = SopSchedule.builder()
- .conversationId(1L)
+ .conversationId(10L)
.sopName("daily_inspection")
.cronExpression("0 0 0 31 2 *")
.build();
+ when(conversationDao.findByIdAndCreator(10L, "alice"))
+ .thenReturn(Optional.of(ChatConversation.builder()
+ .id(10L)
+ .creator("alice")
+ .build()));
IllegalArgumentException exception = assertThrows(
- IllegalArgumentException.class, () ->
scheduleService.createSchedule(schedule));
+ IllegalArgumentException.class, () ->
service.createSchedule(schedule));
assertTrue(exception.getMessage().contains("no future execution
time"));
- verifyNoInteractions(sopScheduleDao);
+ verifyNoInteractions(scheduleDao);
+ }
+
+ private SopSchedule schedule(Long id, String creator) {
+ return SopSchedule.builder()
+ .id(id)
+ .conversationId(10L)
+ .sopName("daily_inspection")
+ .cronExpression("0 0 9 * * ?")
+ .creator(creator)
+ .build();
}
}
diff --git
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImplTest.java
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImplTest.java
new file mode 100644
index 0000000000..669868a061
--- /dev/null
+++
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/tools/impl/MonitorToolsImplTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.tools.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.usthe.sureness.subject.SubjectSum;
+import java.util.Optional;
+import org.apache.hertzbeat.ai.config.McpContextHolder;
+import org.apache.hertzbeat.ai.dao.ChatConversationDao;
+import org.apache.hertzbeat.manager.service.AppService;
+import org.apache.hertzbeat.manager.service.MonitorService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Verifies that protected monitor creation cannot load another user's
+ * conversation credentials.
+ */
+@ExtendWith(MockitoExtension.class)
+class MonitorToolsImplTest {
+
+ @Mock
+ private MonitorService monitorService;
+
+ @Mock
+ private AppService appService;
+
+ @Mock
+ private ChatConversationDao conversationDao;
+
+ @InjectMocks
+ private MonitorToolsImpl monitorTools;
+
+ @AfterEach
+ void clearContext() {
+ McpContextHolder.clear();
+ }
+
+ @Test
+ void protectedAddShouldRejectConversationOutsideCurrentCreator() {
+ SubjectSum subject = mock(SubjectSum.class);
+ when(subject.getPrincipal()).thenReturn("alice");
+ McpContextHolder.setSubject(subject);
+ when(conversationDao.findByIdAndCreator(10L,
"alice")).thenReturn(Optional.empty());
+
+ String result = monitorTools.addMonitorProtected(
+ 10L, "database", "mysql", 60, "{\"host\":\"db.local\"}", null);
+
+ assertEquals("Error: Conversation not found or inaccessible", result);
+ verify(conversationDao).findByIdAndCreator(10L, "alice");
+ verifyNoInteractions(monitorService);
+ }
+}
diff --git
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatConversation.java
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatConversation.java
index 22e2f94639..0a8d3e51f5 100644
---
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatConversation.java
+++
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatConversation.java
@@ -19,12 +19,14 @@ package org.apache.hertzbeat.common.entity.ai;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import org.springframework.data.annotation.CreatedBy;
@@ -47,7 +49,9 @@ import java.util.List;
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
-@Table(name = "hzb_ai_conversation")
+@Table(name = "hzb_ai_conversation", indexes = {
+ @Index(name = "idx_ai_conversation_creator", columnList = "creator")
+})
@AllArgsConstructor
@NoArgsConstructor
public class ChatConversation {
@@ -81,5 +85,6 @@ public class ChatConversation {
@OneToMany(mappedBy = "conversation")
private List<ChatMessage> messages;
+ @JsonIgnore
private String securityData;
}
diff --git
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java
index d0efbfb38f..a362df38b1 100644
---
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java
+++
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java
@@ -51,6 +51,7 @@ import
org.springframework.data.jpa.domain.support.AuditingEntityListener;
@EntityListeners(AuditingEntityListener.class)
@Table(name = "hzb_sop_schedule", indexes = {
@Index(name = "idx_schedule_conversation_id", columnList =
"conversation_id"),
+ @Index(name = "idx_schedule_creator_conversation", columnList = "creator,
conversation_id"),
@Index(name = "idx_schedule_enabled_next", columnList = "enabled,
next_run_time")
})
@AllArgsConstructor
diff --git
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/ai/ChatConversationTest.java
similarity index 50%
copy from
hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
copy to
hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/ai/ChatConversationTest.java
index 971b51f7cb..8d6cd93a23 100644
---
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/ChatConversationDao.java
+++
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/ai/ChatConversationTest.java
@@ -15,15 +15,31 @@
* limitations under the License.
*/
-package org.apache.hertzbeat.ai.dao;
+package org.apache.hertzbeat.common.entity.ai;
-import org.apache.hertzbeat.common.entity.ai.ChatConversation;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.hertzbeat.common.util.JsonUtil;
+import org.junit.jupiter.api.Test;
/**
- * Repository interface for Conversation entities
+ * Tests AI conversation serialization.
*/
-@Repository
-public interface ChatConversationDao extends JpaRepository<ChatConversation,
Long> {
+class ChatConversationTest {
+
+ @Test
+ void serializationShouldExcludeStoredSecurityData() {
+ ChatConversation conversation = ChatConversation.builder()
+ .id(1L)
+ .title("Owned conversation")
+ .securityData("encrypted-value")
+ .build();
+
+ String json = JsonUtil.toJson(conversation);
+
+ assertNotNull(json);
+ assertFalse(json.contains("securityData"));
+ assertFalse(json.contains("encrypted-value"));
+ }
}
diff --git
a/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql
b/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql
new file mode 100644
index 0000000000..a0aa6a82d8
--- /dev/null
+++
b/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql
@@ -0,0 +1,24 @@
+-- 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.
+
+UPDATE hzb_sop_schedule
+SET enabled = 0
+WHERE creator IS NULL
+ OR TRIM(creator) = '';
+
+CREATE INDEX IF NOT EXISTS idx_schedule_creator_conversation
+ ON hzb_sop_schedule(creator, conversation_id);
diff --git
a/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql
b/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql
new file mode 100644
index 0000000000..3d0a986ea1
--- /dev/null
+++
b/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql
@@ -0,0 +1,24 @@
+-- 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.
+
+UPDATE hzb_sop_schedule
+SET enabled = 0
+WHERE creator IS NULL
+ OR TRIM(creator) = '';
+
+CREATE INDEX idx_schedule_creator_conversation
+ ON hzb_sop_schedule(creator, conversation_id);
diff --git
a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql
b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql
new file mode 100644
index 0000000000..44aaf71220
--- /dev/null
+++
b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql
@@ -0,0 +1,24 @@
+-- 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.
+
+UPDATE hzb_sop_schedule
+SET enabled = 0
+WHERE creator IS NULL
+ OR BTRIM(creator) = '';
+
+CREATE INDEX idx_schedule_creator_conversation
+ ON hzb_sop_schedule(creator, conversation_id);
diff --git a/home/docs/start/upgrade.md b/home/docs/start/upgrade.md
index ffa38aca9a..4fbf39648b 100644
--- a/home/docs/start/upgrade.md
+++ b/home/docs/start/upgrade.md
@@ -129,4 +129,30 @@ When `warehouse.store.greptime.enabled=true`, HertzBeat
writes two different kin
- `bin/shutdown.sh` stops the HertzBeat process and downloads the new
installation package
- Refer to [Installation package to install HertzBeat](./package-deploy) to
start with the new installation package and configure the database connection
in `application.yml`
+## AI Schedule Ownership After Upgrade
+
+AI conversations without a recorded creator are isolated and do not appear in
+any user's conversation list. Scheduled AI SOP tasks are owned by the creator
+of their target conversation. During upgrade, schedules without a recorded
+creator are disabled. Schedules without a target conversation or whose creator
+does not match the conversation creator are disabled before they can execute.
+The records remain in the database so an administrator can recover them after
+verifying the intended owner.
+
+Ownerless schedules are disabled by the database migration. Missing
+conversations and creator mismatches are rechecked and disabled before every
+background execution.
+
+Before re-enabling a legacy schedule:
+
+1. Back up the metadata database.
+2. Verify the owner of the target row in `hzb_ai_conversation`.
+3. Set the same verified principal in the conversation and schedule `creator`
+ columns.
+4. Re-enable only the reviewed schedule.
+
+Do not assign all legacy rows to a shared account. A schedule is executed only
+while its stored creator still owns the target conversation; ownership
+mismatches are disabled automatically.
+
**HAVE FUN**
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]