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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion backend/modules/datasources/connectors/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
19 changes: 7 additions & 12 deletions backend/modules/datasources/repository/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down Expand Up @@ -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{}).
Expand Down
105 changes: 105 additions & 0 deletions backend/modules/datasources/repository/tenancy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 12 additions & 3 deletions backend/modules/datasources/usecase/asset_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
31 changes: 26 additions & 5 deletions backend/modules/datasources/usecase/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}

Expand All @@ -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
}
Expand Down
Loading
Loading