Skip to content

feat(deleter): project delete refuses while resources block it - #2008

Open
AmanGIT07 wants to merge 2 commits into
mainfrom
feat/project-delete-blockers
Open

AmanGIT07 wants to merge 2 commits into
mainfrom
feat/project-delete-blockers

Conversation

@AmanGIT07

@AmanGIT07 AmanGIT07 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

DeleteProject now refuses while the project's resources block the delete, using the RESOURCES_EXIST check from #2005. The refusal is failed_precondition with one google.rpc.PreconditionFailure violation per blocking namespace. The organization delete removes its projects without the project check, so a resource that blocks a project delete does not stop an organization delete. No delete is refused while app.deletion is disabled. A project that is not found counts as deleted, so a repeat delete does not fail in the deleter.

Changes

  • core/deleter/service.go:
    • DeleteProject looks the project up by id with IncludeDisabled. A project that is not found returns success and nothing is removed; an id that is not a uuid returns project.ErrInvalidUUID.
    • It then runs ProjectResourceBlockers, and returns a BlockedError naming the project when anything blocks.
    • deleteProject holds the removal steps and skips the blocker check, because what blocks a project delete must not block an organization delete. DeleteOrganization calls it for each project. A policy that is already gone is skipped.
  • core/deleter/deleter.go: BlockedError gains ProjectID. Error() names the project or the organization and leaves out empty messages, so a project refusal reads project cannot be deleted yet.
  • internal/api/v1beta1connect/deleter.go: DeleteProject answers failed_precondition with the blockers, and not_found for an id that is not a uuid. The blocked-delete log lines carry project_id and no longer say organization.
  • test/e2e/regression/api_test.go: the API suite runs with app.deletion enabled and a rule for compute/order (project: block, organization: allow).

Technical Details

  • On the Connect protocol, failed_precondition is HTTP 400. A violation carries the blocker type and the namespace; PreconditionFailure has no field for the count.
  • DeleteProject with a project name answered 500 before; it now answers not_found.
  • A repeat DeleteProject through the API is refused by the permission check with permission_denied, because the delete removed the project's SpiceDB relations. It does not reach the deleter.
  • OrganizationResourceBlockers has no caller yet, so the organization delete applies no resource rule and the organization rules of the deletion config are ignored for now. feat(deleter): org delete refuses while resources block it #2017 wires it into the organization delete.

Test Plan

  • core/deleter/service_test.go TestDeleteProject: a blocked project returns the BlockedError and removes nothing; a project that is not found returns success and removes nothing; a policy that is already gone does not stop the delete; a name returns project.ErrInvalidUUID; the existing removal cases expect the lookup.
  • TestDeleteOrganization: with the deletion config on, an org whose project holds a resource that blocks a project delete is deleted, and the resource is removed.
  • internal/api/v1beta1connect/deleter_test.go TestHandler_DeleteProject: a blocked delete answers failed_precondition with one RESOURCES_EXIST violation; a missing project and a name answer not_found.
  • e2e TestProjectDeleteRefusedWhileResourcesBlock: a project holding a compute/order resource is refused with that violation and stays readable; after the resource is deleted, the project delete succeeds; an org whose project still holds the resource is deleted.
  • go test ./core/... ./internal/api/... ./pkg/server/... passes.
  • e2e TestEndToEndAPIRegressionTestSuite, TestEndToEndServiceUsersRegressionTestSuite and TestEndToEndPATRegressionTestSuite pass.
  • golangci-lint run ./core/deleter/... ./internal/api/v1beta1connect/... ./test/e2e/regression/... reports no issues.

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
frontier Ready Ready Preview Oct 22, 2026 10:27am UTC

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Summary

Summary by CodeRabbit

  • New Features

    • Project deletion validates identifiers and reports missing or invalid projects as not found.
    • Deletion is blocked when resources prevent project removal, with details identifying the blockers.
    • Organization deletion continues its cascade when project resource cleanup fails.
  • Bug Fixes

    • Improved blocked-deletion messages for projects and organizations.
    • Empty blocker messages are excluded from user-facing errors.

Project deletion now validates project IDs, loads disabled projects, checks resource blockers, and deletes unblocked projects. The Connect API maps blocked, missing, and invalid project errors. Organization deletion uses the internal project teardown path.

