From e9bcdc85f47d7b9670bc56d24669b2352496e09f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 28 May 2026 21:28:50 +0000 Subject: [PATCH] Reject last owner demotion Return a conflict when membership role updates would demote the final active owner in an organization. Add an end-to-end regression that verifies the mutation fails and leaves the owner role intact. Signed-off-by: Cursor Agent --- e2e/console/user_test.go | 103 ++++++++++++++++++ .../api/connect/v1/membership_resolvers.go | 4 + 2 files changed, 107 insertions(+) diff --git a/e2e/console/user_test.go b/e2e/console/user_test.go index 5218a640e..c33193165 100644 --- a/e2e/console/user_test.go +++ b/e2e/console/user_test.go @@ -115,6 +115,109 @@ func TestUser_UpdateMembership(t *testing.T) { assert.Equal(t, "VIEWER", mutationResult.UpdateMembership.Membership.Role) } +func TestUser_UpdateMembershipRejectsLastOwnerDemotion(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + queryMembers := ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + members(first: 10) { + edges { + node { + id + identity { id } + role + } + } + } + } + } + } + ` + + var membersResult struct { + Node struct { + Members struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Identity struct { + ID string `json:"id"` + } `json:"identity"` + Role string `json:"role"` + } `json:"node"` + } `json:"edges"` + } `json:"members"` + } `json:"node"` + } + + err := owner.ExecuteConnect(queryMembers, map[string]any{ + "id": owner.GetOrganizationID().String(), + }, &membersResult) + require.NoError(t, err) + + var ownerMembershipID string + for _, edge := range membersResult.Node.Members.Edges { + if edge.Node.Identity.ID == owner.GetUserID().String() { + ownerMembershipID = edge.Node.ID + assert.Equal(t, "OWNER", edge.Node.Role) + break + } + } + + require.NotEmpty(t, ownerMembershipID, "Should find owner member") + + mutation := ` + mutation($input: UpdateMembershipInput!) { + updateMembership(input: $input) { + membership { + id + role + } + } + } + ` + + err = owner.ExecuteConnect(mutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "membershipId": ownerMembershipID, + "role": "VIEWER", + }, + }, nil) + testutil.RequireErrorCode(t, err, "CONFLICT") + + var gqlErrors testutil.GraphQLErrors + require.ErrorAs(t, err, &gqlErrors) + assert.Equal(t, "cannot demote last active owner", gqlErrors[0].Message) + + queryMembership := ` + query($id: ID!) { + node(id: $id) { + ... on Membership { + id + role + } + } + } + ` + + var membershipResult struct { + Node struct { + ID string `json:"id"` + Role string `json:"role"` + } `json:"node"` + } + + err = owner.ExecuteConnect(queryMembership, map[string]any{ + "id": ownerMembershipID, + }, &membershipResult) + require.NoError(t, err) + assert.Equal(t, "OWNER", membershipResult.Node.Role) +} + func TestUser_RemoveUser(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) diff --git a/pkg/server/api/connect/v1/membership_resolvers.go b/pkg/server/api/connect/v1/membership_resolvers.go index d42c58a77..080b63490 100644 --- a/pkg/server/api/connect/v1/membership_resolvers.go +++ b/pkg/server/api/connect/v1/membership_resolvers.go @@ -63,6 +63,10 @@ func (r *mutationResolver) UpdateMembership(ctx context.Context, input types.Upd membership, err := r.iam.OrganizationService.UpdateMembership(ctx, input.OrganizationID, input.MembershipID, input.Role) if err != nil { + if _, ok := errors.AsType[*iam.ErrLastActiveOwner](err); ok { + return nil, gqlutils.Conflictf(ctx, "cannot demote last active owner") + } + r.logger.ErrorCtx(ctx, "cannot update membership", log.Error(err)) return nil, gqlutils.Internal(ctx) }