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