Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
258c8c8
feat(store): list the live personal access tokens of an organization
rohilsurana Oct 6, 2026
f1f44cb
feat(userpat): delete every personal access token of an organization
rohilsurana Oct 9, 2026
56aa725
fix(userpat): org delete skips a token that was already revoked
rohilsurana Oct 12, 2026
3e1b59d
feat(userpat): list deleted tokens of an org too
rohilsurana Oct 12, 2026
fd742c0
fix(userpat): org delete removes the policies of tokens that are alre…
rohilsurana Oct 12, 2026
cd8b525
feat(deleter): soft-delete the org's domains and tokens before the or…
rohilsurana Oct 6, 2026
9218314
fix(deleter): remove domains and tokens before the org policies
rohilsurana Oct 8, 2026
14f0cf1
fix(deleter): org delete skips a domain that was already removed
rohilsurana Oct 12, 2026
c0f4ae8
feat(store): organization delete keeps the row and frees its name
rohilsurana Oct 6, 2026
6aeec07
test(e2e): deleting an org hides it, frees its name and removes its d…
rohilsurana Oct 6, 2026
05e318d
test(e2e): a personal access token of a deleted org stops authenticating
rohilsurana Oct 7, 2026
2a674b7
test(e2e): name the org delete probe after what it checks
rohilsurana Oct 8, 2026
4b13c1e
test(e2e): check the token's access in SpiceDB after its org is deleted
rohilsurana Oct 8, 2026
1be7c1d
feat(deleter): delete disabled projects and groups with the org
rohilsurana Oct 9, 2026
d288a28
test(e2e): deleting an org also deletes its disabled projects and groups
rohilsurana Oct 9, 2026
34bea62
refactor(membership): find a disabled group with one GetByIDs call
rohilsurana Oct 13, 2026
48581d0
feat(store): group and project list can include disabled rows
rohilsurana Oct 13, 2026
f0731dd
refactor(deleter): list enabled and disabled children in one query
rohilsurana Oct 13, 2026
705dfb3
merge: bring main into feat/org-delete-refuses-disabled-children
rohilsurana Oct 13, 2026
6b36b82
Merge remote-tracking branch 'origin/main' into feat/org-delete-refus…
rohilsurana Oct 13, 2026
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
14 changes: 10 additions & 4 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,7 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error {
}

// delete all related projects
projects, err := d.projService.List(ctx, project.Filter{
OrgID: id,
})
projects, err := d.listOrgProjects(ctx, id)
if err != nil {
return err
}
Expand All @@ -316,7 +314,7 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error {
}

// delete all related groups
groups, err := d.groupService.List(ctx, group.Filter{OrganizationID: id})
groups, err := d.listOrgGroups(ctx, id)
if err != nil {
return err
}
Expand Down Expand Up @@ -547,6 +545,14 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
return d.userService.Delete(ctx, userID)
}

func (d Service) listOrgProjects(ctx context.Context, orgID string) ([]project.Project, error) {
return d.projService.List(ctx, project.Filter{OrgID: orgID, IncludeDisabled: true})
}

func (d Service) listOrgGroups(ctx context.Context, orgID string) ([]group.Group, error) {
return d.groupService.List(ctx, group.Filter{OrganizationID: orgID, IncludeDisabled: true})
}

