Whitelist ownership grants via allow policies

Replace the deny-based restriction on granting OWNER with role-scoped
allow policies so authorization fails closed: admins may create and
update memberships only when the assigned role is not OWNER, and the
absence of a target role no longer implies permission.

To keep console UI gating accurate without loosening the base grants,
the permission field gains an optional typed options argument
(PermissionOptionsInput) that forwards target_role into the dry-run
authorization. Only the two role-related console calls (create user,
update membership) pass it; the OWNER option stays hidden for admins via
the existing assignable-roles helper.

Add a non-regression test that an admin cannot promote a member to OWNER
while still being able to change members between non-owner roles.
This commit is contained in:
Sacha Al Himdani
2026-07-07 10:56:33 +02:00
parent ff9cb881e8
commit 86c45875a4
12 changed files with 133 additions and 27 deletions

View File

@@ -265,6 +265,9 @@ var IAMAdminPolicy = policy.NewPolicy(
WithSID("membership-admin-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Can update memberships, but neither of an existing owner (resource.role)
// nor to grant ownership (resource.target_role); only owner can grant
// ownership.
policy.Allow(
ActionMembershipUpdate,
).
@@ -272,13 +275,13 @@ var IAMAdminPolicy = policy.NewPolicy(
When(
policy.Equals("principal.organization_id", "resource.organization_id"),
policy.NotEquals("resource.role", "OWNER"),
policy.NotEquals("resource.target_role", "OWNER"),
),
// Can view membership profiles (scoped to own organization)
// Can view and manage membership profiles (scoped to own organization)
policy.Allow(
ActionMembershipProfileGet,
ActionMembershipProfileList,
ActionMembershipProfileCreate,
ActionMembershipProfileUpdate,
ActionMembershipProfileDelete,
ActionMembershipProfileActivate,
@@ -287,6 +290,15 @@ var IAMAdminPolicy = policy.NewPolicy(
WithSID("membership-profile-admin-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Can create members, but not with the OWNER role (resource.target_role);
// only owner can grant ownership.
policy.Allow(ActionMembershipProfileCreate).
WithSID("membership-profile-admin-create").
When(
policy.Equals("principal.organization_id", "resource.organization_id"),
policy.NotEquals("resource.target_role", "OWNER"),
),
// Can view identities of members in the same organization
policy.Allow(ActionIdentityGet).
WithSID("view-member-identity").
@@ -313,15 +325,6 @@ var IAMAdminPolicy = policy.NewPolicy(
policy.Deny(ActionMembershipDelete).
WithSID("deny-remove-member"),
// Cannot grant ownership, whether by creating an OWNER member or promoting an
// existing member to OWNER (only owner can grant ownership)
policy.Deny(ActionMembershipProfileCreate).
WithSID("deny-create-owner").
When(policy.Equals("resource.target_role", "OWNER")),
policy.Deny(ActionMembershipUpdate).
WithSID("deny-promote-owner").
When(policy.Equals("resource.target_role", "OWNER")),
// Cannot manage SAML configurations (only owner can)
policy.Deny(
ActionSAMLConfigurationCreate,

View File

@@ -17,6 +17,7 @@ scalar Datetime
scalar Upload
scalar EmailAddr
scalar OAuth2Scope
scalar Map
interface Node {
id: ID!

View File

@@ -16,7 +16,7 @@ type Membership implements Node {
lastSession: Session @goField(forceResolver: true)
permission(action: String!): Boolean!
permission(action: String!, attributes: Map): Boolean!
@goField(forceResolver: true)
@authentication(required: PRESENT)
}

View File

@@ -39,7 +39,7 @@ type Organization implements Node {
viewer: Profile @goField(forceResolver: true)
permission(action: String!): Boolean!
permission(action: String!, attributes: Map): Boolean!
@goField(forceResolver: true)
@authentication(required: PRESENT)
}

View File

@@ -44,8 +44,8 @@ func (r *membershipResolver) LastSession(ctx context.Context, obj *types.Members
}
// Permission is the resolver for the permission field.
func (r *membershipResolver) Permission(ctx context.Context, obj *types.Membership, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
func (r *membershipResolver) Permission(ctx context.Context, obj *types.Membership, action string, attributes map[string]any) (bool, error) {
return r.Resolver.permission(ctx, obj, action, attributes)
}
// UpdateMembership is the resolver for the updateMembership field.

View File

@@ -358,8 +358,8 @@ func (r *organizationResolver) Viewer(ctx context.Context, obj *types.Organizati
}
// Permission is the resolver for the permission field.
func (r *organizationResolver) Permission(ctx context.Context, obj *types.Organization, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
func (r *organizationResolver) Permission(ctx context.Context, obj *types.Organization, action string, attributes map[string]any) (bool, error) {
return r.Resolver.permission(ctx, obj, action, attributes)
}
// Organization returns schema.OrganizationResolver implementation.

View File

@@ -129,7 +129,18 @@ func NewMux(
}
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
_, err := r.authorize(ctx, obj.GetID(), action, authz.WithDryRun())
return r.permission(ctx, obj, action, nil)
}
func (r *Resolver) permission(ctx context.Context, obj types.Node, action string, attributes map[string]any) (bool, error) {
opts := []authz.AuthorizeFuncOption{authz.WithDryRun()}
for key, value := range attributes {
if s, ok := value.(string); ok {
opts = append(opts, authz.WithAttr(key, s))
}
}
_, err := r.authorize(ctx, obj.GetID(), action, opts...)
return err == nil, nil
}