Commit 334beb40 authored by Alan (Maciej) Paruszewski's avatar Alan (Maciej) Paruszewski Committed by Mikołaj Wawrzyniak

Extend Vulnerability GraphQL API with External Issue Links

This change adds externalIssueLinks to Vulnerability GraphQL API to
fetch linked issues in external tracker.
parent 24bf932b
...@@ -8817,6 +8817,46 @@ enum EpicWildcardId { ...@@ -8817,6 +8817,46 @@ enum EpicWildcardId {
NONE NONE
} }
"""
Represents an external issue
"""
type ExternalIssue {
"""
Timestamp of when the issue was created
"""
createdAt: Time
"""
Type of external tracker
"""
externalTracker: String
"""
Relative reference of the issue in the external tracker
"""
relativeReference: String
"""
Status of the issue in the external tracker
"""
status: String
"""
Title of the issue in the external tracker
"""
title: String
"""
Timestamp of when the issue was updated
"""
updatedAt: Time
"""
URL to the issue in the external tracker
"""
webUrl: String
}
type GeoNode { type GeoNode {
""" """
The maximum concurrency of container repository sync for this secondary node The maximum concurrency of container repository sync for this secondary node
...@@ -24757,6 +24797,11 @@ type VulnerabilitiesCountByDayEdge { ...@@ -24757,6 +24797,11 @@ type VulnerabilitiesCountByDayEdge {
node: VulnerabilitiesCountByDay node: VulnerabilitiesCountByDay
} }
"""
Identifier of Vulnerabilities::ExternalIssueLink
"""
scalar VulnerabilitiesExternalIssueLinkID
""" """
Represents a vulnerability Represents a vulnerability
""" """
...@@ -24801,6 +24846,31 @@ type Vulnerability implements Noteable { ...@@ -24801,6 +24846,31 @@ type Vulnerability implements Noteable {
last: Int last: Int
): DiscussionConnection! ): DiscussionConnection!
"""
List of external issue links related to the vulnerability
"""
externalIssueLinks(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): VulnerabilityExternalIssueLinkConnection!
""" """
GraphQL ID of the vulnerability GraphQL ID of the vulnerability
""" """
...@@ -25039,6 +25109,71 @@ type VulnerabilityEdge { ...@@ -25039,6 +25109,71 @@ type VulnerabilityEdge {
node: Vulnerability node: Vulnerability
} }
"""
Represents an external issue link of a vulnerability
"""
type VulnerabilityExternalIssueLink {
"""
The external issue attached to the issue link
"""
externalIssue: ExternalIssue
"""
GraphQL ID of the external issue link
"""
id: VulnerabilitiesExternalIssueLinkID!
"""
Type of the external issue link
"""
linkType: VulnerabilityExternalIssueLinkType!
}
"""
The connection type for VulnerabilityExternalIssueLink.
"""
type VulnerabilityExternalIssueLinkConnection {
"""
A list of edges.
"""
edges: [VulnerabilityExternalIssueLinkEdge]
"""
A list of nodes.
"""
nodes: [VulnerabilityExternalIssueLink]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type VulnerabilityExternalIssueLinkEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: VulnerabilityExternalIssueLink
}
"""
The type of the external issue link related to a vulnerability
"""
enum VulnerabilityExternalIssueLinkType {
"""
Created link type
"""
CREATED
}
""" """
The grade of the vulnerable project The grade of the vulnerable project
""" """
...@@ -25307,6 +25442,11 @@ type VulnerabilityPermissions { ...@@ -25307,6 +25442,11 @@ type VulnerabilityPermissions {
""" """
adminVulnerability: Boolean! adminVulnerability: Boolean!
"""
Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource
"""
adminVulnerabilityExternalIssueLink: Boolean!
""" """
Indicates the user can perform `admin_vulnerability_issue_link` on this resource Indicates the user can perform `admin_vulnerability_issue_link` on this resource
""" """
......
...@@ -24704,6 +24704,117 @@ ...@@ -24704,6 +24704,117 @@
], ],
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "ExternalIssue",
"description": "Represents an external issue",
"fields": [
{
"name": "createdAt",
"description": "Timestamp of when the issue was created",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "externalTracker",
"description": "Type of external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "relativeReference",
"description": "Relative reference of the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "Status of the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": "Title of the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp of when the issue was updated",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webUrl",
"description": "URL to the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Float", "name": "Float",
...@@ -72114,6 +72225,16 @@ ...@@ -72114,6 +72225,16 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "SCALAR",
"name": "VulnerabilitiesExternalIssueLinkID",
"description": "Identifier of Vulnerabilities::ExternalIssueLink",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "Vulnerability", "name": "Vulnerability",
...@@ -72222,6 +72343,63 @@ ...@@ -72222,6 +72343,63 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "externalIssueLinks",
"description": "List of external issue links related to the vulnerability",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "id", "name": "id",
"description": "GraphQL ID of the vulnerability", "description": "GraphQL ID of the vulnerability",
...@@ -72908,6 +73086,198 @@ ...@@ -72908,6 +73086,198 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"description": "Represents an external issue link of a vulnerability",
"fields": [
{
"name": "externalIssue",
"description": "The external issue attached to the issue link",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ExternalIssue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "GraphQL ID of the external issue link",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "VulnerabilitiesExternalIssueLinkID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "linkType",
"description": "Type of the external issue link",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkType",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkConnection",
"description": "The connection type for VulnerabilityExternalIssueLink.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkType",
"description": "The type of the external issue link related to a vulnerability",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "CREATED",
"description": "Created link type",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "ENUM", "kind": "ENUM",
"name": "VulnerabilityGrade", "name": "VulnerabilityGrade",
...@@ -73708,6 +74078,24 @@ ...@@ -73708,6 +74078,24 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "adminVulnerabilityExternalIssueLink",
"description": "Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "adminVulnerabilityIssueLink", "name": "adminVulnerabilityIssueLink",
"description": "Indicates the user can perform `admin_vulnerability_issue_link` on this resource", "description": "Indicates the user can perform `admin_vulnerability_issue_link` on this resource",
...@@ -1495,6 +1495,20 @@ Autogenerated return type of EpicTreeReorder. ...@@ -1495,6 +1495,20 @@ Autogenerated return type of EpicTreeReorder.
| `clientMutationId` | String | A unique identifier for the client performing the mutation. | | `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
### ExternalIssue
Represents an external issue.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `createdAt` | Time | Timestamp of when the issue was created |
| `externalTracker` | String | Type of external tracker |
| `relativeReference` | String | Relative reference of the issue in the external tracker |
| `status` | String | Status of the issue in the external tracker |
| `title` | String | Title of the issue in the external tracker |
| `updatedAt` | Time | Timestamp of when the issue was updated |
| `webUrl` | String | URL to the issue in the external tracker |
### GeoNode ### GeoNode
| Field | Type | Description | | Field | Type | Description |
...@@ -3731,6 +3745,7 @@ Represents a vulnerability. ...@@ -3731,6 +3745,7 @@ Represents a vulnerability.
| `description` | String | Description of the vulnerability | | `description` | String | Description of the vulnerability |
| `detectedAt` | Time! | Timestamp of when the vulnerability was first detected | | `detectedAt` | Time! | Timestamp of when the vulnerability was first detected |
| `discussions` | DiscussionConnection! | All discussions on this noteable | | `discussions` | DiscussionConnection! | All discussions on this noteable |
| `externalIssueLinks` | VulnerabilityExternalIssueLinkConnection! | List of external issue links related to the vulnerability |
| `id` | ID! | GraphQL ID of the vulnerability | | `id` | ID! | GraphQL ID of the vulnerability |
| `identifiers` | VulnerabilityIdentifier! => Array | Identifiers of the vulnerability. | | `identifiers` | VulnerabilityIdentifier! => Array | Identifiers of the vulnerability. |
| `issueLinks` | VulnerabilityIssueLinkConnection! | List of issue links related to the vulnerability | | `issueLinks` | VulnerabilityIssueLinkConnection! | List of issue links related to the vulnerability |
...@@ -3768,6 +3783,16 @@ Autogenerated return type of VulnerabilityDismiss. ...@@ -3768,6 +3783,16 @@ Autogenerated return type of VulnerabilityDismiss.
| `errors` | String! => Array | Errors encountered during execution of the mutation. | | `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `vulnerability` | Vulnerability | The vulnerability after dismissal | | `vulnerability` | Vulnerability | The vulnerability after dismissal |
### VulnerabilityExternalIssueLink
Represents an external issue link of a vulnerability.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `externalIssue` | ExternalIssue | The external issue attached to the issue link |
| `id` | VulnerabilitiesExternalIssueLinkID! | GraphQL ID of the external issue link |
| `linkType` | VulnerabilityExternalIssueLinkType! | Type of the external issue link |
### VulnerabilityIdentifier ### VulnerabilityIdentifier
Represents a vulnerability identifier. Represents a vulnerability identifier.
...@@ -3862,6 +3887,7 @@ Check permissions for the current user on a vulnerability. ...@@ -3862,6 +3887,7 @@ Check permissions for the current user on a vulnerability.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `adminVulnerability` | Boolean! | Indicates the user can perform `admin_vulnerability` on this resource | | `adminVulnerability` | Boolean! | Indicates the user can perform `admin_vulnerability` on this resource |
| `adminVulnerabilityExternalIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource |
| `adminVulnerabilityIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_issue_link` on this resource | | `adminVulnerabilityIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_issue_link` on this resource |
| `createVulnerability` | Boolean! | Indicates the user can perform `create_vulnerability` on this resource | | `createVulnerability` | Boolean! | Indicates the user can perform `create_vulnerability` on this resource |
| `createVulnerabilityExport` | Boolean! | Indicates the user can perform `create_vulnerability_export` on this resource | | `createVulnerabilityExport` | Boolean! | Indicates the user can perform `create_vulnerability_export` on this resource |
...@@ -4764,6 +4790,14 @@ Possible states of a user. ...@@ -4764,6 +4790,14 @@ Possible states of a user.
| `private` | | | `private` | |
| `public` | | | `public` | |
### VulnerabilityExternalIssueLinkType
The type of the external issue link related to a vulnerability.
| Value | Description |
| ----- | ----------- |
| `CREATED` | Created link type |
### VulnerabilityGrade ### VulnerabilityGrade
The grade of the vulnerable project. The grade of the vulnerable project.
......
# frozen_string_literal: true
module Projects
module Integrations
module Jira
class ByIdsFinder
include ReactiveService
self.reactive_cache_key = ->(finder) { [finder.model_name] }
self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) }
attr_reader :project, :jira_issue_ids
def self.from_cache(project_id, jira_issue_ids)
project = Project.find(project_id)
new(project, jira_issue_ids)
end
def initialize(project, jira_issue_ids)
@project = project
@jira_issue_ids = jira_issue_ids
end
def execute
with_reactive_cache(*cache_args) { |issues| issues }
end
def calculate_reactive_cache(*)
# rubocop: disable CodeReuse/Finder
::Projects::Integrations::Jira::IssuesFinder
.new(project, issue_ids: jira_issue_ids)
.execute
.then { |issues| { issues: issues, error: nil } }
rescue ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, ::Projects::Integrations::Jira::IssuesFinder::RequestError => error
{ issues: [], error: error.message }
# rubocop: enable CodeReuse/Finder
end
def clear_cache!
clear_reactive_cache!(*cache_args)
end
def model_name
self.class.name.underscore.tr('/', '_')
end
def cache_args
[project.id, jira_issue_ids]
end
private
def id
nil
end
end
end
end
end
...@@ -13,7 +13,7 @@ module Projects ...@@ -13,7 +13,7 @@ module Projects
def valid_params def valid_params
@valid_params ||= %i[page per_page search state status author_username assignee_username] @valid_params ||= %i[page per_page search state status author_username assignee_username]
# to permit array params you need to init them to an empty array # to permit array params you need to init them to an empty array
@valid_params << { labels: [], vulnerability_ids: [] } @valid_params << { labels: [], vulnerability_ids: [], issue_ids: [] }
end end
end end
......
# frozen_string_literal: true
module Resolvers
class ExternalIssueResolver < BaseResolver
description 'Retrieve a single issue from external tracker'
type Types::ExternalIssueType, null: true
def resolve
BatchLoader::GraphQL.for(object.external_issue_key).batch(key: object.external_type) do |external_issue_keys, loader, args|
case args[:key]
when 'jira'
jira_issues(external_issue_keys).each do |external_issue|
loader.call(
external_issue.id,
::Integrations::Jira::IssueSerializer.new.represent(external_issue, project: object.vulnerability.project)
)
end
end
end
end
private
def jira_issues(issue_ids)
result = ::Projects::Integrations::Jira::ByIdsFinder.new(object.vulnerability.project, issue_ids).execute
return [] if result.nil?
raise GraphQL::ExecutionError, result[:error] if result[:error].present?
result[:issues]
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ExternalIssueType < BaseObject
graphql_name 'ExternalIssue'
description 'Represents an external issue'
field :title, GraphQL::STRING_TYPE, null: true,
description: 'Title of the issue in the external tracker'
field :relative_reference, GraphQL::STRING_TYPE, null: true,
description: 'Relative reference of the issue in the external tracker'
field :status, GraphQL::STRING_TYPE, null: true,
description: 'Status of the issue in the external tracker'
field :external_tracker, GraphQL::STRING_TYPE, null: true,
description: 'Type of external tracker'
field :web_url, GraphQL::STRING_TYPE, null: true,
description: 'URL to the issue in the external tracker'
field :created_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was created'
field :updated_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was updated'
def relative_reference
object.dig(:references, :relative)
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
...@@ -8,7 +8,7 @@ module Types ...@@ -8,7 +8,7 @@ module Types
abilities :read_vulnerability_feedback, :create_vulnerability_feedback, :destroy_vulnerability_feedback, abilities :read_vulnerability_feedback, :create_vulnerability_feedback, :destroy_vulnerability_feedback,
:update_vulnerability_feedback, :create_vulnerability, :create_vulnerability_export, :update_vulnerability_feedback, :create_vulnerability, :create_vulnerability_export,
:admin_vulnerability, :admin_vulnerability_issue_link :admin_vulnerability, :admin_vulnerability_issue_link, :admin_vulnerability_external_issue_link
end end
end end
end end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkType < BaseObject
graphql_name 'VulnerabilityExternalIssueLink'
description 'Represents an external issue link of a vulnerability'
authorize :read_vulnerability
field :id, GlobalIDType[::Vulnerabilities::ExternalIssueLink], null: false,
description: 'GraphQL ID of the external issue link'
field :link_type, ::Types::Vulnerability::ExternalIssueLinkTypeEnum, null: false,
description: 'Type of the external issue link'
field :external_issue, ::Types::ExternalIssueType, null: true,
description: 'The external issue attached to the issue link',
resolver: Resolvers::ExternalIssueResolver
end
end
end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkTypeEnum < BaseEnum
graphql_name 'VulnerabilityExternalIssueLinkType'
description 'The type of the external issue link related to a vulnerability'
::Vulnerabilities::ExternalIssueLink.link_types.keys.each do |link_type|
value link_type.to_s.upcase, value: link_type.to_s, description: "#{link_type.titleize} link type"
end
end
end
end
...@@ -42,6 +42,9 @@ module Types ...@@ -42,6 +42,9 @@ module Types
description: "List of issue links related to the vulnerability", description: "List of issue links related to the vulnerability",
resolver: Resolvers::Vulnerabilities::IssueLinksResolver resolver: Resolvers::Vulnerabilities::IssueLinksResolver
field :external_issue_links, ::Types::Vulnerability::ExternalIssueLinkType.connection_type, null: false,
description: 'List of external issue links related to the vulnerability'
field :location, VulnerabilityLocationType, null: true, field :location, VulnerabilityLocationType, null: true,
description: 'Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability' description: 'Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability'
......
...@@ -223,6 +223,7 @@ module EE ...@@ -223,6 +223,7 @@ module EE
enable :create_vulnerability_export enable :create_vulnerability_export
enable :admin_vulnerability enable :admin_vulnerability
enable :admin_vulnerability_issue_link enable :admin_vulnerability_issue_link
enable :admin_vulnerability_external_issue_link
end end
rule { issues_disabled & merge_requests_disabled }.policy do rule { issues_disabled & merge_requests_disabled }.policy do
...@@ -274,6 +275,7 @@ module EE ...@@ -274,6 +275,7 @@ module EE
prevent :create_vulnerability prevent :create_vulnerability
prevent :admin_vulnerability prevent :admin_vulnerability
prevent :admin_vulnerability_issue_link prevent :admin_vulnerability_issue_link
prevent :admin_vulnerability_external_issue_link
end end
rule { auditor & ~guest }.policy do rule { auditor & ~guest }.policy do
......
# frozen_string_literal: true
module Vulnerabilities
class ExternalIssueLinkPolicy < BasePolicy
delegate { @subject.vulnerability.project }
end
end
...@@ -19,6 +19,7 @@ module Jira ...@@ -19,6 +19,7 @@ module Jira
@sort = params[:sort] || DEFAULT_SORT @sort = params[:sort] || DEFAULT_SORT
@sort_direction = params[:sort_direction] || DEFAULT_SORT_DIRECTION @sort_direction = params[:sort_direction] || DEFAULT_SORT_DIRECTION
@vulnerability_ids = params[:vulnerability_ids] @vulnerability_ids = params[:vulnerability_ids]
@issue_ids = params[:issue_ids]
end end
def execute def execute
...@@ -30,7 +31,7 @@ module Jira ...@@ -30,7 +31,7 @@ module Jira
private private
attr_reader :jira_project_key, :sort, :sort_direction, :search, :labels, :status, :reporter, :assignee, :state, :vulnerability_ids attr_reader :jira_project_key, :sort, :sort_direction, :search, :labels, :status, :reporter, :assignee, :state, :vulnerability_ids, :issue_ids
def jql_filters def jql_filters
[ [
...@@ -41,7 +42,8 @@ module Jira ...@@ -41,7 +42,8 @@ module Jira
by_assignee, by_assignee,
by_open_and_closed, by_open_and_closed,
by_summary_and_description, by_summary_and_description,
by_vulnerability_ids by_vulnerability_ids,
by_issue_ids
].compact.join(' AND ') ].compact.join(' AND ')
end end
...@@ -104,6 +106,15 @@ module Jira ...@@ -104,6 +106,15 @@ module Jira
.then { |query| "(#{query})" } .then { |query| "(#{query})" }
end end
def by_issue_ids
return if issue_ids.blank?
issue_ids
.map { |issue_id| %Q[id = #{escape_quotes(issue_id.to_s)}] }
.join(' OR ')
.then { |query| "(#{query})" }
end
def escape_quotes(param) def escape_quotes(param)
param.gsub('\\', '\\\\\\').gsub('"', '\\"') param.gsub('\\', '\\\\\\').gsub('"', '\\"')
end end
......
---
title: Extend Vulnerability GraphQL API with External Issue Links
merge_request: 48616
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Integrations::Jira::ByIdsFinder do
include ReactiveCachingHelpers
let_it_be(:project) { create(:project) }
let(:jira_issue_ids) { %w[10000 10001] }
let(:finder_params) { [project, issue_ids: jira_issue_ids] }
let(:by_ids_finder) { described_class.new(project, jira_issue_ids) }
describe '#execute' do
context 'when reactive_caching is still fetching data' do
it 'returns nil' do
expect(by_ids_finder.execute).to be_nil
end
end
context 'when reactive_caching has finished' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return([{ jira_issue: 1 }, { jira_issue: 2 }])
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns issues encapsulated in hash' do
expect(by_ids_finder.execute).to eq(issues: [{ jira_issue: 1 }, { jira_issue: 2 }], error: nil)
end
end
context 'when reactive_caching failed with ::Projects::Integrations::Jira::IssuesFinder::IntegrationError' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_raise(::Projects::Integrations::Jira::IssuesFinder::IntegrationError, 'project key not set')
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns empty issues list with error message' do
expect(by_ids_finder.execute).to eq(issues: [], error: 'project key not set')
end
end
context 'when reactive_caching failed with ::Projects::Integrations::Jira::IssuesFinder::RequestError' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_raise(::Projects::Integrations::Jira::IssuesFinder::RequestError, 'jira instance not found')
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns empty issues list with error message' do
expect(by_ids_finder.execute).to eq(issues: [], error: 'jira instance not found')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::ExternalIssueResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
context 'when Jira issues are requested' do
let_it_be(:vulnerability_external_issue_link) { create(:vulnerabilities_external_issue_link) }
let(:jira_issue) do
double(
id: vulnerability_external_issue_link.external_issue_key,
summary: 'Issue Title',
created: Time.at(1606348800).utc,
updated: Time.at(1606348800).utc,
resolutiondate: Time.at(1606348800).utc,
status: double(name: 'To Do'),
key: 'GV-1',
labels: [],
reporter: double(displayName: 'User', accountId: '10000'),
assignee: nil,
client: double(options: { site: nil })
)
end
let(:expected_result) do
{
'project_id' => vulnerability_external_issue_link.vulnerability.project_id,
'title' => 'Issue Title',
'created_at' => '2020-11-26T00:00:00.000Z',
'updated_at' => '2020-11-26T00:00:00.000Z',
'closed_at' => '2020-11-26T00:00:00.000Z',
'status' => 'To Do',
'labels' => [],
'author' => {
'name' => 'User',
'web_url' => 'people/10000'
},
'assignees' => [],
'web_url' => 'browse/GV-1',
'references' => {
'relative' => 'GV-1'
},
'external_tracker' => 'jira'
}
end
context 'when Jira API responds with nil' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(nil)
end
end
it 'sends request to Jira to fetch issues' do
params = [vulnerability_external_issue_link.vulnerability.project, [vulnerability_external_issue_link.external_issue_key]]
expect_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder, *params) do |issues_finder|
expect(issues_finder).to receive(:execute).and_return(nil)
end
batch_sync { resolve_external_issue({}) }
end
it 'returns nil' do
result = batch_sync { resolve_external_issue({}) }
expect(result).to be_nil
end
end
context 'when Jira API responds with found issues' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(issues: [jira_issue])
end
end
it 'sends request to Jira to fetch issues' do
params = [vulnerability_external_issue_link.vulnerability.project, [vulnerability_external_issue_link.external_issue_key]]
expect_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder, *params) do |issues_finder|
expect(issues_finder).to receive(:execute).and_return(issues: [jira_issue])
end
batch_sync { resolve_external_issue({}) }
end
it 'returns serialized Jira issues' do
result = batch_sync { resolve_external_issue({}) }
expect(result.as_json).to eq(expected_result)
end
end
context 'when Jira API responds with an integration error' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(error: 'Jira service not configured.')
end
end
it 'raises a GraphQL exception' do
expect { batch_sync { resolve_external_issue({}) } }.to raise_error(GraphQL::ExecutionError, 'Jira service not configured.')
end
end
context 'when Jira API responds with an request error' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(error: 'Jira service unavailable.')
end
end
it 'raises a GraphQL exception' do
expect { batch_sync { resolve_external_issue({}) } }.to raise_error(GraphQL::ExecutionError, 'Jira service unavailable.')
end
end
def resolve_external_issue(args)
resolve(described_class, obj: vulnerability_external_issue_link, args: args, ctx: { current_user: current_user })
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ExternalIssue'] do
let(:expected_fields) { %i[title relative_reference status external_tracker web_url created_at updated_at] }
subject { described_class }
it { is_expected.to have_graphql_fields(expected_fields) }
end
...@@ -6,7 +6,7 @@ RSpec.describe Types::PermissionTypes::Vulnerability do ...@@ -6,7 +6,7 @@ RSpec.describe Types::PermissionTypes::Vulnerability do
it do it do
expected_permissions = %i[read_vulnerability_feedback create_vulnerability_feedback destroy_vulnerability_feedback expected_permissions = %i[read_vulnerability_feedback create_vulnerability_feedback destroy_vulnerability_feedback
update_vulnerability_feedback create_vulnerability create_vulnerability_export update_vulnerability_feedback create_vulnerability create_vulnerability_export
admin_vulnerability admin_vulnerability_issue_link] admin_vulnerability admin_vulnerability_issue_link admin_vulnerability_external_issue_link]
expected_permissions.each do |permission| expected_permissions.each do |permission|
expect(described_class).to have_graphql_field(permission) expect(described_class).to have_graphql_field(permission)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLinkType'] do
let(:expected_values) { %w[CREATED] }
subject { described_class.values.keys }
it { is_expected.to contain_exactly(*expected_values) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLink'] do
let(:expected_fields) { %i[id link_type external_issue] }
subject { described_class }
it { is_expected.to have_graphql_fields(expected_fields) }
end
...@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Vulnerability'] do ...@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Vulnerability'] do
detected_at detected_at
confirmed_at confirmed_at
notes notes
external_issue_links
discussions] discussions]
end end
......
...@@ -24,7 +24,7 @@ RSpec.describe ProjectPolicy do ...@@ -24,7 +24,7 @@ RSpec.describe ProjectPolicy do
%i[ %i[
admin_vulnerability_feedback read_project_audit_events read_project_security_dashboard admin_vulnerability_feedback read_project_audit_events read_project_security_dashboard
read_vulnerability read_vulnerability_scanner create_vulnerability create_vulnerability_export admin_vulnerability read_vulnerability read_vulnerability_scanner create_vulnerability create_vulnerability_export admin_vulnerability
admin_vulnerability_issue_link read_merge_train admin_vulnerability_issue_link admin_vulnerability_external_issue_link read_merge_train
] ]
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::ExternalIssueLinkPolicy do
let!(:user) { create(:user) }
let!(:project) { create(:project) }
let!(:vulnerability) { create(:vulnerability, project: project) }
let!(:vulnerability_external_issue_link) { build(:vulnerabilities_external_issue_link, vulnerability: vulnerability, author: user) }
subject { described_class.new(user, vulnerability_external_issue_link) }
context 'when the security_dashboard feature is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
context "when the current user has developer access to the vulnerability's project" do
before do
project.add_developer(user)
end
it { is_expected.to be_allowed(:admin_vulnerability_external_issue_link) }
end
context "when the current user does not have developer access to the vulnerability's project" do
it { is_expected.to be_disallowed(:admin_vulnerability_external_issue_link) }
end
end
context 'when the security_dashboard feature is disabled' do
before do
stub_licensed_features(security_dashboard: false)
project.add_developer(user)
end
it { is_expected.to be_disallowed(:admin_vulnerability_external_issue_link) }
end
end
...@@ -117,5 +117,13 @@ RSpec.describe Jira::JqlBuilderService do ...@@ -117,5 +117,13 @@ RSpec.describe Jira::JqlBuilderService do
expect(subject).to eq('project = PROJECT_KEY AND (description ~ "/-/security/vulnerabilities/1" OR description ~ "/-/security/vulnerabilities/25") order by created DESC') expect(subject).to eq('project = PROJECT_KEY AND (description ~ "/-/security/vulnerabilities/1" OR description ~ "/-/security/vulnerabilities/25") order by created DESC')
end end
end end
context 'with issue_ids params' do
let(:params) { { issue_ids: %w[1 25] } }
it 'builds jql' do
expect(subject).to eq('project = PROJECT_KEY AND (id = 1 OR id = 25) order by created DESC')
end
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment