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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

adaudit_domain "github.com/utmstack/utmstack/backend/modules/adaudit/domain"
threatintel_domain "github.com/utmstack/utmstack/backend/modules/threatintel/domain"
alerts_domain "github.com/utmstack/utmstack/backend/modules/alerts/domain"
appconfig_domain "github.com/utmstack/utmstack/backend/modules/appconfig/domain"
audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain"
Expand Down Expand Up @@ -68,6 +69,7 @@ func Models() []any {
dashboards_domain.DashboardFilter{},
loganalyzer_domain.SavedQuery{},
adaudit_domain.ADUser{},
threatintel_domain.AnalystConversation{},
}
}

Expand Down
1 change: 1 addition & 0 deletions backend/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ func initModules(db *gorm.DB, cfg *config) *modules {
env.String("UPDATES_DIR", "/updates", false),
env.String("INTEGRATIONS_CONFIG_DIR", "/workdir/pipeline", false),
cipher,
db,
)

integrationsMod := integrations.NewModule(db, cipher,
Expand Down
20 changes: 13 additions & 7 deletions backend/modules/audit/domain/event_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,12 @@ const (
VISUALIZATION_UPDATE_SUCCESS ApplicationEventType = "VISUALIZATION_UPDATE_SUCCESS"
VISUALIZATION_DELETE_ATTEMPT ApplicationEventType = "VISUALIZATION_DELETE_ATTEMPT"
VISUALIZATION_DELETE_SUCCESS ApplicationEventType = "VISUALIZATION_DELETE_SUCCESS"
DASHBOARD_FILTER_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_CREATE_ATTEMPT"
DASHBOARD_FILTER_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_CREATE_SUCCESS"
DASHBOARD_FILTER_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_UPDATE_ATTEMPT"
DASHBOARD_FILTER_UPDATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_UPDATE_SUCCESS"
DASHBOARD_FILTER_DELETE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_DELETE_ATTEMPT"
DASHBOARD_FILTER_DELETE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_DELETE_SUCCESS"
DASHBOARD_FILTER_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_CREATE_ATTEMPT"
DASHBOARD_FILTER_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_CREATE_SUCCESS"
DASHBOARD_FILTER_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_UPDATE_ATTEMPT"
DASHBOARD_FILTER_UPDATE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_UPDATE_SUCCESS"
DASHBOARD_FILTER_DELETE_ATTEMPT ApplicationEventType = "DASHBOARD_FILTER_DELETE_ATTEMPT"
DASHBOARD_FILTER_DELETE_SUCCESS ApplicationEventType = "DASHBOARD_FILTER_DELETE_SUCCESS"
DASHBOARD_LAYOUT_CREATE_ATTEMPT ApplicationEventType = "DASHBOARD_LAYOUT_CREATE_ATTEMPT"
DASHBOARD_LAYOUT_CREATE_SUCCESS ApplicationEventType = "DASHBOARD_LAYOUT_CREATE_SUCCESS"
DASHBOARD_LAYOUT_UPDATE_ATTEMPT ApplicationEventType = "DASHBOARD_LAYOUT_UPDATE_ATTEMPT"
Expand Down Expand Up @@ -209,7 +209,13 @@ const (
// MCP tool calls — every invocation of an MCP-exposed tool is audited
// through the same Logger sink used by REST handlers, so /audit covers
// both transports uniformly. ResourceType holds the tool name.
APP_EVENT_MCP_TOOL_CALL ApplicationEventType = "MCP_TOOL_CALL"
APP_EVENT_MCP_TOOL_CALL ApplicationEventType = "MCP_TOOL_CALL"
THREATINTEL_ANALYST_CREATE_ATTEMPT ApplicationEventType = "THREATINTEL_ANALYST_CREATE_ATTEMPT"
THREATINTEL_ANALYST_CREATE_SUCCESS ApplicationEventType = "THREATINTEL_ANALYST_CREATE_SUCCESS"
THREATINTEL_ANALYST_UPDATE_ATTEMPT ApplicationEventType = "THREATINTEL_ANALYST_UPDATE_ATTEMPT"
THREATINTEL_ANALYST_UPDATE_SUCCESS ApplicationEventType = "THREATINTEL_ANALYST_UPDATE_SUCCESS"
THREATINTEL_ANALYST_DELETE_ATTEMPT ApplicationEventType = "THREATINTEL_ANALYST_DELETE_ATTEMPT"
THREATINTEL_ANALYST_DELETE_SUCCESS ApplicationEventType = "THREATINTEL_ANALYST_DELETE_SUCCESS"
// SERVER_MODULE_CREATE_ATTEMPT ApplicationEventType = "SERVER_MODULE_CREATE_ATTEMPT"
// SERVER_MODULE_CREATE_SUCCESS ApplicationEventType = "SERVER_MODULE_CREATE_SUCCESS"
// SERVER_MODULE_UPDATE_ATTEMPT ApplicationEventType = "SERVER_MODULE_UPDATE_ATTEMPT"
Expand Down
27 changes: 27 additions & 0 deletions backend/modules/threatintel/domain/analyst_conversation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package domain

import (
"time"
)

// AnalystConversation is one AI-analyst chat, scoped to its tenant and creator.
// The frontend generates its own base36 id before the first save, so Id is a
// string rather than a UUID — the backend never rewrites it.
//
// Turns and History are full JSON snapshots of the conversation state. A turn
// and the history it produced are written together at the end of the agent
// loop, so a mid-stream crash loses nothing — the conversation is simply
// shorter.
type AnalystConversation struct {
ID string `gorm:"column:id;size:64;primaryKey" json:"id"`
TenantID string `gorm:"column:tenant_id;size:100;not null;index;uniqueIndex:idx_ti_analyst_conv_tenant_id,priority:1" json:"-"`
UserID string `gorm:"column:user_id;size:100;not null;index" json:"userId"`
Title string `gorm:"column:title;size:255;not null;uniqueIndex:idx_ti_analyst_conv_tenant_id" json:"title"`
Turns string `gorm:"column:turns;type:text;not null;default:'[]'" json:"turns"`
History string `gorm:"column:history;type:text;not null;default:'[]'" json:"history"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;index" json:"updatedAt"`
CreatedDate time.Time `gorm:"column:created_date" json:"createdDate"`
ModifiedDate time.Time `gorm:"column:modified_date" json:"modifiedDate"`
}

func (AnalystConversation) TableName() string { return "ti_analyst_conversation" }
158 changes: 158 additions & 0 deletions backend/modules/threatintel/handler/analyst.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package handler

import (
"encoding/json"
"errors"
"net/http"
"time"

"github.com/gin-gonic/gin"
"github.com/google/uuid"

"github.com/utmstack/utmstack/backend/modules/audit"
audit_connectors "github.com/utmstack/utmstack/backend/modules/audit/connectors"
audit_domain "github.com/utmstack/utmstack/backend/modules/audit/domain"
"github.com/utmstack/utmstack/backend/modules/threatintel/domain"
"github.com/utmstack/utmstack/backend/modules/threatintel/repository"
"github.com/utmstack/utmstack/backend/pkg/authz"
)

type AnalystHandler struct {
store *repository.AnalystStore
}

var errConversationNotFound = errors.New("conversation not found")

func NewAnalystHandler(store *repository.AnalystStore) *AnalystHandler {
return &AnalystHandler{store: store}
}

// scope returns (tenantID, userID) for the request, or "" "" when the actor
// is missing. Both values come from the authenticated actor — the tenant from
// the request context, the user from the middleware's "user_id" entry — so a
// tenant's conversations are never listed against another tenant.
func (h *AnalystHandler) scope(c *gin.Context) (string, string) {
tenantID := authz.TenantIDFromContext(c.Request.Context())
uidRaw, _ := c.Get("user_id")
switch uid := uidRaw.(type) {
case string:
return tenantID, uid
case uuid.UUID:
return tenantID, uid.String()
}
return tenantID, ""
}

type conversationDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Turns interface{} `json:"turns"`
History interface{} `json:"history"`
UpdatedAt time.Time `json:"updatedAt"`
}

func (h *AnalystHandler) List(c *gin.Context) {
tenantID, userID := h.scope(c)
if tenantID == "" || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
rows, err := h.store.List(c.Request.Context(), tenantID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list conversations"})
return
}
out := make([]conversationDTO, 0, len(rows))
for _, row := range rows {
var turns interface{}
_ = json.Unmarshal([]byte(row.Turns), &turns)
var history interface{}
_ = json.Unmarshal([]byte(row.History), &history)
out = append(out, conversationDTO{ID: row.ID, Title: row.Title, Turns: turns, History: history, UpdatedAt: row.UpdatedAt})
}
c.JSON(http.StatusOK, gin.H{"conversations": out})
}

func (h *AnalystHandler) Create(c *gin.Context) {
tenantID, userID := h.scope(c)
if tenantID == "" || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req conversationDTO
if err := c.ShouldBindJSON(&req); err != nil || req.ID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id and valid JSON body required"})
return
}
turns, _ := json.Marshal(req.Turns)
history, _ := json.Marshal(req.History)
conv := &domain.AnalystConversation{
ID: req.ID,
TenantID: tenantID,
UserID: userID,
Title: req.Title,
Turns: string(turns),
History: string(history),
}
if err := h.store.Create(c.Request.Context(), conv); err != nil {
audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.create", ResourceType: "threatintel_analyst_conversation", ResourceID: conv.ID, Metadata: map[string]any{"title": req.Title}},
audit_domain.THREATINTEL_ANALYST_CREATE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_CREATE_SUCCESS, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create conversation"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": conv.ID})
}

