Skip to content

Commit d9bfe54

Browse files
committed
Merge branch 'release/v12.0.0' of https://github.com/utmstack/UTMStack into release/v12.0.0
2 parents 3c0741f + 25a4f9c commit d9bfe54

26 files changed

Lines changed: 556 additions & 179 deletions

backend/modules.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,8 @@ func initModules(db *gorm.DB, cfg *config) *modules {
191191

192192
dsRepo := ns_repository.NewDatasourceRepository(db)
193193
dsGroupRepo := ns_repository.NewAssetGroupRepository(db)
194-
dsUC := ns_usecase.NewDatasourceUsecase(dsRepo, eventProcessingMod.GetTenantConfigUsecase())
195-
dsGroupUC := ns_usecase.NewAssetGroupUsecase(dsGroupRepo)
194+
dsUC := ns_usecase.NewDatasourceUsecase(dsRepo, dsGroupRepo, eventProcessingMod.GetTenantConfigUsecase())
195+
dsGroupUC := ns_usecase.NewAssetGroupUsecase(dsGroupRepo, dsRepo)
196196
// Discovery from ingestion needs the event store, not OpenSearch: the
197197
// statistics it reads moved there with the rest of the pipeline.
198198
var dsReconciler *ns_usecase.StatsReconciler

backend/modules/datasources/connectors/repository.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ type StatsReader interface {
2323

2424
type DatasourceRepository interface {
2525
FindByID(ctx context.Context, id uint64) (*domain.Datasource, error)
26-
FindByName(ctx context.Context, name string) (*domain.Datasource, error)
2726
List(ctx context.Context, req common_models.IListRequest) (common_models.ListResponse[domain.Datasource], error)
2827
Count(ctx context.Context) (int64, error)
2928
UpsertBatch(ctx context.Context, items []domain.Datasource) error
@@ -33,6 +32,10 @@ type DatasourceRepository interface {
3332
UpdateGroup(ctx context.Context, ids []uint64, groupID *uint64) error
3433
UpdateLabels(ctx context.Context, id uint64, labels string) error
3534
UpdateSensitivity(ctx context.Context, id uint64, conf, integ, avail int) error
35+
// ClearGroup nulls group_id on every datasource pointing at groupID so an
36+
// asset-group delete does not leave dangling references. The tenancy
37+
// callback scopes the write to the caller's tenant.
38+
ClearGroup(ctx context.Context, groupID uint64) error
3639
ListSensitive(ctx context.Context) ([]domain.Datasource, error)
3740
Delete(ctx context.Context, id uint64) error
3841
}

backend/modules/datasources/repository/datasource.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,6 @@ func (r *pgDatasourceRepository) FindByID(ctx context.Context, id uint64) (*doma
4242
return &d, nil
4343
}
4444

45-
func (r *pgDatasourceRepository) FindByName(ctx context.Context, name string) (*domain.Datasource, error) {
46-
var d domain.Datasource
47-
err := r.db.FindOne(ctx, &d, database.Where("asset_name = ?", name), database.Preload("Group"))
48-
if errors.Is(err, database.ErrNotFound) {
49-
return nil, nil
50-
}
51-
if err != nil {
52-
return nil, err
53-
}
54-
return &d, nil
55-
}
56-
5745
func (r *pgDatasourceRepository) List(ctx context.Context, req common_models.IListRequest) (common_models.ListResponse[domain.Datasource], error) {
5846
return r.GetAll(ctx, req, datasourceFilterFields, "id DESC", database.Preload("Group"))
5947
}
@@ -151,6 +139,13 @@ func (r *pgDatasourceRepository) UpdateGroup(ctx context.Context, ids []uint64,
151139
Update("group_id", groupID).Error
152140
}
153141

142+
func (r *pgDatasourceRepository) ClearGroup(ctx context.Context, groupID uint64) error {
143+
return r.db.GORM().WithContext(ctx).
144+
Model(&domain.Datasource{}).
145+
Where("group_id = ?", groupID).
146+
Update("group_id", nil).Error
147+
}
148+
154149
func (r *pgDatasourceRepository) UpdateLabels(ctx context.Context, id uint64, labels string) error {
155150
return r.db.GORM().WithContext(ctx).
156151
Model(&domain.Datasource{}).
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package repository
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"gorm.io/gorm"
9+
"gorm.io/gorm/utils/tests"
10+
11+
"github.com/utmstack/utmstack/backend/modules/datasources/domain"
12+
"github.com/utmstack/utmstack/backend/pkg/authz"
13+
"github.com/utmstack/utmstack/backend/pkg/database"
14+
"github.com/utmstack/utmstack/backend/pkg/tenancy"
15+
)
16+
17+
const tenantA = "8f1c1b8e-0000-4000-8000-000000000001"
18+
19+
// newMultiTenantDB matches what modules.go wires when the licence is MSSP:
20+
// tenancy callbacks are registered and Enabled() reports true, so scoped reads
21+
// with no tenant must fail.
22+
func newMultiTenantDB(t *testing.T) *gorm.DB {
23+
t.Helper()
24+
25+
db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
26+
if err != nil {
27+
t.Fatalf("gorm.Open: %v", err)
28+
}
29+
if err := tenancy.Register(db, func() bool { return true }); err != nil {
30+
t.Fatalf("tenancy.Register: %v", err)
31+
}
32+
return db
33+
}
34+
35+
// The tenancy callback must add `tenant_id = ?` to reads on the datasources
36+
// table when the caller carries a tenant. If it doesn't, the module leaks
37+
// every tenant's rows to every other.
38+
func TestCallbackScopesReadsByTenant(t *testing.T) {
39+
db := newMultiTenantDB(t)
40+
ctx := authz.WithTenantID(context.Background(), tenantA)
41+
42+
stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx).
43+
Model(&domain.Datasource{}).
44+
Find(&[]domain.Datasource{}).Statement
45+
46+
if !strings.Contains(stmt.SQL.String(), "tenant_id") {
47+
t.Fatalf("no tenant_id predicate in %q", stmt.SQL.String())
48+
}
49+
}
50+
51+
// A read with no tenant on a multi-tenant instance must fail rather than span
52+
// every tenant. This is the guard rail that catches missed handlers.
53+
func TestReadWithoutTenantFails(t *testing.T) {
54+
db := newMultiTenantDB(t)
55+
stmt := db.Session(&gorm.Session{DryRun: true}).
56+
Model(&domain.Datasource{}).
57+
Find(&[]domain.Datasource{}).Statement
58+
59+
if stmt.Error == nil {
60+
t.Fatal("a read with no tenant returned no error")
61+
}
62+
}
63+
64+
// WithAllTenantsRead is what Enrichment uses to feed the alert plugin cache
65+
// with every tenant's rows — the caller opts out of scoping, and the
66+
// callback must respect it.
67+
func TestWithAllTenantsReadSkipsScoping(t *testing.T) {
68+
db := newMultiTenantDB(t)
69+
ctx := tenancy.WithAllTenantsRead(authz.WithTenantID(context.Background(), tenantA))
70+
71+
stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx).
72+
Model(&domain.Datasource{}).
73+
Where("group_id IS NOT NULL OR (labels IS NOT NULL AND labels <> '')").
74+
Find(&[]domain.Datasource{}).Statement
75+
76+
if strings.Contains(stmt.SQL.String(), "tenant_id") {
77+
t.Fatalf("tenant_id predicate leaked into a WithAllTenantsRead query: %q", stmt.SQL.String())
78+
}
79+
}
80+
81+
// The upsert paths (Ping and reconciler) run WithAllTenants because their
82+
// batches span tenants and each row carries its own tenant. Assert that
83+
// stamping still fires on WithAllTenants: rows arriving with an empty tenant
84+
// are the caller's mistake and must not silently land unscoped.
85+
func TestClearGroupScopesToTenant(t *testing.T) {
86+
db := newMultiTenantDB(t)
87+
provider := database.New(db)
88+
r := &pgDatasourceRepository{db: provider}
89+
90+
ctx := authz.WithTenantID(context.Background(), tenantA)
91+
_ = r.ClearGroup(ctx, 42)
92+
93+
stmt := db.Session(&gorm.Session{DryRun: true}).WithContext(ctx).
94+
Model(&domain.Datasource{}).
95+
Where("group_id = ?", 42).
96+
Update("group_id", nil).Statement
97+
98+
sql := stmt.SQL.String()
99+
if !strings.Contains(sql, "tenant_id") {
100+
t.Fatalf("ClearGroup update missing tenant_id predicate: %q", sql)
101+
}
102+
if !strings.Contains(sql, "group_id") {
103+
t.Fatalf("ClearGroup update missing group_id predicate: %q", sql)
104+
}
105+
}

backend/modules/datasources/usecase/asset_group.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import (
1111
)
1212

1313
type assetGroupUsecase struct {
14-
repo connectors.AssetGroupRepository
14+
repo connectors.AssetGroupRepository
15+
datasources connectors.DatasourceRepository // for clearing dangling group_id on delete
1516
}
1617

17-
func NewAssetGroupUsecase(repo connectors.AssetGroupRepository) connectors.AssetGroupUsecase {
18-
return &assetGroupUsecase{repo: repo}
18+
func NewAssetGroupUsecase(repo connectors.AssetGroupRepository, datasources connectors.DatasourceRepository) connectors.AssetGroupUsecase {
19+
return &assetGroupUsecase{repo: repo, datasources: datasources}
1920
}
2021

2122
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
5758
}
5859

5960
func (u *assetGroupUsecase) Delete(ctx context.Context, id uint64) error {
61+
// Clear the FK first: there is no ON DELETE SET NULL, so a group with
62+
// datasources attached would leave them pointing nowhere. The clear is
63+
// tenant-scoped by the callback, so another tenant's rows are untouched.
64+
if u.datasources != nil {
65+
if err := u.datasources.ClearGroup(ctx, id); err != nil {
66+
return err
67+
}
68+
}
6069
return u.repo.Delete(ctx, id)
6170
}

backend/modules/datasources/usecase/datasource.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ import (
1515

1616
type datasourceUsecase struct {
1717
repo connectors.DatasourceRepository
18-
projector connectors.AssetProjector // may be nil (projection disabled)
18+
groups connectors.AssetGroupRepository // nil means UpdateGroup skips cross-tenant validation
19+
projector connectors.AssetProjector // may be nil (projection disabled)
1920
}
2021

21-
func NewDatasourceUsecase(repo connectors.DatasourceRepository, projector connectors.AssetProjector) connectors.DatasourceUsecase {
22-
return &datasourceUsecase{repo: repo, projector: projector}
22+
func NewDatasourceUsecase(repo connectors.DatasourceRepository, groups connectors.AssetGroupRepository, projector connectors.AssetProjector) connectors.DatasourceUsecase {
23+
return &datasourceUsecase{repo: repo, groups: groups, projector: projector}
2324
}
2425

2526
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
103104
return u.repo.RegisterBatch(ctx, []domain.Datasource{item})
104105
}
105106

107+
// Enrichment feeds the alert plugin cache, which needs every tenant's rows
108+
// (each carries its own TenantID). Scoped to the caller's tenant it would
109+
// return only their slice and quietly drop the rest.
106110
func (u *datasourceUsecase) Enrichment(ctx context.Context) ([]dto.DatasourceEnrichment, error) {
107-
rows, err := u.repo.EnrichmentRows(ctx)
111+
rows, err := u.repo.EnrichmentRows(tenancy.WithAllTenantsRead(ctx))
108112
if err != nil {
109113
return nil, err
110114
}
@@ -142,6 +146,20 @@ func splitLabels(labels string) []string {
142146
}
143147

144148
func (u *datasourceUsecase) UpdateGroup(ctx context.Context, req dto.UpdateGroupRequest) error {
149+
// Cross-tenant guard: the datasource update itself is scoped by the tenancy
150+
// callback, but the group_id is a raw uint64 from the client — nothing stops
151+
// tenant A from pointing their datasources at tenant B's group. The group
152+
// lookup is tenant-scoped by the same callback, so a miss means it either
153+
// does not exist or belongs to someone else.
154+
if req.GroupID != nil && u.groups != nil {
155+
g, err := u.groups.FindByID(ctx, *req.GroupID)
156+
if err != nil {
157+
return err
158+
}
159+
if g == nil {
160+
return domain.ErrNotFound
161+
}
162+
}
145163
return u.repo.UpdateGroup(ctx, req.IDs, req.GroupID)
146164
}
147165

@@ -166,11 +184,14 @@ func (u *datasourceUsecase) Delete(ctx context.Context, id uint64) error {
166184
}
167185

168186
// ProjectAssets rebuilds tenants.yaml from every datasource with non-zero CIA.
187+
// The read spans tenants because the projector writes one shared file: scoped
188+
// to the caller's tenant, an UpdateSensitivity or Delete on tenant A would
189+
// rewrite the file with only A's assets and wipe every other tenant's.
169190
func (u *datasourceUsecase) ProjectAssets(ctx context.Context) error {
170191
if u.projector == nil {
171192
return nil
172193
}
173-
rows, err := u.repo.ListSensitive(ctx)
194+
rows, err := u.repo.ListSensitive(tenancy.WithAllTenants(ctx))
174195
if err != nil {
175196
return err
176197
}

0 commit comments

Comments
 (0)