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
20 changes: 13 additions & 7 deletions core/deleter/deleter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,38 @@ package deleter

import "strings"

// Reasons an organization delete can be blocked. The API error carries
// Reasons a delete can be blocked. The API error carries
// them as violation types, so a client can tell the reasons apart
// without reading the message text.
const (
BlockerActiveSubscription = "ACTIVE_SUBSCRIPTION"
BlockerUnpaidInvoice = "UNPAID_INVOICE"
BlockerNegativeTokenBalance = "NEGATIVE_TOKEN_BALANCE"
BlockerResourcesExist = "RESOURCES_EXIST"
)

// Kinds of entity a Blocker's Subject id refers to.
const (
SubjectSubscription = "billing_subscription"
SubjectInvoice = "billing_invoice"
SubjectBillingAccount = "billing_account"
SubjectSubscription = "billing_subscription"
SubjectInvoice = "billing_invoice"
SubjectBillingAccount = "billing_account"
SubjectResourceNamespace = "resource_namespace"
)

// Blocker is one reason an organization cannot be deleted right now.
// Blocker is one reason a project or an organization cannot be deleted right now.
type Blocker struct {
// Type is one of the Blocker* constants.
Type string
// Subject is the id of the entity behind the reason.
// Subject is the id of the entity behind the reason, or the namespace
// for BlockerResourcesExist.
Subject string
// SubjectType is one of the Subject* constants and says what kind of
// entity the Subject id refers to.
SubjectType string
// Message says what blocks the delete and how to clear it.
// Count is the number of blocking resources, for BlockerResourcesExist.
Count int
Comment thread
AmanGIT07 marked this conversation as resolved.
// Message says what blocks the delete and how to clear it. Empty for
// BlockerResourcesExist.
Message string
}

Expand Down
76 changes: 76 additions & 0 deletions core/deleter/resource_blockers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package deleter

import (
"context"
"fmt"
"maps"
"slices"

"github.com/raystack/frontier/core/resource"
)

const metadataDeletable = "deletable"

func (d Service) ProjectResourceBlockers(ctx context.Context, projectID string) ([]Blocker, error) {
Comment thread
AmanGIT07 marked this conversation as resolved.
if !d.deletionConfig.Enabled {
return nil, nil
}
resources, err := d.resService.List(ctx, resource.Filter{ProjectID: projectID})
if err != nil {
return nil, fmt.Errorf("failed to list resources of project[%s]: %w", projectID, err)
}
return d.deletionConfig.blockers(ParentProject, resources), nil
}

func (d Service) OrganizationResourceBlockers(ctx context.Context, orgID string) ([]Blocker, error) {
if !d.deletionConfig.Enabled {
return nil, nil
}
resources, err := d.resService.List(ctx, resource.Filter{OrgID: orgID})
if err != nil {
return nil, fmt.Errorf("failed to list resources of organization[%s]: %w", orgID, err)
}
projects, err := d.listOrgProjects(ctx, orgID)
if err != nil {
return nil, fmt.Errorf("failed to list projects of organization[%s]: %w", orgID, err)
}
for _, p := range projects {
projectResources, err := d.resService.List(ctx, resource.Filter{ProjectID: p.ID})
Comment thread
AmanGIT07 marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("failed to list resources of project[%s]: %w", p.ID, err)
}
resources = append(resources, projectResources...)
}
return d.deletionConfig.blockers(ParentOrganization, resources), nil
}

func (c Config) blockers(parent Parent, resources []resource.Resource) []Blocker {
counts := map[string]int{}
for _, r := range resources {
if c.blocks(parent, r) {
counts[r.NamespaceID]++
}
}
var blockers []Blocker
for _, namespace := range slices.Sorted(maps.Keys(counts)) {
blockers = append(blockers, Blocker{
Type: BlockerResourcesExist,
Subject: namespace,
SubjectType: SubjectResourceNamespace,
Count: counts[namespace],
})
}
return blockers
}

