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
5 changes: 4 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ func buildAPIDependencies(
if err := cfg.App.Consent.Validate(); err != nil {
return api.Deps{}, err
}
if err := cfg.App.Deletion.Validate(); err != nil {
return api.Deps{}, err
}

var tokenKeySet jwk.Set
if len(cfg.App.Authentication.Token.RSAPath) > 0 {
Expand Down Expand Up @@ -599,7 +602,7 @@ func buildAPIDependencies(
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
domainService, serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
creditService, orgKycService, planService,
mailDialer, cfg.Billing.OrgDeleteNotice,
mailDialer, cfg.Billing.OrgDeleteNotice, cfg.App.Deletion,
)

// we should default it with a stdout logger repository as postgres can start to bloat really fast
Expand Down
61 changes: 61 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package config

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"

"github.com/raystack/frontier/core/deleter"
"github.com/raystack/frontier/internal/store/spicedb"
"github.com/raystack/frontier/pkg/db"
)
Expand Down Expand Up @@ -63,3 +67,60 @@ func TestLoad(t *testing.T) {
})
}
}

func TestLoadDeletion(t *testing.T) {
t.Run("reads namespace keys and rules", func(t *testing.T) {
want := 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.ParentOrganization: deleter.RuleAllow,
},
},
}

got, err := Load("testdata/deletion.yaml")
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !reflect.DeepEqual(got.App.Deletion, want) {
t.Errorf("Load() got = %v\nwant %v", got.App.Deletion, want)
}
})

t.Run("an environment variable turns the check off", func(t *testing.T) {
t.Setenv("FRONTIER_SERVICE_APP_DELETION_ENABLED", "false")

got, err := Load("testdata/deletion.yaml")
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got.App.Deletion.Enabled {
t.Errorf("Load() got enabled = true, want false")
}
})

t.Run("a misspelled key reaches the boot check", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
body := "app:\n deletion:\n enabled: true\n resources:\n compute/machine: { projct: block }\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}

got, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err := got.App.Deletion.Validate(); err == nil || !strings.Contains(err.Error(), `unknown key "projct"`) {
t.Errorf("Validate() error = %v, want an unknown key error", err)
}
})
}
12 changes: 12 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,18 @@ app:
version: "2026-02-14"
url: "https://example.org/legal/eula/2026-02-14"

# live resources that block a project or organization delete
deletion:
# false (the default) keeps deletes as they are
enabled: false
# a rule per namespace for each delete: block, allow, or
# block_unless_deletable (blocks until the resource metadata has
# "deletable": true). a namespace or delete with no rule blocks.
resources:
compute/machine: { project: block, organization: allow }
compute/order: { project: block_unless_deletable, organization: block_unless_deletable }
storage/file: { organization: allow }

# metaschema cache configuration
metaschema:
# how often each server reloads the metaschema cache from the database, so a
Expand Down
9 changes: 9 additions & 0 deletions config/testdata/deletion.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: 1

app:
deletion:
enabled: true
resources:
compute/machine: { project: block, organization: allow }
compute/order: { project: block_unless_deletable, organization: block_unless_deletable }
storage/file: { organization: allow }
70 changes: 70 additions & 0 deletions core/deleter/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package deleter

import (
"fmt"
"maps"
"slices"

"github.com/raystack/frontier/internal/bootstrap/schema"
)

// Parent is what is being deleted.
type Parent string

const (
ParentProject Parent = "project"
ParentOrganization Parent = "organization"
)

type Rule string

const (
RuleBlock Rule = "block"
// RuleBlockUnlessDeletable blocks until the resource metadata has
// deletable set to true.
RuleBlockUnlessDeletable Rule = "block_unless_deletable"
RuleAllow Rule = "allow"
)

type Rules map[Parent]Rule

type Config struct {
Enabled bool `yaml:"enabled" mapstructure:"enabled" default:"false"`
Resources map[string]Rules `yaml:"resources" mapstructure:"resources"`
}

