Enforce at least one owner

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-05 16:15:48 +01:00
parent 9936df64d9
commit 5c64e304e8
4 changed files with 69 additions and 0 deletions

View File

@@ -21,6 +21,8 @@ import (
type MembershipFilter struct { type MembershipFilter struct {
email *mail.Addr email *mail.Addr
role *MembershipRole
state *MembershipState
} }
func NewMembershipFilter() *MembershipFilter { func NewMembershipFilter() *MembershipFilter {
@@ -36,9 +38,29 @@ func (f *MembershipFilter) Email() *mail.Addr {
return f.email 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 { func (f *MembershipFilter) SQLArguments() pgx.StrictNamedArgs {
return pgx.StrictNamedArgs{ return pgx.StrictNamedArgs{
"filter_email": f.email, "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 i.email_address = @filter_email::text
ELSE TRUE ELSE TRUE
END 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
)` )`
} }

View File

@@ -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) 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 } type ErrOrganizationNotFound struct{ OrganizationID gid.GID }
func NewOrganizationNotFoundError(organizationID gid.GID) error { func NewOrganizationNotFoundError(organizationID gid.GID) error {

View File

@@ -257,6 +257,21 @@ func (s *OrganizationService) RemoveMember(
return NewMembershipManagedBySCIMError(membershipID) 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) err := membership.Delete(ctx, tx, scope, membershipID)
if err != nil { if err != nil {
return fmt.Errorf("cannot delete membership: %w", err) return fmt.Errorf("cannot delete membership: %w", err)

View File

@@ -968,10 +968,16 @@ func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveM
err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID) err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID)
if err != nil { if err != nil {
var errManagedBySCIM *iam.ErrMembershipManagedBySCIM var errManagedBySCIM *iam.ErrMembershipManagedBySCIM
var errLastActiveOwner *iam.ErrLastActiveOwner
if errors.As(err, &errManagedBySCIM) { if errors.As(err, &errManagedBySCIM) {
return nil, gqlutils.Conflict(err) 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)) r.logger.ErrorCtx(ctx, "cannot remove member from organization", log.Error(err))
return nil, gqlutils.InternalServerError(ctx) return nil, gqlutils.InternalServerError(ctx)
} }