From c9a4cb9edabba78500913720bc884a6cab7902e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 12 Aug 2026 14:18:24 -0600 Subject: [PATCH 1/5] feat[backend](alerting-rules,appconfig,compilance,pipelines): added bulk operations for system admins --- .../modules/appconfig/dto/bulk_branding.go | 12 + backend/modules/appconfig/dto/bulk_smtp.go | 20 ++ .../appconfig/handler/bulk_branding.go | 154 +++++++++++ .../appconfig/handler/bulk_branding_test.go | 52 ++++ .../modules/appconfig/handler/bulk_smtp.go | 111 ++++++++ .../appconfig/handler/bulk_smtp_test.go | 57 ++++ backend/modules/appconfig/module.go | 40 +-- backend/modules/appconfig/routes.go | 10 +- backend/modules/compliance/dto/bulk.go | 36 +++ backend/modules/compliance/handler/bulk.go | 259 ++++++++++++++++++ .../modules/compliance/handler/bulk_test.go | 48 ++++ backend/modules/compliance/module.go | 11 +- backend/modules/compliance/routes.go | 12 +- .../dto/bulk_correlation_rule.go | 24 ++ .../eventprocessing/dto/bulk_pipeline.go | 26 ++ .../handler/bulk_correlation_rule.go | 142 ++++++++++ .../eventprocessing/handler/bulk_pipeline.go | 152 ++++++++++ backend/modules/eventprocessing/module.go | 4 +- backend/modules/eventprocessing/routes.go | 20 +- backend/modules/iam/dto/bulk_idp.go | 18 ++ backend/modules/iam/handler/bulk_idp.go | 128 +++++++++ backend/modules/iam/handler/bulk_idp_test.go | 40 +++ backend/modules/iam/module.go | 6 +- backend/modules/iam/routes.go | 9 +- backend/modules/soar/dto/bulk.go | 29 ++ backend/modules/soar/handler/bulk.go | 150 ++++++++++ backend/modules/soar/handler/bulk_test.go | 45 +++ backend/modules/soar/module.go | 4 + backend/modules/soar/routes.go | 10 +- backend/pkg/common_models/bulk.go | 33 +++ 30 files changed, 1634 insertions(+), 28 deletions(-) create mode 100644 backend/modules/appconfig/dto/bulk_branding.go create mode 100644 backend/modules/appconfig/dto/bulk_smtp.go create mode 100644 backend/modules/appconfig/handler/bulk_branding.go create mode 100644 backend/modules/appconfig/handler/bulk_branding_test.go create mode 100644 backend/modules/appconfig/handler/bulk_smtp.go create mode 100644 backend/modules/appconfig/handler/bulk_smtp_test.go create mode 100644 backend/modules/compliance/dto/bulk.go create mode 100644 backend/modules/compliance/handler/bulk.go create mode 100644 backend/modules/compliance/handler/bulk_test.go create mode 100644 backend/modules/eventprocessing/dto/bulk_correlation_rule.go create mode 100644 backend/modules/eventprocessing/dto/bulk_pipeline.go create mode 100644 backend/modules/eventprocessing/handler/bulk_correlation_rule.go create mode 100644 backend/modules/eventprocessing/handler/bulk_pipeline.go create mode 100644 backend/modules/iam/dto/bulk_idp.go create mode 100644 backend/modules/iam/handler/bulk_idp.go create mode 100644 backend/modules/iam/handler/bulk_idp_test.go create mode 100644 backend/modules/soar/dto/bulk.go create mode 100644 backend/modules/soar/handler/bulk.go create mode 100644 backend/modules/soar/handler/bulk_test.go create mode 100644 backend/pkg/common_models/bulk.go 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) +} From 789f04fcf4ce0bc1012c7f762279d383b526e98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 12 Aug 2026 14:34:50 -0600 Subject: [PATCH 2/5] fix[frontend](soar,settings,compilance,branding): added system admin bulk operations --- .../alerting-rules/components/rule-drawer.tsx | 73 ++++++ .../features/branding/pages/BrandingPage.tsx | 76 +++++- .../compliance/components/ControlEditor.tsx | 50 +++- .../compliance/components/FrameworkEditor.tsx | 50 +++- .../components/FilterFormDrawer.tsx | 56 ++++- .../pages/ParsingFiltersPage.tsx | 39 ++- .../components/BroadcastDialog.tsx | 227 ++++++++++++++++++ .../components/BroadcastResultPanel.tsx | 61 +++++ .../components/PlatformBroadcastButton.tsx | 68 ++++++ .../hooks/useBroadcastAction.ts | 55 +++++ .../hooks/useTenantsForBroadcast.ts | 63 +++++ .../src/features/platform-broadcast/index.ts | 19 ++ .../services/broadcast-http.service.ts | 112 +++++++++ .../settings/pages/EmailConfigurationPage.tsx | 45 +++- .../settings/pages/IdentityProvidersPage.tsx | 63 +++-- .../features/soar/components/FlowEditor.tsx | 63 ++++- .../src/features/soar/pages/FlowsPage.tsx | 44 ++-- 17 files changed, 1106 insertions(+), 58 deletions(-) create mode 100644 frontend/src/features/platform-broadcast/components/BroadcastDialog.tsx create mode 100644 frontend/src/features/platform-broadcast/components/BroadcastResultPanel.tsx create mode 100644 frontend/src/features/platform-broadcast/components/PlatformBroadcastButton.tsx create mode 100644 frontend/src/features/platform-broadcast/hooks/useBroadcastAction.ts create mode 100644 frontend/src/features/platform-broadcast/hooks/useTenantsForBroadcast.ts create mode 100644 frontend/src/features/platform-broadcast/index.ts create mode 100644 frontend/src/features/platform-broadcast/services/broadcast-http.service.ts 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({ )}
-
+
{!creating && !readOnly && (confirmDelete ? (
@@ -216,6 +217,53 @@ export function ControlEditor({ ) : ( ))} + {creating && ( + { + let f = form + if (mode === 'code') { + const r = yamlToControlForm(yaml) + if (!r.ok) throw new Error(r.error) + f = r.form + } + const payload = formToControl(f) + return broadcast(BULK_PATHS.compliance.controlCreate, selector, payload) + }} + /> + )} + {!creating && !readOnly && ( + <> + { + let f = form + if (mode === 'code') { + const r = yamlToControlForm(yaml) + if (!r.ok) throw new Error(r.error) + f = r.form + } + const payload = formToControl(f) + return broadcast(BULK_PATHS.compliance.controlUpdate, selector, payload) + }} + /> + { + return broadcast(BULK_PATHS.compliance.controlDelete, selector, { id: control!.id }) + }} + /> + + )}
{!readOnly && ( ))} + {creating && ( + { + let f = form + if (mode === 'code') { + const r = yamlToFrameworkForm(yaml) + if (!r.ok) throw new Error(r.error) + f = r.form + } + const payload = formToFramework(f) + return broadcast(BULK_PATHS.compliance.frameworkCreate, selector, payload) + }} + /> + )} + {!creating && !readOnly && ( + <> + { + let f = form + if (mode === 'code') { + const r = yamlToFrameworkForm(yaml) + if (!r.ok) throw new Error(r.error) + f = r.form + } + const payload = formToFramework(f) + return broadcast(BULK_PATHS.compliance.frameworkUpdate, selector, payload) + }} + /> + { + return broadcast(BULK_PATHS.compliance.frameworkDelete, selector, { key: framework!.key }) + }} + /> + + )}
{!readOnly && ( + @@ -267,10 +296,29 @@ export function FilterFormDrawer({ filter, creating, onClose, onSaved }: Props) {t('parsingFilters.editor.test')} {!readOnly && ( - + <> + {creating ? ( + + ) : ( + + )} + + )}
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')} -