func (c Config) Validate() error {
if !c.Enabled {
return nil
}
if len(c.Resources) == 0 {
return fmt.Errorf("app.deletion is enabled but lists no resources")
}
// sorted for a stable error
for _, namespace := range slices.Sorted(maps.Keys(c.Resources)) {
if err := validateResource(namespace, c.Resources[namespace]); err != nil {
return err
}
}
return nil
}

func validateResource(namespace string, rules Rules) error {
if !schema.IsValidPermissionNamespace(namespace) {
return fmt.Errorf("app.deletion resource %q must look like service/name, for example compute/machine", namespace)
}
if len(rules) == 0 {
return fmt.Errorf("app.deletion resource %q lists no rules", namespace)
}
for _, parent := range slices.Sorted(maps.Keys(rules)) {
if parent != ParentProject && parent != ParentOrganization {
return fmt.Errorf("app.deletion resource %q has unknown key %q: use %s or %s",
namespace, parent, ParentProject, ParentOrganization)
}
if rule := rules[parent]; rule != RuleBlock && rule != RuleBlockUnlessDeletable && rule != RuleAllow {
return fmt.Errorf("app.deletion resource %q has unknown %s rule %q: use %s, %s or %s",
namespace, parent, rule, RuleBlock, RuleBlockUnlessDeletable, RuleAllow)
}
}
return nil
}
107 changes: 107 additions & 0 deletions core/deleter/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package deleter_test

import (
"fmt"
"testing"

"github.com/raystack/frontier/core/deleter"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfig_Validate(t *testing.T) {
t.Run("accepts every rule", func(t *testing.T) {
require.NoError(t, enabledDeletionConfig().Validate())
})

t.Run("accepts a disabled block", func(t *testing.T) {
require.NoError(t, deleter.Config{}.Validate())
})

t.Run("does not check a disabled block", func(t *testing.T) {
config := deleter.Config{
Resources: map[string]deleter.Rules{
"compute/machine": {deleter.ParentProject: "blok"},
},
}

require.NoError(t, config.Validate())

config.Enabled = true
require.Error(t, config.Validate())
})

t.Run("rejects enabled with no resources", func(t *testing.T) {
err := deleter.Config{Enabled: true}.Validate()

require.Error(t, err)
assert.Contains(t, err.Error(), "lists no resources")
})

t.Run("rejects a resource with no rules", func(t *testing.T) {
for _, rules := range []deleter.Rules{nil, {}} {
config := enabledDeletionConfig()
config.Resources["compute/disk"] = rules

err := config.Validate()

require.Error(t, err)
assert.Contains(t, err.Error(), `"compute/disk" lists no rules`)
}
})

for _, parent := range []deleter.Parent{"projct", "org", "workspace"} {
t.Run(fmt.Sprintf("rejects key %q", parent), func(t *testing.T) {
config := enabledDeletionConfig()
config.Resources["compute/disk"] = deleter.Rules{parent: deleter.RuleBlock}

err := config.Validate()

require.Error(t, err)
assert.Contains(t, err.Error(), `"compute/disk" has unknown key "`+string(parent)+`"`)
})
}

for _, rule := range []deleter.Rule{"blok", "", "BLOCK", "block_unless_finished"} {
t.Run(fmt.Sprintf("rejects rule %q", rule), func(t *testing.T) {
config := enabledDeletionConfig()
config.Resources["compute/order"][deleter.ParentOrganization] = rule

err := config.Validate()

require.Error(t, err)
assert.Contains(t, err.Error(), `"compute/order" has unknown organization rule "`+string(rule)+`"`)
})
}

for _, namespace := range []string{"machine", "compute/", "compute/machine/disk", "compute_machine"} {
t.Run(fmt.Sprintf("rejects namespace %q", namespace), func(t *testing.T) {
config := enabledDeletionConfig()
config.Resources[namespace] = deleter.Rules{deleter.ParentProject: deleter.RuleBlock}

err := config.Validate()

require.Error(t, err)
assert.Contains(t, err.Error(), `"`+namespace+`" must look like service/name`)
})
}
}

func enabledDeletionConfig() 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.ParentOrganization: deleter.RuleAllow,
},
},
}
}
5 changes: 4 additions & 1 deletion core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ type Service struct {
// owners their organization was deleted
mailDialer mailer.Dialer
deleteNoticeConfig billing.OrgDeleteNoticeConfig
deletionConfig Config
}

