refactor(integrations-jira): Move domain to credentials and retrieve metadata during connection test (#8637)

This commit is contained in:
Víctor Fernández Poyatos
2025-09-03 17:24:42 +02:00
committed by GitHub
parent b15e3d339c
commit 0463fd0830
7 changed files with 392 additions and 159 deletions
+32 -84
View File
@@ -9134,23 +9134,10 @@ components:
the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
description: JIRA integration does not accept any configuration in
the payload. Leave it as an empty JSON object (`{}`).
properties: {}
additionalProperties: false
credentials:
oneOf:
- type: object
@@ -9197,17 +9184,17 @@ components:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
required:
- user_mail
- domain
- api_token
- domain
writeOnly: true
required:
- integration_type
@@ -9331,23 +9318,10 @@ components:
in the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
description: JIRA integration does not accept any configuration
in the payload. Leave it as an empty JSON object (`{}`).
properties: {}
additionalProperties: false
credentials:
oneOf:
- type: object
@@ -9395,17 +9369,17 @@ components:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
required:
- user_mail
- domain
- api_token
- domain
writeOnly: true
required:
- integration_type
@@ -9544,23 +9518,10 @@ components:
the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
description: JIRA integration does not accept any configuration in
the payload. Leave it as an empty JSON object (`{}`).
properties: {}
additionalProperties: false
credentials:
oneOf:
- type: object
@@ -9607,17 +9568,17 @@ components:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
required:
- user_mail
- domain
- api_token
- domain
writeOnly: true
relationships:
type: object
@@ -10986,23 +10947,10 @@ components:
in the current execution.
- type: object
title: JIRA
properties:
project_key:
type: string
description: The JIRA project key where issues will be created
(e.g., 'PROJ', 'SEC').
issue_types:
type: array
items:
type: string
description: List of JIRA issue types to create for findings.
issue_labels:
type: array
items:
type: string
description: List of labels to apply to created JIRA issues..
required:
- project_key
description: JIRA integration does not accept any configuration
in the payload. Leave it as an empty JSON object (`{}`).
properties: {}
additionalProperties: false
credentials:
oneOf:
- type: object
@@ -11050,17 +10998,17 @@ components:
type: string
format: email
description: The email address of the JIRA user account.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
api_token:
type: string
description: The API token for authentication with JIRA. This
can be generated from your Atlassian account settings.
domain:
type: string
description: The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').
required:
- user_mail
- domain
- api_token
- domain
writeOnly: true
relationships:
type: object
+96 -6
View File
@@ -503,28 +503,38 @@ class TestProwlerIntegrationConnectionTest:
assert integration.configuration["regions"]["ap-south-1"] is False
integration.save.assert_called_once()
@patch("api.utils.rls_transaction")
@patch("api.utils.Jira")
def test_jira_connection_success_basic_auth(self, mock_jira_class):
def test_jira_connection_success_basic_auth(
self, mock_jira_class, mock_rls_transaction
):
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.tenant_id = "test-tenant-id"
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
}
integration.configuration = {
"domain": "example.atlassian.net",
}
integration.configuration = {}
# Mock successful JIRA connection with projects
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
mock_connection.projects = {"PROJ1": "Project 1", "PROJ2": "Project 2"}
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
assert result.is_connected is True
assert result.error is None
# Verify JIRA connection was called with correct parameters including domain from credentials
mock_jira_class.test_connection.assert_called_once_with(
user_mail="test@example.com",
api_token="test_api_token",
@@ -532,32 +542,112 @@ class TestProwlerIntegrationConnectionTest:
raise_on_exception=False,
)
# Verify rls_transaction was called with correct tenant_id
mock_rls_transaction.assert_called_once_with("test-tenant-id")
# Verify projects were saved to integration configuration
assert integration.configuration["projects"] == {
"PROJ1": "Project 1",
"PROJ2": "Project 2",
}
# Verify integration.save() was called
integration.save.assert_called_once()
@patch("api.utils.rls_transaction")
@patch("api.utils.Jira")
def test_jira_connection_failure_invalid_credentials(self, mock_jira_class):
def test_jira_connection_failure_invalid_credentials(
self, mock_jira_class, mock_rls_transaction
):
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.tenant_id = "test-tenant-id"
integration.credentials = {
"user_mail": "invalid@example.com",
"api_token": "invalid_token",
}
integration.configuration = {
"domain": "invalid.atlassian.net",
}
integration.configuration = {}
# Mock failed JIRA connection
mock_connection = MagicMock()
mock_connection.is_connected = False
mock_connection.error = Exception("Authentication failed: Invalid credentials")
mock_connection.projects = {} # Empty projects when connection fails
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
assert result.is_connected is False
assert "Authentication failed: Invalid credentials" in str(result.error)
# Verify JIRA connection was called with correct parameters
mock_jira_class.test_connection.assert_called_once_with(
user_mail="invalid@example.com",
api_token="invalid_token",
domain="invalid.atlassian.net",
raise_on_exception=False,
)
# Verify rls_transaction was called even on failure
mock_rls_transaction.assert_called_once_with("test-tenant-id")
# Verify empty projects dict was saved to integration configuration
assert integration.configuration["projects"] == {}
# Verify integration.save() was called even on connection failure
integration.save.assert_called_once()
@patch("api.utils.rls_transaction")
@patch("api.utils.Jira")
def test_jira_connection_projects_update_with_existing_configuration(
self, mock_jira_class, mock_rls_transaction
):
"""Test that projects are properly updated when integration already has configuration data"""
integration = MagicMock()
integration.integration_type = Integration.IntegrationChoices.JIRA
integration.tenant_id = "test-tenant-id"
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
"domain": "example.atlassian.net",
}
integration.configuration = {
"issue_types": ["Bug", "Task"], # Existing configuration
"projects": {"OLD_PROJ": "Old Project"}, # Will be overwritten
}
# Mock successful JIRA connection with new projects
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
mock_connection.projects = {
"NEW_PROJ1": "New Project 1",
"NEW_PROJ2": "New Project 2",
}
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
assert result.is_connected is True
assert result.error is None
# Verify projects were updated (old projects replaced with new ones)
assert integration.configuration["projects"] == {
"NEW_PROJ1": "New Project 1",
"NEW_PROJ2": "New Project 2",
}
# Verify other configuration fields were preserved
assert integration.configuration["issue_types"] == ["Bug", "Task"]
# Verify integration.save() was called
integration.save.assert_called_once()
+178 -36
View File
@@ -5660,18 +5660,6 @@ class TestIntegrationViewSet:
},
{},
),
# JIRA
(
Integration.IntegrationChoices.JIRA,
{
"project_key": "JIRA",
"domain": "prowlerdomain",
},
{
"api_token": "this-is-an-api-token-for-jira-that-works-for-sure",
"user_mail": "testing@prowler.com",
},
),
],
)
def test_integrations_create_valid(
@@ -5720,6 +5708,47 @@ class TestIntegrationViewSet:
== data["data"]["relationships"]["providers"]["data"][0]["id"]
)
def test_integrations_create_valid_jira(
self,
authenticated_client,
):
"""Jira integrations are special"""
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"domain": "prowlerdomain",
"api_token": "this-is-an-api-token-for-jira-that-works-for-sure",
"user_mail": "testing@prowler.com",
},
"enabled": True,
},
}
}
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Integration.objects.count() == 1
integration = Integration.objects.first()
integration_configuration = response.json()["data"]["attributes"][
"configuration"
]
assert "projects" in integration_configuration
assert "issue_types" in integration_configuration
assert "domain" in integration_configuration
assert integration.enabled == data["data"]["attributes"]["enabled"]
assert (
integration.integration_type
== data["data"]["attributes"]["integration_type"]
)
assert "credentials" not in response.json()["data"]["attributes"]
def test_integrations_create_valid_relationships(
self,
authenticated_client,
@@ -5822,31 +5851,37 @@ class TestIntegrationViewSet:
{
"integration_type": "jira",
"configuration": {
"project_key": "JIRA",
"projects": ["JIRA"],
},
"credentials": {},
"credentials": {"domain": "prowlerdomain"},
},
"required",
"domain",
"invalid",
"configuration",
),
(
{
"integration_type": "jira",
"configuration": {
"project_key": "JIRA",
"credentialss": {
"domain": "prowlerdomain",
"api_token": "api-token",
"user_mail": "test@prowler.com",
},
},
"required",
"configuration",
),
(
{
"integration_type": "jira",
"configuration": {},
},
"required",
"credentials",
),
(
{
"integration_type": "jira",
"configuration": {
"project_key": "JIRA",
"domain": "prowlerdomain",
},
"configuration": {},
"credentials": {"api_token": "api-token"},
},
"invalid",
@@ -6093,32 +6128,21 @@ class TestIntegrationViewSet:
== "/data/attributes/configuration"
)
def test_integrations_create_duplicate_jira(
self, authenticated_client, providers_fixture
):
provider = providers_fixture[0]
def test_integrations_create_duplicate_jira(self, authenticated_client):
# Create first JIRA integration
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {
"project_key": "TEST",
"domain": "test.atlassian.net",
},
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
"domain": "prowlerdomain",
},
"enabled": True,
},
"relationships": {
"providers": {
"data": [{"type": "providers", "id": str(provider.id)}]
}
},
}
}
@@ -6145,6 +6169,124 @@ class TestIntegrationViewSet:
== "/data/attributes/configuration"
)
def test_integrations_update_jira_configuration_readonly(
self, authenticated_client
):
# Create JIRA integration first
create_data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
"domain": "initial-domain",
},
"enabled": True,
},
}
}
# Create the integration
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(create_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
integration_id = response.json()["data"]["id"]
# Attempt to update configuration - should be ignored/not allowed
update_data = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": {
"configuration": {
"projects": {"NEW_PROJECT": "New Project"},
"issue_types": ["Epic", "Story"],
"domain": "malicious-domain",
}
},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration_id}),
data=json.dumps(update_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_integrations_update_jira_credentials_domain_reflects_in_configuration(
self, authenticated_client
):
# Create JIRA integration first
create_data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "test-api-token",
"domain": "original-domain",
},
"enabled": True,
},
}
}
# Create the integration
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(create_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
integration_id = response.json()["data"]["id"]
# Verify initial domain in configuration
initial_integration = response.json()["data"]
assert (
initial_integration["attributes"]["configuration"]["domain"]
== "original-domain"
)
# Update credentials with new domain
update_data = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": {
"credentials": {
"user_mail": "updated@example.com",
"api_token": "updated-api-token",
"domain": "updated-domain",
}
},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration_id}),
data=json.dumps(update_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
# Verify the new domain is reflected in configuration
updated_integration = response.json()["data"]
configuration = updated_integration["attributes"]["configuration"]
assert configuration["domain"] == "updated-domain"
# Verify other configuration fields are preserved
assert "projects" in configuration
assert "issue_types" in configuration
@pytest.mark.django_db
class TestSAMLTokenValidation:
+31 -3
View File
@@ -6,10 +6,11 @@ from django.db.models import Subquery
from rest_framework.exceptions import NotFound, ValidationError
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Processor, Provider, Resource
from api.v1.serializers import FindingMetadataSerializer
from prowler.lib.outputs.jira.jira import Jira
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError, JiraNoProjectsError
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
@@ -238,11 +239,15 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
return connection
elif integration.integration_type == Integration.IntegrationChoices.JIRA:
return Jira.test_connection(
jira_connection = Jira.test_connection(
**integration.credentials,
domain=integration.configuration["domain"],
raise_on_exception=False,
)
project_keys = jira_connection.projects if jira_connection.is_connected else {}
with rls_transaction(str(integration.tenant_id)):
integration.configuration["projects"] = project_keys
integration.save()
return jira_connection
elif integration.integration_type == Integration.IntegrationChoices.SLACK:
pass
else:
@@ -342,3 +347,26 @@ def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
serializer.is_valid(raise_exception=True)
return serializer.data
def initialize_prowler_integration(integration: Integration) -> Jira:
# TODO Refactor other integrations to use this function
if integration.integration_type == Integration.IntegrationChoices.JIRA:
try:
return Jira(
**integration.credentials, domain=integration.configuration["domain"]
)
except JiraBasicAuthError as jira_auth_error:
with rls_transaction(str(integration.tenant_id)):
integration.connected = False
integration.connection_last_checked_at = datetime.now(tz=timezone.utc)
integration.save()
raise jira_auth_error
def get_jira_integration_metadata(jira_integration: Integration) -> dict:
prowler_jira = initialize_prowler_integration(jira_integration)
try:
return prowler_jira.get_jira_metadata()
except JiraNoProjectsError:
return {}
@@ -68,10 +68,11 @@ class SecurityHubConfigSerializer(BaseValidateSerializer):
class JiraConfigSerializer(BaseValidateSerializer):
project_key = serializers.CharField(required=True)
domain = serializers.CharField(required=True)
issue_types = serializers.ListField(required=False, child=serializers.CharField())
issue_labels = serializers.ListField(required=False, child=serializers.CharField())
domain = serializers.CharField(read_only=True)
issue_types = serializers.ListField(
read_only=True, child=serializers.CharField(), default=["Task", "Bug"]
)
projects = serializers.DictField(read_only=True)
class Meta:
resource_name = "integrations"
@@ -95,6 +96,7 @@ class AWSCredentialSerializer(BaseValidateSerializer):
class JiraCredentialSerializer(BaseValidateSerializer):
user_mail = serializers.EmailField(required=True)
api_token = serializers.CharField(required=True)
domain = serializers.CharField(required=True)
class Meta:
resource_name = "integrations"
@@ -165,8 +167,12 @@ class JiraCredentialSerializer(BaseValidateSerializer):
"description": "The API token for authentication with JIRA. This can be generated from your "
"Atlassian account settings.",
},
"domain": {
"type": "string",
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
},
},
"required": ["user_mail", "api_token"],
"required": ["user_mail", "api_token", "domain"],
},
]
}
@@ -218,27 +224,10 @@ class IntegrationCredentialField(serializers.JSONField):
{
"type": "object",
"title": "JIRA",
"properties": {
"project_key": {
"type": "string",
"description": "The JIRA project key where issues will be created (e.g., 'PROJ', 'SEC').",
},
"domain": {
"type": "string",
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
},
"issue_types": {
"type": "array",
"items": {"type": "string"},
"description": "List of JIRA issue types to create for findings.",
},
"issue_labels": {
"type": "array",
"items": {"type": "string"},
"description": "List of labels to apply to created JIRA issues..",
},
},
"required": ["project_key", "domain"],
"description": "JIRA integration does not accept any configuration in the payload. Leave it as an "
"empty JSON object (`{}`).",
"properties": {},
"additionalProperties": False,
},
]
}
+39 -3
View File
@@ -1972,8 +1972,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
integration_type == Integration.IntegrationChoices.JIRA
and Integration.objects.filter(
configuration__contains={
"domain": attrs.get("configuration").get("domain"),
"project_key": attrs.get("configuration").get("project_key"),
"domain": attrs.get("configuration").get("domain")
}
).exists()
):
@@ -2038,7 +2037,28 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
config_serializer = SecurityHubConfigSerializer
credentials_serializers = [AWSCredentialSerializer]
elif integration_type == Integration.IntegrationChoices.JIRA:
if providers:
raise serializers.ValidationError(
{
"providers": "Relationship field is not accepted. This integration applies to all providers."
}
)
if configuration:
raise serializers.ValidationError(
{
"configuration": "This integration does not support custom configuration."
}
)
config_serializer = JiraConfigSerializer
# Create non-editable configuration for JIRA integration
default_jira_issue_types = ["Task", "Bug"]
configuration.update(
{
"projects": {},
"issue_types": default_jira_issue_types,
"domain": credentials.get("domain"),
}
)
credentials_serializers = [JiraCredentialSerializer]
else:
raise serializers.ValidationError(
@@ -2103,6 +2123,10 @@ class IntegrationSerializer(RLSSerializer):
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
)
return representation
@@ -2203,7 +2227,10 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
def validate(self, attrs):
integration_type = self.instance.integration_type
providers = attrs.get("providers")
configuration = attrs.get("configuration") or self.instance.configuration
if integration_type != Integration.IntegrationChoices.JIRA:
configuration = attrs.get("configuration") or self.instance.configuration
else:
configuration = attrs.get("configuration", {})
credentials = attrs.get("credentials") or self.instance.credentials
self.validate_integration_data(
@@ -2233,6 +2260,15 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return super().update(instance, validated_data)
def to_representation(self, instance):
representation = super().to_representation(instance)
# Ensure JIRA integrations show updated domain in configuration from credentials
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
)
return representation
# Processors