Regression 19.4: Admin Roles and Permissions page fails with "Failed to fetch roles" when instance-level custom roles are used as role_approvers
Summary
On GitLab Self-Managed 19.4.1-ee (Linux package, Ultimate), /admin/application_settings/roles_and_permissions shows "Failed to fetch roles" and no roles are listed at all. The getInstanceRoles GraphQL query raises on the dependentSecurityPolicies field of MemberRole.
This is a 19.4 regression introduced by !252128 (merged). Affected is any instance that has instance-level custom roles (member_roles.namespace_id IS NULL) which are referenced by numeric ID in the role_approvers of an approval policy.
Root cause
!252128 (merged) changed FetchPolicyApproversService#custom_roles:
- MemberRole.for_instance.id_in(custom_role_ids)
+ MemberRole.for_instance.in_organization(container.organization_id).id_in(custom_role_ids)Resolvers::Members::ApprovalPolicyResolver#container returns object.namespace, which is nil for instance-level custom roles, so container.organization_id raises. Before !252128 (merged) this branch never touched container, which is why the page works in 19.3 and earlier.
The same MR does guard that case in ee/app/finders/security/approval_groups_finder.rb:
def groups_by_ids_or_paths
relation = Group.by_ids_or_paths(group_ids, group_paths)
return relation unless container
relation.in_organization(container.root_ancestor.organization)
endThe equivalent guard is missing in the service.
Steps to reproduce
- Self-Managed instance, Ultimate license, 19.4 or later.
- Create an instance-level custom role, so
namespace_idisNULL. - Reference that role by its numeric ID in
role_approversof anapproval_policy. - Open
/admin/application_settings/roles_and_permissions.
Current behavior
The page renders the error "Failed to fetch roles" because the GraphQL query fails with NoMethodError: undefined method 'organization_id' for nil.
Full backtrace
ee/app/services/security/security_orchestration_policies/fetch_policy_approvers_service.rb:103:in `custom_roles'
MemberRole.for_instance.in_organization(container.organization_id).id_in(custom_role_ids)
from ee/app/services/security/security_orchestration_policies/fetch_policy_approvers_service.rb:31:in `block in execute'
from ee/app/services/security/security_orchestration_policies/fetch_policy_approvers_service.rb:23:in `execute'
from ee/app/graphql/resolvers/concerns/construct_approval_policies.rb:47:in `approvers'
from ee/app/graphql/resolvers/concerns/construct_approval_policies.rb:15:in `construct_scan_result_policy'
from ee/app/graphql/resolvers/concerns/construct_approval_policies.rb:9:in `construct_scan_result_policies'
from ee/app/graphql/resolvers/members/approval_policy_resolver.rb:26:in `resolve'Expected behavior
The page lists all roles. custom_roles resolves instance-level roles even when no container is available.
Scope of impact
- Only
dependentSecurityPoliciesis affected. On the same instance,standardRoles,memberRolesincludingusersCount, andadminMemberRolesall resolve in under 0.05s. - Group-level role pages are not affected, because there
containeris the group. - Policy enforcement does not appear to be affected. In the enforcement path the container is the project or group that the policy applies to, so it is never
nil. Only the admin UI breaks.
Workaround
Removing the numeric role IDs from role_approvers in the affected policies makes the page load again. This is confirmed to work, but it weakens the approval rules, so it is not a real solution. If a policy lists only custom roles as approvers, a replacement approver has to be added, otherwise the policy is left with approvals_required and nobody who can approve.
The affected policies can be listed in the Rails console:
MemberRole.for_instance.each do |role|
role.dependent_security_policies.each do |policy|
puts "role #{role.id} (#{role.name}) depends on policy #{policy.id} (#{policy.name})"
end
endProposed fix
Mirror the guard from ApprovalGroupsFinder: skip the organization scope when container is nil. Alternatively scope by the organization_id that the member_roles rows already carry, since that value is available without a container.