fix(permission): clean up leftover access in SpiceDB when a permission is deleted#1685
fix(permission): clean up leftover access in SpiceDB when a permission is deleted#1685whoAbhishekSah wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR implements cascading deletion for permissions. When a permission is deleted, all role→permission relation tuples referencing that permission are also removed via RelationService. The permission service is wired with the relation service, a DeletePermission API handler is exposed, superuser authorization is applied, and end-to-end tests validate the cascade behavior. ChangesPermission Cascade Deletion
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (3)
core/permission/service_test.go (2)
219-220: ⚡ Quick winAssert the exact cascade delete relation payload.
Line 219 and Line 252 use
mock.Anythingfor the relation argument, so tests won’t catch a broken namespace/relation-name contract.Proposed test assertion tightening
+ // add imports: + // "github.com/raystack/frontier/core/relation" + // "github.com/raystack/frontier/internal/bootstrap/schema" + expectedRel := relation.Relation{ + Object: relation.Object{ + Namespace: schema.RoleNamespace, + }, + RelationName: perm.Slug, + } - mockRelation.On("Delete", mock.Anything, mock.Anything).Return(nil).Once() + mockRelation.On("Delete", mock.Anything, expectedRel).Return(nil).Once() - mockRelation.On("Delete", mock.Anything, mock.Anything).Return(expectedErr).Once() + mockRelation.On("Delete", mock.Anything, expectedRel).Return(expectedErr).Once()Also applies to: 252-253
242-258: ⚡ Quick winAdd a delete test for ignored
relation.ErrNotExist.
Service.Deleteintentionally toleratesrelation.ErrNotExist; add a subtest to lock that behavior and ensure repo delete still executes.Suggested subtest
+ t.Run("should continue deleting permission when relation sweep returns ErrNotExist", func(t *testing.T) { + mockRepo := mocks.NewRepository(t) + mockRelation := mocks.NewRelationService(t) + svc := permission.NewService(mockRepo, mockRelation) + + permissionID := uuid.New().String() + perm := permission.Permission{ID: permissionID, Name: "delete", Slug: "app_organization_delete"} + + mockRepo.On("Get", mock.Anything, permissionID).Return(perm, nil).Once() + mockRelation.On("Delete", mock.Anything, mock.Anything).Return(relation.ErrNotExist).Once() + mockRepo.On("Delete", mock.Anything, permissionID).Return(nil).Once() + + err := svc.Delete(context.Background(), permissionID) + assert.NoError(t, err) + })test/e2e/regression/service_registration_test.go (1)
170-175: ⚡ Quick winDerive
permSlugvia schema helper instead of hardcoding.Line 170 hardcodes slug formatting; generating it from namespace/name keeps this test stable if slug rules change.
Proposed tweak
- const permSlug = "permcascade_res_act" + const permNamespace = "permcascade/res" + const permName = "act" + permSlug := schema.FQPermissionNameFromNamespace(permNamespace, permName) createPermResp, err := s.testBench.AdminClient.CreatePermission(ctx, connect.NewRequest(&frontierv1beta1.CreatePermissionRequest{ Bodies: []*frontierv1beta1.PermissionRequestBody{ - {Name: "act", Namespace: "permcascade/res"}, + {Name: permName, Namespace: permNamespace}, }, }))
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2dca1fc8-e791-43e9-a9f1-c6f82b8eba39
📒 Files selected for processing (9)
cmd/serve.gocore/permission/mocks/relation_service.gocore/permission/service.gocore/permission/service_test.gointernal/api/v1beta1connect/interfaces.gointernal/api/v1beta1connect/mocks/permission_service.gointernal/api/v1beta1connect/permission.gopkg/server/connect_interceptors/authorization.gotest/e2e/regression/service_registration_test.go
Coverage Report for CI Build 27198032949Coverage decreased (-0.06%) to 43.209%Details
Uncovered Changes
Coverage Regressions4 previously-covered lines in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
27696db to
8c37eb5
Compare
8c37eb5 to
0d86850
Compare
0d86850 to
632e276
Compare
632e276 to
391819b
Compare
Manual verification ✅Tested against a local build of this branch (live ConnectRPC + SpiceDB) as a super admin. The config ( Part 1 — Which permissions can be deleted?The rule: a permission is deletable only if it is not recreated by bootstrap — i.e. it does not come from the base schema or the config file. Built-ins are rejected because the next restart would just bring them back. Setup: picked one permission from each source and called
So only the API-created permission could be deleted; the base-schema and config-defined ones were protected. Note the config check reads the config file live on each request, so it caught Part 2 — What happens to a role that was using a deleted permission, and does an access check still pass?Setup:
Result:
Two things happen to the role on delete:
One detail: after deletion the check returns |
391819b to
6cf5c46
Compare
permission.Delete only removed the DB row. Every role granting the permission keeps app/role:<role>#<slug>@<*> tuples (one per principal type), so deleting a permission left those tuples dangling on a relation no longer backed by any permission row. The method was also unreachable: the DeletePermission RPC was hardwired to "function not available". Changes: - permission.Service gains a relation dependency and, on Delete, sweeps the role->permission tuples by object-namespace (app/role) + relation-name (the permission slug), clearing them across all roles and principal types before removing the row. Tolerates ErrNotExist for unused permissions. - Implement the DeletePermission admin handler and gate it on superuser (previously returned CodeUnavailable), making the guard reachable and giving the data-cleanup effort a way to remove stray permissions. Adds an e2e regression test: create a permission, build a role on it, delete the permission, assert no role->permission tuple remains. Verified it fails without the sweep (tuple lingers) and passes with it. Refs #1661 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6cf5c46 to
57f928c
Compare
Background: how permissions are stored
A permission lives in three places:
permissionstable (Postgres) — one row per permission.rolestable (Postgres) — each role keeps a list of the permission slugs it grants.(format:
app/role:<role-id>#<permission-slug>@<principal>:*)Permission rows are written by exactly one function — bootstrap's
AppendSchema(viamigrateServiceDefinitionToDB). It's triggered from two entry points: at boot (MigrateSchema, from the base schema + config) and by theCreatePermissionAPI.The problems
permission.Deleteonly removed the Postgres row and left the SpiceDB grant tuples behind. As long as those tuples exist, a permission check keeps returning true — the deleted permission still grants access.roles.permissionslist (there's no FK between the tables — it's a denormalized jsonb array), so roles ended up referencing a permission that no longer exists.DeletePermissionadmin RPC was hardwired to return "function not available."The fix
permission.Deleteremoves everyapp/role:*#<slug>grant tuple (across all roles and principal types) before deleting the row, so a check returns false immediately after delete.role.Service.RemovePermissionFromRoles. This goes through the role service (not its repo) to respect domain boundaries; the role service uses a single targeted DB update and deliberately does not funnel throughrole.Update(which would rebuild each role's SpiceDB tuples and emit a per-role audit — pointless here, since the tuples are already gone).permission.Servicereaches it via a setter-injected interface, the sameSetMembershipServicepattern org/project use for mutual service deps.DeletePermissionadmin handler, gated on superuser (was unavailable).FailedPrecondition. Only API-created permissions outside that set (the "stray" ones) are deletable, and for those, deletion now sticks (bootstrap won't re-add them).Known limitation (follow-up, not in this PR)
Tests
TestPermissionDeleteCascade(e2e, real Postgres + SpiceDB):app/role:*#<slug>tuple remains and the role no longer lists the slug;app/organization:get) → assert it's rejected withFailedPrecondition.Plus unit tests for the relation sweep + role prune (
core/permission) and the role-list strip (core/role).Addresses gap (6) of #1661.