From 0463fd0830f5634479ebdb76141a9202f828b010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Wed, 3 Sep 2025 17:24:42 +0200 Subject: [PATCH] refactor(integrations-jira): Move domain to credentials and retrieve metadata during connection test (#8637) --- api/CHANGELOG.md | 2 +- api/src/backend/api/specs/v1.yaml | 116 +++------- api/src/backend/api/tests/test_utils.py | 102 ++++++++- api/src/backend/api/tests/test_views.py | 214 +++++++++++++++--- api/src/backend/api/utils.py | 34 ++- .../api/v1/serializer_utils/integrations.py | 41 ++-- api/src/backend/api/v1/serializers.py | 42 +++- 7 files changed, 392 insertions(+), 159 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index cb8a4e3b17..0694cba39b 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.13.0] (Prowler UNRELEASED) ### Added -- Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622) +- Integration with JIRA, enabling sending findings to a JIRA project [(#8622)](https://github.com/prowler-cloud/prowler/pull/8622), [(#8637)](https://github.com/prowler-cloud/prowler/pull/8637) --- diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 5ff07712a3..21aac9ec79 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -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 diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 853bb58ddb..8d7c3801e0 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -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() diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 2e3fb0afcc..1aaf6224a8 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -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: diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 1901c7972a..4bab4ce2d4 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -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 {} diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index 2d443ddfec..b63eaaa66c 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -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, }, ] } diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index ec97c2719d..27c4180afb 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -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