// ensureDeletable collects everything that blocks deleting the organization and
// returns it all as one BlockedError, so the caller gets a full checklist
// instead of discovering blockers one retry at a time.
Expand Down
168 changes: 140 additions & 28 deletions core/deleter/service_test.go

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion core/group/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,14 @@ func (s Service) Disable(ctx context.Context, id string) error {
// responsibility — see core/deleter.DeleteGroup for the orchestration that
// pairs this with membership.OnGroupDeleted.
func (s Service) DeleteModel(ctx context.Context, id string) error {
group, err := s.repository.GetByID(ctx, id)
found, err := s.repository.GetByIDs(ctx, []string{id}, Filter{IncludeDisabled: true})
if err != nil {
return err
}
if len(found) == 0 {
return ErrNotExist
}
group := found[0]

if err := s.repository.Delete(ctx, id); err != nil {
return err
Expand Down
28 changes: 28 additions & 0 deletions core/group/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,31 @@ func TestService_List_PrincipalFilter(t *testing.T) {
assert.Empty(t, result)
})
}

func TestService_DeleteModel(t *testing.T) {
ctx := context.Background()
newSvc := func(t *testing.T) (*group.Service, *mocks.Repository) {
t.Helper()
mockRepo := mocks.NewRepository(t)
svc := group.NewService(mockRepo, mocks.NewRelationService(t), mocks.NewAuthnService(t), mocks.NewPolicyService(t))
return svc, mockRepo
}

t.Run("deletes a disabled group", func(t *testing.T) {
svc, repo := newSvc(t)
disabled := group.Group{ID: "grp-off", OrganizationID: "org-1", State: group.Disabled}
repo.EXPECT().GetByIDs(mock.Anything, []string{"grp-off"}, group.Filter{IncludeDisabled: true}).
Return([]group.Group{disabled}, nil)
repo.EXPECT().Delete(mock.Anything, "grp-off").Return(nil)

assert.NoError(t, svc.DeleteModel(ctx, "grp-off"))
})

t.Run("a group that does not exist is not found", func(t *testing.T) {
svc, repo := newSvc(t)
repo.EXPECT().GetByIDs(mock.Anything, []string{"grp-x"}, group.Filter{IncludeDisabled: true}).
Return([]group.Group{}, nil)

assert.ErrorIs(t, svc.DeleteModel(ctx, "grp-x"), group.ErrNotExist)
})
}
14 changes: 13 additions & 1 deletion core/membership/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"

"github.com/raystack/frontier/core/group"
"github.com/raystack/frontier/core/policy"
"github.com/raystack/frontier/core/relation"
"github.com/raystack/frontier/core/role"
Expand Down Expand Up @@ -217,7 +218,7 @@ func (s *Service) RemoveAllGroupMembers(ctx context.Context, groupID string) err
// Errors are joined; partial failures are logged so a retry can complete
// the cleanup.
func (s *Service) OnGroupDeleted(ctx context.Context, groupID string) error {
grp, err := s.groupService.Get(ctx, groupID)
grp, err := s.getGroupIncludingDisabled(ctx, groupID)
if err != nil {
return err
}
Expand All @@ -235,6 +236,17 @@ func (s *Service) OnGroupDeleted(ctx context.Context, groupID string) error {
return errs
}

func (s *Service) getGroupIncludingDisabled(ctx context.Context, groupID string) (group.Group, error) {
found, err := s.groupService.GetByIDs(ctx, []string{groupID}, group.Filter{IncludeDisabled: true})
if err != nil {
return group.Group{}, err
}
if len(found) == 0 {
return group.Group{}, group.ErrNotExist
}
return found[0], nil
}

// removeGroupAsPrincipalPolicies deletes every policy where the given group
// is the principal — e.g. policies created by `CreatePolicy(principal=group:X,
// resource=project:Y, role=viewer)` that grant the group access to other
Expand Down
33 changes: 31 additions & 2 deletions core/membership/group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ func TestService_OnGroupDeleted(t *testing.T) {
relSvc := mocks.NewRelationService(t)
grpSvc := mocks.NewGroupService(t)

grpSvc.EXPECT().Get(ctx, groupID).Return(grp, nil)
grpSvc.EXPECT().GetByIDs(ctx, []string{groupID}, group.Filter{IncludeDisabled: true}).Return([]group.Group{grp}, nil)
// RemoveAllGroupMembers — no member policies
policySvc.EXPECT().List(ctx, policy.Filter{GroupID: groupID}).Return([]policy.Policy{}, nil)
// removeGroupAsPrincipalPolicies — one policy granting this group access elsewhere
Expand All @@ -509,9 +509,38 @@ func TestService_OnGroupDeleted(t *testing.T) {
assert.NoError(t, svc.OnGroupDeleted(ctx, groupID))
})

t.Run("cleans up a disabled group", func(t *testing.T) {
policySvc := mocks.NewPolicyService(t)
relSvc := mocks.NewRelationService(t)
grpSvc := mocks.NewGroupService(t)

disabled := grp
disabled.State = group.Disabled
grpSvc.EXPECT().GetByIDs(ctx, []string{groupID}, group.Filter{IncludeDisabled: true}).
Return([]group.Group{disabled}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{GroupID: groupID}).Return([]policy.Policy{}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
PrincipalType: schema.GroupPrincipal,
PrincipalID: groupID,
}).Return([]policy.Policy{}, nil)
relSvc.EXPECT().Delete(ctx, relation.Relation{
Object: relation.Object{ID: groupID, Namespace: schema.GroupNamespace},
Subject: relation.Subject{ID: orgID, Namespace: schema.OrganizationNamespace},
RelationName: schema.OrganizationRelationName,
}).Return(nil)

svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), policySvc, relSvc,
mocks.NewRoleService(t), mocks.NewOrgService(t), mocks.NewUserService(t),
mocks.NewProjectService(t), grpSvc, mocks.NewServiceuserService(t),
mocks.NewAuditRecordRepository(t))

assert.NoError(t, svc.OnGroupDeleted(ctx, groupID))
})

