diff --git a/app/graphql/types/security/ref_input_type.rb b/app/graphql/types/security/ref_input_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..df6aaf507c30955eda9e29d98503c9848fedc93b --- /dev/null +++ b/app/graphql/types/security/ref_input_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Security + class RefInputType < BaseInputObject + graphql_name 'Ref' + description 'Input for specifying a Git reference' + + argument :name, GraphQL::Types::String, required: true, + description: 'Name of the ref.' + + argument :ref_type, Types::RefTypeEnum, required: true, + description: 'Type of the ref (heads or tags).' + end + end +end diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 26ed284ec1e8a16e75b77f6923e94d81a63ab451..b44aee9ad15a05ff772ad3124026f6872b6b9463 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -23083,6 +23083,29 @@ The edge type for [`SecurityPolicyType`](#securitypolicytype). | `cursor` | [`String!`](#string) | A cursor for use in pagination. | | `node` | [`SecurityPolicyType`](#securitypolicytype) | The item at the end of the edge. | +#### `SecurityTrackedRefConnection` + +The connection type for [`SecurityTrackedRef`](#securitytrackedref). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `edges` | [`[SecurityTrackedRefEdge]`](#securitytrackedrefedge) | A list of edges. | +| `nodes` | [`[SecurityTrackedRef]`](#securitytrackedref) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `SecurityTrackedRefEdge` + +The edge type for [`SecurityTrackedRef`](#securitytrackedref). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `cursor` | [`String!`](#string) | A cursor for use in pagination. | +| `node` | [`SecurityTrackedRef`](#securitytrackedref) | The item at the end of the edge. | + #### `SentryErrorConnection` The connection type for [`SentryError`](#sentryerror). @@ -42040,6 +42063,7 @@ Project-level settings for product analytics provider. | `securityPolicyProjectLinkedProjects` | [`ProjectConnection`](#projectconnection) | Projects linked to the project, when used as Security Policy Project. (see [Connections](#connections)) | | `securityScanProfiles` {{< icon name="warning-solid" >}} | [`[ScanProfileType!]`](#scanprofiletype) | **Introduced** in GitLab 18.7. **Status**: Experiment. Security scan profiles attached to the project. | | `securityScanners` | [`SecurityScanners`](#securityscanners) | Information about security analyzers used in the project. | +| `securityTrackedRefs` {{< icon name="warning-solid" >}} | [`SecurityTrackedRefConnection`](#securitytrackedrefconnection) | **Introduced** in GitLab 18.8. **Status**: Experiment. Refs tracked for security vulnerabilities. | | `sentryErrors` | [`SentryErrorCollection`](#sentryerrorcollection) | Paginated collection of Sentry errors on the project. | | `serviceDeskAddress` | [`String`](#string) | E-mail address of the Service Desk. | | `serviceDeskEnabled` | [`Boolean`](#boolean) | Indicates if the project has Service Desk enabled. | @@ -46397,6 +46421,23 @@ Represents a list of security scanners. | `enabled` | [`[SecurityScannerType!]`](#securityscannertype) | List of analyzers which are enabled for the project. | | `pipelineRun` | [`[SecurityScannerType!]`](#securityscannertype) | List of analyzers which ran successfully in the latest pipeline. | +### `SecurityTrackedRef` + +Represents a ref (branch or tag) tracked for security vulnerabilities. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `commit` | [`Commit`](#commit) | Latest commit on the ref. | +| `id` | [`ID!`](#id) | Global ID of the tracked ref. | +| `isDefault` | [`Boolean!`](#boolean) | Whether the ref is the default branch. | +| `isProtected` | [`Boolean!`](#boolean) | Whether the ref is protected. | +| `name` | [`String!`](#string) | Name of the ref (branch or tag name). | +| `refType` | [`RefType!`](#reftype) | Type of the ref (branch or tag). | +| `trackedAt` | [`Time!`](#time) | When tracking was enabled for the ref. | +| `vulnerabilitiesCount` | [`Int!`](#int) | Count of open vulnerabilities on the ref. | + ### `SecurityTrainingUrl` Represents a URL related to a security training. diff --git a/ee/app/finders/security/tracked_refs_finder.rb b/ee/app/finders/security/tracked_refs_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed694e784b7dc06217f6a16f6693ed8ddcee3799 --- /dev/null +++ b/ee/app/finders/security/tracked_refs_finder.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Security + class TrackedRefsFinder + def initialize(project, current_user = nil) + @project = project + @current_user = current_user + end + + def execute + return Security::ProjectTrackedContext.none unless can_read_security_refs? + + @project.security_project_tracked_contexts.tracked + end + + private + + attr_reader :project, :current_user + + def can_read_security_refs? + Ability.allowed?(current_user, :read_security_resource, project) + end + end +end diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb index b120e9c664b2e22d2c5370833cf652177a531d7d..203dfe6dc40a96248dabf8fbf5149a73a5caa6ef 100644 --- a/ee/app/graphql/ee/types/project_type.rb +++ b/ee/app/graphql/ee/types/project_type.rb @@ -716,6 +716,13 @@ module ProjectType description: 'Security scan profiles attached to the project.', authorize: :read_security_scan_profiles, experiment: { milestone: '18.7' } + + field :security_tracked_refs, + ::Types::Security::TrackedRefType.connection_type, + null: true, + description: 'Refs tracked for security vulnerabilities.', + resolver: ::Resolvers::Security::TrackedRefsResolver, + experiment: { milestone: '18.8' } end def tracking_key diff --git a/ee/app/graphql/resolvers/security/tracked_refs_resolver.rb b/ee/app/graphql/resolvers/security/tracked_refs_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..50b973e96c8d03cd5d583a642880c36bb442136c --- /dev/null +++ b/ee/app/graphql/resolvers/security/tracked_refs_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + module Security + class TrackedRefsResolver < BaseResolver + type Types::Security::TrackedRefType.connection_type, null: true + + authorize :read_security_resource + + description 'Security tracked refs for vulnerability tracking' + + def resolve + ::Security::TrackedRefsFinder.new(object, current_user).execute + end + end + end +end diff --git a/ee/app/graphql/types/security/tracked_ref_type.rb b/ee/app/graphql/types/security/tracked_ref_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..c5c6e1048d37e61c511b244b505b0975d1030180 --- /dev/null +++ b/ee/app/graphql/types/security/tracked_ref_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Security + class TrackedRefType < BaseObject + graphql_name 'SecurityTrackedRef' + description 'Represents a ref (branch or tag) tracked for security vulnerabilities' + + authorize :read_security_resource + + field :id, GraphQL::Types::ID, null: false, + description: 'Global ID of the tracked ref.' + + field :name, GraphQL::Types::String, null: false, + description: 'Name of the ref (branch or tag name).' + + field :ref_type, Types::RefTypeEnum, null: false, + description: 'Type of the ref (branch or tag).' + + field :is_default, GraphQL::Types::Boolean, null: false, + description: 'Whether the ref is the default branch.' + + field :is_protected, GraphQL::Types::Boolean, null: false, + description: 'Whether the ref is protected.' + + field :commit, Types::Repositories::CommitType, null: true, + description: 'Latest commit on the ref.' + + field :vulnerabilities_count, GraphQL::Types::Int, null: false, + description: 'Count of open vulnerabilities on the ref.' + + field :tracked_at, Types::TimeType, null: false, + description: 'When tracking was enabled for the ref.' + end + end +end diff --git a/ee/app/policies/security/project_tracked_context_policy.rb b/ee/app/policies/security/project_tracked_context_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..5e2e393065f6f7b8564ab32ca08337e270818ac8 --- /dev/null +++ b/ee/app/policies/security/project_tracked_context_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Security + class ProjectTrackedContextPolicy < BasePolicy + delegate { @subject.project } + end +end diff --git a/ee/spec/finders/security/tracked_refs_finder_spec.rb b/ee/spec/finders/security/tracked_refs_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4fa9aa899b2b51d55d49383d8f3a2e35598ff9d6 --- /dev/null +++ b/ee/spec/finders/security/tracked_refs_finder_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::TrackedRefsFinder, feature_category: :vulnerability_management do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:main_branch) { create(:security_project_tracked_context, :tracked, :default, project: project) } + let_it_be(:tag_ref) do + create(:security_project_tracked_context, :tracked, :tag, project: project, context_name: 'v1.0') + end + + subject(:finder) { described_class.new(project, user) } + + before do + stub_licensed_features(security_dashboard: true) + end + + describe '#execute' do + context 'when user has developer access' do + before_all { project.add_developer(user) } + + it 'returns all tracked refs for the project' do + expect(finder.execute).to contain_exactly(main_branch, tag_ref) + end + end + + context 'when user lacks permission' do + it 'returns empty relation' do + expect(finder.execute).to be_empty + end + end + + context 'when user is nil' do + let(:finder) { described_class.new(project, nil) } + + it 'returns empty relation' do + expect(finder.execute).to be_empty + end + end + end +end diff --git a/ee/spec/graphql/resolvers/security/tracked_refs_resolver_spec.rb b/ee/spec/graphql/resolvers/security/tracked_refs_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..94c2096462bbbfd5396a202b512482d6bcf79724 --- /dev/null +++ b/ee/spec/graphql/resolvers/security/tracked_refs_resolver_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Security::TrackedRefsResolver, feature_category: :vulnerability_management do + include GraphqlHelpers + + before_all do + Security::ProjectTrackedContext.delete_all + end + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + let_it_be(:main_branch) do + create(:security_project_tracked_context, :tracked, :default, + project: project, context_name: 'main', context_type: :branch) + end + + let_it_be(:tag_ref) do + create(:security_project_tracked_context, :tracked, + project: project, context_name: 'v1.1.0', context_type: :tag) + end + + let_it_be(:untracked_ref) do + create(:security_project_tracked_context, :untracked, + project: project, context_name: 'untracked-branch', context_type: :branch) + end + + subject(:resolver) { described_class } + + specify { expect(resolver.type).to eq(Types::Security::TrackedRefType.connection_type) } + specify { expect(resolver.null).to be_truthy } + specify { expect(resolver.required_permissions).to include(:read_security_resource) } + + describe '#resolve' do + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when user has permission' do + before_all do + project.add_developer(user) + end + + it 'returns tracked refs for the project' do + result = resolve(resolver, obj: project, ctx: { current_user: user }) + + expect(result).to contain_exactly(main_branch, tag_ref) + expect(result).not_to include(untracked_ref) + end + + it 'supports pagination' do + result = resolve(resolver, obj: project, ctx: { current_user: user }, args: { first: 1 }) + + expect(result).to be_a(Gitlab::Graphql::Pagination::Keyset::Connection) + expect(result.nodes.size).to be <= 1 + end + end + + context 'when user lacks permission' do + it 'returns empty result' do + result = resolve(resolver, obj: project, ctx: { current_user: user }) + + expect(result).to be_empty + end + end + + context 'when user is nil' do + it 'returns empty result' do + result = resolve(resolver, obj: project, ctx: { current_user: nil }) + + expect(result).to be_empty + end + end + + context 'when security dashboard is not licensed' do + before_all do + project.add_developer(user) + end + + before do + stub_licensed_features(security_dashboard: false) + end + + it 'returns empty result' do + result = resolve(resolver, obj: project, ctx: { current_user: user }) + + expect(result).to be_empty + end + end + end +end diff --git a/ee/spec/graphql/types/project_type_spec.rb b/ee/spec/graphql/types/project_type_spec.rb index 6b87d331325215dee3ca66141d7bbdef5f00f840..dc4cd21a8048cf078ae3c85212a3b52503be59c4 100644 --- a/ee/spec/graphql/types/project_type_spec.rb +++ b/ee/spec/graphql/types/project_type_spec.rb @@ -39,7 +39,7 @@ compliance_standards_adherence target_branch_rules duo_workflow_status_check component_usages vulnerability_archives component_versions vulnerability_statistic analyzer_statuses compliance_requirement_statuses duo_agentic_chat_available container_scanning_for_registry_enabled duo_workflow_workflows - duo_workflow_events security_metrics ai_catalog_item security_scan_profiles + duo_workflow_events security_metrics ai_catalog_item security_scan_profiles security_tracked_refs ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/ee/spec/graphql/types/security/ref_input_type_spec.rb b/ee/spec/graphql/types/security/ref_input_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4a9c44066f1acd885bcf22b69a39cc0d7c681d3c --- /dev/null +++ b/ee/spec/graphql/types/security/ref_input_type_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Security::RefInputType, feature_category: :vulnerability_management do + specify { expect(described_class.graphql_name).to eq('Ref') } + specify { expect(described_class.description).to eq('Input for specifying a Git reference') } + + it 'has the expected arguments' do + expect(described_class.arguments.keys).to contain_exactly('name', 'refType') + end + + describe 'name argument' do + let(:argument) { described_class.arguments['name'] } + + it 'has correct properties' do + expect(argument.type).to eq(GraphQL::Types::String.to_non_null_type) + expect(argument.description).to eq('Name of the ref.') + end + end + + describe 'refType argument' do + let(:argument) { described_class.arguments['refType'] } + + it 'has correct properties' do + expect(argument.type).to eq(Types::RefTypeEnum.to_non_null_type) + expect(argument.description).to eq('Type of the ref (heads or tags).') + end + end +end diff --git a/ee/spec/graphql/types/security/tracked_ref_type_spec.rb b/ee/spec/graphql/types/security/tracked_ref_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b4a746339e4c5ebb41311472ac0a32ad4b315f0d --- /dev/null +++ b/ee/spec/graphql/types/security/tracked_ref_type_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Security::TrackedRefType, feature_category: :vulnerability_management do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:tracked_context) do + create(:security_project_tracked_context, :tracked, :default, + project: project, context_name: 'main', context_type: :branch) + end + + before_all do + project.add_developer(user) + end + + specify { expect(described_class).to require_graphql_authorizations(:read_security_resource) } + + describe 'field definitions' do + subject { described_class } + + it { is_expected.to have_graphql_field(:id) } + it { is_expected.to have_graphql_field(:name) } + it { is_expected.to have_graphql_field(:ref_type) } + it { is_expected.to have_graphql_field(:is_default) } + it { is_expected.to have_graphql_field(:is_protected) } + it { is_expected.to have_graphql_field(:commit) } + it { is_expected.to have_graphql_field(:vulnerabilities_count) } + it { is_expected.to have_graphql_field(:tracked_at) } + + it 'has correct field descriptions' do + expect(described_class.fields['id'].description).to include('Global ID') + expect(described_class.fields['name'].description).to include('Name of the ref') + expect(described_class.fields['refType'].description).to include('Type of the ref') + expect(described_class.fields['isDefault'].description).to include('Whether the ref is the default branch') + expect(described_class.fields['isProtected'].description).to include('Whether the ref is protected') + expect(described_class.fields['commit'].description).to include('Latest commit') + expect(described_class.fields['vulnerabilitiesCount'].description).to include('Count of open vulnerabilities') + expect(described_class.fields['trackedAt'].description).to include('When tracking was enabled') + end + end +end