Changes: Project deletion behavior

Layer / File(s) Summary
Project deletion checks and teardown
core/deleter/deleter.go, core/deleter/service.go, core/deleter/service_test.go
BlockedError supports project targets and omits empty blocker messages. Project deletion validates and loads the project, checks resource blockers, and deletes it when unblocked. Organization deletion calls the internal teardown path. Unit tests cover these cases.
Connect error mapping and logging
internal/api/v1beta1connect/deleter.go, internal/api/v1beta1connect/deleter_test.go
The Connect API maps blocked project deletions to failed-precondition errors and missing or invalid projects to not-found errors. Blocked-delete logs include the project ID.
End-to-end deletion scenarios
test/e2e/regression/api_test.go
Regression tests verify project deletion behavior for blocking compute/order resources and organization deletion with another blocked project.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Suggested reviewers: rohilsurana

Merge Risk: 🟡 Moderate · up to 74cb6

A project delete can remove a newly created blocking resource instead of refusing deletion. Coordinate creation and deletion before merging, unless this risk is explicitly accepted.

Pre-merge checks: Passed 2
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Oct 20, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39763193357

Coverage increased (+0.03%) to 55.674%

Details

  • Coverage increased (+0.03%) from the base build.
  • Patch coverage: 8 uncovered changes across 3 files (36 of 44 lines covered, 81.82%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/deleter/service.go 24 20 83.33%
core/deleter/deleter.go 11 8 72.73%
internal/api/v1beta1connect/deleter.go 9 8 88.89%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 41894
Covered Lines: 23324
Line Coverage: 55.67%
Coverage Strength: 19.32 hits per line

💛 - Coveralls

@AmanGIT07

Copy link
Copy Markdown
Contributor Author

Sandbox run: a local Frontier server with Postgres and SpiceDB in Docker, called over the Connect protocol. The binary was built from #2014 with #2011 applied, so it has main plus #2008, #2011, #2013 and #2014.

Deletion config used:

app:
  deletion:
    enabled: true
    resources:
      resource/aoi: { project: block, organization: allow }
      resource/workflow: { project: block, organization: allow }
      resource/orderitem: { project: block_unless_deletable, organization: block_unless_deletable }
      resource/insight: { project: allow, organization: allow }

resource/report has no rule.

DeleteProject responses when resources block

Project with 2 AOIs, 1 workflow, 1 order item still processing, and 1 finished order item ("deletable": true):
DeleteProject → HTTP 400

{
  "code": "failed_precondition",
  "message": "project cannot be deleted yet",
  "details": [
    {
      "type": "google.rpc.PreconditionFailure",
      "value": "Ch8KD1JFU09VUkNFU19FWElTVBIMcmVzb3VyY2UvYW9pCiUKD1JFU09VUkNFU19FWElTVBIScmVzb3VyY2Uvb3JkZXJpdGVtCiQKD1JFU09VUkNFU19FWElTVBIRcmVzb3VyY2Uvd29ya2Zsb3c",
      "debug": {
        "violations": [
          {
            "type": "RESOURCES_EXIST",
            "subject": "resource/aoi"
          },
          {
            "type": "RESOURCES_EXIST",
            "subject": "resource/orderitem"
          },
          {
            "type": "RESOURCES_EXIST",
            "subject": "resource/workflow"
          }
        ]
      }
    }
  ]
}

Project with 1 resource/report resource (no rule in the config):
DeleteProject → HTTP 400

{
  "code": "failed_precondition",
  "message": "project cannot be deleted yet",
  "details": [
    {
      "type": "google.rpc.PreconditionFailure",
      "value": "CiIKD1JFU09VUkNFU19FWElTVBIPcmVzb3VyY2UvcmVwb3J0",
      "debug": {
        "violations": [
          {
            "type": "RESOURCES_EXIST",
            "subject": "resource/report"
          }
        ]
      }
    }
  ]
}

Disabled project with 1 AOI:
DeleteProject → HTTP 400

{
  "code": "failed_precondition",
  "message": "project cannot be deleted yet",
  "details": [
    {
      "type": "google.rpc.PreconditionFailure",
      "value": "Ch8KD1JFU09VUkNFU19FWElTVBIMcmVzb3VyY2UvYW9p",
      "debug": {
        "violations": [
          {
            "type": "RESOURCES_EXIST",
            "subject": "resource/aoi"
          }
        ]
      }
    }
  ]
}

Each refusal is logged as a warning, for example:

{"level": "WARN", "msg": "delete blocked", "org_id": "", "project_id": "e5969d9b-a95d-4fce-85ac-62f230ff5563", "blockers": 3}

Other checks

Check Expected Observed
The refused project is untouched row live, 5 live row live, 5 live
Delete by an org viewer (no delete permission) permission_denied permission_denied
Delete an empty project (owner) ok ok
Delete the first project above after deleting its 4 blocking resources ok ok
CheckProjectDelete on an org's project holding an AOI can_delete=false, blockers=[('resource/aoi', 1)] can_delete=false, blockers=[('resource/aoi', 1)]
DeleteOrganization on that org (project rules do not apply) ok ok
With enabled: false, delete that project ok ok

Comment thread core/deleter/service.go
if !utils.IsValidUUID(id) {
return project.ErrInvalidUUID
}
projects, err := d.projService.List(ctx, project.Filter{ProjectIDs: []string{id}, IncludeDisabled: true})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeleteProject should be safe to call twice. After the first delete, the project is gone from the live list, so a second call returns project.ErrNotExist, which the handler turns into not_found. The ErrNotExist check on DeleteModel below was added for retries, and this lookup makes it unreachable. Please return success when the project is already deleted.

The authorization check runs before this code. Once the project's relations are removed, a repeat call may be stopped there. Please make sure a project that is not found in authz is handled the same way, so a retry does not fail.

This RPC works with ids only, so the name based lookup for projects can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deleter now returns success when the project is not found, and skips a policy that is already gone.

The permission check stays as it is. After a finished delete the project has no SpiceDB links, so a repeat call through the API answers permission_denied before it reaches the deleter. Today a caller without access gets permission_denied for a live project id and for a missing one alike. Letting a missing id through would answer success for it, so such a caller could tell which ids are live projects.

A project name passes the permission check and is then refused by the uuid check in the deleter, so this RPC still deletes by id only.

Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go
DeleteProject runs the RESOURCES_EXIST check and returns a BlockedError
naming the project, which the API answers with failed_precondition and one
violation per blocking namespace. The org delete removes its projects
without that check. An unknown project or an id that is not a uuid answers
not found. The API e2e suite runs with the deletion config on.
DeleteProject returns success when no live project has the id, and the
project delete skips a policy that is already gone, so a repeat delete
does not fail in the deleter.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 0568226 and 74cb6ad.

📒 Files selected for processing: 2
  • core/deleter/service.go
  • core/deleter/service_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.

Comment thread core/deleter/service.go
if len(projects) == 0 {
return nil
}
blockers, err := d.ProjectResourceBlockers(ctx, id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🏁 Script executed:

set -eu
printf '%s\n' '--- changed files ---'
git diff --name-only 8053fb367641cd0da8c37604f5b67a8c8b6fcfda 74cb6ad54ffc05e1a42857713b2c191b822f58f9
printf '%s\n' '--- deletion symbols and references ---'
rg -n -F --glob '*.go' -e 'ProjectResourceBlockers' -e 'func (d *Deleter) deleteProject' -e 'deleteProject(ctx' -e 'DeleteProject' core internal pkg
printf '%s\n' '--- service.go ---'
nl -ba core/deleter/service.go | sed -n '180,280p'
printf '%s\n' '--- deleter.go ---'
nl -ba core/deleter/deleter.go | sed -n '1,180p'
printf '%s\n' '--- resource-related directories ---'
find core internal pkg -type f -name '*.go' | grep -E '/(resource|aoi|workflow|order|namespace|project)/' | head -200

Repository: raystack/frontier

Length of output: 18798


🏁 Script executed:

set -eu
printf '%s\n' '--- changed files ---'
git diff --name-only 8053fb367641cd0da8c37604f5b67a8c8b6fcfda 74cb6ad54ffc05e1a42857713b2c191b822f58f9
printf '%s\n' '--- deletion symbols and references ---'
rg -n -F --glob '*.go' -e 'ProjectResourceBlockers' -e 'func (d *Deleter) deleteProject' -e 'deleteProject(ctx' -e 'DeleteProject' core internal pkg || test "$?" -eq 1
printf '%s\n' '--- service.go ---'
nl -ba core/deleter/service.go | sed -n '180,280p'
printf '%s\n' '--- deleter.go ---'
nl -ba core/deleter/deleter.go | sed -n '1,180p'
printf '%s\n' '--- resource-related files ---'
find core internal pkg -type f -name '*.go' | grep -E '/(resource|aoi|workflow|order|namespace|project)/' | head -200

Repository: raystack/frontier

Length of output: 18792


🏁 Script executed:

set -eu
printf '%s\n' '--- blocker implementation ---'
nl -ba core/deleter/resource_blockers.go | sed -n '1,220p'
printf '%s\n' '--- resource service declarations and create path ---'
rg -n -F --glob '*.go' -e 'func (.*) Create' -e 'Create(ctx' -e 'CreateResource' -e 'ResourceService' core/resource internal/api | head -240
nl -ba core/resource/service.go | sed -n '1,260p'
printf '%s\n' '--- resource repository declarations and implementations ---'
rg -n -F --glob '*.go' -e 'type Repository interface' -e 'func (.*) Create' -e 'INSERT INTO' -e 'ProjectID' core/resource internal pkg | head -300
printf '%s\n' '--- transaction and lock references around project/resource operations ---'
rg -n -i --glob '*.go' -e 'transaction' -e 'tx\.' -e 'for update' -e 'advisory' -e 'mutex' -e 'lock' core/deleter core/resource core/project internal/api | head -300

Repository: raystack/frontier

Length of output: 42220


🏁 Script executed:

set -eu
printf '%s\n' '--- blocker implementation ---'
nl -ba core/deleter/resource_blockers.go | sed -n '1,180p'
printf '%s\n' '--- resource create symbols ---'
rg -n -F --glob '*.go' -e 'func (s Service) Create' -e 'func (.*) Create' -e 'Create(ctx' core/resource core/namespace internal/api | head -240
printf '%s\n' '--- resource service ---'
nl -ba core/resource/service.go | sed -n '1,280p'
printf '%s\n' '--- resource repository interfaces and implementations ---'
rg -n -F --glob '*.go' -e 'type Repository interface' -e 'Create(ctx context.Context' -e 'DeleteModel(ctx context.Context' -e 'Purge(ctx context.Context' core/resource core/project pkg | head -300
printf '%s\n' '--- synchronization and transaction references ---'
rg -n -i --glob '*.go' -e 'transaction' -e 'for update' -e 'advisory' -e 'mutex' -e 'lock' core/deleter core/resource core/project internal/api | head -300

Repository: raystack/frontier

Length of output: 42043


🏁 Script executed:

set -eu
printf '%s\n' '--- complete resource Create block ---'
nl -ba core/resource/service.go | sed -n '80,175p'
printf '%s\n' '--- resource endpoint block ---'
nl -ba internal/api/v1beta1connect/resource.go | sed -n '205,240p'
printf '%s\n' '--- resource repository Create declaration ---'
nl -ba core/resource/resource.go | sed -n '1,45p'

Repository: raystack/frontier

Length of output: 7080


🏁 Script executed:

set -eu
printf '%s\n' '--- concrete resource persistence ---'
rg -n -F --glob '*.go' -e 'type ResourceRepository' -e 'func (.*Resource.*) Create' -e 'func (.*resource.*) Create' -e 'func (.*) DeleteModel' internal/store core
printf '%s\n' '--- candidate resource repository ---'
nl -ba internal/store/postgres/resource_repository.go | sed -n '1,260p'
printf '%s\n' '--- project persistence declarations and implementations ---'
rg -n -F --glob '*.go' -e 'DeleteModel' -e 'type ProjectRepository' internal/store core/project

Repository: raystack/frontier

Length of output: 11255


Serialize resource creation with project deletion.

DeleteProject checks blockers, then deleteProject lists resources again. Resource creation remains possible between these calls. A newly created blocking resource can be found by the second list, purged, and omitted from the BlockedError, so the delete can return success. Coordinate resource creation and deletion so this interval cannot admit new resources.

This branch was successfully deployed

1 active deployment
Preview — 74cb6ad5 Deployed Oct 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants