From a90e4d26a0a04a8f59717f71dd7caf8d02b37bf1 Mon Sep 17 00:00:00 2001 From: Sashi Kumar Kumaresan Date: Tue, 29 Jul 2025 12:03:57 +0200 Subject: [PATCH 1/3] Add users, groups and roles to security policy bypass_settings This change validates the users, groups and roles against MR approval policy bypass settings to check if the reason is provided through git push options. EE: true Changelog: added --- ee/app/models/ee/group_member.rb | 7 ++ ee/app/models/ee/project_team.rb | 6 + .../ee/gitlab/checks/security/policy_check.rb | 5 +- .../policy_bypass_checker.rb | 111 ++++++++++++++++-- .../push_bypass_checker.rb | 8 +- .../policy_bypass_checker_spec.rb | 3 +- lib/gitlab/push_options.rb | 3 + 7 files changed, 127 insertions(+), 16 deletions(-) diff --git a/ee/app/models/ee/group_member.rb b/ee/app/models/ee/group_member.rb index c2b8b73058aa11..018f5481b16033 100644 --- a/ee/app/models/ee/group_member.rb +++ b/ee/app/models/ee/group_member.rb @@ -47,6 +47,13 @@ def member_of_group?(group, user) exists?(group: group, user: user) end + def direct_member_of_groups?(group_ids, user) + active_without_invites_and_requests + .non_minimal_access + .where(source_id: group_ids) + .exists?(user: user) + end + def filter_by_enterprise_users(value) subquery = ::UserDetail.where( diff --git a/ee/app/models/ee/project_team.rb b/ee/app/models/ee/project_team.rb index af61084968264e..0c743234c849a5 100644 --- a/ee/app/models/ee/project_team.rb +++ b/ee/app/models/ee/project_team.rb @@ -24,6 +24,12 @@ def members_with_access_level_or_custom_roles(levels: [], member_role_ids: []) users end + def user_exists_with_access_level_or_custom_roles?(user, levels: [], member_role_ids: []) + return false unless levels.any? || member_role_ids.any? + + members_with_access_level_or_custom_roles(levels: levels, member_role_ids: member_role_ids).exists?(user: user) + end + override :add_members def add_members( users, diff --git a/ee/lib/ee/gitlab/checks/security/policy_check.rb b/ee/lib/ee/gitlab/checks/security/policy_check.rb index 903fddd8e35995..5ad3104de7bd95 100644 --- a/ee/lib/ee/gitlab/checks/security/policy_check.rb +++ b/ee/lib/ee/gitlab/checks/security/policy_check.rb @@ -37,7 +37,10 @@ def policies_bypass_applied? return false if ::Feature.disabled?(:security_policies_bypass_options_tokens_accounts, project) ::Security::ScanResultPolicies::PushBypassChecker.new( - project: project, user_access: user_access, branch_name: branch_name + project: project, + user_access: user_access, + branch_name: branch_name, + push_options: change_access.push_options ).check_bypass! end diff --git a/ee/lib/security/scan_result_policies/policy_bypass_checker.rb b/ee/lib/security/scan_result_policies/policy_bypass_checker.rb index 7f2a4bd0ba7b81..21477152ff579b 100644 --- a/ee/lib/security/scan_result_policies/policy_bypass_checker.rb +++ b/ee/lib/security/scan_result_policies/policy_bypass_checker.rb @@ -5,11 +5,12 @@ module ScanResultPolicies class PolicyBypassChecker include Gitlab::Utils::StrongMemoize - def initialize(security_policy:, project:, user_access:, branch_name:) + def initialize(security_policy:, project:, user_access:, branch_name:, push_options:) @security_policy = security_policy @project = project @user = user_access.user @branch_name = branch_name + @push_options = push_options end def bypass_allowed? @@ -18,6 +19,10 @@ def bypass_allowed? bypass_with_access_token? || bypass_with_service_account? end + private + + attr_reader :security_policy, :project, :user, :branch_name, :push_options + def bypass_with_access_token? policy_token_ids = security_policy.bypass_settings.access_token_ids return false if policy_token_ids.blank? @@ -27,7 +32,13 @@ def bypass_with_access_token? user_token_ids = user.personal_access_tokens.active.id_in(policy_token_ids).pluck_primary_key return false if user_token_ids.blank? - log_bypass_audit!(:access_token, user_token_ids) + log_bypass_audit!( + bypass_audit_message(:access_token, user_token_ids), + additional_details: { + bypass_type: :access_token, + access_token_ids: user_token_ids + } + ) true end @@ -38,20 +49,86 @@ def bypass_with_service_account? return false unless user.service_account? return false unless policy_service_account_ids.include?(user.id) - log_bypass_audit!(:service_account, user.id) + log_bypass_audit!( + bypass_audit_message(:service_account, user.id), + additional_details: { + bypass_type: :service_account, + service_account_id: user.id + } + ) true end - private + def users_can_bypass? + return false if user.project_bot? || user.service_account? + return false if security_policy.bypass_settings.user_ids.include?(user.id) - attr_reader :security_policy, :project, :user, :branch_name + reason = reason_from_push_options + return false if reason.blank? - def log_bypass_audit!(type, id) - message = <<~MSG.squish - Branch push restriction on '#{branch_name}' for project '#{project.full_path}' - has been bypassed by #{type} with ID: #{id} - MSG + log_bypass_audit!( + bypass_audit_message(:user, user.id, reason), + additional_details: { + bypass_type: :user, + user_id: user.id, + reason: reason + } + ) + true + end + + def groups_can_bypass? + group_ids = security_policy.bypass_settings.group_ids + + return false if group_ids.blank? + return false unless GroupMember.direct_member_of_groups?(group_ids, user) + + reason = reason_from_push_options + return false if reason.blank? + + log_bypass_audit!( + bypass_audit_message(:user, user.id, reason), + additional_details: { + bypass_type: :group, + group_ids: group_ids, + user_id: user.id, + reason: reason + } + ) + true + end + + def roles_can_bypass? + default_roles = security_policy.bypass_settings.default_roles + custom_role_ids = security_policy.bypass_settings.custom_role_ids + + return false if default_roles.blank? && custom_role_ids.blank? + + return false unless project.team.user_exists_with_access_level_or_custom_roles?( + user, levels: default_roles, member_role_ids: custom_role_ids + ) + + reason = reason_from_push_options + return false if reason.blank? + + log_bypass_audit!( + bypass_audit_message(:user, user.id, reason), + additional_details: { + bypass_type: :role, + default_roles: default_roles, + custom_role_ids: custom_role_ids, + reason: reason + } + ) + true + end + + def reason_from_push_options + push_options.get(:security_policy)&.dig(:bypass_reason) + end + strong_memoize_attr :reason_from_push_options + def log_bypass_audit!(message, additional_details = {}) Gitlab::Audit::Auditor.audit( name: "security_policy_#{type}_push_bypass", author: user, @@ -63,9 +140,21 @@ def log_bypass_audit!(type, id) security_policy_name: security_policy.name, security_policy_id: security_policy.id, branch_name: branch_name - } + }.merge(additional_details) ) end + + def bypass_audit_message(type, id, reason = nil) + identifier = get_bypass_identifier(type, id) + + message = <<~MSG.squish + Branch push restriction on '#{branch_name}' for project '#{project.full_path}' + has been bypassed by #{type} with ID: #{identifier} + MSG + + message += " with reason: #{reason}" if reason.present? + message + end end end end diff --git a/ee/lib/security/scan_result_policies/push_bypass_checker.rb b/ee/lib/security/scan_result_policies/push_bypass_checker.rb index 92d6dc600869d0..77a828e9ad3024 100644 --- a/ee/lib/security/scan_result_policies/push_bypass_checker.rb +++ b/ee/lib/security/scan_result_policies/push_bypass_checker.rb @@ -5,10 +5,11 @@ module ScanResultPolicies class PushBypassChecker include Gitlab::Utils::StrongMemoize - def initialize(project:, user_access:, branch_name:) + def initialize(project:, user_access:, branch_name:, push_options:) @project = project @user_access = user_access @branch_name = branch_name + @push_options = push_options end def check_bypass! @@ -22,14 +23,15 @@ def check_bypass! private - attr_reader :project, :user_access, :branch_name + attr_reader :project, :user_access, :branch_name, :push_options def bypass_allowed?(policy) Security::ScanResultPolicies::PolicyBypassChecker.new( security_policy: policy, project: project, user_access: user_access, - branch_name: branch_name + branch_name: branch_name, + push_options: push_options ).bypass_allowed? end end diff --git a/ee/spec/lib/security/scan_result_policies/policy_bypass_checker_spec.rb b/ee/spec/lib/security/scan_result_policies/policy_bypass_checker_spec.rb index a96bfa1482322e..2a5f962d279d8e 100644 --- a/ee/spec/lib/security/scan_result_policies/policy_bypass_checker_spec.rb +++ b/ee/spec/lib/security/scan_result_policies/policy_bypass_checker_spec.rb @@ -18,7 +18,8 @@ describe '#bypass_allowed?' do subject(:bypass_allowed?) do described_class.new( - security_policy: security_policy, project: project, user_access: user_access, branch_name: branch_name + security_policy: security_policy, project: project, user_access: user_access, branch_name: branch_name, + push_options: {} ).bypass_allowed? end diff --git a/lib/gitlab/push_options.rb b/lib/gitlab/push_options.rb index d218b9c1177f8a..d7656e01e6cfa9 100644 --- a/lib/gitlab/push_options.rb +++ b/lib/gitlab/push_options.rb @@ -33,6 +33,9 @@ class PushOptions }, secret_push_protection: { keys: [:skip_all] + }, + security_policy: { + keys: [:bypass_reason] } }).freeze -- GitLab From b6d1e0caad1c47433e9273823388f8c0072f814b Mon Sep 17 00:00:00 2001 From: Sashi Kumar Kumaresan Date: Wed, 30 Jul 2025 00:33:06 +0200 Subject: [PATCH 2/3] Update audit events --- doc/user/compliance/audit_event_types.md | 3 +++ .../security_policy_group_push_bypass.yml | 11 ++++++++++ .../security_policy_role_push_bypass.yml | 11 ++++++++++ .../security_policy_user_push_bypass.yml | 11 ++++++++++ .../policy_bypass_checker.rb | 22 ++++++++++++++----- 5 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 ee/config/audit_events/types/security_policy_group_push_bypass.yml create mode 100644 ee/config/audit_events/types/security_policy_role_push_bypass.yml create mode 100644 ee/config/audit_events/types/security_policy_user_push_bypass.yml diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 5da74211bb8b6e..edeb5a7cd9afc5 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -539,12 +539,15 @@ Audit event types belong to the following product categories. | [`security_policy_access_token_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196249) | Branch push that is blocked by a security policy is bypassed for configured access token | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549644) | Project | | [`security_policy_create`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/192797) | A security policy is created | {{< icon name="check-circle" >}} Yes | GitLab [18.1](https://gitlab.com/gitlab-org/gitlab/-/issues/539230) | Project | | [`security_policy_delete`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/192797) | A security policy is deleted | {{< icon name="check-circle" >}} Yes | GitLab [18.1](https://gitlab.com/gitlab-org/gitlab/-/issues/539230) | Project | +| [`security_policy_group_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199546) | Branch push that is blocked by a security policy is bypassed for configured groups | {{< icon name="check-circle" >}} Yes | GitLab [18.3](https://gitlab.com/gitlab-org/gitlab/-/issues/549797) | Project | | [`security_policy_limit_exceeded`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196005) | Enabled policies count exceeded the maximum allowed limit for policy type | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/550891) | Project | | [`security_policy_merge_request_merged_with_policy_violations`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195775) | A merge request merged with security policy violations | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/549813) | Project | | [`security_policy_pipeline_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196628) | A pipeline with security policy jobs failed | {{< icon name="dotted-circle" >}} No | GitLab [18.3](https://gitlab.com/gitlab-org/gitlab/-/issues/539232) | Project | | [`security_policy_pipeline_skipped`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195325) | A security policy pipeline is skipped | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/539232) | Project | +| [`security_policy_role_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199546) | Branch push that is blocked by a security policy is bypassed for configured roles | {{< icon name="check-circle" >}} Yes | GitLab [18.3](https://gitlab.com/gitlab-org/gitlab/-/issues/549797) | Project | | [`security_policy_service_account_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196249) | Branch push that is blocked by a security policy is bypassed for configured service account | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549644) | Project | | [`security_policy_update`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/192797) | A security policy is updated | {{< icon name="check-circle" >}} Yes | GitLab [18.1](https://gitlab.com/gitlab-org/gitlab/-/issues/539230) | Project | +| [`security_policy_user_push_bypass`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199546) | Branch push that is blocked by a security policy is bypassed for configured users | {{< icon name="check-circle" >}} Yes | GitLab [18.3](https://gitlab.com/gitlab-org/gitlab/-/issues/549797) | Project | | [`security_policy_violations_detected`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193482) | Security policy violation is detected in the merge request | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/549811) | Project | | [`security_policy_violations_resolved`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/193482) | Security policy violations are resolved in the merge request | {{< icon name="dotted-circle" >}} No | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/549812) | Project | | [`security_policy_yaml_invalidated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/196721) | The policy YAML is invalidated in security policy project | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/work_items/550892) | Project | diff --git a/ee/config/audit_events/types/security_policy_group_push_bypass.yml b/ee/config/audit_events/types/security_policy_group_push_bypass.yml new file mode 100644 index 00000000000000..1e8b8d6905dd17 --- /dev/null +++ b/ee/config/audit_events/types/security_policy_group_push_bypass.yml @@ -0,0 +1,11 @@ +--- +name: security_policy_group_push_bypass +description: Branch push that is blocked by a security policy is bypassed for configured + groups +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/549797 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199546 +feature_category: security_policy_management +milestone: '18.3' +saved_to_database: true +streamed: true +scope: [Project] diff --git a/ee/config/audit_events/types/security_policy_role_push_bypass.yml b/ee/config/audit_events/types/security_policy_role_push_bypass.yml new file mode 100644 index 00000000000000..6738e61c48b2ca --- /dev/null +++ b/ee/config/audit_events/types/security_policy_role_push_bypass.yml @@ -0,0 +1,11 @@ +--- +name: security_policy_role_push_bypass +description: Branch push that is blocked by a security policy is bypassed for configured + roles +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/549797 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199546 +feature_category: security_policy_management +milestone: '18.3' +saved_to_database: true +streamed: true +scope: [Project] diff --git a/ee/config/audit_events/types/security_policy_user_push_bypass.yml b/ee/config/audit_events/types/security_policy_user_push_bypass.yml new file mode 100644 index 00000000000000..f8264f4cdfe4b1 --- /dev/null +++ b/ee/config/audit_events/types/security_policy_user_push_bypass.yml @@ -0,0 +1,11 @@ +--- +name: security_policy_user_push_bypass +description: Branch push that is blocked by a security policy is bypassed for configured + users +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/549797 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199546 +feature_category: security_policy_management +milestone: '18.3' +saved_to_database: true +streamed: true +scope: [Project] diff --git a/ee/lib/security/scan_result_policies/policy_bypass_checker.rb b/ee/lib/security/scan_result_policies/policy_bypass_checker.rb index 21477152ff579b..4fde9b3ffc528c 100644 --- a/ee/lib/security/scan_result_policies/policy_bypass_checker.rb +++ b/ee/lib/security/scan_result_policies/policy_bypass_checker.rb @@ -16,7 +16,7 @@ def initialize(security_policy:, project:, user_access:, branch_name:, push_opti def bypass_allowed? return false unless user - bypass_with_access_token? || bypass_with_service_account? + bypass_with_access_token? || bypass_with_service_account? || bypass_with_user? end private @@ -33,6 +33,7 @@ def bypass_with_access_token? return false if user_token_ids.blank? log_bypass_audit!( + :access_token, bypass_audit_message(:access_token, user_token_ids), additional_details: { bypass_type: :access_token, @@ -50,6 +51,7 @@ def bypass_with_service_account? return false unless policy_service_account_ids.include?(user.id) log_bypass_audit!( + :service_account, bypass_audit_message(:service_account, user.id), additional_details: { bypass_type: :service_account, @@ -59,14 +61,22 @@ def bypass_with_service_account? true end + def bypass_with_user? + return false if Feature.disabled?(:security_policies_bypass_options_group_roles, project) + + users_can_bypass? || groups_can_bypass? || roles_can_bypass? + end + def users_can_bypass? return false if user.project_bot? || user.service_account? - return false if security_policy.bypass_settings.user_ids.include?(user.id) + return false if security_policy.bypass_settings.user_ids.blank? + return false unless security_policy.bypass_settings.user_ids.include?(user.id) reason = reason_from_push_options return false if reason.blank? log_bypass_audit!( + :user, bypass_audit_message(:user, user.id, reason), additional_details: { bypass_type: :user, @@ -87,6 +97,7 @@ def groups_can_bypass? return false if reason.blank? log_bypass_audit!( + :group, bypass_audit_message(:user, user.id, reason), additional_details: { bypass_type: :group, @@ -112,6 +123,7 @@ def roles_can_bypass? return false if reason.blank? log_bypass_audit!( + :role, bypass_audit_message(:user, user.id, reason), additional_details: { bypass_type: :role, @@ -128,7 +140,7 @@ def reason_from_push_options end strong_memoize_attr :reason_from_push_options - def log_bypass_audit!(message, additional_details = {}) + def log_bypass_audit!(type, message, additional_details = {}) Gitlab::Audit::Auditor.audit( name: "security_policy_#{type}_push_bypass", author: user, @@ -145,11 +157,9 @@ def log_bypass_audit!(message, additional_details = {}) end def bypass_audit_message(type, id, reason = nil) - identifier = get_bypass_identifier(type, id) - message = <<~MSG.squish Branch push restriction on '#{branch_name}' for project '#{project.full_path}' - has been bypassed by #{type} with ID: #{identifier} + has been bypassed by #{type} with ID: #{id} MSG message += " with reason: #{reason}" if reason.present? -- GitLab From 51467ee9d9e0acc423f7aa641c57ba920f8c9633 Mon Sep 17 00:00:00 2001 From: Sashi Kumar Kumaresan Date: Wed, 30 Jul 2025 19:27:27 +0200 Subject: [PATCH 3/3] Fix push_options and add spec --- ee/app/models/ee/group_member.rb | 2 +- ee/app/models/ee/project_team.rb | 3 +- .../ee/gitlab/checks/security/policy_check.rb | 3 + .../policy_bypass_checker.rb | 10 +- .../push_bypass_checker.rb | 2 + .../checks/security/policy_check_spec.rb | 20 ++ .../push_bypass_checker_spec.rb | 9 +- ee/spec/models/ee/group_member_spec.rb | 95 +++++++++ ee/spec/models/ee/project_team_spec.rb | 201 ++++++++++++++++++ lib/gitlab/checks/changes_access.rb | 3 +- lib/gitlab/checks/single_change_access.rb | 5 +- 11 files changed, 344 insertions(+), 9 deletions(-) diff --git a/ee/app/models/ee/group_member.rb b/ee/app/models/ee/group_member.rb index 018f5481b16033..310270ac70058c 100644 --- a/ee/app/models/ee/group_member.rb +++ b/ee/app/models/ee/group_member.rb @@ -51,7 +51,7 @@ def direct_member_of_groups?(group_ids, user) active_without_invites_and_requests .non_minimal_access .where(source_id: group_ids) - .exists?(user: user) + .exists?(id: user.id) end def filter_by_enterprise_users(value) diff --git a/ee/app/models/ee/project_team.rb b/ee/app/models/ee/project_team.rb index 0c743234c849a5..57f8f75a820a6b 100644 --- a/ee/app/models/ee/project_team.rb +++ b/ee/app/models/ee/project_team.rb @@ -26,8 +26,9 @@ def members_with_access_level_or_custom_roles(levels: [], member_role_ids: []) def user_exists_with_access_level_or_custom_roles?(user, levels: [], member_role_ids: []) return false unless levels.any? || member_role_ids.any? + return false unless user - members_with_access_level_or_custom_roles(levels: levels, member_role_ids: member_role_ids).exists?(user: user) + members_with_access_level_or_custom_roles(levels: levels, member_role_ids: member_role_ids).exists?(id: user.id) end override :add_members diff --git a/ee/lib/ee/gitlab/checks/security/policy_check.rb b/ee/lib/ee/gitlab/checks/security/policy_check.rb index 5ad3104de7bd95..698ec77812ce48 100644 --- a/ee/lib/ee/gitlab/checks/security/policy_check.rb +++ b/ee/lib/ee/gitlab/checks/security/policy_check.rb @@ -7,6 +7,7 @@ module Security module PolicyCheck PUSH_ERROR_MESSAGE = "Push is blocked by settings overridden by a security policy" FORCE_PUSH_ERROR_MESSAGE = "Force push is blocked by settings overridden by a security policy" + BYPASS_REASON_ERROR_MESSAGE = "Bypass reason is required when bypassing security policy restrictions" LOG_MESSAGE = "Checking if scan result policies apply to branch..." def validate! @@ -42,6 +43,8 @@ def policies_bypass_applied? branch_name: branch_name, push_options: change_access.push_options ).check_bypass! + rescue ::Security::ScanResultPolicies::PolicyBypassChecker::BypassReasonRequiredError + raise ::Gitlab::GitAccess::ForbiddenError, BYPASS_REASON_ERROR_MESSAGE end def force_push? diff --git a/ee/lib/security/scan_result_policies/policy_bypass_checker.rb b/ee/lib/security/scan_result_policies/policy_bypass_checker.rb index 4fde9b3ffc528c..3782a06e980e6c 100644 --- a/ee/lib/security/scan_result_policies/policy_bypass_checker.rb +++ b/ee/lib/security/scan_result_policies/policy_bypass_checker.rb @@ -5,6 +5,8 @@ module ScanResultPolicies class PolicyBypassChecker include Gitlab::Utils::StrongMemoize + BypassReasonRequiredError = Class.new(StandardError) + def initialize(security_policy:, project:, user_access:, branch_name:, push_options:) @security_policy = security_policy @project = project @@ -73,7 +75,7 @@ def users_can_bypass? return false unless security_policy.bypass_settings.user_ids.include?(user.id) reason = reason_from_push_options - return false if reason.blank? + raise BypassReasonRequiredError, "Bypass reason is required for user bypass" if reason.blank? log_bypass_audit!( :user, @@ -94,7 +96,7 @@ def groups_can_bypass? return false unless GroupMember.direct_member_of_groups?(group_ids, user) reason = reason_from_push_options - return false if reason.blank? + raise BypassReasonRequiredError, "Bypass reason is required for group bypass" if reason.blank? log_bypass_audit!( :group, @@ -120,7 +122,7 @@ def roles_can_bypass? ) reason = reason_from_push_options - return false if reason.blank? + raise BypassReasonRequiredError, "Bypass reason is required for role bypass" if reason.blank? log_bypass_audit!( :role, @@ -136,6 +138,8 @@ def roles_can_bypass? end def reason_from_push_options + return if push_options.nil? + push_options.get(:security_policy)&.dig(:bypass_reason) end strong_memoize_attr :reason_from_push_options diff --git a/ee/lib/security/scan_result_policies/push_bypass_checker.rb b/ee/lib/security/scan_result_policies/push_bypass_checker.rb index 77a828e9ad3024..a9b53035f8fcc7 100644 --- a/ee/lib/security/scan_result_policies/push_bypass_checker.rb +++ b/ee/lib/security/scan_result_policies/push_bypass_checker.rb @@ -33,6 +33,8 @@ def bypass_allowed?(policy) branch_name: branch_name, push_options: push_options ).bypass_allowed? + rescue Security::ScanResultPolicies::PolicyBypassChecker::BypassReasonRequiredError + raise end end end diff --git a/ee/spec/lib/gitlab/checks/security/policy_check_spec.rb b/ee/spec/lib/gitlab/checks/security/policy_check_spec.rb index c242699ded0f27..13f8fd5c7c97bb 100644 --- a/ee/spec/lib/gitlab/checks/security/policy_check_spec.rb +++ b/ee/spec/lib/gitlab/checks/security/policy_check_spec.rb @@ -129,6 +129,26 @@ ) end end + + context 'when bypass is allowed but no bypass_reason is provided' do + let_it_be(:regular_user) { create(:user) } + let_it_be(:user_access) { Gitlab::UserAccess.new(regular_user, container: project) } + + before do + # Create a policy that allows user bypass but requires a reason + create(:security_policy, linked_projects: [project], content: { + bypass_settings: { + users: [{ id: regular_user.id }] + } + }) + end + + it 'raises bypass reason error' do + expect { policy_check! }.to raise_error( + Gitlab::GitAccess::ForbiddenError, described_class::BYPASS_REASON_ERROR_MESSAGE + ) + end + end end context 'when the security_policies_bypass_options_tokens_accounts feature flag is disabled' do diff --git a/ee/spec/lib/security/scan_result_policies/push_bypass_checker_spec.rb b/ee/spec/lib/security/scan_result_policies/push_bypass_checker_spec.rb index 302a507c2bf88f..7a1802d41e2e37 100644 --- a/ee/spec/lib/security/scan_result_policies/push_bypass_checker_spec.rb +++ b/ee/spec/lib/security/scan_result_policies/push_bypass_checker_spec.rb @@ -8,7 +8,14 @@ let_it_be(:branch_name) { 'main' } let_it_be(:user) { create(:user, :project_bot) } let_it_be(:user_access) { Gitlab::UserAccess.new(user, container: project) } - let_it_be(:checker) { described_class.new(project: project, user_access: user_access, branch_name: branch_name) } + let_it_be(:checker) do + described_class.new( + project: project, + user_access: user_access, + branch_name: branch_name, + push_options: {} + ) + end describe '#check_bypass!' do context 'when the feature is not available' do diff --git a/ee/spec/models/ee/group_member_spec.rb b/ee/spec/models/ee/group_member_spec.rb index ffd3eb760e3e6e..879134623b9eb1 100644 --- a/ee/spec/models/ee/group_member_spec.rb +++ b/ee/spec/models/ee/group_member_spec.rb @@ -167,6 +167,101 @@ end end + describe '.direct_member_of_groups?' do + let_it_be(:group1) { create(:group) } + let_it_be(:group2) { create(:group) } + let_it_be(:group3) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + + context 'when user is a direct member of the specified groups' do + before do + group1.add_developer(user) + group2.add_maintainer(user) + end + + it 'returns true for single group' do + expect(described_class.direct_member_of_groups?([group1.id], user)).to be true + end + + it 'returns true for multiple groups where user is member of all' do + expect(described_class.direct_member_of_groups?([group1.id, group2.id], user)).to be true + end + + it 'returns true for multiple groups where user is member of some' do + expect(described_class.direct_member_of_groups?([group1.id, group3.id], user)).to be true + end + + it 'returns false for groups where user is not a member' do + expect(described_class.direct_member_of_groups?([group3.id], user)).to be false + end + + it 'returns false for other user' do + expect(described_class.direct_member_of_groups?([group1.id, group2.id], other_user)).to be false + end + end + + context 'when user has minimal access level' do + before do + stub_licensed_features(minimal_access_role: true) + group1.add_member(user, Gitlab::Access::MINIMAL_ACCESS) + end + + it 'returns false as minimal access is excluded' do + expect(described_class.direct_member_of_groups?([group1.id], user)).to be false + end + end + + context 'when user has pending invite' do + before do + create(:group_member, :invited, group: group1, user: nil, invite_email: user.email) + end + + it 'returns false as invites are excluded' do + expect(described_class.direct_member_of_groups?([group1.id], user)).to be false + end + end + + context 'when user has pending request' do + before do + create(:group_member, :awaiting, group: group1, user: user) + end + + it 'returns false as requests are excluded' do + expect(described_class.direct_member_of_groups?([group1.id], user)).to be false + end + end + + context 'when user is blocked' do + before do + user.update!(state: :blocked) + group1.add_developer(user) + end + + it 'returns false as blocked users are excluded' do + expect(described_class.direct_member_of_groups?([group1.id], user)).to be false + end + end + + context 'with empty group_ids' do + it 'returns false' do + expect(described_class.direct_member_of_groups?([], user)).to be false + end + end + + context 'with nil group_ids' do + it 'returns false' do + expect(described_class.direct_member_of_groups?(nil, user)).to be false + end + end + + context 'with nil user' do + it 'returns false' do + expect(described_class.direct_member_of_groups?([group1.id], nil)).to be false + end + end + end + describe '.filter_by_enterprise_users' do let_it_be(:group) { create(:group) } let_it_be(:enterprise_user_member_1_of_group) { group.add_developer(create(:user, enterprise_group_id: group.id)) } diff --git a/ee/spec/models/ee/project_team_spec.rb b/ee/spec/models/ee/project_team_spec.rb index c76c39740ea036..8e9fb579a855c1 100644 --- a/ee/spec/models/ee/project_team_spec.rb +++ b/ee/spec/models/ee/project_team_spec.rb @@ -122,4 +122,205 @@ it { is_expected.to be_empty } end end + + describe '#user_exists_with_access_level_or_custom_roles?' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:custom_role) { create(:member_role) } + let_it_be(:another_custom_role) { create(:member_role) } + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:guest) { create(:user) } + let_it_be(:non_member) { create(:user) } + + before do + create(:project_member, :developer, project: project, user: developer, member_role: custom_role) + create(:project_member, :maintainer, project: project, user: maintainer) + create(:project_member, :reporter, project: project, user: reporter) + create(:project_member, :guest, project: project, user: guest) + end + + subject(:user_exists) do + project.team.user_exists_with_access_level_or_custom_roles?(user, levels: levels, + member_role_ids: member_role_ids) + end + + context 'when no parameters are provided' do + let(:levels) { [] } + let(:member_role_ids) { [] } + let(:user) { developer } + + it 'returns false' do + expect(user_exists).to be false + end + end + + context 'when filtering by access level' do + let(:levels) { [Gitlab::Access::MAINTAINER] } + let(:member_role_ids) { [] } + + context 'when user has the specified access level' do + let(:user) { maintainer } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user does not have the specified access level' do + let(:user) { developer } + + it 'returns false' do + expect(user_exists).to be false + end + end + + context 'when user is not a member of the project' do + let(:user) { non_member } + + it 'returns false' do + expect(user_exists).to be false + end + end + + context 'when filtering by multiple access levels' do + let(:levels) { [Gitlab::Access::MAINTAINER, Gitlab::Access::REPORTER] } + let(:member_role_ids) { [] } + + context 'when user has one of the specified access levels' do + let(:user) { maintainer } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user has another of the specified access levels' do + let(:user) { reporter } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user does not have any of the specified access levels' do + let(:user) { guest } + + it 'returns false' do + expect(user_exists).to be false + end + end + end + end + + context 'when filtering by custom roles' do + let(:levels) { [] } + let(:member_role_ids) { [custom_role.id] } + + context 'when user has the specified custom role' do + let(:user) { developer } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user does not have the specified custom role' do + let(:user) { maintainer } + + it 'returns false' do + expect(user_exists).to be false + end + end + + context 'when user is not a member of the project' do + let(:user) { non_member } + + it 'returns false' do + expect(user_exists).to be false + end + end + + context 'when filtering by multiple custom roles' do + let(:member_role_ids) { [custom_role.id, another_custom_role.id] } + + context 'when user has one of the specified custom roles' do + let(:user) { developer } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user does not have any of the specified custom roles' do + let(:user) { maintainer } + + it 'returns false' do + expect(user_exists).to be false + end + end + end + + context 'when filtering with non-existent custom role' do + let(:member_role_ids) { [non_existing_record_id] } + + context 'when user is a member' do + let(:user) { developer } + + it 'returns false' do + expect(user_exists).to be false + end + end + end + end + + context 'when filtering by both access level and custom roles' do + let(:levels) { [Gitlab::Access::MAINTAINER] } + let(:member_role_ids) { [custom_role.id] } + + context 'when user has the specified access level' do + let(:user) { maintainer } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user has the specified custom role' do + let(:user) { developer } + + it 'returns true' do + expect(user_exists).to be true + end + end + + context 'when user has neither the access level nor the custom role' do + let(:user) { guest } + + it 'returns false' do + expect(user_exists).to be false + end + end + + context 'when user is not a member of the project' do + let(:user) { non_member } + + it 'returns false' do + expect(user_exists).to be false + end + end + end + + context 'when user is nil' do + let(:levels) { [Gitlab::Access::MAINTAINER] } + let(:member_role_ids) { [] } + let(:user) { nil } + + it 'returns false' do + expect(user_exists).to be false + end + end + end end diff --git a/lib/gitlab/checks/changes_access.rb b/lib/gitlab/checks/changes_access.rb index 4b0ba12de51198..0e5eaa7761c42b 100644 --- a/lib/gitlab/checks/changes_access.rb +++ b/lib/gitlab/checks/changes_access.rb @@ -104,7 +104,8 @@ def single_change_accesses protocol: protocol, logger: logger, commits: commits, - gitaly_context: gitaly_context + gitaly_context: gitaly_context, + push_options: push_options ) end end diff --git a/lib/gitlab/checks/single_change_access.rb b/lib/gitlab/checks/single_change_access.rb index 0640af9e6253e9..6d40d3a7a77244 100644 --- a/lib/gitlab/checks/single_change_access.rb +++ b/lib/gitlab/checks/single_change_access.rb @@ -5,13 +5,13 @@ module Checks class SingleChangeAccess ATTRIBUTES = %i[user_access project skip_authorization protocol oldrev newrev ref - branch_name tag_name logger commits gitaly_context].freeze + branch_name tag_name logger commits gitaly_context push_options].freeze attr_reader(*ATTRIBUTES) def initialize( change, user_access:, project:, - protocol:, logger:, commits: nil, gitaly_context: nil + protocol:, logger:, commits: nil, gitaly_context: nil, push_options: nil ) @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) @branch_ref = Gitlab::Git.branch_ref?(@ref) @@ -23,6 +23,7 @@ def initialize( @protocol = protocol @commits = commits @gitaly_context = gitaly_context + @push_options = push_options @logger = logger @logger.append_message("Running checks for ref: #{@branch_name || @tag_name}") -- GitLab