diff --git a/backend/go.mod b/backend/go.mod index 42f86705f..0438bc946 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -26,7 +26,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 - github.com/threatwinds/go-sdk v1.1.27-0.20260811073440-251cb9d842cd + github.com/threatwinds/go-sdk v1.1.28 github.com/tidwall/gjson v1.19.0 github.com/utmstack/license-manager-sdk v0.1.0 golang.org/x/crypto v0.55.0 diff --git a/backend/go.sum b/backend/go.sum index b245f2fdc..8df8f9e31 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -408,6 +408,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/threatwinds/go-sdk v1.1.27-0.20260811073440-251cb9d842cd h1:u5x6DfEkBErQurxPjtFYnd2xfW1ruD6kPxnYzGaHQCc= github.com/threatwinds/go-sdk v1.1.27-0.20260811073440-251cb9d842cd/go.mod h1:Mr5r+NTTe8k28gVS7EgEorn76VPtf0vPSaUCD2bWRSU= +github.com/threatwinds/go-sdk v1.1.28 h1:dnYIAVC5jQgr7jm1VGMS7dQcl/08GVODDJp3dhV6D9U= +github.com/threatwinds/go-sdk v1.1.28/go.mod h1:aN6Oe3zJop9ngS83oZcKFXDLKWzrny2XhkYm7uoyDbQ= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= diff --git a/backend/modules/appconfig/dto/bulk_branding.go b/backend/modules/appconfig/dto/bulk_branding.go new file mode 100644 index 000000000..208ec3e1b --- /dev/null +++ b/backend/modules/appconfig/dto/bulk_branding.go @@ -0,0 +1,12 @@ +package dto + +import "github.com/utmstack/utmstack/backend/pkg/common_models" + +type BulkBrandingUpdateRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Branding BrandingRequest `json:"branding"` +} + +type BulkBrandingAssetRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` +} diff --git a/backend/modules/appconfig/dto/bulk_smtp.go b/backend/modules/appconfig/dto/bulk_smtp.go new file mode 100644 index 000000000..2ff9d42e4 --- /dev/null +++ b/backend/modules/appconfig/dto/bulk_smtp.go @@ -0,0 +1,20 @@ +package dto + +import "github.com/utmstack/utmstack/backend/pkg/common_models" + +// BulkSMTPField is one key→value pair to upsert. +type BulkSMTPField struct { + Key string `json:"key" binding:"required"` + Value string `json:"value"` +} + +// BulkSMTPUpdateRequest sets SMTP fields across N tenants. +type BulkSMTPUpdateRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Fields []BulkSMTPField `json:"fields" binding:"required,min=1"` +} + +// BulkSMTPTestRequest sends a test mail from N tenants. +type BulkSMTPTestRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` +} diff --git a/backend/modules/appconfig/handler/bulk_branding.go b/backend/modules/appconfig/handler/bulk_branding.go new file mode 100644 index 000000000..90bed681e --- /dev/null +++ b/backend/modules/appconfig/handler/bulk_branding.go @@ -0,0 +1,154 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/appconfig/connectors" + "github.com/utmstack/utmstack/backend/modules/appconfig/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// BulkBrandingHandler applies branding changes across multiple tenants in one call. +type BulkBrandingHandler struct { + brand connectors.BrandingUsecase + uploadDir string + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkBrandingHandler(brand connectors.BrandingUsecase, uploadDir string, tenantLister func(context.Context) ([]string, error)) *BulkBrandingHandler { + return &BulkBrandingHandler{brand: brand, uploadDir: uploadDir, tenantLister: tenantLister} +} + +// resolveTenants returns the target tenant IDs, enumerating active ones when AllTenants is set. +// ponytail: DefaultTenantID filtered out of AllTenants — bulk ops must not +// overwrite the platform-plane config; callers can still target it explicitly +// via TenantIDs. +func resolveTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) { + if !sel.AllTenants { + return sel.TenantIDs, nil + } + all, err := lister(ctx) + if err != nil { + return nil, err + } + out := make([]string, 0, len(all)) + for _, id := range all { + if id != authz.DefaultTenantID { + out = append(out, id) + } + } + return out, nil +} + +// Update godoc +// +// @Summary Bulk update branding across tenants +// @Description Applies the same branding configuration to multiple tenants. Partial failures are recorded; succeeded tenants are listed separately. +// @Tags Branding +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkBrandingUpdateRequest true "Selector + branding overrides" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/branding/bulk/update [post] +func (h *BulkBrandingHandler) Update(c *gin.Context) { + var req dto.BulkBrandingUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + actorEmail := c.GetString("user_email") + var result common_models.BulkResult + for _, tid := range tenantIDs { + // ponytail: skip default (platform-plane) tenant — bulk calls must not silently overwrite operator branding + if tid == authz.DefaultTenantID { + continue + } + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.brand.Update(ctx, actorEmail, req.Branding) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// UploadAsset godoc +// +// @Summary Bulk upload a branding asset across tenants +// @Description Saves the file once then points the given slot URL at it for every selected tenant. Partial failures are recorded. +// @Tags Branding +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param slot path string true "Asset slot: logo|logoDark|favicon|reportLogo|reportCover" +// @Param file formData file true "Image file (png/jpg/webp/gif/svg/ico, ≤5MB)" +// @Param selector formData string true "JSON-encoded BulkTenantSelector" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/branding/bulk/upload-asset/{slot} [post] +func (h *BulkBrandingHandler) UploadAsset(c *gin.Context) { + slot := c.Param("slot") + if !validBrandingSlots[slot] { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown asset slot"}) + return + } + + // Parse selector from multipart form field "selector". + var sel common_models.BulkTenantSelector + if raw := strings.TrimSpace(c.PostForm("selector")); raw == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing selector field"}) + return + } else if err := json.Unmarshal([]byte(raw), &sel); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid selector JSON: " + err.Error()}) + return + } + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, brandingMaxBytes+512) + fh, err := c.FormFile("file") + if err != nil { + if strings.Contains(err.Error(), "request body too large") { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image is too large (max 5MB)"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) + return + } + + // Reuse existing BrandingHandler to store the file once. + bh := &BrandingHandler{uploadDir: h.uploadDir} + url, err := bh.storeBrandingFile(slot, fh) + if err != nil { + c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": err.Error()}) + return + } + + tenantIDs, err := resolveTenants(c.Request.Context(), sel, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + actorEmail := c.GetString("user_email") + var result common_models.BulkResult + for _, tid := range tenantIDs { + // ponytail: skip default (platform-plane) tenant — bulk calls must not silently overwrite operator branding + if tid == authz.DefaultTenantID { + continue + } + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.brand.SetAsset(ctx, actorEmail, slot, url) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/modules/appconfig/handler/bulk_branding_test.go b/backend/modules/appconfig/handler/bulk_branding_test.go new file mode 100644 index 000000000..aba91fb77 --- /dev/null +++ b/backend/modules/appconfig/handler/bulk_branding_test.go @@ -0,0 +1,52 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// stubLister returns a fixed list for testing. +func stubLister(ids ...string) func(context.Context) ([]string, error) { + return func(_ context.Context) ([]string, error) { return ids, nil } +} + +func TestResolveTenants_explicit(t *testing.T) { + sel := common_models.BulkTenantSelector{TenantIDs: []string{"a", "b"}} + got, err := resolveTenants(context.Background(), sel, nil) + if err != nil || len(got) != 2 { + t.Fatalf("want 2 ids, got %v %v", got, err) + } +} + +func TestResolveTenants_allFiltersDefault(t *testing.T) { + const defaultTID = "ce66672c-e36d-4761-a8c8-90058fee1a24" + sel := common_models.BulkTenantSelector{AllTenants: true} + got, err := resolveTenants(context.Background(), sel, stubLister(defaultTID, "tenant-2")) + if err != nil { + t.Fatal(err) + } + for _, id := range got { + if id == defaultTID { + t.Fatalf("DefaultTenantID must be filtered out, got %v", got) + } + } + if len(got) != 1 || got[0] != "tenant-2" { + t.Fatalf("want [tenant-2], got %v", got) + } +} + +func TestBulkResultPartialFailure(t *testing.T) { + var r common_models.BulkResult + r.Append("t1", nil) + r.Append("t2", errors.New("boom")) + r.Append("t3", nil) + if len(r.Succeeded) != 2 || len(r.Failed) != 1 { + t.Fatalf("want 2 succeeded 1 failed, got %v / %v", r.Succeeded, r.Failed) + } + if r.Failed[0].TenantID != "t2" { + t.Fatalf("wrong failed tenant: %v", r.Failed[0]) + } +} diff --git a/backend/modules/appconfig/handler/bulk_smtp.go b/backend/modules/appconfig/handler/bulk_smtp.go new file mode 100644 index 000000000..32cbe5560 --- /dev/null +++ b/backend/modules/appconfig/handler/bulk_smtp.go @@ -0,0 +1,111 @@ +package handler + +import ( + "context" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/appconfig/connectors" + "github.com/utmstack/utmstack/backend/modules/appconfig/domain" + "github.com/utmstack/utmstack/backend/modules/appconfig/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" + "github.com/utmstack/utmstack/backend/pkg/constants" +) + +// BulkSMTPHandler handles platform-admin bulk SMTP operations. +type BulkSMTPHandler struct { + uc connectors.Usecase + store connectors.Store + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkSMTPHandler(uc connectors.Usecase, store connectors.Store, lister func(context.Context) ([]string, error)) *BulkSMTPHandler { + return &BulkSMTPHandler{uc: uc, store: store, tenantLister: lister} +} + +// Update godoc +// +// @Summary Bulk update SMTP config across tenants +// @Tags Platform Config +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkSMTPUpdateRequest true "Fields + selector" +// @Success 200 {object} common_models.BulkResult +// @Router /platform/config/smtp/bulk/update [post] +func (h *BulkSMTPHandler) Update(c *gin.Context) { + var req dto.BulkSMTPUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + actor := c.GetString("user_email") + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + var tenErr error + for _, kv := range req.Fields { + if _, err := h.uc.Update(ctx, actor, kv.Key, dto.UpsertRequest{Value: kv.Value}); err != nil { + tenErr = err + break + } + } + result.Append(tid, tenErr) + } + c.JSON(http.StatusOK, result) +} + +// Test godoc +// +// @Summary Bulk test SMTP config across tenants +// @Tags Platform Config +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkSMTPTestRequest true "Selector" +// @Success 200 {object} common_models.BulkResult +// @Router /platform/config/smtp/bulk/test [post] +func (h *BulkSMTPHandler) Test(c *gin.Context) { + var req dto.BulkSMTPTestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + cfg := h.loadMailConfig(ctx) + result.Append(tid, h.uc.CheckMail(ctx, []domain.MailConfig{cfg})) + } + c.JSON(http.StatusOK, result) +} + +func (h *BulkSMTPHandler) loadMailConfig(ctx context.Context) domain.MailConfig { + get := func(key string) string { + v, _, _ := h.store.GetString(ctx, key) + return v + } + cfg := domain.MailConfig{ + Host: get(constants.PROP_MAIL_HOST), + Username: get(constants.PROP_MAIL_USERNAME), + Password: get(constants.PROP_MAIL_PASSWORD), + From: get(constants.PROP_MAIL_FROM), + AuthType: get(constants.PROP_MAIL_SMTP_AUTH), + } + if p, err := strconv.Atoi(get(constants.PROP_MAIL_PORT)); err == nil { + cfg.Port = p + } + return cfg +} diff --git a/backend/modules/appconfig/handler/bulk_smtp_test.go b/backend/modules/appconfig/handler/bulk_smtp_test.go new file mode 100644 index 000000000..98820291b --- /dev/null +++ b/backend/modules/appconfig/handler/bulk_smtp_test.go @@ -0,0 +1,57 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// noopLister returns two tenant IDs for testing. +func noopLister(_ context.Context) ([]string, error) { + return []string{"tenant-a", "tenant-b"}, nil +} + +func TestBulkResult_PartialFailure(t *testing.T) { + var r common_models.BulkResult + r.Append("tenant-a", nil) + r.Append("tenant-b", errors.New("smtp refused")) + + if len(r.Succeeded) != 1 || r.Succeeded[0] != "tenant-a" { + t.Fatalf("expected one success, got %v", r.Succeeded) + } + if len(r.Failed) != 1 || r.Failed[0].TenantID != "tenant-b" { + t.Fatalf("expected one failure, got %v", r.Failed) + } +} + +func TestResolveTenants_AllExcludesDefault(t *testing.T) { + lister := func(_ context.Context) ([]string, error) { + return []string{"tenant-a", "ce66672c-e36d-4761-a8c8-90058fee1a24", "tenant-b"}, nil + } + sel := common_models.BulkTenantSelector{AllTenants: true} + ids, err := resolveTenants(context.Background(), sel, lister) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + if id == "ce66672c-e36d-4761-a8c8-90058fee1a24" { + t.Fatal("DefaultTenantID must be excluded from AllTenants enumeration") + } + } + if len(ids) != 2 { + t.Fatalf("expected 2 tenants, got %d", len(ids)) + } +} + +func TestResolveTenants_ExplicitList(t *testing.T) { + sel := common_models.BulkTenantSelector{TenantIDs: []string{"tenant-x"}} + ids, err := resolveTenants(context.Background(), sel, noopLister) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 || ids[0] != "tenant-x" { + t.Fatalf("expected explicit list passthrough, got %v", ids) + } +} diff --git a/backend/modules/appconfig/module.go b/backend/modules/appconfig/module.go index 69d3f299c..395ce2039 100644 --- a/backend/modules/appconfig/module.go +++ b/backend/modules/appconfig/module.go @@ -1,6 +1,8 @@ package appconfig import ( + "context" + mail_connectors "github.com/utmstack/utmstack/backend/internal/mail/connectors" "github.com/utmstack/utmstack/backend/modules/appconfig/connectors" "github.com/utmstack/utmstack/backend/modules/appconfig/handler" @@ -15,23 +17,27 @@ type mailerSetter interface { } type Module struct { - usecase connectors.Usecase - store connectors.Store - handler *handler.Handler - branding connectors.BrandingUsecase - brandingHandler *handler.BrandingHandler + usecase connectors.Usecase + store connectors.Store + handler *handler.Handler + branding connectors.BrandingUsecase + brandingHandler *handler.BrandingHandler + bulkBrandingHandler *handler.BulkBrandingHandler + bulkSMTPHandler *handler.BulkSMTPHandler } -func NewModule(db *gorm.DB, cipher *secret.Cipher, uploadDir string) *Module { +func NewModule(db *gorm.DB, cipher *secret.Cipher, uploadDir string, tenantLister func(context.Context) ([]string, error)) *Module { repo := repository.NewRepository(db) brandingSvc := usecase.NewBranding(repo) svc := usecase.New(repo, cipher, brandingSvc) return &Module{ - usecase: svc, - store: svc, - handler: handler.NewHandler(svc), - branding: brandingSvc, - brandingHandler: handler.NewBrandingHandler(brandingSvc, uploadDir), + usecase: svc, + store: svc, + handler: handler.NewHandler(svc), + branding: brandingSvc, + brandingHandler: handler.NewBrandingHandler(brandingSvc, uploadDir), + bulkBrandingHandler: handler.NewBulkBrandingHandler(brandingSvc, uploadDir, tenantLister), + bulkSMTPHandler: handler.NewBulkSMTPHandler(svc, svc, tenantLister), } } @@ -45,11 +51,13 @@ func (m *Module) SetWhiteLabelEntitlement(fn func() bool) { } } -func (m *Module) Handler() *handler.Handler { return m.handler } -func (m *Module) BrandingHandler() *handler.BrandingHandler { return m.brandingHandler } -func (m *Module) Branding() connectors.BrandingUsecase { return m.branding } -func (m *Module) Store() connectors.Store { return m.store } -func (m *Module) Usecase() connectors.Usecase { return m.usecase } +func (m *Module) Handler() *handler.Handler { return m.handler } +func (m *Module) BrandingHandler() *handler.BrandingHandler { return m.brandingHandler } +func (m *Module) BulkBrandingHandler() *handler.BulkBrandingHandler { return m.bulkBrandingHandler } +func (m *Module) BulkSMTPHandler() *handler.BulkSMTPHandler { return m.bulkSMTPHandler } +func (m *Module) Branding() connectors.BrandingUsecase { return m.branding } +func (m *Module) Store() connectors.Store { return m.store } +func (m *Module) Usecase() connectors.Usecase { return m.usecase } func (m *Module) SetMailer(mailer mail_connectors.MailService) { if s, ok := m.usecase.(mailerSetter); ok { diff --git a/backend/modules/appconfig/routes.go b/backend/modules/appconfig/routes.go index 677950e4f..5ca009cf6 100644 --- a/backend/modules/appconfig/routes.go +++ b/backend/modules/appconfig/routes.go @@ -5,7 +5,7 @@ import ( "github.com/utmstack/utmstack/backend/pkg/http/middleware" ) -func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, enterprise gin.HandlerFunc) { +func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, enterprise gin.HandlerFunc, platform gin.HandlerFunc) { // Public, read-only display preference (timezone + date format). Non-sensitive, // consumed app-wide (incl. pre-login) to render timestamps consistently. api.GET("/date-format", m.Handler().DateFormat) @@ -22,4 +22,12 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, e b.POST("/assets/:slot", userAuth, enterprise, middleware.RequirePermission("config.write"), m.BrandingHandler().UploadAsset) b.GET("/public", m.BrandingHandler().Public) b.POST("/seed", userAuth, middleware.RequireInternal(), m.BrandingHandler().Seed) + + pb := api.Group("/platform/branding", userAuth, platform, middleware.RequirePermission("config.write"), enterprise) + pb.POST("/bulk/update", m.BulkBrandingHandler().Update) + pb.POST("/bulk/upload-asset/:slot", m.BulkBrandingHandler().UploadAsset) + + ps := api.Group("/platform/config/smtp", userAuth, platform, middleware.RequirePermission("config.write")) + ps.POST("/bulk/update", m.BulkSMTPHandler().Update) + ps.POST("/bulk/test", m.BulkSMTPHandler().Test) } diff --git a/backend/modules/compliance/dto/bulk.go b/backend/modules/compliance/dto/bulk.go new file mode 100644 index 000000000..6d435d993 --- /dev/null +++ b/backend/modules/compliance/dto/bulk.go @@ -0,0 +1,36 @@ +package dto + +import ( + "github.com/utmstack/utmstack/backend/modules/compliance/domain" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +type BulkCreateFrameworkRequest struct { + Selector common_models.BulkTenantSelector `json:"selector" binding:"required"` + Framework domain.Framework `json:"framework" binding:"required"` +} + +type BulkUpdateFrameworkRequest struct { + Selector common_models.BulkTenantSelector `json:"selector" binding:"required"` + Framework domain.Framework `json:"framework" binding:"required"` +} + +type BulkDeleteFrameworkRequest struct { + Selector common_models.BulkTenantSelector `json:"selector" binding:"required"` + FrameworkKey string `json:"frameworkKey" binding:"required"` +} + +type BulkCreateControlRequest struct { + Selector common_models.BulkTenantSelector `json:"selector" binding:"required"` + Control domain.Control `json:"control" binding:"required"` +} + +type BulkUpdateControlRequest struct { + Selector common_models.BulkTenantSelector `json:"selector" binding:"required"` + Control domain.Control `json:"control" binding:"required"` +} + +type BulkDeleteControlRequest struct { + Selector common_models.BulkTenantSelector `json:"selector" binding:"required"` + ControlID string `json:"controlId" binding:"required"` +} diff --git a/backend/modules/compliance/handler/bulk.go b/backend/modules/compliance/handler/bulk.go new file mode 100644 index 000000000..01eb41a85 --- /dev/null +++ b/backend/modules/compliance/handler/bulk.go @@ -0,0 +1,259 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "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/compliance/connectors" + "github.com/utmstack/utmstack/backend/modules/compliance/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +type BulkComplianceHandler struct { + uc connectors.FrameworkUsecase + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkComplianceHandler(uc connectors.FrameworkUsecase, lister func(context.Context) ([]string, error)) *BulkComplianceHandler { + return &BulkComplianceHandler{uc: uc, tenantLister: lister} +} + +func resolveTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) { + if sel.AllTenants { + return lister(ctx) + } + return sel.TenantIDs, nil +} + +// @Summary Bulk create framework across tenants +// @Tags Platform Compliance +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkCreateFrameworkRequest true "Bulk create framework" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/compliance/frameworks/bulk/create [post] +func (h *BulkComplianceHandler) CreateFramework(c *gin.Context) { + var req dto.BulkCreateFrameworkRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, opErr := h.uc.CreateFramework(ctx, req.Framework) + result.Append(tid, opErr) + if opErr == nil { + audit.Record(c, audit_connectors.Event{ + Action: "bulk.compliance.framework.create", + ResourceType: "compliance_framework", + ResourceID: req.Framework.Key, + Metadata: map[string]any{"tenantId": tid}, + }, audit_domain.COMPLIANCE_FRAMEWORK_CREATE_ATTEMPT, audit_domain.COMPLIANCE_FRAMEWORK_CREATE_SUCCESS, nil) + } + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk update framework across tenants +// @Tags Platform Compliance +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkUpdateFrameworkRequest true "Bulk update framework" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/compliance/frameworks/bulk/update [post] +func (h *BulkComplianceHandler) UpdateFramework(c *gin.Context) { + var req dto.BulkUpdateFrameworkRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, opErr := h.uc.UpdateFramework(ctx, req.Framework) + result.Append(tid, opErr) + if opErr == nil { + audit.Record(c, audit_connectors.Event{ + Action: "bulk.compliance.framework.update", + ResourceType: "compliance_framework", + ResourceID: req.Framework.Key, + Metadata: map[string]any{"tenantId": tid}, + }, audit_domain.COMPLIANCE_FRAMEWORK_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_FRAMEWORK_UPDATE_SUCCESS, nil) + } + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk delete framework across tenants +// @Tags Platform Compliance +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkDeleteFrameworkRequest true "Bulk delete framework" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/compliance/frameworks/bulk/delete [post] +func (h *BulkComplianceHandler) DeleteFramework(c *gin.Context) { + var req dto.BulkDeleteFrameworkRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + opErr := h.uc.DeleteFramework(ctx, req.FrameworkKey) + result.Append(tid, opErr) + if opErr == nil { + audit.Record(c, audit_connectors.Event{ + Action: "bulk.compliance.framework.delete", + ResourceType: "compliance_framework", + ResourceID: req.FrameworkKey, + Metadata: map[string]any{"tenantId": tid}, + }, audit_domain.COMPLIANCE_FRAMEWORK_DELETE_ATTEMPT, audit_domain.COMPLIANCE_FRAMEWORK_DELETE_SUCCESS, nil) + } + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk create control across tenants +// @Tags Platform Compliance +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkCreateControlRequest true "Bulk create control" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/compliance/controls/bulk/create [post] +func (h *BulkComplianceHandler) CreateControl(c *gin.Context) { + var req dto.BulkCreateControlRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, opErr := h.uc.CreateControl(ctx, req.Control) + result.Append(tid, opErr) + if opErr == nil { + audit.Record(c, audit_connectors.Event{ + Action: "bulk.compliance.control.create", + ResourceType: "compliance_control", + ResourceID: req.Control.ID, + Metadata: map[string]any{"tenantId": tid}, + }, audit_domain.COMPLIANCE_CONTROL_CREATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_CREATE_SUCCESS, nil) + } + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk update control across tenants +// @Tags Platform Compliance +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkUpdateControlRequest true "Bulk update control" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/compliance/controls/bulk/update [post] +func (h *BulkComplianceHandler) UpdateControl(c *gin.Context) { + var req dto.BulkUpdateControlRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, opErr := h.uc.UpdateControl(ctx, req.Control) + result.Append(tid, opErr) + if opErr == nil { + audit.Record(c, audit_connectors.Event{ + Action: "bulk.compliance.control.update", + ResourceType: "compliance_control", + ResourceID: req.Control.ID, + Metadata: map[string]any{"tenantId": tid}, + }, audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, nil) + } + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk delete control across tenants +// @Tags Platform Compliance +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkDeleteControlRequest true "Bulk delete control" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/compliance/controls/bulk/delete [post] +func (h *BulkComplianceHandler) DeleteControl(c *gin.Context) { + var req dto.BulkDeleteControlRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + opErr := h.uc.DeleteControl(ctx, req.ControlID) + result.Append(tid, opErr) + if opErr == nil { + audit.Record(c, audit_connectors.Event{ + Action: "bulk.compliance.control.delete", + ResourceType: "compliance_control", + ResourceID: req.ControlID, + Metadata: map[string]any{"tenantId": tid}, + }, audit_domain.COMPLIANCE_CONTROL_DELETE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_DELETE_SUCCESS, nil) + } + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/modules/compliance/handler/bulk_test.go b/backend/modules/compliance/handler/bulk_test.go new file mode 100644 index 000000000..b82188066 --- /dev/null +++ b/backend/modules/compliance/handler/bulk_test.go @@ -0,0 +1,48 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// stubLister returns fixed tenant IDs. +func stubLister(ids []string) func(context.Context) ([]string, error) { + return func(_ context.Context) ([]string, error) { return ids, nil } +} + +func TestResolveTenants_AllTenants(t *testing.T) { + want := []string{"a", "b"} + got, err := resolveTenants(context.Background(), + common_models.BulkTenantSelector{AllTenants: true}, + stubLister(want)) + if err != nil || len(got) != len(want) { + t.Fatalf("expected %v, got %v err %v", want, got, err) + } +} + +func TestResolveTenants_ExplicitIDs(t *testing.T) { + ids := []string{"x", "y"} + got, err := resolveTenants(context.Background(), + common_models.BulkTenantSelector{TenantIDs: ids}, + func(_ context.Context) ([]string, error) { return nil, errors.New("should not be called") }) + if err != nil || len(got) != 2 { + t.Fatalf("expected explicit ids, got %v err %v", got, err) + } +} + +// TestBulkResult_PartialFailure checks that Append records both success and failure. +func TestBulkResult_PartialFailure(t *testing.T) { + var r common_models.BulkResult + r.Append("t1", nil) + r.Append("t2", errors.New("boom")) + r.Append("t3", nil) + if len(r.Succeeded) != 2 || len(r.Failed) != 1 { + t.Fatalf("succeeded=%d failed=%d", len(r.Succeeded), len(r.Failed)) + } + if r.Failed[0].TenantID != "t2" { + t.Fatalf("wrong failed tenant: %s", r.Failed[0].TenantID) + } +} diff --git a/backend/modules/compliance/module.go b/backend/modules/compliance/module.go index 597c99d38..949c9ec63 100644 --- a/backend/modules/compliance/module.go +++ b/backend/modules/compliance/module.go @@ -24,6 +24,7 @@ type Module struct { bodyRetention time.Duration frameworkH *handler.FrameworkHandler + bulkH *handler.BulkComplianceHandler reportH *handler.ReportHandler scheduleH *handler.ScheduleHandler scheduler *usecase.ReportScheduler @@ -34,11 +35,12 @@ type Module struct { scheduleUC connectors.ScheduleUsecase } -func (m *Module) GetFrameworkUsecase() connectors.FrameworkUsecase { return m.frameworkUC } -func (m *Module) GetEvaluatorUsecase() connectors.EvaluatorUsecase { return m.evaluatorUC } -func (m *Module) GetScheduleUsecase() connectors.ScheduleUsecase { return m.scheduleUC } +func (m *Module) GetFrameworkUsecase() connectors.FrameworkUsecase { return m.frameworkUC } +func (m *Module) GetEvaluatorUsecase() connectors.EvaluatorUsecase { return m.evaluatorUC } +func (m *Module) GetScheduleUsecase() connectors.ScheduleUsecase { return m.scheduleUC } +func (m *Module) GetBulkHandler() *handler.BulkComplianceHandler { return m.bulkH } -func NewModule(db *gorm.DB, events repository.Reader, mailSvc mail_connectors.MailService, brand connectors.BrandingProvider, isEnterprise func() bool) *Module { +func NewModule(db *gorm.DB, events repository.Reader, mailSvc mail_connectors.MailService, brand connectors.BrandingProvider, isEnterprise func() bool, tenantLister func(context.Context) ([]string, error)) *Module { src := env.String("COMPLIANCE_SRC_DIR", "/utmstack/compliance", false) root := env.String("COMPLIANCE_DIR", "/workdir/compliance", false) @@ -86,6 +88,7 @@ func NewModule(db *gorm.DB, events repository.Reader, mailSvc mail_connectors.Ma bodyRetention: time.Duration(retentionDays) * 24 * time.Hour, frameworkH: handler.NewFrameworkHandler(frameworkUC), + bulkH: handler.NewBulkComplianceHandler(frameworkUC, tenantLister), reportH: handler.NewReportHandler(evaluatorUC), scheduleH: handler.NewScheduleHandler(scheduleUC), scheduler: scheduler, diff --git a/backend/modules/compliance/routes.go b/backend/modules/compliance/routes.go index 337155299..178f576ca 100644 --- a/backend/modules/compliance/routes.go +++ b/backend/modules/compliance/routes.go @@ -6,7 +6,7 @@ import ( "github.com/utmstack/utmstack/backend/pkg/http/middleware" ) -func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { +func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, platform gin.HandlerFunc) { read := middleware.RequirePermission("compliance.read") write := middleware.RequirePermission("compliance.write") @@ -41,4 +41,14 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { sched.GET("/by-user", read, m.scheduleH.ListByUser) sched.GET("/by-id/:id", read, m.scheduleH.GetByID) sched.DELETE("/:id", write, m.scheduleH.Delete) + + // Platform-admin bulk operations (cross-tenant). + bh := m.GetBulkHandler() + pg := api.Group("/platform/compliance", userAuth, platform, write) + pg.POST("/frameworks/bulk/create", bh.CreateFramework) + pg.POST("/frameworks/bulk/update", bh.UpdateFramework) + pg.POST("/frameworks/bulk/delete", bh.DeleteFramework) + pg.POST("/controls/bulk/create", bh.CreateControl) + pg.POST("/controls/bulk/update", bh.UpdateControl) + pg.POST("/controls/bulk/delete", bh.DeleteControl) } diff --git a/backend/modules/eventprocessing/dto/bulk_correlation_rule.go b/backend/modules/eventprocessing/dto/bulk_correlation_rule.go new file mode 100644 index 000000000..ddb71deb9 --- /dev/null +++ b/backend/modules/eventprocessing/dto/bulk_correlation_rule.go @@ -0,0 +1,24 @@ +package dto + +import "github.com/utmstack/utmstack/backend/pkg/common_models" + +type BulkCreateCorrelationRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Rule CreateCorrelationRuleRequest `json:"rule"` +} + +type BulkUpdateCorrelationRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Rule UpdateCorrelationRuleRequest `json:"rule"` +} + +type BulkDeleteCorrelationRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath" binding:"required"` +} + +type BulkActivateCorrelationRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath" binding:"required"` + Active bool `json:"active"` +} diff --git a/backend/modules/eventprocessing/dto/bulk_pipeline.go b/backend/modules/eventprocessing/dto/bulk_pipeline.go new file mode 100644 index 000000000..0e3da98e6 --- /dev/null +++ b/backend/modules/eventprocessing/dto/bulk_pipeline.go @@ -0,0 +1,26 @@ +package dto + +import "github.com/utmstack/utmstack/backend/pkg/common_models" + +type BulkCreatePipelineRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath" binding:"required"` + Content string `json:"content" binding:"required"` +} + +type BulkUpdatePipelineRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath" binding:"required"` + Content string `json:"content" binding:"required"` +} + +type BulkDeletePipelineRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath" binding:"required"` +} + +type BulkActivatePipelineRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath" binding:"required"` + Active bool `json:"active"` +} diff --git a/backend/modules/eventprocessing/handler/bulk_correlation_rule.go b/backend/modules/eventprocessing/handler/bulk_correlation_rule.go new file mode 100644 index 000000000..e8811c6ad --- /dev/null +++ b/backend/modules/eventprocessing/handler/bulk_correlation_rule.go @@ -0,0 +1,142 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/eventprocessing/connectors" + "github.com/utmstack/utmstack/backend/modules/eventprocessing/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +type BulkCorrelationRuleHandler struct { + uc connectors.CorrelationRuleUsecase + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkCorrelationRuleHandler(uc connectors.CorrelationRuleUsecase, tenantLister func(context.Context) ([]string, error)) *BulkCorrelationRuleHandler { + return &BulkCorrelationRuleHandler{uc: uc, tenantLister: tenantLister} +} + +// @Summary Bulk create correlation rule +// @Description Installs the same correlation rule in N tenants (each gets its own copy in its user overlay). +// @Tags Platform / Correlation Rules +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkCreateCorrelationRuleRequest true "selector + rule" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/correlation-rule/bulk/create [post] +func (h *BulkCorrelationRuleHandler) Create(c *gin.Context) { + var req dto.BulkCreateCorrelationRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + result.Append(tid, h.uc.Create(ctx, req.Rule)) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk update correlation rule +// @Description Updates the correlation rule with matching relPath across N tenants. System-owned rules refuse per tenant. +// @Tags Platform / Correlation Rules +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkUpdateCorrelationRuleRequest true "selector + rule (includes relPath)" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/correlation-rule/bulk/update [post] +func (h *BulkCorrelationRuleHandler) Update(c *gin.Context) { + var req dto.BulkUpdateCorrelationRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + result.Append(tid, h.uc.Update(ctx, req.Rule)) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk delete correlation rule +// @Description Deletes the correlation rule with matching relPath across N tenants. System-owned rules refuse per tenant. +// @Tags Platform / Correlation Rules +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkDeleteCorrelationRuleRequest true "selector + relPath" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/correlation-rule/bulk/delete [post] +func (h *BulkCorrelationRuleHandler) Delete(c *gin.Context) { + var req dto.BulkDeleteCorrelationRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + result.Append(tid, h.uc.Delete(ctx, req.RelPath)) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk activate/deactivate correlation rule +// @Description Enables or disables the correlation rule per tenant. +// @Tags Platform / Correlation Rules +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkActivateCorrelationRuleRequest true "selector + relPath + active" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/correlation-rule/bulk/activate [post] +func (h *BulkCorrelationRuleHandler) Activate(c *gin.Context) { + var req dto.BulkActivateCorrelationRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.uc.SetActive(ctx, req.RelPath, req.Active) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/modules/eventprocessing/handler/bulk_pipeline.go b/backend/modules/eventprocessing/handler/bulk_pipeline.go new file mode 100644 index 000000000..224e53a0a --- /dev/null +++ b/backend/modules/eventprocessing/handler/bulk_pipeline.go @@ -0,0 +1,152 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/eventprocessing/connectors" + "github.com/utmstack/utmstack/backend/modules/eventprocessing/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +type BulkPipelineHandler struct { + uc connectors.PipelineUsecase + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkPipelineHandler(uc connectors.PipelineUsecase, tenantLister func(context.Context) ([]string, error)) *BulkPipelineHandler { + return &BulkPipelineHandler{uc: uc, tenantLister: tenantLister} +} + +func resolveTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) { + if sel.AllTenants { + return lister(ctx) + } + return sel.TenantIDs, nil +} + +// @Summary Bulk create pipeline +// @Description Installs the same pipeline in N tenants (each gets a copy in its user overlay). +// @Tags Platform / Pipelines +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkCreatePipelineRequest true "selector + relPath + content" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/pipelines/bulk/create [post] +func (h *BulkPipelineHandler) Create(c *gin.Context) { + var req dto.BulkCreatePipelineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.uc.Create(ctx, dto.CreatePipelineRequest{RelPath: req.RelPath, Content: req.Content}) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk update pipeline +// @Description Updates the pipeline with matching relPath across N tenants. +// @Tags Platform / Pipelines +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkUpdatePipelineRequest true "selector + relPath + content" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/pipelines/bulk/update [post] +func (h *BulkPipelineHandler) Update(c *gin.Context) { + var req dto.BulkUpdatePipelineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.uc.Update(ctx, dto.UpdatePipelineRequest{RelPath: req.RelPath, Content: req.Content}) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk delete pipeline +// @Description Deletes the pipeline with matching relPath across N tenants. System-pipeline guard preserved. +// @Tags Platform / Pipelines +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkDeletePipelineRequest true "selector + relPath" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/pipelines/bulk/delete [post] +func (h *BulkPipelineHandler) Delete(c *gin.Context) { + var req dto.BulkDeletePipelineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + err := h.uc.Delete(ctx, req.RelPath) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk activate/deactivate pipeline +// @Description Enables or disables a pipeline per tenant. +// @Tags Platform / Pipelines +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkActivatePipelineRequest true "selector + relPath + active" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/eventprocessing/pipelines/bulk/activate [post] +func (h *BulkPipelineHandler) Activate(c *gin.Context) { + var req dto.BulkActivatePipelineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + err := h.uc.SetActive(ctx, req.RelPath, req.Active) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/modules/eventprocessing/module.go b/backend/modules/eventprocessing/module.go index eceb87a8b..d23cbc90b 100644 --- a/backend/modules/eventprocessing/module.go +++ b/backend/modules/eventprocessing/module.go @@ -90,8 +90,8 @@ func NewModule(db *gorm.DB, events *eventstore.Store, auditLogger audit_connecto pipelineStore: pipelineStore, pipelineBootstrap: pipelineBootstrap, engineConfigBootstrap: engineConfigBootstrap, - pipelineHandler: handler.NewPipelineHandler(pipelineUC), - ingestionStatsHandler: handler.NewIngestionStatsHandler(ingestionStatsUC), + pipelineHandler: handler.NewPipelineHandler(pipelineUC), + ingestionStatsHandler: handler.NewIngestionStatsHandler(ingestionStatsUC), playgroundHandler: playgroundH, playgroundUsecase: playgroundUC, } diff --git a/backend/modules/eventprocessing/routes.go b/backend/modules/eventprocessing/routes.go index 24e0417bc..cc392c81b 100644 --- a/backend/modules/eventprocessing/routes.go +++ b/backend/modules/eventprocessing/routes.go @@ -1,11 +1,14 @@ package eventprocessing import ( + "context" + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/eventprocessing/handler" "github.com/utmstack/utmstack/backend/pkg/http/middleware" ) -func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { +func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, platform gin.HandlerFunc, tenantLister func(context.Context) ([]string, error)) { rph := m.GetRegexPatternHandler() crh := m.GetCorrelationRuleHandler() fh := m.GetPipelineHandler() @@ -52,4 +55,19 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { pg := g.Group("/playground") pg.POST("/test-pipeline", write, ph.TestPipeline) pg.POST("/test-rule", write, ph.TestRule) + + // Platform-admin bulk endpoints (default-tenant admins only). + bh := handler.NewBulkPipelineHandler(m.GetPipelineUsecase(), tenantLister) + bp := api.Group("/platform/eventprocessing/pipelines/bulk", userAuth, platform, write) + bp.POST("/create", bh.Create) + bp.POST("/update", bh.Update) + bp.POST("/delete", bh.Delete) + bp.POST("/activate", bh.Activate) + + bcr := handler.NewBulkCorrelationRuleHandler(m.GetCorrelationRuleUsecase(), tenantLister) + bcg := api.Group("/platform/eventprocessing/correlation-rule/bulk", userAuth, platform, write) + bcg.POST("/create", bcr.Create) + bcg.POST("/update", bcr.Update) + bcg.POST("/delete", bcr.Delete) + bcg.POST("/activate", bcr.Activate) } diff --git a/backend/modules/iam/dto/bulk_idp.go b/backend/modules/iam/dto/bulk_idp.go new file mode 100644 index 000000000..941bc3e71 --- /dev/null +++ b/backend/modules/iam/dto/bulk_idp.go @@ -0,0 +1,18 @@ +package dto + +import "github.com/utmstack/utmstack/backend/pkg/common_models" + +type BulkCreateIDPRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Provider IdentityProviderRequest `json:"provider"` +} + +type BulkUpdateIDPRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Provider IdentityProviderRequest `json:"provider"` +} + +type BulkDeleteIDPRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + ProviderID string `json:"providerId"` +} diff --git a/backend/modules/iam/handler/bulk_idp.go b/backend/modules/iam/handler/bulk_idp.go new file mode 100644 index 000000000..5071b3721 --- /dev/null +++ b/backend/modules/iam/handler/bulk_idp.go @@ -0,0 +1,128 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/utmstack/utmstack/backend/modules/iam/connectors" + "github.com/utmstack/utmstack/backend/modules/iam/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +type BulkIDPHandler struct { + uc connectors.IdentityProviderUsecase + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkIDPHandler(uc connectors.IdentityProviderUsecase, tenantLister func(context.Context) ([]string, error)) *BulkIDPHandler { + return &BulkIDPHandler{uc: uc, tenantLister: tenantLister} +} + +// ponytail: resolveTenants duplicated from eventprocessing — same package boundary, not worth a shared pkg +func resolveIDPTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) { + if sel.AllTenants { + return lister(ctx) + } + return sel.TenantIDs, nil +} + +// @Summary Bulk create identity provider +// @Description Creates the same IdP config in N tenants. +// @Tags Platform / Identity Providers +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkCreateIDPRequest true "selector + provider config" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/identity-providers/bulk/create [post] +func (h *BulkIDPHandler) Create(c *gin.Context) { + var req dto.BulkCreateIDPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveIDPTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.uc.Create(ctx, req.Provider) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk update identity provider +// @Description Updates an IdP config across N tenants. +// @Tags Platform / Identity Providers +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkUpdateIDPRequest true "selector + provider config" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/identity-providers/bulk/update [post] +func (h *BulkIDPHandler) Update(c *gin.Context) { + var req dto.BulkUpdateIDPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tenantIDs, err := resolveIDPTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.uc.Update(ctx, req.Provider) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// @Summary Bulk delete identity provider +// @Description Deletes an IdP by ID across N tenants. +// @Tags Platform / Identity Providers +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param input body dto.BulkDeleteIDPRequest true "selector + providerId" +// @Success 200 {object} common_models.BulkResult +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /platform/identity-providers/bulk/delete [post] +func (h *BulkIDPHandler) Delete(c *gin.Context) { + var req dto.BulkDeleteIDPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + providerID, err := uuid.Parse(req.ProviderID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid providerId"}) + return + } + tenantIDs, err := resolveIDPTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tenantIDs { + ctx := authz.WithTenantID(c.Request.Context(), tid) + err := h.uc.Delete(ctx, providerID) + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/modules/iam/handler/bulk_idp_test.go b/backend/modules/iam/handler/bulk_idp_test.go new file mode 100644 index 000000000..a37bd744c --- /dev/null +++ b/backend/modules/iam/handler/bulk_idp_test.go @@ -0,0 +1,40 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// Verify partial-failure recording: one tenant fails, one succeeds. +func TestBulkIDPResult_PartialFailure(t *testing.T) { + var result common_models.BulkResult + result.Append("tenant-a", nil) + result.Append("tenant-b", errors.New("not found")) + + if len(result.Succeeded) != 1 || result.Succeeded[0] != "tenant-a" { + t.Fatalf("expected tenant-a in succeeded, got %v", result.Succeeded) + } + if len(result.Failed) != 1 || result.Failed[0].TenantID != "tenant-b" { + t.Fatalf("expected tenant-b in failed, got %v", result.Failed) + } +} + +// Verify resolveIDPTenants respects AllTenants flag. +func TestResolveIDPTenants(t *testing.T) { + lister := func(_ context.Context) ([]string, error) { return []string{"x", "y"}, nil } + + // explicit list + ids, err := resolveIDPTenants(context.Background(), common_models.BulkTenantSelector{TenantIDs: []string{"a"}}, lister) + if err != nil || len(ids) != 1 || ids[0] != "a" { + t.Fatalf("explicit: got %v %v", ids, err) + } + + // all tenants + ids, err = resolveIDPTenants(context.Background(), common_models.BulkTenantSelector{AllTenants: true}, lister) + if err != nil || len(ids) != 2 { + t.Fatalf("allTenants: got %v %v", ids, err) + } +} diff --git a/backend/modules/iam/module.go b/backend/modules/iam/module.go index cd418b2d7..25c7912a9 100644 --- a/backend/modules/iam/module.go +++ b/backend/modules/iam/module.go @@ -16,6 +16,7 @@ type Module struct { tfaHandler *handler.TfaHandler apiKeyHandler *handler.APIKeyHandler idpHandler *handler.IdentityProviderHandler + bulkIDPHandler *handler.BulkIDPHandler federationHandler *handler.FederationHandler authUsecase connectors.AuthUsecase @@ -36,6 +37,7 @@ func NewModule( idpUsecase connectors.IdentityProviderUsecase, federationUC connectors.FederationUsecase, uploadDir string, + tenantLister func(context.Context) ([]string, error), ) *Module { return &Module{ authHandler: handler.NewAuthHandler(authUsecase, uploadDir), @@ -44,6 +46,7 @@ func NewModule( tfaHandler: handler.NewTfaHandler(tfaUsecase), apiKeyHandler: handler.NewAPIKeyHandler(apiKeyUsecase), idpHandler: handler.NewIdentityProviderHandler(idpUsecase), + bulkIDPHandler: handler.NewBulkIDPHandler(idpUsecase, tenantLister), federationHandler: handler.NewFederationHandler(federationUC), authUsecase: authUsecase, userUsecase: userUsecase, @@ -60,7 +63,8 @@ func (m *Module) GetUserHandler() *handler.UserHandler { return m.us func (m *Module) GetRoleHandler() *handler.RoleHandler { return m.roleHandler } func (m *Module) GetTfaHandler() *handler.TfaHandler { return m.tfaHandler } func (m *Module) GetAPIKeyHandler() *handler.APIKeyHandler { return m.apiKeyHandler } -func (m *Module) GetIDPHandler() *handler.IdentityProviderHandler { return m.idpHandler } +func (m *Module) GetIDPHandler() *handler.IdentityProviderHandler { return m.idpHandler } +func (m *Module) GetBulkIDPHandler() *handler.BulkIDPHandler { return m.bulkIDPHandler } func (m *Module) GetFederationHandler() *handler.FederationHandler { return m.federationHandler } func (m *Module) GetAuthUsecase() connectors.AuthUsecase { return m.authUsecase } func (m *Module) GetTfaUsecase() connectors.TfaUsecase { return m.tfaUsecase } diff --git a/backend/modules/iam/routes.go b/backend/modules/iam/routes.go index 8271b2634..4a895f9f6 100644 --- a/backend/modules/iam/routes.go +++ b/backend/modules/iam/routes.go @@ -5,7 +5,7 @@ import ( "github.com/utmstack/utmstack/backend/pkg/http/middleware" ) -func RegisterRoutes(api *gin.RouterGroup, module *Module, userAuth gin.HandlerFunc, enterprise, enterpriseLicense gin.HandlerFunc) { +func RegisterRoutes(api *gin.RouterGroup, module *Module, userAuth gin.HandlerFunc, enterprise, enterpriseLicense gin.HandlerFunc, platform gin.HandlerFunc) { auth := module.GetAuthHandler() users := module.GetUserHandler() roles := module.GetRoleHandler() @@ -71,6 +71,13 @@ func RegisterRoutes(api *gin.RouterGroup, module *Module, userAuth gin.HandlerFu idpGroup.GET("/:id/group-mappings", middleware.RequirePermission("idp.read"), idp.ListMappings) api.GET("/idp-providers", idp.PublicList) + + bulkIDP := module.GetBulkIDPHandler() + bulkIDPGroup := api.Group("/platform/identity-providers/bulk", userAuth, platform, enterprise, middleware.RequirePermission("idp.write")) + bulkIDPGroup.POST("/create", bulkIDP.Create) + bulkIDPGroup.POST("/update", bulkIDP.Update) + bulkIDPGroup.POST("/delete", bulkIDP.Delete) + sso := module.GetFederationHandler() ssoGroup := api.Group("/sso/:name", enterpriseLicense) ssoGroup.GET("/login", sso.Start) diff --git a/backend/modules/soar/dto/bulk.go b/backend/modules/soar/dto/bulk.go new file mode 100644 index 000000000..0a2d7a0a0 --- /dev/null +++ b/backend/modules/soar/dto/bulk.go @@ -0,0 +1,29 @@ +package dto + +import "github.com/utmstack/utmstack/backend/pkg/common_models" + +// BulkCreateRuleRequest creates a rule in each selected tenant. +type BulkCreateRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + Rule CreateRuleRequest `json:"rule"` +} + +// BulkUpdateRuleRequest updates a rule (by relPath) in each selected tenant. +type BulkUpdateRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath"` + Rule UpdateRuleRequest `json:"rule"` +} + +// BulkDeleteRuleRequest deletes a rule (by relPath) in each selected tenant. +type BulkDeleteRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath"` +} + +// BulkEnableRuleRequest enables or disables a rule in each selected tenant. +type BulkEnableRuleRequest struct { + Selector common_models.BulkTenantSelector `json:"selector"` + RelPath string `json:"relPath"` + Enabled bool `json:"enabled"` +} diff --git a/backend/modules/soar/handler/bulk.go b/backend/modules/soar/handler/bulk.go new file mode 100644 index 000000000..71a5168a9 --- /dev/null +++ b/backend/modules/soar/handler/bulk.go @@ -0,0 +1,150 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/soar/connectors" + "github.com/utmstack/utmstack/backend/modules/soar/dto" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// BulkHandler serves platform-admin bulk endpoints for soar rules. +type BulkHandler struct { + ruleUC connectors.RuleUsecase + tenantLister func(context.Context) ([]string, error) +} + +func NewBulkHandler( + ruleUC connectors.RuleUsecase, + tenantLister func(context.Context) ([]string, error), +) *BulkHandler { + return &BulkHandler{ruleUC: ruleUC, tenantLister: tenantLister} +} + +func resolveTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) { + if sel.AllTenants { + return lister(ctx) + } + return sel.TenantIDs, nil +} + +// BulkCreateRule godoc +// +// @Summary Bulk create SOAR rule across tenants +// @Tags Platform SOAR +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body dto.BulkCreateRuleRequest true "Request" +// @Success 200 {object} common_models.BulkResult +// @Router /platform/soar/rules/bulk/create [post] +func (h *BulkHandler) BulkCreateRule(c *gin.Context) { + var req dto.BulkCreateRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tids, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tids { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.ruleUC.Create(ctx, req.Rule, "platform-admin") + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// BulkUpdateRule godoc +// +// @Summary Bulk update SOAR rule across tenants +// @Tags Platform SOAR +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body dto.BulkUpdateRuleRequest true "Request" +// @Success 200 {object} common_models.BulkResult +// @Router /platform/soar/rules/bulk/update [post] +func (h *BulkHandler) BulkUpdateRule(c *gin.Context) { + var req dto.BulkUpdateRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tids, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tids { + ctx := authz.WithTenantID(c.Request.Context(), tid) + _, err := h.ruleUC.Update(ctx, req.RelPath, req.Rule, "platform-admin") + result.Append(tid, err) + } + c.JSON(http.StatusOK, result) +} + +// BulkDeleteRule godoc +// +// @Summary Bulk delete SOAR rule across tenants +// @Tags Platform SOAR +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body dto.BulkDeleteRuleRequest true "Request" +// @Success 200 {object} common_models.BulkResult +// @Router /platform/soar/rules/bulk/delete [post] +func (h *BulkHandler) BulkDeleteRule(c *gin.Context) { + var req dto.BulkDeleteRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tids, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tids { + ctx := authz.WithTenantID(c.Request.Context(), tid) + result.Append(tid, h.ruleUC.Delete(ctx, req.RelPath)) + } + c.JSON(http.StatusOK, result) +} + +// BulkEnableRule godoc +// +// @Summary Bulk enable/disable SOAR rule across tenants +// @Tags Platform SOAR +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body dto.BulkEnableRuleRequest true "Request" +// @Success 200 {object} common_models.BulkResult +// @Router /platform/soar/rules/bulk/enable [post] +func (h *BulkHandler) BulkEnableRule(c *gin.Context) { + var req dto.BulkEnableRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tids, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var result common_models.BulkResult + for _, tid := range tids { + ctx := authz.WithTenantID(c.Request.Context(), tid) + result.Append(tid, h.ruleUC.SetEnabled(ctx, req.RelPath, req.Enabled)) + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/modules/soar/handler/bulk_test.go b/backend/modules/soar/handler/bulk_test.go new file mode 100644 index 000000000..7a8bc63c0 --- /dev/null +++ b/backend/modules/soar/handler/bulk_test.go @@ -0,0 +1,45 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/pkg/common_models" +) + +// stubLister returns the given IDs. +func stubLister(ids []string) func(context.Context) ([]string, error) { + return func(context.Context) ([]string, error) { return ids, nil } +} + +func TestResolveTenants_AllTenants(t *testing.T) { + ids, err := resolveTenants(context.Background(), + common_models.BulkTenantSelector{AllTenants: true}, + stubLister([]string{"a", "b"})) + if err != nil || len(ids) != 2 { + t.Fatalf("expected 2 ids, got %v err=%v", ids, err) + } +} + +func TestResolveTenants_Specific(t *testing.T) { + ids, err := resolveTenants(context.Background(), + common_models.BulkTenantSelector{TenantIDs: []string{"x"}}, + stubLister([]string{"ignored"})) + if err != nil || len(ids) != 1 || ids[0] != "x" { + t.Fatalf("expected [x], got %v err=%v", ids, err) + } +} + +func TestBulkResult_PartialFailure(t *testing.T) { + var r common_models.BulkResult + r.Append("ok-tenant", nil) + r.Append("bad-tenant", errors.New("boom")) + + if len(r.Succeeded) != 1 || r.Succeeded[0] != "ok-tenant" { + t.Fatalf("unexpected succeeded: %v", r.Succeeded) + } + if len(r.Failed) != 1 || r.Failed[0].TenantID != "bad-tenant" { + t.Fatalf("unexpected failed: %v", r.Failed) + } +} diff --git a/backend/modules/soar/module.go b/backend/modules/soar/module.go index 13542f589..75461d3fd 100644 --- a/backend/modules/soar/module.go +++ b/backend/modules/soar/module.go @@ -22,6 +22,7 @@ type Module struct { executionHandler *handler.ExecutionHandler variableHandler *handler.VariableHandler commandWSHandler *handler.CommandWSHandler + bulkHandler *handler.BulkHandler ruleUsecase connectors.RuleUsecase executionUsecase connectors.ExecutionUsecase @@ -38,6 +39,7 @@ func NewModule( agentClient *agentmanager.AgentManagerClient, signer *jwtpkg.Signer, cipher *secret.Cipher, + tenantLister func(context.Context) ([]string, error), ) *Module { flowsSrc := env.String("SOAR_FLOWS_SRC_DIR", "/utmstack/soar", false) flowsRoot := env.String("SOAR_FLOWS_DIR", "/workdir/soar", false) @@ -65,6 +67,7 @@ func NewModule( executionHandler: handler.NewExecutionHandler(executionUC), variableHandler: handler.NewVariableHandler(variableUC), commandWSHandler: handler.NewCommandWSHandler(agentClient, signer, variableUC, executionUC), + bulkHandler: handler.NewBulkHandler(ruleUC, tenantLister), ruleUsecase: ruleUC, executionUsecase: executionUC, @@ -92,6 +95,7 @@ func (m *Module) Start(ctx context.Context) error { func (m *Module) GetRuleHandler() *handler.RuleHandler { return m.ruleHandler } func (m *Module) GetExecutionHandler() *handler.ExecutionHandler { return m.executionHandler } func (m *Module) GetVariableHandler() *handler.VariableHandler { return m.variableHandler } +func (m *Module) GetBulkHandler() *handler.BulkHandler { return m.bulkHandler } func (m *Module) GetRuleUsecase() connectors.RuleUsecase { return m.ruleUsecase } func (m *Module) GetExecutionUsecase() connectors.ExecutionUsecase { return m.executionUsecase } func (m *Module) GetVariableUsecase() connectors.VariableUsecase { return m.variableUsecase } diff --git a/backend/modules/soar/routes.go b/backend/modules/soar/routes.go index 2f845d6ed..242761fa5 100644 --- a/backend/modules/soar/routes.go +++ b/backend/modules/soar/routes.go @@ -5,10 +5,11 @@ import ( "github.com/utmstack/utmstack/backend/pkg/http/middleware" ) -func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, apiKeyAuth middleware.APIKeyAuthFunc) { +func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, apiKeyAuth middleware.APIKeyAuthFunc, platform gin.HandlerFunc) { rh := m.GetRuleHandler() eh := m.GetExecutionHandler() vh := m.GetVariableHandler() + bh := m.GetBulkHandler() read := middleware.RequirePermission("soar.read") write := middleware.RequirePermission("soar.write") @@ -36,4 +37,11 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc, a m.commandWSHandler.SetAPIKeyAuth(apiKeyAuth) api.GET("/soar/ws/command/:agentId", m.commandWSHandler.CommandStream) + + // Platform-admin bulk endpoints (rules only). + prg := api.Group("/platform/soar/rules/bulk", userAuth, platform, write) + prg.POST("/create", bh.BulkCreateRule) + prg.POST("/update", bh.BulkUpdateRule) + prg.POST("/delete", bh.BulkDeleteRule) + prg.POST("/enable", bh.BulkEnableRule) } diff --git a/backend/pkg/common_models/bulk.go b/backend/pkg/common_models/bulk.go new file mode 100644 index 000000000..9aa181217 --- /dev/null +++ b/backend/pkg/common_models/bulk.go @@ -0,0 +1,33 @@ +package common_models + +// BulkTenantSelector picks which tenants a platform-admin bulk op targets. +// If AllTenants is true, TenantIDs is ignored and the handler enumerates +// every ACTIVE tenant via the tenant usecase. +type BulkTenantSelector struct { + TenantIDs []string `json:"tenantIds"` + AllTenants bool `json:"allTenants"` +} + +// BulkFailure records one per-tenant error so callers can retry just the +// tenants that failed instead of the whole batch. +type BulkFailure struct { + TenantID string `json:"tenantId"` + Error string `json:"error"` +} + +// BulkResult is what every platform-admin bulk endpoint returns. Partial +// success is expected — system-owned guards and per-tenant validation errors +// land in Failed while the rest of the loop continues. +type BulkResult struct { + Succeeded []string `json:"succeeded"` + Failed []BulkFailure `json:"failed"` +} + +// Append records an outcome for one tenant. Nil err means success. +func (r *BulkResult) Append(tenantID string, err error) { + if err != nil { + r.Failed = append(r.Failed, BulkFailure{TenantID: tenantID, Error: err.Error()}) + return + } + r.Succeeded = append(r.Succeeded, tenantID) +} diff --git a/collectors/collector/go.mod b/collectors/collector/go.mod new file mode 100644 index 000000000..a88ab2caf --- /dev/null +++ b/collectors/collector/go.mod @@ -0,0 +1,74 @@ +module github.com/utmstack/UTMStack/collectors/forwarder + +go 1.25.5 + +require ( + github.com/AtlasInsideCorp/AtlasInsideAES v1.0.0 + github.com/elastic/go-sysinfo v1.15.5 + github.com/fsnotify/fsnotify v1.10.1 + github.com/google/uuid v1.6.0 + github.com/kardianos/service v1.3.0 + github.com/netsampler/goflow2 v1.3.7 + github.com/spf13/cobra v1.10.2 + github.com/tehmaze/netflow v0.0.0-20240303214733-8c13bb004068 + github.com/threatwinds/go-sdk v1.1.28 + github.com/threatwinds/logger v1.2.3 + github.com/utmstack/UTMStack/shared v0.0.0 + google.golang.org/grpc v1.83.0 + google.golang.org/protobuf v1.36.12 +) + +replace github.com/utmstack/UTMStack/shared => ../../shared + +require ( + cel.dev/expr v0.25.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/elastic/go-windows v1.0.2 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/cel-go v0.28.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opensearch-project/opensearch-go/v4 v4.6.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + howett.net/plist v1.0.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/collectors/collector/go.sum b/collectors/collector/go.sum new file mode 100644 index 000000000..80ba177af --- /dev/null +++ b/collectors/collector/go.sum @@ -0,0 +1,191 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/AtlasInsideCorp/AtlasInsideAES v1.0.0 h1:TBiBl9KCa4i4epY0/q9WSC4ugavL6+6JUkOXWDnMM6I= +github.com/AtlasInsideCorp/AtlasInsideAES v1.0.0/go.mod h1:cRhQ3TS/VEfu/z+qaciyuDZdtxgaXgaX8+G6Wa5NzBk= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elastic/go-sysinfo v1.15.5 h1:fCVUDmjHgljLUQCygherMnsRRJ9AkuAQIywTL7dEH28= +github.com/elastic/go-sysinfo v1.15.5/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= +github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= +github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= +github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/netsampler/goflow2 v1.3.7 h1:XZaTy8kkMnGXpJ9hS3KbO1McyrFTpVNhVFEx9rNhMmc= +github.com/netsampler/goflow2 v1.3.7/go.mod h1:4UZsVGVAs//iMCptUHn3WNScztJeUhZH7kDW2+/vDdQ= +github.com/opensearch-project/opensearch-go/v4 v4.6.0 h1:Ac8aLtDSmLEyOmv0r1qhQLw3b4vcUhE42NE9k+Z4cRc= +github.com/opensearch-project/opensearch-go/v4 v4.6.0/go.mod h1:3iZtb4SNt3IzaxavKq0dURh1AmtVgYW71E4XqmYnIiQ= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tehmaze/netflow v0.0.0-20240303214733-8c13bb004068 h1:1B+EAUqxEyPByCfk55tB21DtR7WF7Q2w71g7+uVkvIg= +github.com/tehmaze/netflow v0.0.0-20240303214733-8c13bb004068/go.mod h1:QRP5wJOf7gGMGL2fCAfmh/5CMZQspRxT5DqghaPRrjM= +github.com/threatwinds/go-sdk v1.1.28 h1:dnYIAVC5jQgr7jm1VGMS7dQcl/08GVODDJp3dhV6D9U= +github.com/threatwinds/go-sdk v1.1.28/go.mod h1:aN6Oe3zJop9ngS83oZcKFXDLKWzrny2XhkYm7uoyDbQ= +github.com/threatwinds/logger v1.2.3 h1:V2SVAXzbq+/huCvIWOfqzMTH+WBHJxankyBgVG2hy1Y= +github.com/threatwinds/logger v1.2.3/go.mod h1:N+bJKvF4FQNJZLfQpVYWpr6D8iEAFnAQfHYqH5iR1TI= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= +github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/frontend/src/features/alerting-rules/components/rule-drawer.tsx b/frontend/src/features/alerting-rules/components/rule-drawer.tsx index 4bc9d9635..88b740710 100644 --- a/frontend/src/features/alerting-rules/components/rule-drawer.tsx +++ b/frontend/src/features/alerting-rules/components/rule-drawer.tsx @@ -17,6 +17,12 @@ import { ruleFormToYaml, yamlToRuleForm } from '../lib/rule-yaml' import { RuleForm, ruleToForm, formToInput, type RuleFormState } from './rule-form' import { RuleView } from './rule-view' import { Toggle } from './toggle' +import { + PlatformBroadcastButton, + broadcast, + BULK_PATHS, + type BulkSelector, +} from '@/features/platform-broadcast' export function RuleDrawer({ rule, @@ -95,6 +101,36 @@ export function RuleDrawer({ const showForm = editing || !!create + const buildBroadcastInput = () => { + let f = form + if (mode === 'code') { + const r = yamlToRuleForm(yaml) + if (!r.ok) throw new Error(t('alertingRules.editor.yamlError', { error: r.error })) + f = { ...r.form, ruleActive: form.ruleActive } + } + if (!f.name.trim()) throw new Error(t('alertingRules.editor.nameRequired')) + if (!f.definition.trim()) throw new Error(t('alertingRules.editor.definitionRequired')) + return formToInput(f, create ? undefined : rule?.relPath) + } + + const onBroadcastCreate = async (selector: BulkSelector) => { + return broadcast(BULK_PATHS.correlationRules.create, selector, buildBroadcastInput()) + } + const onBroadcastUpdate = async (selector: BulkSelector) => { + return broadcast(BULK_PATHS.correlationRules.update, selector, buildBroadcastInput()) + } + const onBroadcastDelete = async (selector: BulkSelector) => { + if (!rule) throw new Error('No rule to delete') + return broadcast(BULK_PATHS.correlationRules.delete, selector, { relPath: rule.relPath }) + } + const onBroadcastActivate = async (selector: BulkSelector) => { + if (!rule) throw new Error('No rule to activate') + return broadcast(BULK_PATHS.correlationRules.activate, selector, { + relPath: rule.relPath, + active: !rule.ruleActive, + }) + } + return (
e.stopPropagation()}> @@ -110,7 +146,25 @@ export function RuleDrawer({ {rule && !readOnly && !showForm && } {rule && } {rule && !readOnly && onDelete && } + {rule && !readOnly && ( + + )} {rule && onToggle && onToggle(rule, v)} />} + {rule && ( + + )}
@@ -163,6 +217,25 @@ export function RuleDrawer({ {showForm && (
{!create && } + {create ? ( + + ) : ( + !readOnly && ( + + ) + )} diff --git a/frontend/src/features/branding/pages/BrandingPage.tsx b/frontend/src/features/branding/pages/BrandingPage.tsx index f21d68b67..b90bd41bd 100644 --- a/frontend/src/features/branding/pages/BrandingPage.tsx +++ b/frontend/src/features/branding/pages/BrandingPage.tsx @@ -7,6 +7,7 @@ import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { useBilling } from '@/features/billing' import { EnterpriseGate } from '@/shared/components/EnterpriseGate' +import { PlatformBroadcastButton, broadcast, broadcastBrandingAsset, BULK_PATHS } from '@/features/platform-broadcast' import { brandingHttpService } from '../services/branding-http.service' import { useBranding } from '../services/branding.context' import type { Branding, BrandingAssetSlot } from '../types/branding.types' @@ -229,9 +230,31 @@ export function BrandingPage() { {t('branding.restore')} - +
+ + {dirty && ( + { + return broadcast(BULK_PATHS.branding.update, selector, { + enabled: form.enabled, + productName: form.productName, + accentColor: form.accentColor, + logoUrl: form.logoUrl, + logoDarkUrl: form.logoDarkUrl, + faviconUrl: form.faviconUrl, + reportLogoUrl: form.reportLogoUrl, + reportCoverUrl: form.reportCoverUrl, + }) + }} + /> + )} +
)} @@ -260,17 +283,26 @@ function AssetCard({ }) { const inputRef = useRef(null) const [busy, setBusy] = useState(false) + const [pickedFile, setPickedFile] = useState(null) const pick = () => inputRef.current?.click() const onFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] - e.target.value = '' - if (!file) return + if (!file) { + e.target.value = '' + return + } + setPickedFile(file) + } + + const uploadToTenant = async () => { + if (!pickedFile) return setBusy(true) try { - const b = await brandingHttpService.uploadAsset(slot, file) + const b = await brandingHttpService.uploadAsset(slot, pickedFile) await onUploaded(b) + setPickedFile(null) toast.success(t('branding.uploaded')) } catch { toast.error(t('branding.uploadError')) @@ -295,14 +327,32 @@ function AssetCard({ )}
- + {pickedFile && ( + { + return broadcastBrandingAsset(slot, pickedFile, selector) + }} + /> )} - {url ? t('branding.asset.replace') : t('branding.asset.upload')} - + + {pickedFile && ( + + )} ) } diff --git a/frontend/src/features/compliance/components/ControlEditor.tsx b/frontend/src/features/compliance/components/ControlEditor.tsx index b18ea9057..38fcac18d 100644 --- a/frontend/src/features/compliance/components/ControlEditor.tsx +++ b/frontend/src/features/compliance/components/ControlEditor.tsx @@ -6,6 +6,7 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { YamlCodeEditor } from '@/shared/components/YamlCodeEditor' +import { PlatformBroadcastButton, broadcast, BULK_PATHS, type BulkSelector } from '@/features/platform-broadcast' import { complianceService, ComplianceHttpError } from '../services/compliance-http.service' import { CHECK_DATASETS, @@ -206,7 +207,7 @@ export function ControlEditor({ )} diff --git a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx index e8d0e0f31..b559746e1 100644 --- a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx +++ b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx @@ -10,6 +10,7 @@ import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' import { pipelinesHttpService } from '@/features/data-processing/services/data-processing-http.service' import type { Pipeline } from '@/features/data-processing/types/data-processing.types' import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal' +import { PlatformBroadcastButton, broadcast, BULK_PATHS, type BulkSelector } from '@/features/platform-broadcast' import { FilterFormDrawer } from '../components/FilterFormDrawer' import { displayName } from '../lib/filter-model' @@ -159,6 +160,14 @@ export function ParsingFiltersPage() { } } + const onBroadcastDelete = async (f: Pipeline, selector: BulkSelector) => { + return broadcast(BULK_PATHS.pipelines.delete, selector, { relPath: f.relPath }) + } + + const onBroadcastActivate = async (f: Pipeline, active: boolean, selector: BulkSelector) => { + return broadcast(BULK_PATHS.pipelines.activate, selector, { relPath: f.relPath, active }) + } + return (
@@ -264,6 +273,8 @@ export function ParsingFiltersPage() { canMoveUp={i > 0} canMoveDown={i < items.length - 1} reordering={reordering} + onBroadcastDelete={(selector) => onBroadcastDelete(f, selector)} + onBroadcastActivate={(active, selector) => onBroadcastActivate(f, active, selector)} /> ))} void @@ -331,6 +344,8 @@ function Row({ canMoveUp: boolean canMoveDown: boolean reordering: boolean + onBroadcastDelete: (selector: BulkSelector) => Promise>> + onBroadcastActivate: (active: boolean, selector: BulkSelector) => Promise>> }) { const { t } = useTranslation() return ( @@ -361,10 +376,30 @@ function Row({ {t(f.system ? 'parsingFilters.system' : 'parsingFilters.user')}
-
e.stopPropagation()}> +
e.stopPropagation()}> + onBroadcastActivate(f.active, selector)} + variant="ghost" + size="sm" + /> +
+
e.stopPropagation()}> + {t('parsingFilters.view')} + {!f.system && ( + + )}
-
{t('parsingFilters.view')}
e.stopPropagation()}> + setOpen(false)} + onConfirm={props.onBroadcast} + /> + + ) +} diff --git a/frontend/src/features/platform-broadcast/hooks/useBroadcastAction.ts b/frontend/src/features/platform-broadcast/hooks/useBroadcastAction.ts new file mode 100644 index 000000000..1ec2cc3c5 --- /dev/null +++ b/frontend/src/features/platform-broadcast/hooks/useBroadcastAction.ts @@ -0,0 +1,55 @@ +import { useCallback, useState, type ReactNode } from 'react' +import { useAuth } from '@/features/auth/services/auth.context' +import { useSupportTenant } from '@/shared/lib/current-tenant' +import type { BulkResult, BulkSelector } from '../services/broadcast-http.service' + +export interface BroadcastActionConfig { + title: string + intro?: ReactNode + excludeDefaultTenant?: boolean + run: (selector: BulkSelector) => Promise +} + +export interface BroadcastActionHandle { + /** True only when this session may see & fire platform-broadcast actions. */ + canBroadcast: boolean + /** Modal is currently open. */ + open: boolean + /** Trigger the modal with a specific action config. */ + trigger: (config: BroadcastActionConfig) => void + /** Close the modal. */ + close: () => void + /** The config currently mounted in the modal (null when closed). */ + config: BroadcastActionConfig | null +} + +/** + * Gates every platform-broadcast entry point on the same two flags the sidebar + * uses and holds the trigger state. Callers own the `` render; + * this hook only sets `open` and hands back the action config. + */ +export function useBroadcastAction(): BroadcastActionHandle { + const { isPlatformAdmin } = useAuth() + const supportTenant = useSupportTenant() + const canBroadcast = isPlatformAdmin && supportTenant === null + + const [config, setConfig] = useState(null) + + const trigger = useCallback( + (next: BroadcastActionConfig) => { + if (!canBroadcast) return + setConfig(next) + }, + [canBroadcast], + ) + + const close = useCallback(() => setConfig(null), []) + + return { + canBroadcast, + open: config !== null, + trigger, + close, + config, + } +} diff --git a/frontend/src/features/platform-broadcast/hooks/useTenantsForBroadcast.ts b/frontend/src/features/platform-broadcast/hooks/useTenantsForBroadcast.ts new file mode 100644 index 000000000..6985aab04 --- /dev/null +++ b/frontend/src/features/platform-broadcast/hooks/useTenantsForBroadcast.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' +import { tenantsHttpService } from '@/features/tenants/services/tenants-http.service' +import type { Tenant } from '@/features/tenants/types/tenant.types' + +/** + * The platform-plane tenant. Broadcasts to "all tenants" deliberately skip it + * — see backend/bulk.md notes for SMTP and branding. Kept as the module-level + * constant so callers do not each import the same UUID. + */ +export const DEFAULT_TENANT_ID = 'ce66672c-e36d-4761-a8c8-90058fee1a24' + +export interface UseTenantsForBroadcastState { + tenants: Tenant[] + loading: boolean + error: string | null +} + +/** + * Fetch every ACTIVE tenant, once per mount of the modal that uses it. + * + * A modal that only appears on click does not need caching — the network call + * is one round trip against a small list. When it hurts, add SWR here without + * touching callers. + */ +export function useTenantsForBroadcast(open: boolean): UseTenantsForBroadcastState { + const [state, setState] = useState({ + tenants: [], + loading: false, + error: null, + }) + + useEffect(() => { + if (!open) return + let cancelled = false + setState({ tenants: [], loading: true, error: null }) + tenantsHttpService + .list({ status: 'ACTIVE' }) + .then((list) => { + if (cancelled) return + setState({ tenants: list, loading: false, error: null }) + }) + .catch((err: unknown) => { + if (cancelled) return + const msg = err instanceof Error ? err.message : 'Failed to load tenants' + setState({ tenants: [], loading: false, error: msg }) + }) + return () => { + cancelled = true + } + }, [open]) + + return state +} + +/** + * Tenants a "select all" would actually target. Some endpoints (SMTP, branding) + * exclude the platform-plane tenant even when `allTenants=true`; mirror that in + * the picker so the count the operator sees matches what the backend will do. + */ +export function filterForAllTenants(tenants: Tenant[], excludeDefault: boolean): Tenant[] { + if (!excludeDefault) return tenants + return tenants.filter((t) => t.id !== DEFAULT_TENANT_ID) +} diff --git a/frontend/src/features/platform-broadcast/index.ts b/frontend/src/features/platform-broadcast/index.ts new file mode 100644 index 000000000..b5364b974 --- /dev/null +++ b/frontend/src/features/platform-broadcast/index.ts @@ -0,0 +1,19 @@ +export { PlatformBroadcastButton } from './components/PlatformBroadcastButton' +export { BroadcastDialog } from './components/BroadcastDialog' +export { BroadcastResultPanel } from './components/BroadcastResultPanel' +export { useBroadcastAction } from './hooks/useBroadcastAction' +export { + DEFAULT_TENANT_ID, + useTenantsForBroadcast, + filterForAllTenants, +} from './hooks/useTenantsForBroadcast' +export { + BULK_PATHS, + broadcast, + broadcastBrandingAsset, +} from './services/broadcast-http.service' +export type { + BulkSelector, + BulkResult, + BulkFailure, +} from './services/broadcast-http.service' diff --git a/frontend/src/features/platform-broadcast/services/broadcast-http.service.ts b/frontend/src/features/platform-broadcast/services/broadcast-http.service.ts new file mode 100644 index 000000000..438bea2c3 --- /dev/null +++ b/frontend/src/features/platform-broadcast/services/broadcast-http.service.ts @@ -0,0 +1,112 @@ +import { createApiClient } from '@/shared/lib/api-client' + +/** + * One typed service for every `/platform/**\/bulk/**` endpoint. + * + * Every bulk endpoint takes the same selector and returns the same shape. The + * per-endpoint helpers below only differ in the resource payload they merge + * into the request body — the caller assembles that payload from the same + * form state the single-tenant version uses. + * + * Backend contract lives in backend/bulk.md. + */ + +export interface BulkSelector { + tenantIds: string[] + allTenants: boolean +} + +export interface BulkFailure { + tenantId: string + error: string +} + +export interface BulkResult { + succeeded: string[] + failed: BulkFailure[] +} + +const api = createApiClient() + +function withSelector(selector: BulkSelector, resource: T) { + return { selector, ...resource } +} + +async function post( + path: string, + selector: BulkSelector, + resource: Resource, +): Promise { + return api.post(path, withSelector(selector, resource)) +} + +/** Send `resource` to every selected tenant against `path` (a full `/platform/**\/bulk/**` route). */ +export function broadcast( + path: string, + selector: BulkSelector, + resource: Resource, +): Promise { + return post(path, selector, resource) +} + +/** + * Path constants keep the caller sites honest — mistyping one is a compile + * error rather than a 404 discovered in production. + */ +export const BULK_PATHS = { + pipelines: { + create: '/platform/eventprocessing/pipelines/bulk/create', + update: '/platform/eventprocessing/pipelines/bulk/update', + delete: '/platform/eventprocessing/pipelines/bulk/delete', + activate: '/platform/eventprocessing/pipelines/bulk/activate', + }, + correlationRules: { + create: '/platform/eventprocessing/correlation-rule/bulk/create', + update: '/platform/eventprocessing/correlation-rule/bulk/update', + delete: '/platform/eventprocessing/correlation-rule/bulk/delete', + activate: '/platform/eventprocessing/correlation-rule/bulk/activate', + }, + soarRules: { + create: '/platform/soar/rules/bulk/create', + update: '/platform/soar/rules/bulk/update', + delete: '/platform/soar/rules/bulk/delete', + enable: '/platform/soar/rules/bulk/enable', + }, + compliance: { + frameworkCreate: '/platform/compliance/frameworks/bulk/create', + frameworkUpdate: '/platform/compliance/frameworks/bulk/update', + frameworkDelete: '/platform/compliance/frameworks/bulk/delete', + controlCreate: '/platform/compliance/controls/bulk/create', + controlUpdate: '/platform/compliance/controls/bulk/update', + controlDelete: '/platform/compliance/controls/bulk/delete', + }, + smtp: { + update: '/platform/config/smtp/bulk/update', + test: '/platform/config/smtp/bulk/test', + }, + idp: { + create: '/platform/identity-providers/bulk/create', + update: '/platform/identity-providers/bulk/update', + delete: '/platform/identity-providers/bulk/delete', + }, + branding: { + update: '/platform/branding/bulk/update', + uploadAsset: (slot: string) => `/platform/branding/bulk/upload-asset/${slot}`, + }, +} as const + +/** + * Upload a single asset and broadcast the resulting URL — the branding + * endpoint is multipart because the file is uploaded once and every tenant + * points at the same object. + */ +export async function broadcastBrandingAsset( + slot: string, + file: File, + selector: BulkSelector, +): Promise { + const form = new FormData() + form.append('file', file) + form.append('selector', JSON.stringify(selector)) + return api.post(BULK_PATHS.branding.uploadAsset(slot), form) +} diff --git a/frontend/src/features/settings/pages/EmailConfigurationPage.tsx b/frontend/src/features/settings/pages/EmailConfigurationPage.tsx index 90c9d3518..cb67261c5 100644 --- a/frontend/src/features/settings/pages/EmailConfigurationPage.tsx +++ b/frontend/src/features/settings/pages/EmailConfigurationPage.tsx @@ -6,6 +6,7 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { EmailChipInput } from '@/shared/components/ui/email-chip-input' +import { PlatformBroadcastButton, broadcast, BULK_PATHS } from '@/features/platform-broadcast' import { configHttpService } from '../services/config-http.service' /* Backend config keys (utm_configuration_parameter), read/written via /config/:key. */ @@ -206,6 +207,29 @@ export function EmailConfigurationPage() { } } + const smtpPayload = { + host: form.host, + port: form.port, + username: form.username, + password: form.password, + from: form.from, + authType: form.encryption, + orgname: form.organization, + baseUrl: form.baseUrl, + } + + const onBroadcastUpdate = async (selector: { tenantIds: string[]; allTenants: boolean }) => { + return broadcast(BULK_PATHS.smtp.update, selector, smtpPayload) + } + + const onBroadcastTest = async (selector: { tenantIds: string[]; allTenants: boolean }) => { + if (!form.from) { + toast.error(t('emailConfig.test.fromRequired')) + throw new Error('from is required') + } + return broadcast(BULK_PATHS.smtp.test, selector, smtpPayload) + } + return (
@@ -329,13 +353,28 @@ export function EmailConfigurationPage() { )} {test === 'sending' ? t('emailConfig.test.sending') : t('emailConfig.test.button')} + {form.from && ( {t('emailConfig.test.sentTo', { email: form.from })} )}
- +
+ + +
diff --git a/frontend/src/features/settings/pages/IdentityProvidersPage.tsx b/frontend/src/features/settings/pages/IdentityProvidersPage.tsx index 7c1116077..7d3aff029 100644 --- a/frontend/src/features/settings/pages/IdentityProvidersPage.tsx +++ b/frontend/src/features/settings/pages/IdentityProvidersPage.tsx @@ -18,6 +18,7 @@ import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { useBilling } from '@/features/billing' import { EnterpriseGate } from '@/shared/components/EnterpriseGate' +import { PlatformBroadcastButton, broadcast, BULK_PATHS } from '@/features/platform-broadcast' import { IdpHttpError, idpHttpService } from '../services/idp-http.service' import type { GroupMapping, IdentityProvider, IdentityProviderRequest, ProviderType } from '../types/idp.types' import { EMPTY_SETTINGS, PROVIDER_TYPES, REDIRECTING_PROVIDER_TYPES } from '../types/idp.types' @@ -625,13 +626,37 @@ function UpsertDialog({ {t('idp.form.active')} -