func (c Config) blocks(parent Parent, r resource.Resource) bool {
switch c.Resources[r.NamespaceID][parent] {
case RuleAllow:
return false
case RuleBlockUnlessDeletable:
deletable, _ := r.Metadata[metadataDeletable].(bool)
return !deletable
default:
return true
}
}
211 changes: 210 additions & 1 deletion core/deleter/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type deleterMocks struct {
kycSvc *mocks.KycService
planSvc *mocks.PlanService
dialer *mailermocks.Dialer
deletion deleter.Config
}

func newMocks(t *testing.T) deleterMocks {
Expand Down Expand Up @@ -107,7 +108,7 @@ func (m deleterMocks) build() *deleter.Service {
return deleter.NewCascadeDeleter(m.orgSvc, m.projSvc, m.resSvc, m.grpSvc, m.mbrSvc,
m.polSvc, m.roleSvc, m.invSvc, m.usrSvc, m.patSvc, m.domainSvc, m.suSvc,
m.custSvc, m.subSvc, m.invocSvc, m.checkoutSvc, m.creditSvc, m.kycSvc,
m.planSvc, m.dialer, billing.OrgDeleteNoticeConfig{}, deleter.Config{})
m.planSvc, m.dialer, billing.OrgDeleteNoticeConfig{}, m.deletion)
}

func TestDeleteProject(t *testing.T) {
Expand Down Expand Up @@ -1514,3 +1515,211 @@ func TestDeleteUser(t *testing.T) {
assert.ErrorContains(t, err, "pat cleanup boom")
})
}

func blockerConfig() deleter.Config {
return deleter.Config{
Enabled: true,
Resources: map[string]deleter.Rules{
"compute/machine": {
deleter.ParentProject: deleter.RuleBlock,
deleter.ParentOrganization: deleter.RuleAllow,
},
"compute/order": {
deleter.ParentProject: deleter.RuleBlockUnlessDeletable,
deleter.ParentOrganization: deleter.RuleBlockUnlessDeletable,
},
"storage/file": {
deleter.ParentProject: deleter.RuleAllow,
deleter.ParentOrganization: deleter.RuleAllow,
},
"storage/transfer": {
deleter.ParentOrganization: deleter.RuleBlock,
},
},
}
}

func resourcesExist(namespace string, count int) deleter.Blocker {
return deleter.Blocker{
Type: deleter.BlockerResourcesExist,
Subject: namespace,
SubjectType: deleter.SubjectResourceNamespace,
Count: count,
}
}

func TestProjectResourceBlockers(t *testing.T) {
t.Run("returns no blockers and reads nothing while disabled", func(t *testing.T) {
m := newMocks(t)

blockers, err := m.build().ProjectResourceBlockers(context.Background(), "proj-1")

assert.NoError(t, err)
assert.Empty(t, blockers)
})

t.Run("counts blocking resources per namespace", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}).
Return([]resource.Resource{
{ID: "m1", NamespaceID: "compute/machine"},
{ID: "m2", NamespaceID: "compute/machine"},
{ID: "o1", NamespaceID: "compute/order", Metadata: map[string]any{"deletable": true}},
{ID: "o2", NamespaceID: "compute/order", Metadata: map[string]any{"deletable": false}},
{ID: "o3", NamespaceID: "compute/order", Metadata: map[string]any{"deletable": "true"}},
{ID: "o4", NamespaceID: "compute/order"},
{ID: "f1", NamespaceID: "storage/file"},
{ID: "t1", NamespaceID: "storage/transfer"},
{ID: "r1", NamespaceID: "compute/report"},
}, nil)

blockers, err := m.build().ProjectResourceBlockers(context.Background(), "proj-1")

assert.NoError(t, err)
assert.Equal(t, []deleter.Blocker{
resourcesExist("compute/machine", 2),
resourcesExist("compute/order", 3),
resourcesExist("compute/report", 1),
resourcesExist("storage/transfer", 1),
}, blockers)
})

t.Run("returns no blockers when nothing blocks", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}).
Return([]resource.Resource{
{ID: "o1", NamespaceID: "compute/order", Metadata: map[string]any{"deletable": true}},
{ID: "f1", NamespaceID: "storage/file"},
}, nil)

blockers, err := m.build().ProjectResourceBlockers(context.Background(), "proj-1")