func NewCascadeDeleter(orgService OrganizationService, projService ProjectService,
Expand All @@ -183,7 +184,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
invoiceService InvoiceService, checkoutService CheckoutService,
creditService CreditService, kycService KycService,
planService PlanService,
mailDialer mailer.Dialer, deleteNoticeConfig billing.OrgDeleteNoticeConfig) *Service {
mailDialer mailer.Dialer, deleteNoticeConfig billing.OrgDeleteNoticeConfig,
deletionConfig Config) *Service {
return &Service{
projService: projService,
orgService: orgService,
Expand All @@ -206,6 +208,7 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
planService: planService,
mailDialer: mailDialer,
deleteNoticeConfig: deleteNoticeConfig,
deletionConfig: deletionConfig,
}
}

Expand Down
2 changes: 1 addition & 1 deletion core/deleter/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,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{})
m.planSvc, m.dialer, billing.OrgDeleteNoticeConfig{}, deleter.Config{})
}

func TestDeleteProject(t *testing.T) {
Expand Down
32 changes: 32 additions & 0 deletions docs/content/docs/reference/configurations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ app:
title: "End User License Agreement"
version: "2026-02-14"
url: "https://example.org/legal/eula/2026-02-14"
# live resources that block a project or organization delete
deletion:
# false (the default) keeps deletes as they are
enabled: false
# a rule per namespace for each delete. a namespace or delete with no
# rule blocks.
resources:
compute/machine: { project: block, organization: allow }
compute/order: { project: block_unless_deletable, organization: block_unless_deletable }
storage/file: { organization: allow }
# metaschema cache configuration
metaschema:
# how often each server reloads the metaschema cache from the database, so a
Expand Down Expand Up @@ -304,6 +314,28 @@ Every document in the map is required at signup, so there is no per-document `re
| **app.consent.documents.\<id\>.version** | Opaque version, copied into the consent record and compared for equality only. Dates, semver or commit SHAs all work. | `"2026-04-01"` | Yes (when enabled) |
| **app.consent.documents.\<id\>.url** | Absolute URL of the document. Frontier never reads what is behind it. | `"https://example.org/legal/terms"` | Yes (when enabled) |

### Deletion Configurations

Live resources that stop a project or an organization from being deleted. Frontier decides from its own resources table and never asks the service that owns a resource.

The map key is the resource namespace, such as `compute/machine`. Each namespace gives one rule for a project delete and one for an organization delete. A namespace that is not listed, or a delete with no rule, stops that delete. Config is read at boot, so any change here needs a restart.

| **Rule** | **What it does** |
| ------------------------ | --------------------------------------------------------------------------------------------------------- |
| `block` | Any live resource stops the delete. The owning service deletes the resource first. |
| `block_unless_deletable` | A live resource stops the delete unless its metadata has `"deletable": true`. The owning service sets it. |
| `allow` | Never stops the delete. |

Only the boolean `true` counts as deletable. A missing key, `false`, or the string `"true"` stops the delete. A resource update replaces the whole metadata, so the owning service sends `deletable` on every update.

An unknown key or rule, a namespace with no rules, or a namespace not shaped like `service/name` fails at boot.

| **Field** | **Description** | **Example** | **Required** |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ----------- | --------------------- |
| **app.deletion.enabled** | Switches the check. Disabled, live resources never stop a delete. Enabled with no resources fails at boot. | `false` | No (default: `false`) |
| **app.deletion.resources.\<namespace\>.project** | Rule for a project delete. Missing, it stops the delete. | `block` | No |
| **app.deletion.resources.\<namespace\>.organization** | Rule for an organization delete. Missing, it stops the delete. | `allow` | No |

Comment thread
AmanGIT07 marked this conversation as resolved.
### Admin Configurations

| **Field** | **Description** | **Example** | **Required** |
Expand Down
Loading
Loading