Create EE::ProtectedBranchAccess concern (type dispatch, belongs_to, humanize, scope) (+ FF)
## Context | | | |---|---| | **Phase** | 2 of 6 | | **Parallel with** | https://gitlab.com/gitlab-org/gitlab/-/work_items/594878+ <br> https://gitlab.com/gitlab-org/gitlab/-/work_items/594879+ | | **Blocked by** | https://gitlab.com/gitlab-org/gitlab/-/work_items/594874+ | | **Unblocks** | https://gitlab.com/gitlab-org/gitlab/-/work_items/594880+ <br> https://gitlab.com/gitlab-org/gitlab/-/work_items/594881+ <br> https://gitlab.com/gitlab-org/gitlab/-/work_items/594882+ <br> https://gitlab.com/gitlab-org/gitlab/-/work_items/594883+ <br> https://gitlab.com/gitlab-org/gitlab/-/work_items/594884+ | ## Feature Flag Create the feature flag that gates all EE-specific functionality in this epic. ### New file: `ee/config/feature_flags/wip/custom_roles_for_protected_branches.yml` ```yaml --- name: custom_roles_for_protected_branches feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/594877 introduced_by_url: '' rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/594891 milestone: '18.x' group: 'group::source code' type: wip default_enabled: false ``` ### Usage pattern All new EE behaviour introduced in this epic should be guarded with a group entity as we'll be applying these to groups and projects: ```ruby Feature.enabled?(:custom_roles_for_protected_branches, group) ``` ## Summary Create a new `EE::ProtectedBranchAccess` concern that introduces `:member_role` as a new access level type for protected branches — covering type registration, `belongs_to`, `humanize`, scope, and uniqueness validation. This is the core model-layer building block for the entire feature. ## Background The existing access level type system works via polymorphic dispatch. In `ProtectedRefAccess` (CE), `type` returns `:role`. In `EE::ProtectedRefAccess`, the `type` override returns `:user` or `:group` when the respective FK is set. `ProtectedRefDeployKeyAccess` follows the same pattern for `:deploy_key`. We must **not** add `:member_role` to `EE::ProtectedRefAccess` because that concern is shared with `ProtectedTagAccess` and would unintentionally add custom role support to protected tags. Instead, a new `EE::ProtectedBranchAccess` concern is required, prepended specifically into `ProtectedBranchAccess`. ## Relevant files - `app/models/concerns/protected_branch_access.rb` — the CE concern to prepend into - `ee/app/models/concerns/ee/protected_ref_access.rb` — reference pattern for user/group type dispatch - `app/models/concerns/protected_ref_deploy_key_access.rb` — reference pattern for deploy_key type dispatch - `ee/app/models/concerns/member_roles/member_role_relation.rb` — **do not use directly** (it ties `access_level` to `base_access_level` via `set_access_level_based_on_member_role` which conflicts with the existing `access_level` uniqueness validation in `ProtectedRefAccess`) ## Changes required ### New file: `ee/app/models/concerns/ee/protected_branch_access.rb` ```ruby module EE module ProtectedBranchAccess extend ActiveSupport::Concern extend ::Gitlab::Utils::Override module Scopes extend ActiveSupport::Concern included do belongs_to :member_role, optional: true protected_ref_fk = "#{module_parent.model_name.singular}_id" validates :member_role_id, uniqueness: { scope: protected_ref_fk, allow_nil: true } validates :member_role, presence: true, if: -> { member_role_id.present? } end end class_methods do def non_role_types super.concat(%i[member_role]) end end override :type def type return :member_role if member_role_id || member_role super end scope :for_member_role, -> { where.not(member_role_id: nil) } private def humanize_member_role member_role&.name || 'Custom Role' end def member_role? type == :member_role end end end ``` ### Update: `app/models/concerns/protected_branch_access.rb` Add at the bottom: ```ruby ProtectedBranchAccess.include_mod_with('ProtectedBranchAccess::Scopes') ProtectedBranchAccess.prepend_mod_with('ProtectedBranchAccess') ProtectedBranchAccess::ClassMethods.prepend_mod_with('ProtectedBranchAccess::ClassMethods') ``` (Following the same pattern as `ProtectedRefAccess` at the bottom of `app/models/concerns/protected_ref_access.rb`) ### No changes to the three access level model files `ProtectedBranch::MergeAccessLevel`, `ProtectedBranch::PushAccessLevel`, and `ProtectedBranch::UnprotectAccessLevel` all `include ProtectedBranchAccess` — they will automatically gain the new EE concern via the prepend. ## Notes - The `access_level` column value when `member_role_id` is present: it should be set to the `member_role.base_access_level` so that the integer is meaningful for display purposes, but the actual access check will use the `member_role_id` dispatch path (added in a separate issue). This is consistent with how `member_role_id` works on `Member` records. - The `for_role` scope in CE (`ProtectedRefAccess`) uses `non_role_types` to build a `WHERE ... IS NULL` query. Adding `:member_role` to `non_role_types` will correctly exclude member-role-typed rows from the role scope. - Gate new behaviour behind `Feature.enabled?(:custom_roles_for_protected_branches, ...)`. ## Testing - Verify the feature flag YAML is valid and passes `scripts/lint-feature-flags` checks - Unit test: `type` returns `:member_role` when `member_role_id` is set - Unit test: `type` falls through to `super` when `member_role_id` is nil - Unit test: `humanize_member_role` returns the role name - Unit test: `for_member_role` scope returns only rows with `member_role_id` set - Unit test: `for_role` scope correctly excludes member-role rows - Unit test: uniqueness validation on `member_role_id` scoped to `protected_branch_id` ## Dependencies - Issue 1 (DB migration) must be merged first so `member_role_id` column exists ## Labels
issue