Require set-owner authorization to create OWNER membership
An organization ADMIN could mint an OWNER membership via createUser, which only gated iam:membership-profile:create and bypassed the owner-only iam:membership-role:set-owner check that updateMembership already enforces. Gate the requested role in both createUser entry points (connect resolver and the MCP CreateUserTool) with an additional set-owner authorization when the role is OWNER, mirroring updateMembership. Add a regression test that locks the ADMIN/OWNER privilege boundary the fix relies on. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -24,6 +24,77 @@ import (
|
|||||||
"go.probo.inc/probo/e2e/internal/testutil"
|
"go.probo.inc/probo/e2e/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestUser_AdminCannotCreateOwner is a non-regression test for the vertical
|
||||||
|
// privilege escalation where an ADMIN could mint an OWNER membership via
|
||||||
|
// createUser, bypassing the owner-only set-owner authorization that
|
||||||
|
// updateMembership enforces.
|
||||||
|
func TestUser_AdminCannotCreateOwner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||||
|
|
||||||
|
const createUserMutation = `
|
||||||
|
mutation($input: CreateUserInput!) {
|
||||||
|
createUser(input: $input) {
|
||||||
|
profileEdge {
|
||||||
|
node {
|
||||||
|
membership { role }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
newUserInput := func(role string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
|
"emailAddress": factory.SafeEmail(),
|
||||||
|
"fullName": factory.SafeName("Privesc User"),
|
||||||
|
"role": role,
|
||||||
|
"kind": "EMPLOYEE",
|
||||||
|
"additionalEmailAddresses": []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ADMIN must not be able to create an OWNER membership.
|
||||||
|
_, err := admin.DoConnect(createUserMutation, newUserInput("OWNER"))
|
||||||
|
testutil.RequireForbiddenError(t, err)
|
||||||
|
|
||||||
|
// The same ADMIN can still create a lower-privileged member.
|
||||||
|
var adminCreate struct {
|
||||||
|
CreateUser struct {
|
||||||
|
ProfileEdge struct {
|
||||||
|
Node struct {
|
||||||
|
Membership struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"membership"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"profileEdge"`
|
||||||
|
} `json:"createUser"`
|
||||||
|
}
|
||||||
|
err = admin.ExecuteConnect(createUserMutation, newUserInput("ADMIN"), &adminCreate)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ADMIN", adminCreate.CreateUser.ProfileEdge.Node.Membership.Role)
|
||||||
|
|
||||||
|
// An OWNER remains able to create an OWNER membership.
|
||||||
|
var ownerCreate struct {
|
||||||
|
CreateUser struct {
|
||||||
|
ProfileEdge struct {
|
||||||
|
Node struct {
|
||||||
|
Membership struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"membership"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"profileEdge"`
|
||||||
|
} `json:"createUser"`
|
||||||
|
}
|
||||||
|
err = owner.ExecuteConnect(createUserMutation, newUserInput("OWNER"), &ownerCreate)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "OWNER", ownerCreate.CreateUser.ProfileEdge.Node.Membership.Role)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUser_UpdateMembership(t *testing.T) {
|
func TestUser_UpdateMembership(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ func (r *mutationResolver) CreateUser(ctx context.Context, input types.CreateUse
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.Role == coredata.MembershipRoleOwner {
|
||||||
|
if _, err := r.authorize(ctx, input.OrganizationID, iam.ActionMembershipRoleSetOwner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
profile, err := r.iam.OrganizationService.CreateUser(
|
profile, err := r.iam.OrganizationService.CreateUser(
|
||||||
ctx,
|
ctx,
|
||||||
&iam.CreateUserRequest{
|
&iam.CreateUserRequest{
|
||||||
|
|||||||
@@ -2819,6 +2819,12 @@ func (r *Resolver) CreateUserTool(ctx context.Context, req *mcp.CallToolRequest,
|
|||||||
return nil, types.CreateUserOutput{}, err
|
return nil, types.CreateUserOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.Role == coredata.MembershipRoleOwner {
|
||||||
|
if _, err := r.Authorize(ctx, input.OrganizationID, iam.ActionMembershipRoleSetOwner); err != nil {
|
||||||
|
return nil, types.CreateUserOutput{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var contractStart, contractEnd **time.Time
|
var contractStart, contractEnd **time.Time
|
||||||
if input.ContractStartDate != nil {
|
if input.ContractStartDate != nil {
|
||||||
contractStart = &input.ContractStartDate
|
contractStart = &input.ContractStartDate
|
||||||
|
|||||||
Reference in New Issue
Block a user