Skip to content

Commit 98e7b0c

Browse files
authored
feat: org soft-deletion (#2362)
Signed-off-by: Miguel Martinez <[email protected]>
1 parent 6265e9c commit 98e7b0c

20 files changed

+358
-54
lines changed

app/controlplane/Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ migration_sync: check-atlas-tool migration_hash
4545
migration_lint: check-atlas-tool migration_hash
4646
atlas migrate lint --dir ${local_migrations_dir} --dev-url "docker://postgres/15/test?search_path=public" --latest 1 --config file://atlas.hcl --env dev
4747

48+
.PHONY: migration_rebase
49+
# rebase migrations
50+
# example: make migration_rebase 20250327153948
51+
migration_rebase: check-atlas-tool
52+
atlas migrate rebase --dir ${local_migrations_dir} $(filter-out $@,$(MAKECMDGOALS))
53+
4854
.PHONY: migration_new
4955
# generate an empty migration file
5056
migration_new: check-atlas-tool migration_hash

app/controlplane/pkg/biz/organization.go

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,7 @@ func (uc *OrganizationUseCase) FindByName(ctx context.Context, name string) (*Or
242242
return org, nil
243243
}
244244

245-
// Delete deletes an organization and all relevant data
246-
// This includes:
247-
// - The organization
248-
// - The associated repositories
249-
// - The associated integrations
250-
// The reason for just deleting these two associated components only is because
251-
// they have external secrets that need to be deleted as well, and for that we leverage their own delete methods
252-
// The rest of the data gets removed by the database cascade delete
245+
// Delete soft-deletes an organization and all relevant data
253246
func (uc *OrganizationUseCase) Delete(ctx context.Context, id string) error {
254247
orgUUID, err := uuid.Parse(id)
255248
if err != nil {
@@ -263,30 +256,21 @@ func (uc *OrganizationUseCase) Delete(ctx context.Context, id string) error {
263256
return NewErrNotFound("organization")
264257
}
265258

266-
// Delete all the integrations
267-
integrations, err := uc.integrationUC.List(ctx, id)
259+
// Delete all memberships for this organization
260+
// Memberships should be removed when an organization is soft-deleted
261+
// since they represent access rights that should be revoked
262+
memberships, _, err := uc.membershipRepo.FindByOrg(ctx, orgUUID, nil, nil)
268263
if err != nil {
269-
return err
270-
}
271-
272-
for _, i := range integrations {
273-
if err := uc.integrationUC.Delete(ctx, id, i.ID.String()); err != nil {
274-
return err
275-
}
276-
}
277-
278-
backends, err := uc.casBackendUseCase.List(ctx, org.ID)
279-
if err != nil {
280-
return fmt.Errorf("failed to list backends: %w", err)
264+
return fmt.Errorf("failed to find memberships: %w", err)
281265
}
282266

283-
for _, b := range backends {
284-
if err := uc.casBackendUseCase.Delete(ctx, b.ID.String()); err != nil {
285-
return fmt.Errorf("failed to delete backend: %w", err)
267+
for _, m := range memberships {
268+
if err := uc.membershipRepo.Delete(ctx, m.ID); err != nil {
269+
return fmt.Errorf("failed to delete membership: %w", err)
286270
}
287271
}
288272

289-
// Delete the organization
273+
// Soft-delete the organization
290274
return uc.orgRepo.Delete(ctx, orgUUID)
291275
}
292276

app/controlplane/pkg/biz/organization_integration_test.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ func (s *OrgIntegrationTestSuite) TestCreate() {
7373
}
7474

7575
for _, tc := range testCases {
76-
s.T().Run(tc.name, func(_ *testing.T) {
76+
s.Run(tc.name, func() {
7777
org, err := s.Organization.Create(ctx, tc.name)
7878
if tc.expectedError {
7979
s.Error(err)
@@ -166,28 +166,37 @@ func (s *OrgIntegrationTestSuite) TestDeleteOrg() {
166166
assert := assert.New(s.T())
167167
ctx := context.Background()
168168

169-
s.T().Run("invalid org ID", func(t *testing.T) {
169+
s.Run("invalid org ID", func() {
170170
// Invalid org ID
171171
err := s.Organization.Delete(ctx, "invalid")
172172
assert.Error(err)
173173
assert.True(biz.IsErrInvalidUUID(err))
174174
})
175175

176-
s.T().Run("org non existent", func(t *testing.T) {
176+
s.Run("org non existent", func() {
177177
// org not found
178178
err := s.Organization.Delete(ctx, uuid.NewString())
179179
assert.Error(err)
180180
assert.True(biz.IsNotFound(err))
181181
})
182182

183-
s.T().Run("org, integrations and repositories deletion", func(t *testing.T) {
184-
// Mock calls to credentials deletion for both the integration and the OCI repository
185-
s.mockedCredsReaderWriter.On("DeleteCredentials", ctx, "stored-OCI-secret").Return(nil)
183+
s.Run("org soft deletion and membership cleanup", func() {
184+
// With soft-deletion, external credentials are NOT deleted, so no mock expectations
186185

187186
err := s.Organization.Delete(ctx, s.org.ID)
188187
assert.NoError(err)
189188

190-
// Integrations and repo deleted as well
189+
// Org is soft-deleted, so it can't be found
190+
org, err := s.Organization.FindByID(ctx, s.org.ID)
191+
assert.Nil(org)
192+
assert.ErrorAs(err, &biz.ErrNotFound{})
193+
194+
// Memberships are deleted (hard-deleted)
195+
memberships, _, err := s.Membership.ByOrg(ctx, s.org.ID, nil, nil)
196+
assert.NoError(err)
197+
assert.Empty(memberships)
198+
199+
// Related resources become inaccessible through org-scoped queries but remain in DB
191200
integrations, err := s.Integration.List(ctx, s.org.ID)
192201
assert.NoError(err)
193202
assert.Empty(integrations)

app/controlplane/pkg/data/data.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func toTimePtr(t time.Time) *time.Time {
144144
}
145145

146146
func orgScopedQuery(client *ent.Client, orgID uuid.UUID) *ent.OrganizationQuery {
147-
return client.Organization.Query().Where(organization.ID(orgID))
147+
return client.Organization.Query().Where(organization.ID(orgID), organization.DeletedAtIsNil())
148148
}
149149

150150
// WithTx initiates a transaction and wraps the DB function
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Modify "organizations" table
2+
ALTER TABLE "organizations" ADD COLUMN "deleted_at" timestamptz NULL;
3+
-- Drop index "organizations_name_key" from table: "organizations"
4+
DROP INDEX "organizations_name_key";
5+
-- Create index "organization_name" to table: "organizations"
6+
CREATE UNIQUE INDEX "organization_name" ON "organizations" ("name") WHERE (deleted_at IS NULL);

app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
h1:4z4jm6qLVBYu2xAWiD/YxRpstDC0Boyza8y/i5cc1SE=
1+
h1:juodzbTCyqY5JwQbyFWEtF5UeTSUEiafH2wny+s2E2c=
22
20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M=
33
20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g=
44
20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI=
@@ -110,3 +110,4 @@ h1:4z4jm6qLVBYu2xAWiD/YxRpstDC0Boyza8y/i5cc1SE=
110110
20250812111458.sql h1:15yQlZoBymYR5GEjGLtV/j4ZZjg06u6eEzcRRl7vax4=
111111
20250820090420.sql h1:xmJucXMVs+JyXWmyHu7Rv31hhgtAONDTv1mT/sTaJKk=
112112
20250820171503.sql h1:SsLD5Tf6woeFE7/FLI9XVQpnEgx4CJ9d7fWwNOZvOrA=
113+
20250827093032.sql h1:K+XDWewSLoGBM+zjkBMag3mMQFFQyoQ9SePzfRxC694=

app/controlplane/pkg/data/ent/migrate/schema.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,10 @@ var (
413413
// OrganizationsColumns holds the columns for the "organizations" table.
414414
OrganizationsColumns = []*schema.Column{
415415
{Name: "id", Type: field.TypeUUID, Unique: true},
416-
{Name: "name", Type: field.TypeString, Unique: true},
416+
{Name: "name", Type: field.TypeString},
417417
{Name: "created_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"},
418418
{Name: "updated_at", Type: field.TypeTime, Default: "CURRENT_TIMESTAMP"},
419+
{Name: "deleted_at", Type: field.TypeTime, Nullable: true},
419420
{Name: "block_on_policy_violation", Type: field.TypeBool, Default: false},
420421
{Name: "policies_allowed_hostnames", Type: field.TypeJSON, Nullable: true},
421422
}
@@ -424,6 +425,16 @@ var (
424425
Name: "organizations",
425426
Columns: OrganizationsColumns,
426427
PrimaryKey: []*schema.Column{OrganizationsColumns[0]},
428+
Indexes: []*schema.Index{
429+
{
430+
Name: "organization_name",
431+
Unique: true,
432+
Columns: []*schema.Column{OrganizationsColumns[1]},
433+
Annotation: &entsql.IndexAnnotation{
434+
Where: "deleted_at IS NULL",
435+
},
436+
},
437+
},
427438
}
428439
// ProjectsColumns holds the columns for the "projects" table.
429440
ProjectsColumns = []*schema.Column{

app/controlplane/pkg/data/ent/mutation.go

Lines changed: 74 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/pkg/data/ent/organization.go

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/pkg/data/ent/organization/organization.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)