From cb42d942df751f0580af9d0ce3f4a163e5967862 Mon Sep 17 00:00:00 2001 From: aman Date: Sun, 18 Oct 2026 14:31:21 +0530 Subject: [PATCH] feat(deleter): add the app.deletion config for resource delete rules app.deletion gives each resource namespace a rule for a project delete and for an organization delete: block, block_unless_deletable or allow. A namespace or delete with no rule blocks. The block is off by default and is checked at boot. Nothing reads the rules yet. --- cmd/serve.go | 5 +- config/config_test.go | 61 ++++++++++ config/sample.config.yaml | 12 ++ config/testdata/deletion.yaml | 9 ++ core/deleter/config.go | 70 ++++++++++++ core/deleter/config_test.go | 107 ++++++++++++++++++ core/deleter/service.go | 5 +- core/deleter/service_test.go | 2 +- .../content/docs/reference/configurations.mdx | 32 ++++++ pkg/server/config.go | 4 + 10 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 config/testdata/deletion.yaml create mode 100644 core/deleter/config.go create mode 100644 core/deleter/config_test.go diff --git a/cmd/serve.go b/cmd/serve.go index d82a03fa63..59463a558e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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 { @@ -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 diff --git a/config/config_test.go b/config/config_test.go index ecbe0752fa..10d55fdbbd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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" ) @@ -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) + } + }) +} diff --git a/config/sample.config.yaml b/config/sample.config.yaml index 20c2eb6d2c..2a5e090831 100644 --- a/config/sample.config.yaml +++ b/config/sample.config.yaml @@ -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 diff --git a/config/testdata/deletion.yaml b/config/testdata/deletion.yaml new file mode 100644 index 0000000000..508d163083 --- /dev/null +++ b/config/testdata/deletion.yaml @@ -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 } diff --git a/core/deleter/config.go b/core/deleter/config.go new file mode 100644 index 0000000000..a361140b0f --- /dev/null +++ b/core/deleter/config.go @@ -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 +} diff --git a/core/deleter/config_test.go b/core/deleter/config_test.go new file mode 100644 index 0000000000..5b0515ba82 --- /dev/null +++ b/core/deleter/config_test.go @@ -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, + }, + }, + } +} diff --git a/core/deleter/service.go b/core/deleter/service.go index 75ce2a4df6..a39063d1be 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -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, @@ -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, @@ -206,6 +208,7 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic planService: planService, mailDialer: mailDialer, deleteNoticeConfig: deleteNoticeConfig, + deletionConfig: deletionConfig, } } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index 36dba4e3bb..94af4ca09b 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -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) { diff --git a/docs/content/docs/reference/configurations.mdx b/docs/content/docs/reference/configurations.mdx index 316ed57fb6..beb7843787 100644 --- a/docs/content/docs/reference/configurations.mdx +++ b/docs/content/docs/reference/configurations.mdx @@ -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 @@ -304,6 +314,28 @@ Every document in the map is required at signup, so there is no per-document `re | **app.consent.documents.\.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.\.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.\.project** | Rule for a project delete. Missing, it stops the delete. | `block` | No | +| **app.deletion.resources.\.organization** | Rule for an organization delete. Missing, it stops the delete. | `allow` | No | + ### Admin Configurations | **Field** | **Description** | **Example** | **Required** | diff --git a/pkg/server/config.go b/pkg/server/config.go index d3cf666871..c5c219b170 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -4,6 +4,7 @@ import ( "time" "github.com/raystack/frontier/core/consent" + "github.com/raystack/frontier/core/deleter" "github.com/raystack/frontier/core/metaschema" "github.com/raystack/frontier/core/userpat" "github.com/raystack/frontier/core/webhook" @@ -98,6 +99,9 @@ type Config struct { // Consent lists the documents a user must accept at signup. Disabled by default. Consent consent.Config `yaml:"consent" mapstructure:"consent"` + // Deletion says which resources block a project or org delete. Disabled by default. + Deletion deleter.Config `yaml:"deletion" mapstructure:"deletion"` + Metaschema metaschema.Config `yaml:"metaschema" mapstructure:"metaschema"` // AdditionalTraitsPath is a file path to a YAML file containing additional preference traits