assert.NoError(t, err)
assert.Empty(t, blockers)
})

t.Run("returns no blockers for a project with no resources", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}).
Return([]resource.Resource{}, nil)

blockers, err := m.build().ProjectResourceBlockers(context.Background(), "proj-1")

assert.NoError(t, err)
assert.Empty(t, blockers)
})

t.Run("returns error when resource list fails", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}).
Return(nil, errors.New("db error"))

_, err := m.build().ProjectResourceBlockers(context.Background(), "proj-1")

assert.ErrorContains(t, err, "failed to list resources of project[proj-1]: db error")
})
}

func TestOrganizationResourceBlockers(t *testing.T) {
t.Run("returns no blockers and reads nothing while disabled", func(t *testing.T) {
m := newMocks(t)

blockers, err := m.build().OrganizationResourceBlockers(context.Background(), "org-1")

assert.NoError(t, err)
assert.Empty(t, blockers)
})

t.Run("judges the org's own and its projects' resources by the organization rules", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{OrgID: "org-1"}).
Return([]resource.Resource{
{ID: "t1", NamespaceID: "storage/transfer"},
{ID: "f1", NamespaceID: "storage/file"},
}, nil)
m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1", IncludeDisabled: true}).
Return([]project.Project{{ID: "proj-1"}, {ID: "proj-2"}}, nil)
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}).
Return([]resource.Resource{
{ID: "m1", NamespaceID: "compute/machine"},
{ID: "m2", NamespaceID: "compute/machine"},
{ID: "o1", NamespaceID: "compute/order", Metadata: map[string]any{"deletable": false}},
}, nil)
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-2"}).
Return([]resource.Resource{
{ID: "o2", NamespaceID: "compute/order"},
{ID: "o3", NamespaceID: "compute/order", Metadata: map[string]any{"deletable": true}},
{ID: "r1", NamespaceID: "compute/report"},
}, nil)

blockers, err := m.build().OrganizationResourceBlockers(context.Background(), "org-1")

assert.NoError(t, err)
assert.Equal(t, []deleter.Blocker{
resourcesExist("compute/order", 2),
resourcesExist("compute/report", 1),
resourcesExist("storage/transfer", 1),
}, blockers)
})

t.Run("judges the org's own resources when it has no projects", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{OrgID: "org-1"}).
Return([]resource.Resource{
{ID: "t1", NamespaceID: "storage/transfer"},
{ID: "f1", NamespaceID: "storage/file"},
}, nil)
m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1", IncludeDisabled: true}).
Return([]project.Project{}, nil)

blockers, err := m.build().OrganizationResourceBlockers(context.Background(), "org-1")

assert.NoError(t, err)
assert.Equal(t, []deleter.Blocker{resourcesExist("storage/transfer", 1)}, blockers)
})

t.Run("returns error when the org's own resource list fails", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{OrgID: "org-1"}).
Return(nil, errors.New("db error"))

_, err := m.build().OrganizationResourceBlockers(context.Background(), "org-1")

assert.ErrorContains(t, err, "failed to list resources of organization[org-1]: db error")
})

t.Run("returns error when project list fails", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{OrgID: "org-1"}).
Return([]resource.Resource{}, nil)
m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1", IncludeDisabled: true}).
Return(nil, errors.New("db error"))

_, err := m.build().OrganizationResourceBlockers(context.Background(), "org-1")

assert.ErrorContains(t, err, "failed to list projects of organization[org-1]: db error")
})

t.Run("returns error when a project's resource list fails", func(t *testing.T) {
m := newMocks(t)
m.deletion = blockerConfig()
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{OrgID: "org-1"}).
Return([]resource.Resource{}, nil)
m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1", IncludeDisabled: true}).
Return([]project.Project{{ID: "proj-1"}}, nil)
m.resSvc.EXPECT().List(mock.Anything, resource.Filter{ProjectID: "proj-1"}).
Return(nil, errors.New("db error"))

_, err := m.build().OrganizationResourceBlockers(context.Background(), "org-1")

assert.ErrorContains(t, err, "failed to list resources of project[proj-1]: db error")
})
}
Loading