func (h *AnalystHandler) Update(c *gin.Context) {
tenantID, userID := h.scope(c)
if tenantID == "" || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
var req conversationDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "valid JSON body required"})
return
}
conv, err := h.store.Get(c.Request.Context(), tenantID, userID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
return
}
if conv == nil {
audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.update", ResourceType: "threatintel_analyst_conversation", ResourceID: id},
audit_domain.THREATINTEL_ANALYST_UPDATE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_UPDATE_SUCCESS,
errConversationNotFound)
c.JSON(http.StatusNotFound, gin.H{"error": "conversation not found"})
return
}
conv.Title = req.Title
turns, _ := json.Marshal(req.Turns)
history, _ := json.Marshal(req.History)
conv.Turns = string(turns)
conv.History = string(history)
if err := h.store.Save(c.Request.Context(), conv); err != nil {
audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.update", ResourceType: "threatintel_analyst_conversation", ResourceID: id, Metadata: map[string]any{"title": req.Title}},
audit_domain.THREATINTEL_ANALYST_UPDATE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_UPDATE_SUCCESS, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save conversation"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

func (h *AnalystHandler) Delete(c *gin.Context) {
tenantID, userID := h.scope(c)
if tenantID == "" || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
id := c.Param("id")
if err := h.store.Delete(c.Request.Context(), tenantID, userID, id); err != nil {
audit.Record(c, audit_connectors.Event{Action: "threatintel.analyst.delete", ResourceType: "threatintel_analyst_conversation", ResourceID: id},
audit_domain.THREATINTEL_ANALYST_DELETE_ATTEMPT, audit_domain.THREATINTEL_ANALYST_DELETE_SUCCESS, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete conversation"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
10 changes: 9 additions & 1 deletion backend/modules/threatintel/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package threatintel
import (
"context"

"gorm.io/gorm"

"github.com/utmstack/utmstack/backend/modules/threatintel/handler"
"github.com/utmstack/utmstack/backend/modules/threatintel/repository"
"github.com/utmstack/utmstack/backend/modules/threatintel/usecase"
Expand All @@ -13,12 +15,13 @@ import (
type Module struct {
updatesDir string
feedsHandler *handler.FeedsHandler
analystHandler *handler.AnalystHandler
}

// NewModule wires the ThreatWinds surface: the proxy reads the instance
// credentials from updatesDir, and the feed contribution is configured through
// a file in configDir that the feeds plugin watches.
func NewModule(updatesDir, configDir string, cipher *secret.Cipher) *Module {
func NewModule(updatesDir, configDir string, cipher *secret.Cipher, db *gorm.DB) *Module {
instanceconfig.Init(updatesDir)

m := &Module{updatesDir: updatesDir}
Expand All @@ -27,6 +30,9 @@ func NewModule(updatesDir, configDir string, cipher *secret.Cipher) *Module {
usecase.NewFeedsService(repository.NewConfigStore(configDir), cipher),
)
}
if db != nil {
m.analystHandler = handler.NewAnalystHandler(repository.NewAnalystStore(db))
}
return m
}

Expand All @@ -35,3 +41,5 @@ func (m *Module) Start(ctx context.Context) {}
// FeedsHandler is nil when the instance has no encryption key, in which case
// credentials could not be stored safely and the routes are not registered.
func (m *Module) FeedsHandler() *handler.FeedsHandler { return m.feedsHandler }

func (m *Module) AnalystHandler() *handler.AnalystHandler { return m.analystHandler }
72 changes: 72 additions & 0 deletions backend/modules/threatintel/repository/analyst_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package repository

import (
"context"
"errors"
"time"

"gorm.io/gorm"

"github.com/utmstack/utmstack/backend/modules/threatintel/domain"
)

type AnalystStore struct {
db *gorm.DB
}

// NewAnalystStore builds the GORM-backed conversation store.
func NewAnalystStore(db *gorm.DB) *AnalystStore {
return &AnalystStore{db: db}
}

func (s *AnalystStore) Create(ctx context.Context, conv *domain.AnalystConversation) error {
now := time.Now().UTC()
conv.CreatedDate = now
conv.ModifiedDate = now
return s.db.WithContext(ctx).Create(conv).Error
}

func (s *AnalystStore) List(ctx context.Context, tenantID, userID string) ([]domain.AnalystConversation, error) {
var rows []domain.AnalystConversation
err := s.db.WithContext(ctx).
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
Order("updated_at DESC").
Find(&rows).Error
return rows, err
}

func (s *AnalystStore) Get(ctx context.Context, tenantID, userID, id string) (*domain.AnalystConversation, error) {
var row domain.AnalystConversation
err := s.db.WithContext(ctx).
Where("id = ? AND tenant_id = ? AND user_id = ?", id, tenantID, userID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}

func (s *AnalystStore) Save(ctx context.Context, conv *domain.AnalystConversation) error {
now := time.Now().UTC()
conv.UpdatedAt = now
conv.ModifiedDate = now
return s.db.WithContext(ctx).
Model(&domain.AnalystConversation{}).
Where("id = ? AND tenant_id = ? AND user_id = ?", conv.ID, conv.TenantID, conv.UserID).
Updates(map[string]any{
"title": conv.Title,
"turns": conv.Turns,
"history": conv.History,
"updated_at": conv.UpdatedAt,
"modified_date": conv.ModifiedDate,
}).Error
}

func (s *AnalystStore) Delete(ctx context.Context, tenantID, userID, id string) error {
return s.db.WithContext(ctx).
Where("id = ? AND tenant_id = ? AND user_id = ?", id, tenantID, userID).
Delete(&domain.AnalystConversation{}).Error
}
Loading
Loading