From fd0f39017f7b64b8077002cd7739589ec03fb9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 20 May 2026 14:24:01 +0300 Subject: [PATCH 01/11] fix: delete database instance record if task queue enqueue fails to prevent orphaned records --- internal/core/services/instance.go | 8 ++ internal/core/services/instance_test.go | 109 ++++++++++++++++++- internal/core/services/instance_unit_test.go | 87 +++++++++++++++ 3 files changed, 200 insertions(+), 4 deletions(-) diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index 43133992f..5d42af005 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -246,6 +246,10 @@ func (s *InstanceService) LaunchInstance(ctx context.Context, params ports.Launc // Rollback quota reservation on enqueue failure _ = s.tenantSvc.DecrementUsage(ctx, tenantID, "vcpus", it.VCPUs) _ = s.tenantSvc.DecrementUsage(ctx, tenantID, "memory", it.MemoryMB/1024) + // Rollback database record creation + if delErr := s.repo.Delete(ctx, inst.ID); delErr != nil { + s.logger.Error("failed to delete instance record after enqueue failure", "instance_id", inst.ID, "error", delErr) + } return nil, errors.Wrap(errors.Internal, "failed to enqueue provisioning task", err) } @@ -313,6 +317,10 @@ func (s *InstanceService) LaunchInstanceWithOptions(ctx context.Context, opts po if err := s.taskQueue.Enqueue(ctx, "provision_queue", job); err != nil { s.logger.Error("failed to enqueue provision job", "instance_id", inst.ID, "error", err) + // Rollback database record creation + if delErr := s.repo.Delete(ctx, inst.ID); delErr != nil { + s.logger.Error("failed to delete instance record after enqueue failure", "instance_id", inst.ID, "error", delErr) + } return nil, errors.Wrap(errors.Internal, "failed to enqueue provisioning task", err) } diff --git a/internal/core/services/instance_test.go b/internal/core/services/instance_test.go index fe1b8b789..cf75af900 100644 --- a/internal/core/services/instance_test.go +++ b/internal/core/services/instance_test.go @@ -47,13 +47,17 @@ func (r *FaultyInstanceRepository) Create(ctx context.Context, instance *domain. } type InMemoryTaskQueue struct { - jobs []string - mu sync.Mutex + jobs []string + mu sync.Mutex + ShouldFail bool } func (q *InMemoryTaskQueue) Enqueue(ctx context.Context, queueName string, payload interface{}) error { q.mu.Lock() defer q.mu.Unlock() + if q.ShouldFail { + return fmt.Errorf("simulated enqueue failure") + } q.jobs = append(q.jobs, fmt.Sprintf("%v", payload)) return nil } @@ -334,7 +338,7 @@ func TestInstanceServiceLaunchDBFailure(t *testing.T) { assert.Contains(t, err.Error(), "simulated database failure") // Verify no junk in DB (using real repo to check) - list, err := realRepo.List(ctx) + list, err := realRepo.List(ctx, nil) require.NoError(t, err) assert.Empty(t, list) } @@ -413,7 +417,7 @@ func TestInstanceServiceLaunchConcurrency(t *testing.T) { } // Verify all created - list, err := repo.List(ctx) + list, err := repo.List(ctx, nil) require.NoError(t, err) assert.Len(t, list, concurrency) @@ -709,3 +713,100 @@ func TestLaunchInstanceWithOptions(t *testing.T) { _ = compute.DeleteInstance(ctx, inst.ContainerID) } } + +func TestInstanceServiceLaunchEnqueueFailure(t *testing.T) { + db := setupDB(t) + ctx := setupTestUser(t, db) + + repo := postgres.NewInstanceRepository(db) + vpcRepo := postgres.NewVpcRepository(db) + subnetRepo := postgres.NewSubnetRepository(db) + volumeRepo := postgres.NewVolumeRepository(db) + itRepo := postgres.NewInstanceTypeRepository(db) + + compute := noop.NewNoopComputeBackend() + + defaultType := &domain.InstanceType{ID: testInstanceType, Name: "Basic 2", VCPUs: 1, MemoryMB: 128, DiskGB: 1} + _, _ = itRepo.Create(ctx, defaultType) + + rbacSvc := new(MockRBACService) + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + eventSvc := services.NewEventService(services.EventServiceParams{ + Repo: postgres.NewEventRepository(db), + RBACSvc: rbacSvc, + Publisher: nil, + Logger: slog.Default(), + }) + auditSvc := services.NewAuditService(services.AuditServiceParams{ + Repo: postgres.NewAuditRepository(db), + RBACSvc: rbacSvc, + }) + + // Create a task queue that fails + taskQueue := &InMemoryTaskQueue{ShouldFail: true} + + sshKeySvc, err := services.NewSSHKeyService(services.SSHKeyServiceParams{ + Repo: postgres.NewSSHKeyRepo(db), + RBACSvc: rbacSvc, + }) + require.NoError(t, err) + + tenantSvc := services.NewTenantService(services.TenantServiceParams{ + Repo: postgres.NewTenantRepo(db), + UserRepo: postgres.NewUserRepo(db), + RBACSvc: rbacSvc, + Logger: slog.Default(), + }) + + svc := services.NewInstanceService(services.InstanceServiceParams{ + Repo: repo, + VpcRepo: vpcRepo, + SubnetRepo: subnetRepo, + VolumeRepo: volumeRepo, + InstanceTypeRepo: itRepo, + RBAC: rbacSvc, + Compute: compute, + EventSvc: eventSvc, + AuditSvc: auditSvc, + TaskQueue: taskQueue, + Logger: slog.Default(), + TenantSvc: tenantSvc, + SSHKeySvc: sshKeySvc, + }) + + // Attempt LaunchInstance + name := "enqueue-fail-integration" + _, err = svc.LaunchInstance(ctx, coreports.LaunchParams{ + Name: name, + Image: testImage, + InstanceType: testInstanceType, + }) + + // Verify Failure + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + // Verify instance not in DB + inst, err := repo.GetByName(ctx, name) + require.Error(t, err) + assert.Nil(t, inst) + + // Attempt LaunchInstanceWithOptions + optsName := "enqueue-fail-opts-integration" + opts := coreports.CreateInstanceOptions{ + Name: optsName, + ImageName: testImage, + } + _, err = svc.LaunchInstanceWithOptions(ctx, opts) + + // Verify Failure + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + // Verify instance not in DB + instOpts, err := repo.GetByName(ctx, optsName) + require.Error(t, err) + assert.Nil(t, instOpts) +} + diff --git a/internal/core/services/instance_unit_test.go b/internal/core/services/instance_unit_test.go index 003564842..946d986f7 100644 --- a/internal/core/services/instance_unit_test.go +++ b/internal/core/services/instance_unit_test.go @@ -220,6 +220,93 @@ func testInstanceServiceLaunchInstanceUnit(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "quota exceeded") }) + + t.Run("EnqueueFailure", func(t *testing.T) { + params := ports.LaunchParams{ + Name: "enqueue-fail-inst", + Image: "alpine", + InstanceType: "t2.micro", + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + typeRepo.On("GetByID", mock.Anything, "t2.micro").Return(&domain.InstanceType{ + ID: "t2.micro", VCPUs: 1, MemoryMB: 1024, + }, nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "instances", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == params.Name && i.UserID == userID + })).Return(nil).Once() + + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(nil).Once() + + _, err := svc.LaunchInstance(ctx, params) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + repo.AssertExpectations(t) + tenantSvc.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + + t.Run("LaunchInstanceWithOptions_Success", func(t *testing.T) { + opts := ports.CreateInstanceOptions{ + Name: "opts-success", + ImageName: "alpine", + Ports: []string{"80:80"}, + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + return i.Name == opts.Name + })).Return(nil).Once() + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(nil).Once() + + inst, err := svc.LaunchInstanceWithOptions(ctx, opts) + require.NoError(t, err) + assert.NotNil(t, inst) + assert.Equal(t, opts.Name, inst.Name) + + repo.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + + t.Run("LaunchInstanceWithOptions_EnqueueFailure", func(t *testing.T) { + opts := ports.CreateInstanceOptions{ + Name: "opts-fail", + ImageName: "alpine", + Ports: []string{"80:80"}, + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == opts.Name + })).Return(nil).Once() + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(nil).Once() + + _, err := svc.LaunchInstanceWithOptions(ctx, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + repo.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) } func testInstanceServiceLifecycleUnit(t *testing.T) { From 7b10bd866b61081623f90cc86bed1f3679b9194f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 20 May 2026 14:28:29 +0300 Subject: [PATCH 02/11] style: fix formatting in instance_test.go --- internal/core/services/instance_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/core/services/instance_test.go b/internal/core/services/instance_test.go index cf75af900..7bcfb6e90 100644 --- a/internal/core/services/instance_test.go +++ b/internal/core/services/instance_test.go @@ -742,7 +742,7 @@ func TestInstanceServiceLaunchEnqueueFailure(t *testing.T) { Repo: postgres.NewAuditRepository(db), RBACSvc: rbacSvc, }) - + // Create a task queue that fails taskQueue := &InMemoryTaskQueue{ShouldFail: true} @@ -809,4 +809,3 @@ func TestInstanceServiceLaunchEnqueueFailure(t *testing.T) { require.Error(t, err) assert.Nil(t, instOpts) } - From df5d5b3c97766ebb93adb01b0c1b5e56311d35a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 20 May 2026 14:30:59 +0300 Subject: [PATCH 03/11] test: add rollback failure unit tests and handle fixture seeding errors --- internal/core/services/instance_test.go | 3 +- internal/core/services/instance_unit_test.go | 67 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/internal/core/services/instance_test.go b/internal/core/services/instance_test.go index 7bcfb6e90..55628ca97 100644 --- a/internal/core/services/instance_test.go +++ b/internal/core/services/instance_test.go @@ -727,7 +727,8 @@ func TestInstanceServiceLaunchEnqueueFailure(t *testing.T) { compute := noop.NewNoopComputeBackend() defaultType := &domain.InstanceType{ID: testInstanceType, Name: "Basic 2", VCPUs: 1, MemoryMB: 128, DiskGB: 1} - _, _ = itRepo.Create(ctx, defaultType) + _, err := itRepo.Create(ctx, defaultType) + require.NoError(t, err) rbacSvc := new(MockRBACService) rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) diff --git a/internal/core/services/instance_unit_test.go b/internal/core/services/instance_unit_test.go index 946d986f7..6578975cc 100644 --- a/internal/core/services/instance_unit_test.go +++ b/internal/core/services/instance_unit_test.go @@ -260,6 +260,46 @@ func testInstanceServiceLaunchInstanceUnit(t *testing.T) { taskQueue.AssertExpectations(t) }) + t.Run("EnqueueAndRollbackFailure", func(t *testing.T) { + params := ports.LaunchParams{ + Name: "enqueue-fail-inst-rollback-fail", + Image: "alpine", + InstanceType: "t2.micro", + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + typeRepo.On("GetByID", mock.Anything, "t2.micro").Return(&domain.InstanceType{ + ID: "t2.micro", VCPUs: 1, MemoryMB: 1024, + }, nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "instances", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == params.Name && i.UserID == userID + })).Return(nil).Once() + + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(errors.New("delete error")).Once() + + _, err := svc.LaunchInstance(ctx, params) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + assert.NotContains(t, err.Error(), "delete error") + + repo.AssertExpectations(t) + tenantSvc.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + t.Run("LaunchInstanceWithOptions_Success", func(t *testing.T) { opts := ports.CreateInstanceOptions{ Name: "opts-success", @@ -307,6 +347,33 @@ func testInstanceServiceLaunchInstanceUnit(t *testing.T) { repo.AssertExpectations(t) taskQueue.AssertExpectations(t) }) + + t.Run("LaunchInstanceWithOptions_EnqueueAndRollbackFailure", func(t *testing.T) { + opts := ports.CreateInstanceOptions{ + Name: "opts-fail-rollback-fail", + ImageName: "alpine", + Ports: []string{"80:80"}, + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == opts.Name + })).Return(nil).Once() + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(errors.New("delete error")).Once() + + _, err := svc.LaunchInstanceWithOptions(ctx, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + assert.NotContains(t, err.Error(), "delete error") + + repo.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) } func testInstanceServiceLifecycleUnit(t *testing.T) { From a5336d57f5f6af94366bb1aa150c61053420e726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 21 May 2026 13:50:04 +0300 Subject: [PATCH 04/11] fix #682: add tenant_id enforcement to LBRepository queries Load balancer queries now filter by tenant_id instead of user_id to prevent IDOR vulnerability where an attacker could access/modify LBs belonging to other tenants by knowing the LB ID. Changes: - GetByID, GetByName, GetByIdempotencyKey: Added tenant_id to WHERE clause - List: Added tenant_id filter to WHERE clause - Update, Delete: Added tenant_id to prevent cross-tenant modifications - RemoveTarget, ListTargets, UpdateTargetHealth: Added tenant verification via subquery - Updated all LB repository tests to use tenantID context --- internal/repositories/postgres/lb_repo.go | 57 +++++++------ .../repositories/postgres/lb_repo_test.go | 83 ++++++++++--------- 2 files changed, 79 insertions(+), 61 deletions(-) diff --git a/internal/repositories/postgres/lb_repo.go b/internal/repositories/postgres/lb_repo.go index bd2c16e8e..cdc0873af 100644 --- a/internal/repositories/postgres/lb_repo.go +++ b/internal/repositories/postgres/lb_repo.go @@ -38,47 +38,47 @@ func (r *LBRepository) Create(ctx context.Context, lb *domain.LoadBalancer) erro } func (r *LBRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.LoadBalancer, error) { - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE id = $1 AND user_id = $2 + WHERE id = $1 AND tenant_id = $2 ` - return r.scanLB(r.db.QueryRow(ctx, query, id, userID)) + return r.scanLB(r.db.QueryRow(ctx, query, id, tenantID)) } func (r *LBRepository) GetByName(ctx context.Context, name string) (*domain.LoadBalancer, error) { - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE name = $1 AND user_id = $2 + WHERE name = $1 AND tenant_id = $2 ` - return r.scanLB(r.db.QueryRow(ctx, query, name, userID)) + return r.scanLB(r.db.QueryRow(ctx, query, name, tenantID)) } func (r *LBRepository) GetByIdempotencyKey(ctx context.Context, key string) (*domain.LoadBalancer, error) { if key == "" { return nil, errors.New(errors.NotFound, "idempotency key empty") } - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` SELECT id, user_id, idempotency_key, name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE idempotency_key = $1 AND user_id = $2 + WHERE idempotency_key = $1 AND tenant_id = $2 ` - return r.scanLB(r.db.QueryRow(ctx, query, key, userID)) + return r.scanLB(r.db.QueryRow(ctx, query, key, tenantID)) } func (r *LBRepository) List(ctx context.Context) ([]*domain.LoadBalancer, error) { - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE user_id = $1 + WHERE tenant_id = $1 ORDER BY created_at DESC ` - rows, err := r.db.Query(ctx, query, userID) + rows, err := r.db.Query(ctx, query, tenantID) if err != nil { return nil, errors.Wrap(errors.Internal, "failed to list load balancers", err) } @@ -143,12 +143,13 @@ func (r *LBRepository) scanLBs(rows pgx.Rows) ([]*domain.LoadBalancer, error) { } func (r *LBRepository) Update(ctx context.Context, lb *domain.LoadBalancer) error { + tenantID := appcontext.TenantIDFromContext(ctx) query := ` UPDATE load_balancers SET name = $1, port = $2, algorithm = $3, ip = $4, status = $5, version = version + 1 - WHERE id = $6 AND version = $7 AND user_id = $8 + WHERE id = $6 AND version = $7 AND tenant_id = $8 ` - cmd, err := r.db.Exec(ctx, query, lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.UserID) + cmd, err := r.db.Exec(ctx, query, lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to update load balancer", err) } @@ -160,9 +161,9 @@ func (r *LBRepository) Update(ctx context.Context, lb *domain.LoadBalancer) erro } func (r *LBRepository) Delete(ctx context.Context, id uuid.UUID) error { - userID := appcontext.UserIDFromContext(ctx) - query := `DELETE FROM load_balancers WHERE id = $1 AND user_id = $2` - cmd, err := r.db.Exec(ctx, query, id, userID) + tenantID := appcontext.TenantIDFromContext(ctx) + query := `DELETE FROM load_balancers WHERE id = $1 AND tenant_id = $2` + cmd, err := r.db.Exec(ctx, query, id, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to delete load balancer", err) } @@ -188,8 +189,13 @@ func (r *LBRepository) AddTarget(ctx context.Context, target *domain.LBTarget) e } func (r *LBRepository) RemoveTarget(ctx context.Context, lbID, instanceID uuid.UUID) error { - query := `DELETE FROM lb_targets WHERE lb_id = $1 AND instance_id = $2` - cmd, err := r.db.Exec(ctx, query, lbID, instanceID) + tenantID := appcontext.TenantIDFromContext(ctx) + query := ` + DELETE FROM lb_targets + WHERE lb_id = $1 AND instance_id = $2 + AND lb_id IN (SELECT id FROM load_balancers WHERE tenant_id = $3) + ` + cmd, err := r.db.Exec(ctx, query, lbID, instanceID, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to remove load balancer target", err) } @@ -200,12 +206,13 @@ func (r *LBRepository) RemoveTarget(ctx context.Context, lbID, instanceID uuid.U } func (r *LBRepository) ListTargets(ctx context.Context, lbID uuid.UUID) ([]*domain.LBTarget, error) { + tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, lb_id, instance_id, port, weight, health - FROM lb_targets - WHERE lb_id = $1 + SELECT t.id, t.lb_id, t.instance_id, t.port, t.weight, t.health + FROM lb_targets t + WHERE t.lb_id = $1 AND t.lb_id IN (SELECT id FROM load_balancers WHERE tenant_id = $2) ` - rows, err := r.db.Query(ctx, query, lbID) + rows, err := r.db.Query(ctx, query, lbID, tenantID) if err != nil { return nil, errors.Wrap(errors.Internal, "failed to list load balancer targets", err) } @@ -213,12 +220,14 @@ func (r *LBRepository) ListTargets(ctx context.Context, lbID uuid.UUID) ([]*doma } func (r *LBRepository) UpdateTargetHealth(ctx context.Context, lbID, instanceID uuid.UUID, health string) error { + tenantID := appcontext.TenantIDFromContext(ctx) query := ` UPDATE lb_targets SET health = $1 WHERE lb_id = $2 AND instance_id = $3 + AND lb_id IN (SELECT id FROM load_balancers WHERE tenant_id = $4) ` - _, err := r.db.Exec(ctx, query, health, lbID, instanceID) + _, err := r.db.Exec(ctx, query, health, lbID, instanceID, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to update target health", err) } diff --git a/internal/repositories/postgres/lb_repo_test.go b/internal/repositories/postgres/lb_repo_test.go index c539c0f72..c767a731b 100644 --- a/internal/repositories/postgres/lb_repo_test.go +++ b/internal/repositories/postgres/lb_repo_test.go @@ -32,6 +32,7 @@ func TestLBRepositoryCreate(t *testing.T) { lb := &domain.LoadBalancer{ ID: uuid.New(), UserID: uuid.New(), + TenantID: uuid.New(), IdempotencyKey: "key-1", Name: "lb-1", VpcID: uuid.New(), @@ -74,14 +75,14 @@ func TestLBRepositoryGetByID(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) now := time.Now() mock.ExpectQuery(lbQueryPattern). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). - AddRow(id, userID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) + AddRow(id, tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) lb, err := repo.GetByID(ctx, id) require.NoError(t, err) @@ -96,11 +97,11 @@ func TestLBRepositoryGetByID(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectQuery(lbQueryPattern). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnError(pgx.ErrNoRows) lb, err := repo.GetByID(ctx, id) @@ -117,14 +118,14 @@ func TestLBRepositoryList(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) now := time.Now() mock.ExpectQuery(lbQueryPattern). - WithArgs(userID). + WithArgs(tenantID). WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). - AddRow(uuid.New(), userID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) + AddRow(uuid.New(), tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) lbs, err := repo.List(ctx) require.NoError(t, err) @@ -137,11 +138,11 @@ func TestLBRepositoryList(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectQuery(lbQueryPattern). - WithArgs(userID). + WithArgs(tenantID). WillReturnError(errors.New(errDbMessage)) list, err := repo.List(ctx) @@ -157,21 +158,23 @@ func TestLBRepositoryUpdate(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) + tenantID := uuid.New() lb := &domain.LoadBalancer{ ID: uuid.New(), - UserID: uuid.New(), + TenantID: tenantID, Name: "lb-updated", Port: 8080, Algorithm: "least_conn", Status: domain.LBStatusActive, Version: 1, } + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("UPDATE load_balancers"). - WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.UserID). + WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.TenantID). WillReturnResult(pgxmock.NewResult("UPDATE", 1)) - err = repo.Update(context.Background(), lb) + err = repo.Update(ctx, lb) require.NoError(t, err) assert.Equal(t, 2, lb.Version) }) @@ -182,17 +185,19 @@ func TestLBRepositoryUpdate(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: uuid.New(), - UserID: uuid.New(), - Version: 1, + ID: uuid.New(), + TenantID: tenantID, + Version: 1, } + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("UPDATE load_balancers"). - WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.UserID). + WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.TenantID). WillReturnResult(pgxmock.NewResult("UPDATE", 0)) - err = repo.Update(context.Background(), lb) + err = repo.Update(ctx, lb) require.Error(t, err) var theCloudErr *theclouderrors.Error if errors.As(err, &theCloudErr) { @@ -209,11 +214,11 @@ func TestLBRepositoryDelete(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("DELETE FROM load_balancers"). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 1)) err = repo.Delete(ctx, id) @@ -227,11 +232,11 @@ func TestLBRepositoryDelete(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("DELETE FROM load_balancers"). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 0)) err = repo.Delete(ctx, id) @@ -274,12 +279,13 @@ func TestLBRepositoryRemoveTarget(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() instanceID := uuid.New() + tenantID := uuid.New() mock.ExpectExec("DELETE FROM lb_targets"). - WithArgs(lbID, instanceID). + WithArgs(lbID, instanceID, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 1)) - err = repo.RemoveTarget(context.Background(), lbID, instanceID) + err = repo.RemoveTarget(appcontext.WithTenantID(context.Background(), tenantID), lbID, instanceID) require.NoError(t, err) }) @@ -291,12 +297,13 @@ func TestLBRepositoryRemoveTarget(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() instanceID := uuid.New() + tenantID := uuid.New() mock.ExpectExec("DELETE FROM lb_targets"). - WithArgs(lbID, instanceID). + WithArgs(lbID, instanceID, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 0)) - err = repo.RemoveTarget(context.Background(), lbID, instanceID) + err = repo.RemoveTarget(appcontext.WithTenantID(context.Background(), tenantID), lbID, instanceID) require.Error(t, err) }) } @@ -309,13 +316,14 @@ func TestLBRepositoryListTargets(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() + tenantID := uuid.New() - mock.ExpectQuery("SELECT id, lb_id, instance_id, port, weight, health FROM lb_targets"). - WithArgs(lbID). + mock.ExpectQuery("SELECT t.id, t.lb_id, t.instance_id, t.port, t.weight, t.health FROM lb_targets t"). + WithArgs(lbID, tenantID). WillReturnRows(pgxmock.NewRows([]string{"id", "lb_id", "instance_id", "port", "weight", "health"}). AddRow(uuid.New(), lbID, uuid.New(), 80, 1, "healthy")) - targets, err := repo.ListTargets(context.Background(), lbID) + targets, err := repo.ListTargets(appcontext.WithTenantID(context.Background(), tenantID), lbID) require.NoError(t, err) assert.Len(t, targets, 1) }) @@ -330,13 +338,14 @@ func TestLBRepositoryUpdateTargetHealth(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() instanceID := uuid.New() + tenantID := uuid.New() health := "unhealthy" mock.ExpectExec("UPDATE lb_targets"). - WithArgs(health, lbID, instanceID). + WithArgs(health, lbID, instanceID, tenantID). WillReturnResult(pgxmock.NewResult("UPDATE", 1)) - err = repo.UpdateTargetHealth(context.Background(), lbID, instanceID, health) + err = repo.UpdateTargetHealth(appcontext.WithTenantID(context.Background(), tenantID), lbID, instanceID, health) require.NoError(t, err) }) } From 7503c9f3f89bc739e00a926c567b775df85d32ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:33:16 +0300 Subject: [PATCH 05/11] fix: use TenantID instead of UserID in LB cleanup worker The processDeletingLBs function was using WithUserID context but LBRepository.Delete now expects TenantID. This caused delete operations to fail silently (no rows affected) when cleaning up deleted LBs. --- internal/core/services/lb_worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/core/services/lb_worker.go b/internal/core/services/lb_worker.go index 2c2556934..2897d0298 100644 --- a/internal/core/services/lb_worker.go +++ b/internal/core/services/lb_worker.go @@ -115,7 +115,7 @@ func (w *LBWorker) processDeletingLBs(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) w.cleanupLB(gCtx, lb) } if len(lbs) < w.batchLimit { From b790fb06738b3b574d41e580af5a1cf8d41aa374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:06:41 +0300 Subject: [PATCH 06/11] fix: pass tenantID context when cascading LB deletes in VPC deletion cascadeDeleteDependencies was calling lbRepo.Delete without tenantID in context, causing deletes to fail silently after the LB IDOR fix. Now passes vpc.TenantID via context to ensure proper tenant isolation. --- internal/core/services/vpc.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/core/services/vpc.go b/internal/core/services/vpc.go index 154bd44f3..1879c63e0 100644 --- a/internal/core/services/vpc.go +++ b/internal/core/services/vpc.go @@ -279,7 +279,7 @@ func (s *VpcService) DeleteVPC(ctx context.Context, idOrName string, force bool) } } else { // Force delete: cascade delete dependent resources to satisfy FK constraints - if err := s.cascadeDeleteDependencies(ctx, vpc.ID); err != nil { + if err := s.cascadeDeleteDependencies(ctx, vpc.ID, vpc.TenantID); err != nil { return errors.Wrap(errors.Internal, "failed to cascade delete dependencies", err) } } @@ -349,7 +349,7 @@ func (s *VpcService) checkDeleteDependencies(ctx context.Context, vpcID uuid.UUI // cascadeDeleteDependencies deletes all dependent resources of a VPC. // This is used when force=true to bypass FK constraint violations. // Returns an error if any deletion fails (partial failures are reported). -func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID uuid.UUID) error { +func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID, tenantID uuid.UUID) error { // Delete all scaling groups for this VPC directly groups, err := s.asRepo.ListGroups(ctx) if err != nil { @@ -369,9 +369,11 @@ func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID uuid.U if err != nil { return fmt.Errorf("listing load balancers: %w", err) } + // Use tenantID context for LB operations (lbRepo.Delete requires tenant_id) + lbCtx := appcontext.WithTenantID(ctx, tenantID) for _, lb := range lbs { if lb.VpcID == vpcID { - if err := s.lbRepo.Delete(ctx, lb.ID); err != nil { + if err := s.lbRepo.Delete(lbCtx, lb.ID); err != nil { delErrs = append(delErrs, fmt.Errorf("load balancer %s: %w", lb.ID, err)) } } From 57058c368b788c830ade2ba16686fbeec8481ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:28:56 +0300 Subject: [PATCH 07/11] fix: use LB's own TenantID when cascading delete in VPC deletion The cascade delete was using the VPC's TenantID when deleting LBs, but LBs may have a different TenantID. Now each LB is deleted using its own TenantID to ensure the delete query matches the correct row. --- internal/core/services/vpc.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/core/services/vpc.go b/internal/core/services/vpc.go index 1879c63e0..71308990c 100644 --- a/internal/core/services/vpc.go +++ b/internal/core/services/vpc.go @@ -279,7 +279,7 @@ func (s *VpcService) DeleteVPC(ctx context.Context, idOrName string, force bool) } } else { // Force delete: cascade delete dependent resources to satisfy FK constraints - if err := s.cascadeDeleteDependencies(ctx, vpc.ID, vpc.TenantID); err != nil { + if err := s.cascadeDeleteDependencies(ctx, vpc.ID); err != nil { return errors.Wrap(errors.Internal, "failed to cascade delete dependencies", err) } } @@ -349,7 +349,7 @@ func (s *VpcService) checkDeleteDependencies(ctx context.Context, vpcID uuid.UUI // cascadeDeleteDependencies deletes all dependent resources of a VPC. // This is used when force=true to bypass FK constraint violations. // Returns an error if any deletion fails (partial failures are reported). -func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID, tenantID uuid.UUID) error { +func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID uuid.UUID) error { // Delete all scaling groups for this VPC directly groups, err := s.asRepo.ListGroups(ctx) if err != nil { @@ -369,10 +369,10 @@ func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID, tenan if err != nil { return fmt.Errorf("listing load balancers: %w", err) } - // Use tenantID context for LB operations (lbRepo.Delete requires tenant_id) - lbCtx := appcontext.WithTenantID(ctx, tenantID) for _, lb := range lbs { if lb.VpcID == vpcID { + // Use the LB's own TenantID for deletion (not the VPC's) + lbCtx := appcontext.WithTenantID(ctx, lb.TenantID) if err := s.lbRepo.Delete(lbCtx, lb.ID); err != nil { delErrs = append(delErrs, fmt.Errorf("load balancer %s: %w", lb.ID, err)) } From ad2f6eb5804c23c7135c38ec7cbfb271c95fa5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:45:57 +0300 Subject: [PATCH 08/11] fix: add tenant_id to LB SELECT queries and scanLB The LB repository queries were not selecting tenant_id column, causing TenantID to always be zero UUID when scanning LBs. This broke cascade delete in VPC deletion which relies on LB's own TenantID for deletion. Changes: - Added tenant_id to all SELECT queries in lb_repo.go - Updated scanLB to scan tenant_id field - Updated test mocks to include tenant_id column --- internal/repositories/postgres/lb_repo.go | 14 +++++++------- internal/repositories/postgres/lb_repo_test.go | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/repositories/postgres/lb_repo.go b/internal/repositories/postgres/lb_repo.go index cdc0873af..625ebff96 100644 --- a/internal/repositories/postgres/lb_repo.go +++ b/internal/repositories/postgres/lb_repo.go @@ -40,7 +40,7 @@ func (r *LBRepository) Create(ctx context.Context, lb *domain.LoadBalancer) erro func (r *LBRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.LoadBalancer, error) { tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers WHERE id = $1 AND tenant_id = $2 ` @@ -50,7 +50,7 @@ func (r *LBRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.LoadB func (r *LBRepository) GetByName(ctx context.Context, name string) (*domain.LoadBalancer, error) { tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers WHERE name = $1 AND tenant_id = $2 ` @@ -63,7 +63,7 @@ func (r *LBRepository) GetByIdempotencyKey(ctx context.Context, key string) (*do } tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, idempotency_key, name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, idempotency_key, name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers WHERE idempotency_key = $1 AND tenant_id = $2 ` @@ -73,7 +73,7 @@ func (r *LBRepository) GetByIdempotencyKey(ctx context.Context, key string) (*do func (r *LBRepository) List(ctx context.Context) ([]*domain.LoadBalancer, error) { tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers WHERE tenant_id = $1 ORDER BY created_at DESC @@ -87,7 +87,7 @@ func (r *LBRepository) List(ctx context.Context) ([]*domain.LoadBalancer, error) func (r *LBRepository) ListAll(ctx context.Context) ([]*domain.LoadBalancer, error) { query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers ORDER BY created_at DESC ` @@ -100,7 +100,7 @@ func (r *LBRepository) ListAll(ctx context.Context) ([]*domain.LoadBalancer, err func (r *LBRepository) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*domain.LoadBalancer, error) { query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers WHERE status = $1 ORDER BY created_at DESC @@ -117,7 +117,7 @@ func (r *LBRepository) scanLB(row pgx.Row) (*domain.LoadBalancer, error) { var lb domain.LoadBalancer var status string err := row.Scan( - &lb.ID, &lb.UserID, &lb.IdempotencyKey, &lb.Name, &lb.VpcID, &lb.Port, &lb.Algorithm, &lb.IP, &status, &lb.Version, &lb.CreatedAt, + &lb.ID, &lb.UserID, &lb.TenantID, &lb.IdempotencyKey, &lb.Name, &lb.VpcID, &lb.Port, &lb.Algorithm, &lb.IP, &status, &lb.Version, &lb.CreatedAt, ) if err != nil { if stdlib_errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/repositories/postgres/lb_repo_test.go b/internal/repositories/postgres/lb_repo_test.go index c767a731b..f1d827366 100644 --- a/internal/repositories/postgres/lb_repo_test.go +++ b/internal/repositories/postgres/lb_repo_test.go @@ -17,7 +17,7 @@ import ( ) const ( - lbQueryPattern = "SELECT id, user_id, COALESCE.+idempotency_key.+name, vpc_id, port, algorithm, COALESCE.+ip.+status, version, created_at FROM load_balancers" + lbQueryPattern = "SELECT id, user_id, tenant_id, COALESCE.+idempotency_key.+name, vpc_id, port, algorithm, COALESCE.+ip.+status, version, created_at FROM load_balancers" errDbMessage = "db error" errNotFound = "not found" ) @@ -81,8 +81,8 @@ func TestLBRepositoryGetByID(t *testing.T) { mock.ExpectQuery(lbQueryPattern). WithArgs(id, tenantID). - WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). - AddRow(id, tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) + WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). + AddRow(id, uuid.New(), tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) lb, err := repo.GetByID(ctx, id) require.NoError(t, err) @@ -124,8 +124,8 @@ func TestLBRepositoryList(t *testing.T) { mock.ExpectQuery(lbQueryPattern). WithArgs(tenantID). - WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). - AddRow(uuid.New(), tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) + WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). + AddRow(uuid.New(), uuid.New(), tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) lbs, err := repo.List(ctx) require.NoError(t, err) From 41e8b70d755cdcf2b3adb5ec67a0bb946197ea07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:58:51 +0300 Subject: [PATCH 09/11] fix: add tenant_id to LB INSERT statement The Create function was not inserting tenant_id column, causing all LBs to have zero UUID tenant_id. This broke cascade delete which relies on LB's own TenantID for proper deletion filtering. --- internal/repositories/postgres/lb_repo.go | 6 +++--- internal/repositories/postgres/lb_repo_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/repositories/postgres/lb_repo.go b/internal/repositories/postgres/lb_repo.go index 625ebff96..fc7e0bcce 100644 --- a/internal/repositories/postgres/lb_repo.go +++ b/internal/repositories/postgres/lb_repo.go @@ -24,11 +24,11 @@ func NewLBRepository(db DB) *LBRepository { func (r *LBRepository) Create(ctx context.Context, lb *domain.LoadBalancer) error { query := ` - INSERT INTO load_balancers (id, user_id, idempotency_key, name, vpc_id, port, algorithm, ip, status, version, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO load_balancers (id, user_id, tenant_id, idempotency_key, name, vpc_id, port, algorithm, ip, status, version, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ` _, err := r.db.Exec(ctx, query, - lb.ID, lb.UserID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt, + lb.ID, lb.UserID, lb.TenantID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt, ) if err != nil { // Check for unique constraint violation on idempotency_key diff --git a/internal/repositories/postgres/lb_repo_test.go b/internal/repositories/postgres/lb_repo_test.go index f1d827366..01192ba91 100644 --- a/internal/repositories/postgres/lb_repo_test.go +++ b/internal/repositories/postgres/lb_repo_test.go @@ -44,7 +44,7 @@ func TestLBRepositoryCreate(t *testing.T) { } mock.ExpectExec("INSERT INTO load_balancers"). - WithArgs(lb.ID, lb.UserID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt). + WithArgs(lb.ID, lb.UserID, lb.TenantID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt). WillReturnResult(pgxmock.NewResult("INSERT", 1)) err = repo.Create(context.Background(), lb) From 4875ae54507523a3c07619e03aeef7388980029f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:17:03 +0300 Subject: [PATCH 10/11] fix: use WithTenantID in all LB worker loops and LBService.Delete PR #690 changed lb_repo to use TenantID instead of UserID for authorization but only processDeletingLBs was updated. This fixes the remaining 3 worker loops (processCreatingLBs, processActiveLBs, processHealthChecks) and LBService.Delete which were still passing user_id context, causing ListTargets/Update/UpdateTargetHealth to query with nil tenant_id and fail. --- internal/core/services/lb_worker.go | 6 +++--- internal/core/services/loadbalancer.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/core/services/lb_worker.go b/internal/core/services/lb_worker.go index 2897d0298..c3d1461e5 100644 --- a/internal/core/services/lb_worker.go +++ b/internal/core/services/lb_worker.go @@ -94,7 +94,7 @@ func (w *LBWorker) processCreatingLBs(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) w.deployLB(gCtx, lb) } if len(lbs) < w.batchLimit { @@ -174,7 +174,7 @@ func (w *LBWorker) processActiveLBs(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) targets, err := w.lbRepo.ListTargets(gCtx, lb.ID) if err != nil { continue @@ -201,7 +201,7 @@ func (w *LBWorker) processHealthChecks(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) w.checkLBHealth(gCtx, lb) } if len(lbs) < w.batchLimit { diff --git a/internal/core/services/loadbalancer.go b/internal/core/services/loadbalancer.go index 64ccfd4af..52224911d 100644 --- a/internal/core/services/loadbalancer.go +++ b/internal/core/services/loadbalancer.go @@ -183,8 +183,9 @@ func (s *LBService) Delete(ctx context.Context, idOrName string) error { return err } + lbCtx := appcontext.WithTenantID(ctx, lb.TenantID) lb.Status = domain.LBStatusDeleted - if err := s.lbRepo.Update(ctx, lb); err != nil { + if err := s.lbRepo.Update(lbCtx, lb); err != nil { return err } From ec8996dc3c24d46f613eb4732b6c301fa63869b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:23:00 +0300 Subject: [PATCH 11/11] test: verify TenantID context in all LB worker loops Add tenantMatcher helper to assert correct tenant context is passed to all repository calls in worker loops. Update existing worker tests to use TenantID and verify context on: ListTargets, Update, Delete, RemoveProxy, GetByID, UpdateTargetHealth. --- internal/core/services/lb_worker_test.go | 66 +++++++++++++++--------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/internal/core/services/lb_worker_test.go b/internal/core/services/lb_worker_test.go index 710bea2fa..50712990b 100644 --- a/internal/core/services/lb_worker_test.go +++ b/internal/core/services/lb_worker_test.go @@ -9,11 +9,23 @@ import ( "time" "github.com/google/uuid" + appcontext "github.com/poyrazk/thecloud/internal/core/context" "github.com/poyrazk/thecloud/internal/core/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +// tenantMatcher returns a matcher that verifies the context carries the expected TenantID. +func tenantMatcher(expectedTenantID uuid.UUID) func(any) bool { + return func(arg any) bool { + ctx, ok := arg.(context.Context) + if !ok { + return false + } + return appcontext.TenantIDFromContext(ctx) == expectedTenantID + } +} + type mockDialer struct { err error } @@ -162,19 +174,19 @@ func TestLBWorkerProcessCreatingLBs(t *testing.T) { ctx := context.Background() lbID := uuid.New() - userID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: userID, - Status: domain.LBStatusCreating, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusCreating, } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusCreating), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - lbRepo.On("ListTargets", mock.Anything, lbID).Return([]*domain.LBTarget{}, nil) + lbRepo.On("ListTargets", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return([]*domain.LBTarget{}, nil).Once() proxy.On("DeployProxy", mock.Anything, lb, []*domain.LBTarget{}).Return("http://lb-url", nil) - lbRepo.On("Update", mock.Anything, mock.MatchedBy(func(l *domain.LoadBalancer) bool { + lbRepo.On("Update", mock.MatchedBy(tenantMatcher(tenantID)), mock.MatchedBy(func(l *domain.LoadBalancer) bool { return l.ID == lbID && l.Status == domain.LBStatusActive - })).Return(nil) + })).Return(nil).Once() worker.processCreatingLBs(ctx) @@ -191,15 +203,16 @@ func TestLBWorkerProcessDeletingLBs(t *testing.T) { ctx := context.Background() lbID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: uuid.New(), - Status: domain.LBStatusDeleted, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusDeleted, } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusDeleted), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - proxy.On("RemoveProxy", mock.Anything, lbID).Return(nil) - lbRepo.On("Delete", mock.Anything, lbID).Return(nil) + proxy.On("RemoveProxy", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return(nil).Once() + lbRepo.On("Delete", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return(nil).Once() worker.processDeletingLBs(ctx) @@ -216,19 +229,21 @@ func TestLBWorkerProcessActiveLBs(t *testing.T) { ctx := context.Background() lbID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: uuid.New(), - Status: domain.LBStatusActive, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusActive, } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusActive), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - lbRepo.On("ListTargets", mock.Anything, lbID).Return([]*domain.LBTarget{}, nil) - proxy.On("UpdateProxyConfig", mock.Anything, lb, []*domain.LBTarget{}).Return(nil) + lbRepo.On("ListTargets", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return([]*domain.LBTarget{}, nil).Once() + proxy.On("UpdateProxyConfig", mock.MatchedBy(tenantMatcher(tenantID)), lb, []*domain.LBTarget{}).Return(nil).Once() worker.processActiveLBs(ctx) proxy.AssertExpectations(t) + lbRepo.AssertExpectations(t) } func TestLBWorkerProcessHealthChecks(t *testing.T) { @@ -241,10 +256,11 @@ func TestLBWorkerProcessHealthChecks(t *testing.T) { ctx := context.Background() lbID := uuid.New() instID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: uuid.New(), - Status: domain.LBStatusActive, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusActive, } target := &domain.LBTarget{ @@ -259,8 +275,8 @@ func TestLBWorkerProcessHealthChecks(t *testing.T) { } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusActive), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - lbRepo.On("ListTargets", mock.Anything, lbID).Return([]*domain.LBTarget{target}, nil) - instRepo.On("GetByID", mock.Anything, instID).Return(inst, nil) + lbRepo.On("ListTargets", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return([]*domain.LBTarget{target}, nil).Once() + instRepo.On("GetByID", mock.MatchedBy(tenantMatcher(tenantID)), instID).Return(inst, nil).Once() // Since we are mocking, we cannot easily mock net.Dial from within isPortOpen since it's hardcoded. // But we can test that it TRIES to update if health status changes. @@ -300,9 +316,11 @@ func TestLBWorkerCheckTargetHealthUpdates(t *testing.T) { ctx := context.Background() lbID := uuid.New() instID := uuid.New() + tenantID := uuid.New() + ctx = appcontext.WithTenantID(ctx, tenantID) - instRepo.On("GetByID", ctx, instID).Return(&domain.Instance{ID: instID, Ports: "8080:80"}, nil) - lbRepo.On("UpdateTargetHealth", ctx, lbID, instID, "healthy").Return(nil).Once() + instRepo.On("GetByID", mock.MatchedBy(tenantMatcher(tenantID)), instID).Return(&domain.Instance{ID: instID, Ports: "8080:80"}, nil) + lbRepo.On("UpdateTargetHealth", mock.MatchedBy(tenantMatcher(tenantID)), lbID, instID, "healthy").Return(nil).Once() changed := worker.checkTargetHealth(ctx, &domain.LoadBalancer{ID: lbID}, &domain.LBTarget{ InstanceID: instID,