t.Run("returns error if group not found", func(t *testing.T) {
grpSvc := mocks.NewGroupService(t)
grpSvc.EXPECT().Get(ctx, groupID).Return(group.Group{}, group.ErrNotExist)
grpSvc.EXPECT().GetByIDs(ctx, []string{groupID}, group.Filter{IncludeDisabled: true}).
Return([]group.Group{}, nil)

svc := membership.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)),
mocks.NewPolicyService(t), mocks.NewRelationService(t),
Expand Down
74 changes: 74 additions & 0 deletions core/membership/mocks/group_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions core/membership/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type ProjectService interface {
type GroupService interface {
Get(ctx context.Context, idOrName string) (group.Group, error)
List(ctx context.Context, flt group.Filter) ([]group.Group, error)
GetByIDs(ctx context.Context, ids []string, flt ...group.Filter) ([]group.Group, error)
}

type ServiceuserService interface {
Expand Down
1 change: 1 addition & 0 deletions core/project/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Filter struct {
WithMemberCount bool
ProjectIDs []string
State State
IncludeDisabled bool
// NonInherited filters out projects that are inherited from access given through an organization
NonInherited bool
Pagination *pagination.Pagination
Expand Down
5 changes: 3 additions & 2 deletions internal/store/postgres/group_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,10 @@ func (r GroupRepository) List(ctx context.Context, flt group.Filter) ([]group.Gr
if flt.OrganizationID != "" {
sqlStatement = sqlStatement.Where(goqu.Ex{"org_id": flt.OrganizationID})
}
if flt.State != "" {
switch {
case flt.State != "":
sqlStatement = sqlStatement.Where(goqu.Ex{"state": flt.State})
} else {
case !flt.IncludeDisabled:
sqlStatement = sqlStatement.Where(notDisabledGroupExp)
}
if flt.GroupIDs != nil {
Expand Down
17 changes: 17 additions & 0 deletions internal/store/postgres/group_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,3 +657,20 @@ func (s *GroupRepositoryTestSuite) TestSkipsSoftDeletedGroups() {
err = s.repository.SetState(s.ctx, deleted.ID, group.Disabled)
s.Assert().ErrorIs(err, group.ErrNotExist)
}

func (s *GroupRepositoryTestSuite) TestListIncludeDisabled() {
orgID := s.orgs[1].ID

hidden, err := s.repository.List(s.ctx, group.Filter{OrganizationID: orgID})
s.Require().NoError(err)
s.Assert().Len(hidden, 1)
s.Assert().Equal(group.Enabled, hidden[0].State)

all, err := s.repository.List(s.ctx, group.Filter{OrganizationID: orgID, IncludeDisabled: true})
s.Require().NoError(err)
states := map[group.State]int{}
for _, g := range all {
states[g.State]++
}
s.Assert().Equal(map[group.State]int{group.Enabled: 1, group.Disabled: 1}, states)
}
7 changes: 4 additions & 3 deletions internal/store/postgres/project_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,13 @@ func (r ProjectRepository) List(ctx context.Context, flt project.Filter) ([]proj
"id": goqu.Op{"in": flt.ProjectIDs},
})
}
if flt.State == "" {
stmt = stmt.Where(notDisabledProjectExp)
} else {
switch {
case flt.State != "":
stmt = stmt.Where(goqu.Ex{
"state": flt.State.String(),
})
case !flt.IncludeDisabled:
stmt = stmt.Where(notDisabledProjectExp)
}

if flt.Pagination != nil {
Expand Down
21 changes: 21 additions & 0 deletions internal/store/postgres/project_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -521,3 +521,24 @@ func (s *ProjectRepositoryTestSuite) TestSkipsSoftDeletedProjects() {
err = s.repository.SetState(s.ctx, deleted.ID, project.Disabled)
s.Assert().ErrorIs(err, project.ErrNotExist)
}

func (s *ProjectRepositoryTestSuite) TestListIncludeDisabled() {
disabled := s.projects[0]
orgID := disabled.Organization.ID
s.Require().NoError(s.repository.SetState(s.ctx, disabled.ID, project.Disabled))

hidden, err := s.repository.List(s.ctx, project.Filter{OrgID: orgID})
s.Require().NoError(err)
for _, p := range hidden {
s.Assert().NotEqual(disabled.ID, p.ID)
}

all, err := s.repository.List(s.ctx, project.Filter{OrgID: orgID, IncludeDisabled: true})
s.Require().NoError(err)
s.Assert().Len(all, len(hidden)+1)
ids := []string{}
for _, p := range all {
ids = append(ids, p.ID)
}
s.Assert().Contains(ids, disabled.ID)
}
Loading
Loading