feat(api): add Jira issue recovery API

This commit is contained in:
Josema Camacho
2026-09-02 14:02:30 +02:00
committed by GitHub
parent 43de3790d2
commit 99df95297b
12 changed files with 715 additions and 43 deletions
+1 -1
View File
@@ -1 +1 @@
Jira issues created from findings are now tracked per finding UID in the new `jira_issues` table and exposed through `GET /api/v1/jira-issues`; sending a finding that already has an open Jira issue skips it (reported as `skipped_count`), and findings whose issue was closed or deleted in Jira get a new issue that replaces the link
Jira issues created from findings are tracked per provider lifecycle and Jira integration, with concurrency-safe deduplication, recoverable delivery attempts, explicit manual replacement rules, and read-only ledger visibility through `GET /api/v1/jira-issues`
+8
View File
@@ -63,6 +63,8 @@ class BaseViewSet(ModelViewSet):
class BaseRLSViewSet(BaseViewSet):
non_atomic_url_names = frozenset()
def dispatch(self, request, *args, **kwargs):
self.db_alias = self._get_request_db_alias(request)
alias_token = None
@@ -73,6 +75,12 @@ class BaseRLSViewSet(BaseViewSet):
if request is not None:
request.db_alias = self.db_alias
url_name = getattr(
getattr(request, "resolver_match", None), "url_name", None
)
if url_name in self.non_atomic_url_names:
return super().dispatch(request, *args, **kwargs)
with transaction.atomic(using=self.db_alias):
return super().dispatch(request, *args, **kwargs)
finally:
+6
View File
@@ -1920,6 +1920,12 @@ class JiraIssueFilter(BaseProviderFilter):
field_name="issue_status_category",
lookup_expr="in",
)
attempt_state = ChoiceFilter(choices=JiraIssue.AttemptStateChoices.choices)
attempt_state__in = ChoiceInFilter(
choices=JiraIssue.AttemptStateChoices.choices,
field_name="attempt_state",
lookup_expr="in",
)
class Meta:
model = JiraIssue
+43 -2
View File
@@ -1594,16 +1594,57 @@ class TestLimitedVisibility:
def test_jira_issues_limited_to_visible_providers(
self, authenticated_client_rbac_limited, jira_issues_fixture
):
linked, other_provider_issue, _ = jira_issues_fixture
linked, other_provider_issue, unlinked = jira_issues_fixture
response = authenticated_client_rbac_limited.get(reverse("jiraissue-list"))
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [str(linked.id)]
assert {item["id"] for item in response.json()["data"]} == {
str(linked.id),
str(unlinked.id),
}
response = authenticated_client_rbac_limited.get(
reverse("jiraissue-detail", kwargs={"pk": other_provider_issue.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_issue_resolution_requires_manage_integrations(
self, authenticated_client_no_permissions_rbac, jira_issues_fixture
):
*_, unlinked = jira_issues_fixture
response = authenticated_client_no_permissions_rbac.post(
reverse("jiraissue-resolution", kwargs={"pk": unlinked.id}),
data=json.dumps(
{
"data": {
"type": "jira-issues",
"attributes": {"resolution": "confirm_not_created"},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_jira_issue_resolution_hides_out_of_scope_provider(
self, authenticated_client_rbac_limited, jira_issues_fixture
):
_, other_provider_issue, _ = jira_issues_fixture
response = authenticated_client_rbac_limited.post(
reverse("jiraissue-resolution", kwargs={"pk": other_provider_issue.id}),
data=json.dumps(
{
"data": {
"type": "jira-issues",
"attributes": {"resolution": "confirm_not_created"},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_issue_types_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration_fixture
):
+301 -6
View File
@@ -89,6 +89,11 @@ from django.urls import reverse
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from prowler.lib.outputs.jira.models import (
JiraIssueSearchMatch,
JiraIssueSearchOutcome,
JiraIssueSearchResult,
)
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@@ -13558,15 +13563,71 @@ class TestScheduleViewSet:
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.django_db
class TestIntegrationJiraViewSet:
@pytest.mark.parametrize(
("force_replace", "expected"), [(None, False), (True, True)]
)
def test_dispatch_passes_force_confirmation_and_actor(
self,
authenticated_client,
jira_integration_fixture,
findings_fixture,
force_replace,
expected,
):
finding, _ = findings_fixture
task = Task.objects.create(tenant_id=jira_integration_fixture.tenant_id)
attributes = {"project_key": "TEST", "issue_type": "Task"}
if force_replace is not None:
attributes["force_replace"] = force_replace
payload = {
"data": {
"type": "integrations-jira-dispatches",
"attributes": attributes,
}
}
url = reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": jira_integration_fixture.id},
)
url = f"{url}?filter[finding_id]={finding.id}"
with patch("api.v1.views.jira_integration_task.delay") as delay:
delay.return_value = SimpleNamespace(id=task.id)
response = authenticated_client.post(
url,
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_202_ACCEPTED
delay.assert_called_once_with(
tenant_id=str(jira_integration_fixture.tenant_id),
integration_id=str(jira_integration_fixture.id),
project_key="TEST",
issue_type="Task",
finding_ids=[str(finding.id)],
force_replace=expected,
actor_id=str(authenticated_client.user.id),
)
@pytest.mark.django_db
class TestJiraIssueViewSet:
def test_list_hides_reservations(self, authenticated_client, jira_issues_fixture):
def test_list_returns_linked_and_unlinked_rows_in_stable_order(
self, authenticated_client, jira_issues_fixture
):
linked, other_provider_issue, reservation = jira_issues_fixture
response = authenticated_client.get(reverse("jiraissue-list"))
assert response.status_code == status.HTTP_200_OK
ids = {item["id"] for item in response.json()["data"]}
assert ids == {str(linked.id), str(other_provider_issue.id)}
assert str(reservation.id) not in ids
expected = sorted(
(linked, other_provider_issue, reservation),
key=lambda row: (row.finding_uid, str(row.integration_id), str(row.id)),
)
assert [item["id"] for item in response.json()["data"]] == [
str(row.id) for row in expected
]
def test_retrieve(self, authenticated_client, jira_issues_fixture):
linked, *_ = jira_issues_fixture
@@ -13582,21 +13643,36 @@ class TestJiraIssueViewSet:
assert attributes["issue_key"] == "TEST-1"
assert attributes["issue_url"] == "https://test.atlassian.net/browse/TEST-1"
assert attributes["project_key"] == "TEST"
assert attributes["issue_type"] == "Task"
assert attributes["issue_status"] == "To Do"
assert attributes["issue_status_category"] == "new"
assert attributes["status_synced_at"] is not None
assert attributes["attempt_state"] == "idle"
assert attributes["attempt_operation"] is None
assert attributes["attempt_count"] == 0
assert "claim_token" not in attributes
assert "claim_expires_at" not in attributes
assert "delivery_attempt_token" not in attributes
relationships = data["relationships"]
assert relationships["provider"]["data"]["id"] == str(linked.provider_id)
assert relationships["integration"]["data"]["id"] == str(linked.integration_id)
def test_retrieve_reservation_returns_404(
def test_retrieve_unlinked_delivery(
self, authenticated_client, jira_issues_fixture
):
*_, reservation = jira_issues_fixture
response = authenticated_client.get(
reverse("jiraissue-detail", kwargs={"pk": reservation.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.status_code == status.HTTP_200_OK
attributes = response.json()["data"]["attributes"]
assert attributes["issue_id"] is None
assert attributes["issue_key"] is None
assert attributes["issue_url"] is None
assert attributes["attempt_state"] == "creating"
assert attributes["attempt_operation"] == "initial"
assert "claim_token" not in attributes
assert "delivery_attempt_token" not in attributes
def test_retrieve_other_tenant_returns_404(
self, authenticated_client, jira_issues_fixture, tenants_fixture
@@ -13668,6 +13744,14 @@ class TestJiraIssueViewSet:
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [str(linked.id)]
def test_filter_by_attempt_state(self, authenticated_client, jira_issues_fixture):
*_, reservation = jira_issues_fixture
response = authenticated_client.get(
reverse("jiraissue-list"), {"filter[attempt_state]": "creating"}
)
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [str(reservation.id)]
def test_invalid_filter(self, authenticated_client, jira_issues_fixture):
response = authenticated_client.get(
reverse("jiraissue-list"), {"filter[invalid]": "x"}
@@ -13695,6 +13779,217 @@ class TestJiraIssueViewSet:
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@pytest.mark.django_db(transaction=True)
def test_resolution_links_issue_verified_by_delivery_marker(
self, authenticated_client, jira_issues_fixture
):
*_, reservation = jira_issues_fixture
with rls_transaction(str(reservation.tenant_id)):
JiraIssue.objects.filter(id=reservation.id).update(
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
claim_token=None,
claim_expires_at=None,
)
jira = MagicMock()
lookup = JiraIssueSearchResult(
outcome=JiraIssueSearchOutcome.SUCCESS,
matches=(
JiraIssueSearchMatch(
issue_id="20001",
issue_key="TEST-9",
issue_url="https://test.atlassian.net/browse/TEST-9",
),
),
)
def search_by_marker(_marker):
assert connection.in_atomic_block is False
return lookup
jira.search_issues_by_delivery_attempt.side_effect = search_by_marker
payload = {
"data": {
"type": "jira-issues",
"attributes": {
"resolution": "link",
"issue_id": "20001",
"issue_key": "TEST-9",
},
}
}
with (
patch("api.v1.views.initialize_prowler_integration", return_value=jira),
patch("api.v1.views.logger.info") as audit_log,
):
response = authenticated_client.post(
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK, response.json()
assert response["Content-Type"].startswith(API_JSON_CONTENT_TYPE)
data = response.json()["data"]
assert data["type"] == "jira-issues"
assert data["attributes"]["issue_id"] == "20001"
assert data["attributes"]["issue_key"] == "TEST-9"
assert (
data["attributes"]["issue_url"]
== "https://test.atlassian.net/browse/TEST-9"
)
assert data["attributes"]["attempt_state"] == "idle"
jira.search_issues_by_delivery_attempt.assert_called_once_with(
str(reservation.delivery_attempt_token)
)
audit_calls = [
call
for call in audit_log.call_args_list
if call.args == ("jira_issue_operator_resolution",)
]
assert len(audit_calls) == 1
log_kwargs = audit_calls[0].kwargs
assert log_kwargs["extra"]["user_id"] == str(authenticated_client.user.id)
assert log_kwargs["extra"]["metadata"]["resolution"] == "link"
assert log_kwargs["extra"]["metadata"]["jira_issue_id"] == str(reservation.id)
def test_resolution_rejects_issue_not_returned_by_delivery_marker(
self, authenticated_client, jira_issues_fixture
):
*_, reservation = jira_issues_fixture
with rls_transaction(str(reservation.tenant_id)):
JiraIssue.objects.filter(id=reservation.id).update(
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
claim_token=None,
claim_expires_at=None,
)
jira = MagicMock()
jira.search_issues_by_delivery_attempt.return_value = JiraIssueSearchResult(
outcome=JiraIssueSearchOutcome.SUCCESS,
matches=(
JiraIssueSearchMatch(
issue_id="20002",
issue_key="TEST-10",
issue_url="https://test.atlassian.net/browse/TEST-10",
),
),
)
payload = {
"data": {
"type": "jira-issues",
"attributes": {
"resolution": "link",
"issue_id": "20001",
"issue_key": "TEST-9",
},
}
}
with patch("api.v1.views.initialize_prowler_integration", return_value=jira):
response = authenticated_client.post(
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
with rls_transaction(str(reservation.tenant_id)):
reservation.refresh_from_db()
assert reservation.issue_id is None
assert reservation.attempt_state == JiraIssue.AttemptStateChoices.UNCERTAIN
def test_resolution_confirm_not_created_preserves_previous_link(
self, authenticated_client, jira_issues_fixture
):
linked, *_ = jira_issues_fixture
with rls_transaction(str(linked.tenant_id)):
JiraIssue.objects.filter(id=linked.id).update(
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
delivery_attempt_token=uuid4(),
attempt_operation=JiraIssue.AttemptOperationChoices.REPLACEMENT,
attempt_project_key="TEST",
attempt_issue_type="Task",
next_reconcile_at=datetime.now(UTC) + timedelta(minutes=1),
)
payload = {
"data": {
"type": "jira-issues",
"attributes": {"resolution": "confirm_not_created"},
}
}
with patch("api.v1.views.logger.info") as audit_log:
response = authenticated_client.post(
reverse("jiraissue-resolution", kwargs={"pk": linked.id}),
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_200_OK
attributes = response.json()["data"]["attributes"]
assert attributes["issue_id"] == "10001"
assert attributes["issue_key"] == "TEST-1"
assert attributes["attempt_state"] == "retryable_failure"
assert attributes["last_error_code"] == "operator_confirmed_not_created"
assert attributes["next_reconcile_at"] is None
audit_calls = [
call
for call in audit_log.call_args_list
if call.args == ("jira_issue_operator_resolution",)
]
assert len(audit_calls) == 1
assert audit_calls[0].kwargs["extra"]["metadata"]["resolution"] == (
"confirm_not_created"
)
def test_resolution_rejects_fresh_foreign_claim(
self, authenticated_client, jira_issues_fixture
):
*_, reservation = jira_issues_fixture
payload = {
"data": {
"type": "jira-issues",
"attributes": {"resolution": "confirm_not_created"},
}
}
response = authenticated_client.post(
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_409_CONFLICT
def test_resolution_confirm_not_created_rejects_issue_fields(
self, authenticated_client, jira_issues_fixture
):
*_, reservation = jira_issues_fixture
with rls_transaction(str(reservation.tenant_id)):
JiraIssue.objects.filter(id=reservation.id).update(
attempt_state=JiraIssue.AttemptStateChoices.UNCERTAIN,
claim_token=None,
claim_expires_at=None,
)
payload = {
"data": {
"type": "jira-issues",
"attributes": {
"resolution": "confirm_not_created",
"issue_id": "20001",
"issue_key": "TEST-9",
},
}
}
response = authenticated_client.post(
reverse("jiraissue-resolution", kwargs={"pk": reservation.id}),
data=json.dumps(payload),
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestIntegrationViewSet:
+50 -4
View File
@@ -3226,6 +3226,7 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1):
project_key = serializers.CharField(required=True)
issue_type = serializers.CharField(required=True)
force_replace = serializers.BooleanField(required=False, default=False)
class JSONAPIMeta:
resource_name = "integrations-jira-dispatches"
@@ -4160,11 +4161,11 @@ class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
class JiraIssueSerializer(RLSSerializer):
"""
Read-only view of a Jira issue linked to a finding by a Jira integration.
Read-only view of a Jira delivery ledger row for a finding.
Rows are keyed on the finding ``uid`` so the same finding maps to the same
issue across scans. ``issue_status`` is the last status Prowler observed in
Jira (refreshed whenever a dispatch touches the finding), not a live value.
Rows are keyed on the finding ``uid`` so delivery state survives scans.
Current-link fields stay null until Jira creation is confirmed.
``issue_status`` is cached rather than fetched when this resource is read.
"""
class Meta:
@@ -4179,9 +4180,17 @@ class JiraIssueSerializer(RLSSerializer):
"issue_id",
"issue_url",
"project_key",
"issue_type",
"issue_status",
"issue_status_category",
"status_synced_at",
"attempt_state",
"attempt_operation",
"attempt_count",
"last_attempt_at",
"last_error_code",
"last_error_message",
"next_reconcile_at",
"integration",
"provider",
"url",
@@ -4193,6 +4202,43 @@ class JiraIssueSerializer(RLSSerializer):
}
class JiraIssueResolutionSerializer(BaseSerializerV1):
"""Validate an operator decision for an uncertain Jira delivery."""
resolution = serializers.ChoiceField(
choices=("link", "confirm_not_created"), required=True
)
issue_id = serializers.CharField(required=False, allow_blank=False, max_length=64)
issue_key = serializers.CharField(required=False, allow_blank=False, max_length=64)
class JSONAPIMeta:
resource_name = "jira-issues"
def validate(self, attrs):
resolution = attrs["resolution"]
issue_id = attrs.get("issue_id")
issue_key = attrs.get("issue_key")
if resolution == "link":
missing_fields = {
field: "This field is required when linking a Jira issue."
for field, value in (("issue_id", issue_id), ("issue_key", issue_key))
if not value
}
if missing_fields:
raise ValidationError(missing_fields)
if resolution == "confirm_not_created" and (issue_id or issue_key):
raise ValidationError(
{
"resolution": (
"Issue fields are not accepted when confirming that Jira did "
"not create an issue."
)
}
)
return attrs
class MuteRuleSerializer(RLSSerializer):
"""
Serializer for reading MuteRule instances.
+208 -15
View File
@@ -32,6 +32,7 @@ from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import (
ComplianceWarmingError,
ConflictException,
TaskFailedException,
UpstreamAccessDeniedError,
UpstreamAuthenticationError,
@@ -181,6 +182,7 @@ from api.v1.serializers import (
InvitationCreateSerializer,
InvitationSerializer,
InvitationUpdateSerializer,
JiraIssueResolutionSerializer,
JiraIssueSerializer,
LighthouseConfigCreateSerializer,
LighthouseConfigSerializer,
@@ -260,7 +262,7 @@ from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
from django.contrib.postgres.search import SearchQuery
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import (
BooleanField,
Case,
@@ -303,6 +305,10 @@ from drf_spectacular.utils import (
)
from drf_spectacular.views import SpectacularAPIView
from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema
from prowler.lib.outputs.jira.models import (
JiraIssueSearchOutcome,
JiraIssueSearchResult,
)
from prowler.providers.aws.exceptions.exceptions import (
AWSAssumeRoleError,
AWSCredentialsError,
@@ -7004,6 +7010,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
project_key = serializer.validated_data["project_key"]
issue_type = serializer.validated_data["issue_type"]
force_replace = serializer.validated_data["force_replace"]
with transaction.atomic():
task = jira_integration_task.delay(
@@ -7012,6 +7019,8 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
project_key=project_key,
issue_type=issue_type,
finding_ids=finding_ids,
force_replace=force_replace,
actor_id=str(request.user.id),
)
prowler_task = Task.objects.get(id=task.id)
serializer = TaskSerializer(prowler_task)
@@ -7493,51 +7502,235 @@ class TenantApiKeyViewSet(BaseRLSViewSet):
@extend_schema_view(
list=extend_schema(
tags=["Integration"],
summary="List Jira issues linked to findings",
summary="List Jira issue delivery records",
description=(
"Retrieve the Jira issues created from findings through Jira integrations. "
"Each entry links a finding UID to the latest Jira issue created for it, "
"with the last status observed in Jira. Use `filter[finding_uid__in]` "
"and `filter[provider_id]` to check whether specific findings already "
"have a ticket."
"Retrieve Jira delivery records for findings, including attempts without a "
"confirmed Jira issue. Linked records include the last status observed in "
"Jira; status fields are cached and may be stale."
),
),
retrieve=extend_schema(
tags=["Integration"],
summary="Retrieve a Jira issue link",
description="Fetch the Jira issue linked to a finding by the link ID.",
summary="Retrieve a Jira issue delivery record",
description="Fetch a Jira delivery record by its ledger ID.",
),
resolution=extend_schema(
tags=["Integration"],
summary="Resolve an uncertain Jira delivery",
description=(
"Link an issue verified through the delivery marker, or confirm that Jira "
"did not create an issue so the finding can be retried."
),
request=JiraIssueResolutionSerializer,
responses={200: JiraIssueSerializer},
),
)
class JiraIssueViewSet(BaseRLSViewSet):
queryset = JiraIssue.objects.all()
serializer_class = JiraIssueSerializer
filterset_class = JiraIssueFilter
http_method_names = ["get"]
http_method_names = ["get", "post"]
search_fields = ["finding_uid", "issue_key"]
ordering = ["-inserted_at"]
ordering = ["finding_uid", "integration_id", "id"]
ordering_fields = [
"id",
"inserted_at",
"updated_at",
"finding_uid",
"finding_id",
"integration",
"provider",
"issue_key",
"project_key",
"issue_status",
"issue_status_category",
"status_synced_at",
"attempt_state",
"next_reconcile_at",
]
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enables unlimited
# visibility or check visibility via provider group, like findings)
required_permissions = []
# Jira verification must not hold a database transaction open during HTTP I/O.
non_atomic_url_names = frozenset({"jiraissue-resolution"})
def set_required_permissions(self):
self.required_permissions = (
[Permissions.MANAGE_INTEGRATIONS] if self.action == "resolution" else []
)
@extend_schema(exclude=True)
def create(self, request, *args, **kwargs):
raise MethodNotAllowed(method="POST")
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return JiraIssue.objects.none()
# Rows without an issue key are in-flight reservations, not links
queryset = JiraIssue.objects.filter(tenant_id=self.request.tenant_id).exclude(
issue_key=""
)
queryset = JiraIssue.objects.filter(tenant_id=self.request.tenant_id)
if not self.user_role.unlimited_visibility:
queryset = queryset.filter(provider__in=get_providers(self.user_role))
return queryset.select_related("provider", "integration")
@staticmethod
def _resolution_queryset(instance):
return JiraIssue.objects.filter(
id=instance.id,
tenant_id=instance.tenant_id,
attempt_state=instance.attempt_state,
delivery_attempt_token=instance.delivery_attempt_token,
issue_id=instance.issue_id,
claim_token=instance.claim_token,
claim_expires_at=instance.claim_expires_at,
)
@staticmethod
def _validate_resolution_state(instance):
if (
instance.attempt_state
not in {
JiraIssue.AttemptStateChoices.CREATING,
JiraIssue.AttemptStateChoices.UNCERTAIN,
}
or instance.delivery_attempt_token is None
):
raise ConflictException(
detail="This Jira delivery does not require operator resolution."
)
if (
instance.attempt_state == JiraIssue.AttemptStateChoices.CREATING
and instance.claim_token
and instance.claim_expires_at
and instance.claim_expires_at > datetime.now(UTC)
):
raise ConflictException(
detail="Another Jira delivery task still owns this attempt."
)
def _log_resolution(self, instance, resolution, old_issue):
logger.info(
"jira_issue_operator_resolution",
extra={
"user_id": str(self.request.user.id),
"tenant_id": str(instance.tenant_id),
"metadata": {
"resolution": resolution,
"jira_issue_id": str(instance.id),
"integration_id": str(instance.integration_id),
"provider_id": str(instance.provider_id),
"finding_uid": instance.finding_uid,
"old_issue_id": old_issue[0],
"old_issue_key": old_issue[1],
"resolved_issue_id": instance.issue_id,
"resolved_issue_key": instance.issue_key,
},
},
)
@action(detail=True, methods=["post"], url_path="resolution")
def resolution(self, request, pk=None):
with rls_transaction(str(self.request.tenant_id), using=MainRouter.default_db):
instance = self.get_object()
self._validate_resolution_state(instance)
serializer = JiraIssueResolutionSerializer(
data=request.data, context=self.get_serializer_context()
)
serializer.is_valid(raise_exception=True)
resolution = serializer.validated_data["resolution"]
old_issue = (instance.issue_id, instance.issue_key)
now = datetime.now(UTC)
if resolution == "link":
try:
jira = initialize_prowler_integration(instance.integration)
lookup = jira.search_issues_by_delivery_attempt(
str(instance.delivery_attempt_token)
)
except Exception:
logger.exception(
"Jira marker lookup failed during operator resolution for %s",
instance.id,
)
raise ValidationError(
{"resolution": "Jira could not verify the selected issue."}
)
if (
not isinstance(lookup, JiraIssueSearchResult)
or lookup.outcome != JiraIssueSearchOutcome.SUCCESS
or not isinstance(lookup.matches, tuple)
):
raise ValidationError(
{"resolution": "Jira could not verify the selected issue."}
)
issue_id = serializer.validated_data["issue_id"]
issue_key = serializer.validated_data["issue_key"]
matches = [
match
for match in lookup.matches
if getattr(match, "issue_id", None) == issue_id
and getattr(match, "issue_key", None) == issue_key
]
if len(matches) != 1 or not getattr(matches[0], "issue_url", None):
raise ValidationError(
{
"issue_id": (
"The selected issue does not match this delivery attempt."
)
}
)
match = matches[0]
updates = {
"issue_id": match.issue_id[:64],
"issue_key": match.issue_key[:64],
"issue_url": match.issue_url[:2048],
"project_key": instance.attempt_project_key,
"issue_type": instance.attempt_issue_type,
"issue_status": None,
"issue_status_category": None,
"status_synced_at": None,
"attempt_state": JiraIssue.AttemptStateChoices.IDLE,
"claim_token": None,
"claim_expires_at": None,
"last_error_code": None,
"last_error_message": None,
"next_reconcile_at": None,
"updated_at": now,
}
else:
updates = {
"attempt_state": JiraIssue.AttemptStateChoices.RETRYABLE_FAILURE,
"claim_token": None,
"claim_expires_at": None,
"last_error_code": "operator_confirmed_not_created",
"last_error_message": (
"An operator confirmed that Jira did not create the issue."
),
"next_reconcile_at": None,
"updated_at": now,
}
try:
with rls_transaction(str(instance.tenant_id), using=MainRouter.default_db):
updated = self._resolution_queryset(instance).update(**updates)
except IntegrityError as error:
raise ConflictException(
detail="The selected Jira issue is already linked."
) from error
if not updated:
raise ConflictException(
detail="The Jira delivery state changed during resolution."
)
for field, value in updates.items():
setattr(instance, field, value)
self._log_resolution(instance, resolution, old_issue)
response_serializer = JiraIssueSerializer(
instance, context=self.get_serializer_context()
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
# MuteRules
@extend_schema_view(
+1 -1
View File
@@ -1475,7 +1475,7 @@ def jira_integration_fixture(tenants_fixture):
@pytest.fixture
def jira_issues_fixture(jira_integration_fixture, aws_provider_pair, findings_fixture):
"""Two linked issues (one per provider) and one in-flight reservation."""
"""Two linked issues (one per provider) and one in-flight delivery."""
provider, provider2 = aws_provider_pair
finding1, finding2 = findings_fixture
tenant_id = jira_integration_fixture.tenant_id
@@ -1333,6 +1333,7 @@ def _process_jira_delivery_page(
finding_ids: list[str],
claim_token: str,
force_replace: bool,
actor_id: str | None,
) -> list[dict]:
findings, ledger = _load_jira_delivery_page(tenant_id, integration_id, finding_ids)
linked_idle_rows = [
@@ -1501,6 +1502,22 @@ def _process_jira_delivery_page(
if claimed is None:
results.append(_deferred_result(finding, row, "claim_conflict"))
else:
if unknown_status and force_replace:
logger.warning(
"jira_force_replacement",
extra={
"user_id": actor_id,
"tenant_id": str(tenant_id),
"metadata": {
"integration_id": str(integration_id),
"provider_id": str(finding.scan.provider_id),
"finding_uid": finding.uid,
"old_issue_id": row.issue_id,
"old_issue_key": row.issue_key,
"old_issue_url": row.issue_url,
},
},
)
results.append(
_send_claimed_finding(
tenant_id,
@@ -1548,6 +1565,7 @@ def send_findings_to_jira(
*,
task_id: str | None = None,
force_replace: bool = False,
actor_id: str | None = None,
) -> dict:
"""Deliver findings through the concurrency-safe Jira issue ledger."""
claim_token = str(task_id or uuid4())
@@ -1575,6 +1593,7 @@ def send_findings_to_jira(
page,
claim_token,
force_replace,
actor_id,
)
for result in page_results:
_record_delivery_result(summary, result)
+2
View File
@@ -1395,6 +1395,7 @@ def jira_integration_task(
issue_type: str,
finding_ids: list[str],
force_replace: bool = False,
actor_id: str | None = None,
):
return send_findings_to_jira(
tenant_id,
@@ -1404,6 +1405,7 @@ def jira_integration_task(
finding_ids,
task_id=self.request.id,
force_replace=force_replace,
actor_id=actor_id,
)
@@ -1798,6 +1798,7 @@ class TestSafeJiraDelivery:
*,
task_id="jira-task-1",
force_replace=False,
actor_id=None,
):
with patch(
"tasks.jobs.integrations.initialize_prowler_integration",
@@ -1811,6 +1812,7 @@ class TestSafeJiraDelivery:
[str(finding_id) for finding_id in finding_ids],
task_id=task_id,
force_replace=force_replace,
actor_id=actor_id,
)
@staticmethod
@@ -2082,17 +2084,33 @@ class TestSafeJiraDelivery:
assert skipped["skipped_count"] == 1
jira_mock.send_finding.assert_not_called()
forced = self._send(
jira_integration_fixture,
jira_mock,
[finding.id],
task_id="jira-task-3",
force_replace=True,
)
with patch("tasks.jobs.integrations.logger.warning") as warning:
forced = self._send(
jira_integration_fixture,
jira_mock,
[finding.id],
task_id="jira-task-3",
force_replace=True,
actor_id="operator-1",
)
assert forced["created_count"] == 1
row = self._row(jira_integration_fixture, finding)
assert row.issue_key == "TEST-2"
assert row.delivery_attempt_token != original_marker
warning.assert_called_once()
_, log_kwargs = warning.call_args
assert log_kwargs["extra"]["user_id"] == "operator-1"
assert log_kwargs["extra"]["tenant_id"] == str(
jira_integration_fixture.tenant_id
)
assert log_kwargs["extra"]["metadata"] == {
"integration_id": str(jira_integration_fixture.id),
"provider_id": str(finding.scan.provider_id),
"finding_uid": finding.uid,
"old_issue_id": original.issue_id,
"old_issue_key": original.issue_key,
"old_issue_url": original.issue_url,
}
def test_retryable_failure_reuses_delivery_marker(
self, jira_mock, jira_integration_fixture, findings_fixture
@@ -138,12 +138,51 @@ Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJA
### Sending a Finding That Already Has a Jira Issue
Prowler remembers the Jira issue created for each Finding, keyed by the Finding UID, so sending the same Finding again does not create a duplicate issue:
Prowler keeps one Jira delivery record for each Finding UID, Provider, and Jira integration. Sending the same Finding again follows the current Jira state:
* If the linked issue is still open in Jira, the Finding is skipped and the existing issue key is reported in the task result (`skipped_count`, `skipped`).
* If the linked issue is closed (any Jira status in the **Done** category) or was deleted, a new issue is created and becomes the linked issue for that Finding.
* If the linked issue is open, Prowler refreshes its cached status and skips the Finding.
* If Jira moved or renamed the issue, Prowler updates the key and URL after confirming the immutable Jira issue ID, then skips the Finding.
* If the linked issue is in the **Done** category, Prowler creates one replacement. The previous link remains available until Jira confirms the replacement.
* If Jira reports the issue as missing or forbidden, or cannot determine its state, Prowler preserves the existing link and skips the Finding. API clients can send `force_replace: true` to confirm the duplicate risk and request one replacement. This option never replaces an issue that Jira confirms is open.
* If Jira cannot confirm whether a create request succeeded, Prowler records the attempt as `uncertain` and searches Jira by the persisted delivery marker before any retry. It does not send another create request blindly.
The link, and the last status observed in Jira, are available through the API at `GET /api/v1/jira-issues` (filter by `finding_uid`, `finding_uid__in`, `provider_id`, `integration` or `issue_key`). Each Jira integration keeps its own links, so the same Finding can have one issue per integration.
`GET /api/v1/jira-issues` returns both linked and unlinked delivery records. It exposes the current link, attempt state, safe delivery error, and next reconciliation time. It does not expose claim tokens or delivery markers. Filter by `finding_uid`, `finding_uid__in`, `finding_id`, `provider_id`, `integration`, `issue_key`, `issue_status_category`, or `attempt_state`.
The Jira status in this response is the last status Prowler observed during a dispatch. It is cached and may be stale between dispatches. Each Jira integration keeps its own delivery record, so the same Finding can have one issue per integration.
### Recovering an Uncertain Jira Delivery
Users with the **Manage Integrations** permission can resolve an uncertain delivery through `POST /api/v1/jira-issues/{id}/resolution`. Provider visibility still applies.
Use `resolution: "link"` with the Jira issue ID and key when Jira created the issue. Prowler searches by the persisted delivery marker and links only an issue returned by Jira:
```json
{
"data": {
"type": "jira-issues",
"attributes": {
"resolution": "link",
"issue_id": "10001",
"issue_key": "SEC-42"
}
}
}
```
Use `resolution: "confirm_not_created"` without issue fields after confirming that Jira did not create the issue. Prowler clears the claim, preserves any previous link, and marks the attempt as retryable:
```json
{
"data": {
"type": "jira-issues",
"attributes": {
"resolution": "confirm_not_created"
}
}
}
```
Prowler rejects operator resolution while a delivery task still owns a valid claim.
## Integration Status
@@ -162,7 +201,7 @@ Each Jira integration provides management actions through dedicated buttons:
| Button | Purpose | Available Actions | Notes |
|--------|---------|------------------|-------|
| **Test** | Verify integration connectivity | • Test Jira API access<br/>• Validate credentials<br/>• Check project permissions<br/>• Verify work item creation capability | Results displayed in notification message |
| **Credentials** | Update authentication settings | • Change API token<br/>• Update email<br/>• Update Jira domain | Click "Update Credentials" to save changes |
| **Credentials** | Update authentication settings | • Change API token<br/>• Update email | The Jira domain identifies the integration and cannot be changed after creation |
| **Enable/Disable** | Toggle integration status | • Enable or disable integration<br/>| Status change takes effect immediately |
| **Delete** | Remove integration permanently | • Permanently delete integration<br/>• Remove all configuration data | ⚠️ **Cannot be undone** - confirm before deleting |
@@ -296,5 +335,10 @@ If you don't have `jq` installed, run the command without `| jq`.
How to read it:
* "created_count": number of Jira issues successfully created.
* "failed_count": number of Jira issues that could not be created. If `failed_count > 0` or the issue does not appear in Jira, please contact us so we can assist while detailed logs are not available through the UI.
* `created_count`: Jira issues that were confirmed and linked.
* `skipped_count`: Findings that kept an existing Jira issue.
* `deferred_count`: Findings currently owned by another delivery task.
* `uncertain_count`: Attempts that need delivery-marker reconciliation or operator recovery.
* `failed_count`: Attempts that Jira rejected or that can be retried later.
The `results` list contains safe per-Finding details and is capped at 100 entries. The counters remain exact when `truncated` is `true`.