diff --git a/backend/modules.go b/backend/modules.go index 925766372..c58b4f049 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -212,8 +212,8 @@ func initModules(db *gorm.DB, cfg *config) *modules { dsRepo := ns_repository.NewDatasourceRepository(db) dsGroupRepo := ns_repository.NewAssetGroupRepository(db) - dsUC := ns_usecase.NewDatasourceUsecase(dsRepo, eventProcessingMod.GetTenantConfigUsecase()) - dsGroupUC := ns_usecase.NewAssetGroupUsecase(dsGroupRepo) + dsUC := ns_usecase.NewDatasourceUsecase(dsRepo, dsGroupRepo, eventProcessingMod.GetTenantConfigUsecase()) + dsGroupUC := ns_usecase.NewAssetGroupUsecase(dsGroupRepo, dsRepo) // Discovery from ingestion needs the event store, not OpenSearch: the // statistics it reads moved there with the rest of the pipeline. var dsReconciler *ns_usecase.StatsReconciler diff --git a/backend/modules/datasources/connectors/repository.go b/backend/modules/datasources/connectors/repository.go index 0735d8aff..679abd1fc 100644 --- a/backend/modules/datasources/connectors/repository.go +++ b/backend/modules/datasources/connectors/repository.go @@ -23,7 +23,6 @@ type StatsReader interface { type DatasourceRepository interface { FindByID(ctx context.Context, id uint64) (*domain.Datasource, error) - FindByName(ctx context.Context, name string) (*domain.Datasource, error) List(ctx context.Context, req common_models.IListRequest) (common_models.ListResponse[domain.Datasource], error) Count(ctx context.Context) (int64, error) UpsertBatch(ctx context.Context, items []domain.Datasource) error @@ -33,6 +32,10 @@ type DatasourceRepository interface { UpdateGroup(ctx context.Context, ids []uint64, groupID *uint64) error UpdateLabels(ctx context.Context, id uint64, labels string) error UpdateSensitivity(ctx context.Context, id uint64, conf, integ, avail int) error + // ClearGroup nulls group_id on every datasource pointing at groupID so an + // asset-group delete does not leave dangling references. The tenancy + // callback scopes the write to the caller's tenant. + ClearGroup(ctx context.Context, groupID uint64) error ListSensitive(ctx context.Context) ([]domain.Datasource, error) Delete(ctx context.Context, id uint64) error } diff --git a/backend/modules/datasources/repository/datasource.go b/backend/modules/datasources/repository/datasource.go index a97264c63..d611534a1 100644 --- a/backend/modules/datasources/repository/datasource.go +++ b/backend/modules/datasources/repository/datasource.go @@ -42,18 +42,6 @@ func (r *pgDatasourceRepository) FindByID(ctx context.Context, id uint64) (*doma return &d, nil } -func (r *pgDatasourceRepository) FindByName(ctx context.Context, name string) (*domain.Datasource, error) { - var d domain.Datasource - err := r.db.FindOne(ctx, &d, database.Where("asset_name = ?", name), database.Preload("Group")) - if errors.Is(err, database.ErrNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &d, nil -} - func (r *pgDatasourceRepository) List(ctx context.Context, req common_models.IListRequest) (common_models.ListResponse[domain.Datasource], error) { return r.GetAll(ctx, req, datasourceFilterFields, "id DESC", database.Preload("Group")) } @@ -151,6 +139,13 @@ func (r *pgDatasourceRepository) UpdateGroup(ctx context.Context, ids []uint64, Update("group_id", groupID).Error } +func (r *pgDatasourceRepository) ClearGroup(ctx context.Context, groupID uint64) error { + return r.db.GORM().WithContext(ctx). + Model(&domain.Datasource{}). + Where("group_id = ?", groupID). + Update("group_id", nil).Error +} + func (r *pgDatasourceRepository) UpdateLabels(ctx context.Context, id uint64, labels string) error { return r.db.GORM().WithContext(ctx). Model(&domain.Datasource{}). diff --git a/backend/modules/datasources/repository/tenancy_test.go b/backend/modules/datasources/repository/tenancy_test.go new file mode 100644 index 000000000..66a9e05a3 --- /dev/null +++ b/backend/modules/datasources/repository/tenancy_test.go @@ -0,0 +1,105 @@ +package repository + +import ( + "context" + "strings" + "testing" + + "gorm.io/gorm" + "gorm.io/gorm/utils/tests" + + "github.com/utmstack/utmstack/backend/modules/datasources/domain" + "github.com/utmstack/utmstack/backend/pkg/authz" + "github.com/utmstack/utmstack/backend/pkg/database" + "github.com/utmstack/utmstack/backend/pkg/tenancy" +) + +const tenantA = "8f1c1b8e-0000-4000-8000-000000000001" + +// newMultiTenantDB matches what modules.go wires when the licence is MSSP: +// tenancy callbacks are registered and Enabled() reports true, so scoped reads +// with no tenant must fail. +func newMultiTenantDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("gorm.Open: %v", err) + } + if err := tenancy.Register(db, func() bool { return true }); err != nil { + t.Fatalf("tenancy.Register: %v", err) + } + return db +} + +// The tenancy callback must add `tenant_id = ?` to reads on the datasources +// table when the caller carries a tenant. If it doesn't, the module leaks +// every tenant's rows to every other. +func TestCallbackScopesReadsByTenant(t *testing.T) { + db := newMultiTenantDB(t) + ctx := authz.WithTenantID(context.Background(), tenantA) + + stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx). + Model(&domain.Datasource{}). + Find(&[]domain.Datasource{}).Statement + + if !strings.Contains(stmt.SQL.String(), "tenant_id") { + t.Fatalf("no tenant_id predicate in %q", stmt.SQL.String()) + } +} + +// A read with no tenant on a multi-tenant instance must fail rather than span +// every tenant. This is the guard rail that catches missed handlers. +func TestReadWithoutTenantFails(t *testing.T) { + db := newMultiTenantDB(t) + stmt := db.Session(&gorm.Session{DryRun: true}). + Model(&domain.Datasource{}). + Find(&[]domain.Datasource{}).Statement + + if stmt.Error == nil { + t.Fatal("a read with no tenant returned no error") + } +} + +// WithAllTenantsRead is what Enrichment uses to feed the alert plugin cache +// with every tenant's rows — the caller opts out of scoping, and the +// callback must respect it. +func TestWithAllTenantsReadSkipsScoping(t *testing.T) { + db := newMultiTenantDB(t) + ctx := tenancy.WithAllTenantsRead(authz.WithTenantID(context.Background(), tenantA)) + + stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx). + Model(&domain.Datasource{}). + Where("group_id IS NOT NULL OR (labels IS NOT NULL AND labels <> '')"). + Find(&[]domain.Datasource{}).Statement + + if strings.Contains(stmt.SQL.String(), "tenant_id") { + t.Fatalf("tenant_id predicate leaked into a WithAllTenantsRead query: %q", stmt.SQL.String()) + } +} + +// The upsert paths (Ping and reconciler) run WithAllTenants because their +// batches span tenants and each row carries its own tenant. Assert that +// stamping still fires on WithAllTenants: rows arriving with an empty tenant +// are the caller's mistake and must not silently land unscoped. +func TestClearGroupScopesToTenant(t *testing.T) { + db := newMultiTenantDB(t) + provider := database.New(db) + r := &pgDatasourceRepository{db: provider} + + ctx := authz.WithTenantID(context.Background(), tenantA) + _ = r.ClearGroup(ctx, 42) + + stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx). + Model(&domain.Datasource{}). + Where("group_id = ?", 42). + Update("group_id", nil).Statement + + sql := stmt.SQL.String() + if !strings.Contains(sql, "tenant_id") { + t.Fatalf("ClearGroup update missing tenant_id predicate: %q", sql) + } + if !strings.Contains(sql, "group_id") { + t.Fatalf("ClearGroup update missing group_id predicate: %q", sql) + } +} diff --git a/backend/modules/datasources/usecase/asset_group.go b/backend/modules/datasources/usecase/asset_group.go index 6de45db70..5085fbfee 100644 --- a/backend/modules/datasources/usecase/asset_group.go +++ b/backend/modules/datasources/usecase/asset_group.go @@ -11,11 +11,12 @@ import ( ) type assetGroupUsecase struct { - repo connectors.AssetGroupRepository + repo connectors.AssetGroupRepository + datasources connectors.DatasourceRepository // for clearing dangling group_id on delete } -func NewAssetGroupUsecase(repo connectors.AssetGroupRepository) connectors.AssetGroupUsecase { - return &assetGroupUsecase{repo: repo} +func NewAssetGroupUsecase(repo connectors.AssetGroupRepository, datasources connectors.DatasourceRepository) connectors.AssetGroupUsecase { + return &assetGroupUsecase{repo: repo, datasources: datasources} } func (u *assetGroupUsecase) Create(ctx context.Context, g *domain.UtmAssetGroup) (*domain.UtmAssetGroup, error) { @@ -57,5 +58,13 @@ func (u *assetGroupUsecase) List(ctx context.Context, req common_models.IListReq } func (u *assetGroupUsecase) Delete(ctx context.Context, id uint64) error { + // Clear the FK first: there is no ON DELETE SET NULL, so a group with + // datasources attached would leave them pointing nowhere. The clear is + // tenant-scoped by the callback, so another tenant's rows are untouched. + if u.datasources != nil { + if err := u.datasources.ClearGroup(ctx, id); err != nil { + return err + } + } return u.repo.Delete(ctx, id) } diff --git a/backend/modules/datasources/usecase/datasource.go b/backend/modules/datasources/usecase/datasource.go index dcbf3dc36..3e2f13f0b 100644 --- a/backend/modules/datasources/usecase/datasource.go +++ b/backend/modules/datasources/usecase/datasource.go @@ -15,11 +15,12 @@ import ( type datasourceUsecase struct { repo connectors.DatasourceRepository - projector connectors.AssetProjector // may be nil (projection disabled) + groups connectors.AssetGroupRepository // nil means UpdateGroup skips cross-tenant validation + projector connectors.AssetProjector // may be nil (projection disabled) } -func NewDatasourceUsecase(repo connectors.DatasourceRepository, projector connectors.AssetProjector) connectors.DatasourceUsecase { - return &datasourceUsecase{repo: repo, projector: projector} +func NewDatasourceUsecase(repo connectors.DatasourceRepository, groups connectors.AssetGroupRepository, projector connectors.AssetProjector) connectors.DatasourceUsecase { + return &datasourceUsecase{repo: repo, groups: groups, projector: projector} } func (u *datasourceUsecase) GetByID(ctx context.Context, id uint64) (*dto.DatasourceDTO, error) { @@ -103,8 +104,11 @@ func (u *datasourceUsecase) Register(ctx context.Context, req dto.RegisterReques return u.repo.RegisterBatch(ctx, []domain.Datasource{item}) } +// Enrichment feeds the alert plugin cache, which needs every tenant's rows +// (each carries its own TenantID). Scoped to the caller's tenant it would +// return only their slice and quietly drop the rest. func (u *datasourceUsecase) Enrichment(ctx context.Context) ([]dto.DatasourceEnrichment, error) { - rows, err := u.repo.EnrichmentRows(ctx) + rows, err := u.repo.EnrichmentRows(tenancy.WithAllTenantsRead(ctx)) if err != nil { return nil, err } @@ -142,6 +146,20 @@ func splitLabels(labels string) []string { } func (u *datasourceUsecase) UpdateGroup(ctx context.Context, req dto.UpdateGroupRequest) error { + // Cross-tenant guard: the datasource update itself is scoped by the tenancy + // callback, but the group_id is a raw uint64 from the client — nothing stops + // tenant A from pointing their datasources at tenant B's group. The group + // lookup is tenant-scoped by the same callback, so a miss means it either + // does not exist or belongs to someone else. + if req.GroupID != nil && u.groups != nil { + g, err := u.groups.FindByID(ctx, *req.GroupID) + if err != nil { + return err + } + if g == nil { + return domain.ErrNotFound + } + } return u.repo.UpdateGroup(ctx, req.IDs, req.GroupID) } @@ -166,11 +184,14 @@ func (u *datasourceUsecase) Delete(ctx context.Context, id uint64) error { } // ProjectAssets rebuilds tenants.yaml from every datasource with non-zero CIA. +// The read spans tenants because the projector writes one shared file: scoped +// to the caller's tenant, an UpdateSensitivity or Delete on tenant A would +// rewrite the file with only A's assets and wipe every other tenant's. func (u *datasourceUsecase) ProjectAssets(ctx context.Context) error { if u.projector == nil { return nil } - rows, err := u.repo.ListSensitive(ctx) + rows, err := u.repo.ListSensitive(tenancy.WithAllTenants(ctx)) if err != nil { return err } diff --git a/backend/modules/datasources/usecase/tenancy_test.go b/backend/modules/datasources/usecase/tenancy_test.go new file mode 100644 index 000000000..442f5024c --- /dev/null +++ b/backend/modules/datasources/usecase/tenancy_test.go @@ -0,0 +1,183 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "github.com/utmstack/utmstack/backend/modules/datasources/connectors" + "github.com/utmstack/utmstack/backend/modules/datasources/domain" + "github.com/utmstack/utmstack/backend/modules/datasources/dto" + "github.com/utmstack/utmstack/backend/pkg/common_models" + "github.com/utmstack/utmstack/backend/pkg/tenancy" +) + +// tenancyFakeDSRepo captures how the usecase calls the repository so we can +// assert scoping without spinning up a database. It embeds the interface to +// keep the surface minimal — only what tests exercise is overridden. +type tenancyFakeDSRepo struct { + connectors.DatasourceRepository + + updatedGroupIDs []uint64 + updatedGroup *uint64 + updateCalled bool + + clearedGroup uint64 + clearCalled bool + + enrichmentAllRead bool + sensitiveAllScope bool +} + +func (f *tenancyFakeDSRepo) UpdateGroup(_ context.Context, ids []uint64, groupID *uint64) error { + f.updateCalled = true + f.updatedGroupIDs = ids + f.updatedGroup = groupID + return nil +} + +func (f *tenancyFakeDSRepo) ClearGroup(_ context.Context, groupID uint64) error { + f.clearCalled = true + f.clearedGroup = groupID + return nil +} + +func (f *tenancyFakeDSRepo) EnrichmentRows(ctx context.Context) ([]domain.Datasource, error) { + f.enrichmentAllRead = tenancy.ReadsAllTenants(ctx) + return nil, nil +} + +func (f *tenancyFakeDSRepo) ListSensitive(ctx context.Context) ([]domain.Datasource, error) { + f.sensitiveAllScope = tenancy.SpansAllTenants(ctx) + return nil, nil +} + +type tenancyFakeGroupRepo struct { + connectors.AssetGroupRepository + + // findResult keys on the id — a nil result models "belongs to another + // tenant" (the tenancy callback would filter it out). + findResult map[uint64]*domain.UtmAssetGroup +} + +func (f *tenancyFakeGroupRepo) FindByID(_ context.Context, id uint64) (*domain.UtmAssetGroup, error) { + return f.findResult[id], nil +} + +// UpdateGroup must reject a groupID the caller cannot see. Under the tenancy +// callback the group lookup already scopes by tenant, so a nil result means +// the group either does not exist or belongs to someone else — either way, +// stamping datasources with it is wrong. +func TestUpdateGroupRejectsCrossTenantGroup(t *testing.T) { + repo := &tenancyFakeDSRepo{} + groups := &tenancyFakeGroupRepo{findResult: map[uint64]*domain.UtmAssetGroup{}} // no matches + u := &datasourceUsecase{repo: repo, groups: groups} + + groupID := uint64(99) + err := u.UpdateGroup(context.Background(), dto.UpdateGroupRequest{IDs: []uint64{1, 2}, GroupID: &groupID}) + if !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("UpdateGroup with cross-tenant group returned %v, want ErrNotFound", err) + } + if repo.updateCalled { + t.Fatal("UpdateGroup wrote datasources despite an invalid group") + } +} + +// A groupID the caller owns must pass through unchanged. +func TestUpdateGroupAcceptsOwnedGroup(t *testing.T) { + groupID := uint64(7) + repo := &tenancyFakeDSRepo{} + groups := &tenancyFakeGroupRepo{findResult: map[uint64]*domain.UtmAssetGroup{ + groupID: {ID: groupID, TenantID: "tenantA"}, + }} + u := &datasourceUsecase{repo: repo, groups: groups} + + if err := u.UpdateGroup(context.Background(), dto.UpdateGroupRequest{IDs: []uint64{1}, GroupID: &groupID}); err != nil { + t.Fatalf("UpdateGroup returned %v", err) + } + if !repo.updateCalled { + t.Fatal("UpdateGroup did not delegate to the repo") + } + if repo.updatedGroup == nil || *repo.updatedGroup != groupID { + t.Fatalf("repo got groupID = %v, want %d", repo.updatedGroup, groupID) + } +} + +// Clearing the group (groupID == nil) is always allowed — no lookup needed. +func TestUpdateGroupClearingSkipsValidation(t *testing.T) { + repo := &tenancyFakeDSRepo{} + // Deliberately no groups repo — the usecase must not need one to clear. + u := &datasourceUsecase{repo: repo, groups: nil} + + if err := u.UpdateGroup(context.Background(), dto.UpdateGroupRequest{IDs: []uint64{1}, GroupID: nil}); err != nil { + t.Fatalf("UpdateGroup(nil) returned %v", err) + } + if !repo.updateCalled { + t.Fatal("UpdateGroup(nil) did not delegate to the repo") + } +} + +// Enrichment is served to the alert plugin cache, which needs every tenant's +// rows. Scoped to the caller's tenant it would return only their slice. +func TestEnrichmentReadsAllTenants(t *testing.T) { + repo := &tenancyFakeDSRepo{} + u := &datasourceUsecase{repo: repo} + + if _, err := u.Enrichment(context.Background()); err != nil { + t.Fatalf("Enrichment: %v", err) + } + if !repo.enrichmentAllRead { + t.Fatal("Enrichment did not opt into WithAllTenantsRead") + } +} + +// ProjectAssets rewrites a shared tenants.yaml. Scoped to the caller's tenant +// an UpdateSensitivity or Delete would overwrite the file with only that +// tenant's assets and wipe every other tenant's. +func TestProjectAssetsSpansAllTenants(t *testing.T) { + repo := &tenancyFakeDSRepo{} + u := &datasourceUsecase{repo: repo, projector: nopProjector{}} + + if err := u.ProjectAssets(context.Background()); err != nil { + t.Fatalf("ProjectAssets: %v", err) + } + if !repo.sensitiveAllScope { + t.Fatal("ProjectAssets read sensitive rows scoped to one tenant") + } +} + +type nopProjector struct{} + +func (nopProjector) ProjectAssets([]common_models.AssetSensitivity) error { return nil } + +// Deleting a group must clear datasource.group_id — postgres has no +// ON DELETE SET NULL, so a raw delete leaves dangling references. +func TestAssetGroupDeleteClearsDangling(t *testing.T) { + dsRepo := &tenancyFakeDSRepo{} + groupRepo := &tenancyFakeGroupRepoWithDelete{} + u := &assetGroupUsecase{repo: groupRepo, datasources: dsRepo} + + if err := u.Delete(context.Background(), 42); err != nil { + t.Fatalf("Delete: %v", err) + } + if !dsRepo.clearCalled { + t.Fatal("Delete did not clear datasource.group_id") + } + if dsRepo.clearedGroup != 42 { + t.Fatalf("cleared groupID = %d, want 42", dsRepo.clearedGroup) + } + if !groupRepo.deleteCalled { + t.Fatal("Delete did not remove the group row") + } +} + +type tenancyFakeGroupRepoWithDelete struct { + connectors.AssetGroupRepository + + deleteCalled bool +} + +func (f *tenancyFakeGroupRepoWithDelete) Delete(_ context.Context, _ uint64) error { + f.deleteCalled = true + return nil +}