From 5c64e304e876290fb1c485d8f58425023156956e Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 5 Jan 2026 16:15:48 +0100 Subject: [PATCH] Enforce at least one owner Signed-off-by: Bryan Frimin --- pkg/coredata/membership_filter.go | 36 ++++++++++++++++++++++++ pkg/iam/errors.go | 12 ++++++++ pkg/iam/organization_service.go | 15 ++++++++++ pkg/server/api/connect/v1/v1_resolver.go | 6 ++++ 4 files changed, 69 insertions(+) diff --git a/pkg/coredata/membership_filter.go b/pkg/coredata/membership_filter.go index 4971b3193..0ab393801 100644 --- a/pkg/coredata/membership_filter.go +++ b/pkg/coredata/membership_filter.go @@ -21,6 +21,8 @@ import ( type MembershipFilter struct { email *mail.Addr + role *MembershipRole + state *MembershipState } func NewMembershipFilter() *MembershipFilter { @@ -36,9 +38,29 @@ func (f *MembershipFilter) Email() *mail.Addr { return f.email } +func (f *MembershipFilter) WithRole(role MembershipRole) *MembershipFilter { + f.role = &role + return f +} + +func (f *MembershipFilter) Role() *MembershipRole { + return f.role +} + +func (f *MembershipFilter) WithState(state MembershipState) *MembershipFilter { + f.state = &state + return f +} + +func (f *MembershipFilter) State() *MembershipState { + return f.state +} + func (f *MembershipFilter) SQLArguments() pgx.StrictNamedArgs { return pgx.StrictNamedArgs{ "filter_email": f.email, + "filter_role": f.role, + "filter_state": f.state, } } @@ -50,5 +72,19 @@ func (f *MembershipFilter) SQLFragment() string { i.email_address = @filter_email::text ELSE TRUE END +) +AND ( + CASE + WHEN @filter_role::text IS NOT NULL THEN + m.role = @filter_role::authz_role + ELSE TRUE + END +) +AND ( + CASE + WHEN @filter_state::text IS NOT NULL THEN + m.state = @filter_state::membership_state + ELSE TRUE + END )` } diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index cb02cb1d3..ccc1bb135 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -147,6 +147,18 @@ func (e ErrMembershipManagedBySCIM) Error() string { return fmt.Sprintf("membership %q is managed by SCIM and cannot be deleted manually", e.MembershipID) } +type ErrLastActiveOwner struct { + MembershipID gid.GID +} + +func NewLastActiveOwnerError(membershipID gid.GID) error { + return &ErrLastActiveOwner{MembershipID: membershipID} +} + +func (e ErrLastActiveOwner) Error() string { + return fmt.Sprintf("cannot remove membership %q: last active owner of the organization", e.MembershipID) +} + type ErrOrganizationNotFound struct{ OrganizationID gid.GID } func NewOrganizationNotFoundError(organizationID gid.GID) error { diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index cc5521cac..75340411a 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -257,6 +257,21 @@ func (s *OrganizationService) RemoveMember( return NewMembershipManagedBySCIMError(membershipID) } + if membership.Role == coredata.MembershipRoleOwner && membership.State == coredata.MembershipStateActive { + memberships := coredata.Memberships{} + filter := coredata.NewMembershipFilter(). + WithRole(coredata.MembershipRoleOwner). + WithState(coredata.MembershipStateActive) + count, err := memberships.CountByOrganizationID(ctx, tx, scope, organizationID, filter) + if err != nil { + return fmt.Errorf("cannot count active owners: %w", err) + } + + if count <= 1 { + return NewLastActiveOwnerError(membershipID) + } + } + err := membership.Delete(ctx, tx, scope, membershipID) if err != nil { return fmt.Errorf("cannot delete membership: %w", err) diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index ae86dadaa..f33530491 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -968,10 +968,16 @@ func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveM err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID) if err != nil { var errManagedBySCIM *iam.ErrMembershipManagedBySCIM + var errLastActiveOwner *iam.ErrLastActiveOwner + if errors.As(err, &errManagedBySCIM) { return nil, gqlutils.Conflict(err) } + if errors.As(err, &errLastActiveOwner) { + return nil, gqlutils.Conflict(err) + } + r.logger.ErrorCtx(ctx, "cannot remove member from organization", log.Error(err)) return nil, gqlutils.InternalServerError(ctx) }