fix(api): scope integrations to role provider visibility (#12060)

(cherry picked from commit d70a7e3d02)
This commit is contained in:
Pedro Martín
2026-07-22 11:50:04 +02:00
parent 1f27066635
commit 2f5137fd9a
13 changed files with 600 additions and 84 deletions
@@ -0,0 +1 @@
Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers`
@@ -0,0 +1 @@
Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility
@@ -0,0 +1 @@
Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant
@@ -0,0 +1 @@
Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them
+7 -1
View File
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
from api.filters import CustomDjangoFilterBackend
from api.models import Role, UserRoleRelationship
from api.rbac.permissions import HasPermissions
from api.rbac.permissions import HasPermissions, get_role
from django.conf import settings
from django.db import transaction
from django.utils.functional import cached_property
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
context["tenant_id"] = self.request.tenant_id
return context
@cached_property
def user_role(self):
"""Role of the requesting user in the active tenant, resolved once per request."""
return get_role(self.request.user, self.request.tenant_id)
class BaseTenantViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
+31 -2
View File
@@ -1,8 +1,8 @@
from enum import Enum
from api.db_router import MainRouter
from api.models import Provider, Role, User
from django.db.models import QuerySet
from api.models import Integration, Provider, Role, User
from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]:
return Provider.objects.filter(
tenant_id=tenant_id, provider_groups__in=provider_groups
).distinct()
def get_integrations(
role: Role, providers: QuerySet[Provider] | None = None
) -> QuerySet[Integration]:
"""
Return a distinct queryset of Integrations visible to the given role.
Integrations with no providers attached are tenant-wide, as is always the case for
Jira, and stay visible regardless of the provider visibility of the role. Integrations
attached to providers are only visible when the role can access at least one of them.
Args:
role: A Role instance.
providers: Optional queryset of the providers accessible by the role, to reuse
an already resolved `get_providers(role)` result within the same request.
Returns:
A QuerySet of Integration objects visible to the role.
"""
queryset = Integration.objects.filter(tenant_id=role.tenant_id)
if role.unlimited_visibility:
return queryset
if providers is None:
providers = get_providers(role)
return queryset.filter(
Q(providers__isnull=True) | Q(providers__in=providers)
).distinct()
+18 -9
View File
@@ -6629,8 +6629,10 @@ paths:
/api/v1/integrations:
get:
operationId: api_v1_integrations_list
description: Retrieve a list of all configured integrations with options for
filtering by various criteria.
description: |-
Retrieve a list of all configured integrations with options for filtering by various criteria.
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
summary: List all integrations
parameters:
- in: query
@@ -6781,7 +6783,8 @@ paths:
post:
operationId: api_v1_integrations_create
description: Register a new integration with the system, providing necessary
configuration details.
configuration details. Only providers visible to the role can be attached
to the integration.
summary: Create a new integration
tags:
- Integration
@@ -6810,7 +6813,7 @@ paths:
post:
operationId: api_v1_integrations_jira_dispatches_create
description: |-
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
## Known Limitations
@@ -6883,7 +6886,8 @@ paths:
get:
operationId: api_v1_integrations_jira_issue_types_retrieve
description: Fetch the available issue types from Jira for a given project key
and update the integration configuration.
and update the integration configuration. Jira integrations are tenant-wide
and do not require unlimited visibility.
summary: Get available issue types for a Jira project
parameters:
- in: query
@@ -6924,7 +6928,8 @@ paths:
get:
operationId: api_v1_integrations_retrieve
description: Fetch detailed information about a specific integration by its
ID.
ID. Integrations outside the provider visibility of the role are reported
the same way as one that does not exist.
summary: Retrieve integration details
parameters:
- in: query
@@ -6978,7 +6983,8 @@ paths:
patch:
operationId: api_v1_integrations_partial_update
description: Modify certain fields of an existing integration without affecting
other settings.
other settings. Integrations attached to providers outside the visibility
of the role cannot be modified by it.
summary: Partially update an integration
parameters:
- in: path
@@ -7013,7 +7019,8 @@ paths:
description: ''
delete:
operationId: api_v1_integrations_destroy
description: Remove an integration from the system by its ID.
description: Remove an integration from the system by its ID. Integrations attached
to providers outside the visibility of the role cannot be deleted by it.
summary: Delete an integration
parameters:
- in: path
@@ -7033,7 +7040,9 @@ paths:
/api/v1/integrations/{id}/connection:
post:
operationId: api_v1_integrations_connection_create
description: Try to verify integration connection
description: Try to verify integration connection. Integrations outside the
provider visibility of the role are reported the same way as one that does
not exist.
summary: Check integration connection
parameters:
- in: path
+359
View File
@@ -3,6 +3,8 @@ from unittest.mock import ANY, Mock, patch
import pytest
from api.models import (
Integration,
IntegrationProviderRelationship,
Membership,
ProviderGroup,
ProviderGroupMembership,
@@ -681,6 +683,363 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
@pytest.fixture
def jira_integration(self, tenants_fixture):
# Jira is a tenant-wide integration: it is not attached to any provider
return Integration.objects.create(
tenant_id=tenants_fixture[0].id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.JIRA,
configuration={"projects": {"TEST": "Test project"}},
credentials={
"domain": "test",
"user_mail": "a@b.com",
"api_token": "token",
},
)
@pytest.fixture
def out_of_scope_integration(self, tenants_fixture, provider_factory):
tenant_id = tenants_fixture[0].id
integration = Integration.objects.create(
tenant_id=tenant_id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.AMAZON_S3,
configuration={
"bucket_name": "bucket",
"output_directory": "output",
},
credentials={"aws_access_key_id": "key"},
)
IntegrationProviderRelationship.objects.create(
tenant_id=tenant_id,
integration=integration,
provider=provider_factory(),
)
return integration
def test_integrations_list_includes_tenant_wide_integration(
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration,
aws_provider_pair,
):
# Integration 2 is attached to both providers, so make both visible to the role
# to assert the provider join does not duplicate it in the listing
ProviderGroupMembership.objects.create(
tenant_id=aws_provider_pair[1].tenant_id,
provider=aws_provider_pair[1],
provider_group=ProviderGroup.objects.get(name="limited_visibility_group"),
)
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
# The tenant-wide Jira integration is visible without unlimited visibility
assert str(jira_integration.id) in integration_ids
# Integrations attached to more than one visible provider are not duplicated
assert integration_ids.count(str(integrations_fixture[1].id)) == 1
assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
):
# A role with no provider group at all sees no provider, but still needs Jira
RoleProviderGroupRelationship.objects.all().delete()
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
assert integration_ids == [str(jira_integration.id)]
def test_integrations_include_providers_hides_out_of_scope_providers(
self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
):
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.get(
reverse("integration-list"), {"include": "providers"}
)
assert response.status_code == status.HTTP_200_OK
included_ids = {item["id"] for item in response.json().get("included", [])}
assert str(aws_provider_pair[0].id) in included_ids
# Sideloaded resources must not disclose the provider the role cannot see
assert str(hidden_provider.id) not in included_ids
def test_integrations_list_with_sparse_fields(
self, authenticated_client_rbac_limited, integrations_fixture
):
response = authenticated_client_rbac_limited.get(
reverse("integration-list"), {"fields[integrations]": "enabled"}
)
assert response.status_code == status.HTTP_200_OK
assert all(
list(item["attributes"].keys()) == ["enabled"]
for item in response.json()["data"]
)
def test_integrations_list_excludes_out_of_scope_integration(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
assert str(out_of_scope_integration.id) not in integration_ids
def test_integration_detail_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.get(
reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_integration_connection_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-connection", kwargs={"pk": out_of_scope_integration.id}
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_integration_update_allowed_when_fully_visible(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
):
# Integration 1 is only related to provider1, which the role can access
integration = integrations_fixture[0]
payload = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {
"enabled": False,
# integration_type is `amazon_s3`
"credentials": {"aws_access_key_id": "new_value"},
"configuration": {
"bucket_name": "new_bucket_name",
"output_directory": "new_output_directory",
},
},
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
integration.refresh_from_db()
assert integration.enabled is False
# Tenant-wide integrations have no provider restricting the role
payload = {
"data": {
"type": "integrations",
"id": str(jira_integration.id),
"attributes": {"enabled": False},
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": jira_integration.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
jira_integration.refresh_from_db()
assert jira_integration.enabled is False
def test_integration_create_rejects_out_of_scope_provider(
self, authenticated_client_rbac_limited, aws_provider_pair
):
# provider2 is not in any provider group assigned to the role
payload = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": "amazon_s3",
"configuration": {
"bucket_name": "attacker_bucket",
"output_directory": "output",
},
"credentials": {"aws_access_key_id": "key"},
},
"relationships": {
"providers": {
"data": [
{"type": "providers", "id": str(aws_provider_pair[1].id)}
]
}
},
}
}
response = authenticated_client_rbac_limited.post(
reverse("integration-list"),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Integration.objects.filter(
integrationproviderrelationship__provider=aws_provider_pair[1],
configuration__bucket_name="attacker_bucket",
).exists()
@pytest.mark.parametrize("submitted_providers", [True, False])
def test_integration_update_denied_when_shared_with_hidden_provider(
self,
authenticated_client_rbac_limited,
integrations_fixture,
aws_provider_pair,
submitted_providers,
):
# Integration 2 is related to provider1 (visible) and provider2 (not visible).
# Editing it would reach beyond the visibility of the role, just like deleting
# it, so both are rejected consistently
integration = integrations_fixture[1]
visible_provider, hidden_provider = aws_provider_pair
payload = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {
"enabled": False,
# integration_type is `amazon_s3`
"credentials": {"aws_access_key_id": "new_value"},
"configuration": {
"bucket_name": "new_bucket_name",
"output_directory": "new_output_directory",
},
},
}
}
if submitted_providers:
payload["data"]["relationships"] = {
"providers": {
"data": [{"type": "providers", "id": str(visible_provider.id)}]
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
integration.refresh_from_db()
assert integration.enabled is True
assert integration.providers.filter(id=hidden_provider.id).exists()
assert integration.providers.filter(id=visible_provider.id).exists()
def test_integration_delete_denied_when_shared_with_hidden_provider(
self, authenticated_client_rbac_limited, integrations_fixture
):
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
integration = integrations_fixture[1]
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": integration.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Integration.objects.filter(id=integration.id).exists()
def test_integration_delete_allowed_when_fully_visible(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
):
# Integration 1 is only related to provider1, which the role can access
integration = integrations_fixture[0]
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": integration.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Integration.objects.filter(id=integration.id).exists()
# Tenant-wide integrations have no provider restricting the role
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": jira_integration.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_jira_issue_types_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration
):
with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
response = authenticated_client_rbac_limited.get(
reverse(
"integration-jira-issue-types",
kwargs={"integration_pk": jira_integration.id},
),
{"project_key": "TEST"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["issue_types"] == ["Task"]
def test_jira_issue_types_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.get(
reverse(
"integration-jira-issue-types",
kwargs={"integration_pk": out_of_scope_integration.id},
),
{"project_key": "TEST"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_dispatches_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": out_of_scope_integration.id},
),
data=json.dumps({}),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_dispatches_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": jira_integration.id},
),
data=json.dumps({}),
content_type="application/vnd.api+json",
)
# The integration is reachable: the request fails on payload validation, not RBAC
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_providers(
self,
@@ -1,7 +1,9 @@
import os
import re
from api.models import Integration, IntegrationProviderRelationship, Provider
from api.v1.serializer_utils.base import BaseValidateSerializer
from django.db import transaction
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
@@ -10,6 +12,24 @@ ATLASSIAN_SITE_NAME_REGEX = re.compile(
)
def replace_integration_providers(
integration: Integration, providers: list[Provider], tenant_id: str
) -> None:
"""Replace the provider relationships of an integration with the given set."""
# Atomic on its own, so callers without an ambient transaction cannot leave the
# integration with no relationships if the recreation fails halfway
with transaction.atomic():
IntegrationProviderRelationship.objects.filter(integration=integration).delete()
IntegrationProviderRelationship.objects.bulk_create(
[
IntegrationProviderRelationship(
integration=integration, provider=provider, tenant_id=tenant_id
)
for provider in providers
]
)
class S3ConfigSerializer(BaseValidateSerializer):
bucket_name = serializers.CharField()
output_directory = serializers.CharField(allow_blank=True)
+56 -32
View File
@@ -47,6 +47,7 @@ from api.v1.serializer_utils.integrations import (
JiraCredentialSerializer,
S3ConfigSerializer,
SecurityHubConfigSerializer,
replace_integration_providers,
)
from api.v1.serializer_utils.lighthouse import (
BedrockCredentialsSerializer,
@@ -2716,6 +2717,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1):
# Integrations
class IntegrationProviderVisibilityMixin:
"""
Keep the `providers` relationship within the provider visibility of the role.
The view injects `allowed_providers` in the serializer context: `None` when the role
has unlimited visibility, and the queryset of visible providers otherwise. Roles with
limited visibility can neither attach providers they cannot see nor discover, through
the serialized output, the ones already attached.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
allowed_providers = self.context.get("allowed_providers")
if allowed_providers is not None:
self.fields["providers"].child_relation.queryset = allowed_providers
def hide_restricted_providers(self, representation: dict) -> dict:
allowed_providers = self.context.get("allowed_providers")
# `providers` is missing when the request asks for a subset of the fields
if allowed_providers is None or "providers" not in representation:
return representation
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
representation["providers"] = [
provider
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
return representation
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
def validate(self, attrs):
integration_type = attrs.get("integration_type")
@@ -2848,7 +2880,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
class IntegrationSerializer(RLSSerializer):
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
"""
Serializer for the Integration model.
"""
@@ -2877,15 +2909,9 @@ class IntegrationSerializer(RLSSerializer):
}
def to_representation(self, instance):
representation = super().to_representation(instance)
allowed_providers = self.context.get("allowed_providers")
if allowed_providers:
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
representation["providers"] = [
provider
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
representation = self.hide_restricted_providers(
super().to_representation(instance)
)
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
@@ -2893,7 +2919,9 @@ class IntegrationSerializer(RLSSerializer):
return representation
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
class IntegrationCreateSerializer(
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
):
credentials = IntegrationCredentialField(write_only=True)
configuration = IntegrationConfigField()
providers = serializers.ResourceRelatedField(
@@ -2944,22 +2972,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
tenant_id = self.context.get("tenant_id")
providers = validated_data.pop("providers", [])
integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
through_model_instances = [
IntegrationProviderRelationship(
integration=integration,
provider=provider,
tenant_id=tenant_id,
with transaction.atomic():
integration = Integration.objects.create(
tenant_id=tenant_id, **validated_data
)
for provider in providers
]
IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
replace_integration_providers(integration, providers, tenant_id)
return integration
class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
class IntegrationUpdateSerializer(
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
):
credentials = IntegrationCredentialField(write_only=True, required=False)
configuration = IntegrationConfigField(required=False)
providers = serializers.ResourceRelatedField(
@@ -3004,15 +3028,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
def update(self, instance, validated_data):
tenant_id = self.context.get("tenant_id")
if validated_data.get("providers") is not None:
instance.providers.clear()
new_relationships = [
IntegrationProviderRelationship(
integration=instance, provider=provider, tenant_id=tenant_id
)
for provider in validated_data["providers"]
]
IntegrationProviderRelationship.objects.bulk_create(new_relationships)
# Relationships are replaced here, so they are kept out of the default
# `ModelSerializer.update()`, which would otherwise reset them all. The view
# rejects updates on integrations shared with providers hidden to the role, so
# every existing relationship is visible to the requester at this point
providers = validated_data.pop("providers", None)
if providers is not None:
replace_integration_providers(instance, providers, tenant_id)
# Preserve regions field for Security Hub integrations
if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB:
@@ -3024,7 +3046,9 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return super().update(instance, validated_data)
def to_representation(self, instance):
representation = super().to_representation(instance)
representation = self.hide_restricted_providers(
super().to_representation(instance)
)
# Ensure JIRA integrations show updated domain in configuration from credentials
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
+92 -39
View File
@@ -124,7 +124,12 @@ from api.models import (
UserRoleRelationship,
)
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import Permissions, get_providers, get_role
from api.rbac.permissions import (
Permissions,
get_integrations,
get_providers,
get_role,
)
from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
from api.utils import (
@@ -281,6 +286,7 @@ from django.shortcuts import redirect
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
from drf_spectacular.settings import spectacular_settings
@@ -6652,27 +6658,34 @@ class ScheduleViewSet(BaseRLSViewSet):
list=extend_schema(
tags=["Integration"],
summary="List all integrations",
description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n"
"Integrations attached to one or more providers are only returned when the role can access at least one of "
"those providers, and each integration lists only the providers visible to the role. Integrations not "
"attached to any provider, such as Jira, are tenant-wide and are returned for every role.",
),
retrieve=extend_schema(
tags=["Integration"],
summary="Retrieve integration details",
description="Fetch detailed information about a specific integration by its ID.",
description="Fetch detailed information about a specific integration by its ID. Integrations outside the "
"provider visibility of the role are reported the same way as one that does not exist.",
),
create=extend_schema(
tags=["Integration"],
summary="Create a new integration",
description="Register a new integration with the system, providing necessary configuration details.",
description="Register a new integration with the system, providing necessary configuration details. Only "
"providers visible to the role can be attached to the integration.",
),
partial_update=extend_schema(
tags=["Integration"],
summary="Partially update an integration",
description="Modify certain fields of an existing integration without affecting other settings.",
description="Modify certain fields of an existing integration without affecting other settings. Integrations "
"attached to providers outside the visibility of the role cannot be modified by it.",
),
destroy=extend_schema(
tags=["Integration"],
summary="Delete an integration",
description="Remove an integration from the system by its ID.",
description="Remove an integration from the system by its ID. Integrations attached to providers outside "
"the visibility of the role cannot be deleted by it.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@@ -6685,18 +6698,27 @@ class IntegrationViewSet(BaseRLSViewSet):
ordering = ["integration_type", "-inserted_at"]
# RBAC required permissions
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
allowed_providers = None
@cached_property
def allowed_providers(self):
"""
Providers the role can access, or None when it has unlimited visibility.
Resolved per request and independently of the action, so that writes are scoped
as tightly as reads.
"""
if self.user_role.unlimited_visibility:
return None
return get_providers(self.user_role)
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all integrations
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
else:
# User lacks permission, filter providers based on provider groups associated with the role
allowed_providers = get_providers(user_roles)
queryset = Integration.objects.filter(providers__in=allowed_providers)
self.allowed_providers = allowed_providers
queryset = get_integrations(self.user_role, providers=self.allowed_providers)
if self.allowed_providers is not None and self.action in ("list", "retrieve"):
# Restrict the relationship itself, so that the providers hidden to the role
# are left out of the sideloaded resources of `?include=providers` too
queryset = queryset.prefetch_related(
Prefetch("providers", queryset=self.allowed_providers)
)
return queryset
def get_serializer_class(self):
@@ -6711,16 +6733,33 @@ class IntegrationViewSet(BaseRLSViewSet):
context["allowed_providers"] = self.allowed_providers
return context
def get_object(self):
instance = super().get_object()
# Writes on an integration shared with providers hidden to the role would reach
# beyond its visibility, so both editing and deleting are rejected consistently
if (
self.action in ("partial_update", "destroy")
and self.allowed_providers is not None
and instance.providers.exclude(
id__in=self.allowed_providers.values("id")
).exists()
):
raise PermissionDenied(
"The integration is attached to providers outside the visibility of your role."
)
return instance
@extend_schema(
tags=["Integration"],
summary="Check integration connection",
description="Try to verify integration connection",
description="Try to verify integration connection. Integrations outside the provider visibility of the role "
"are reported the same way as one that does not exist.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
get_object_or_404(Integration, pk=pk)
get_object_or_404(self.get_queryset(), pk=pk)
with transaction.atomic():
task = check_integration_connection_task.delay(
integration_id=pk, tenant_id=self.request.tenant_id
@@ -6743,7 +6782,8 @@ class IntegrationViewSet(BaseRLSViewSet):
tags=["Integration"],
summary="Send findings to a Jira integration",
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
"provided.\n\n"
"provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings "
"sent are limited to the providers the role can access.\n\n"
"## Known Limitations\n\n"
"### Issue Types with Required Custom Fields\n\n"
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
@@ -6787,24 +6827,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
return []
return super().get_filter_backends()
def get_queryset(self):
tenant_id = self.request.tenant_id
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all findings
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
else:
# User lacks permission, filter findings based on provider groups associated with the role
queryset = Finding.all_objects.filter(
scan__provider__in=get_providers(user_roles)
)
@cached_property
def allowed_providers(self):
"""
Providers the role can access, or None when it has unlimited visibility.
return queryset
Resolved once per request and shared between the findings queryset and the
integration lookup.
"""
if self.user_role.unlimited_visibility:
return None
return get_providers(self.user_role)
def get_queryset(self):
if self.allowed_providers is None:
# User has unlimited visibility, return all findings
return Finding.all_objects.filter(tenant_id=self.request.tenant_id)
# Findings are limited to the providers the role can access
return Finding.all_objects.filter(scan__provider__in=self.allowed_providers)
def get_integration(self, integration_pk):
"""Retrieve the integration, honoring the provider visibility of the user's role."""
return get_object_or_404(
get_integrations(self.user_role, providers=self.allowed_providers),
pk=integration_pk,
)
@extend_schema(
tags=["Integration"],
summary="Get available issue types for a Jira project",
description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
description="Fetch the available issue types from Jira for a given project key and update the integration "
"configuration. Jira integrations are tenant-wide and do not require unlimited visibility.",
parameters=[
OpenApiParameter(
name="project_key",
@@ -6817,7 +6870,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
)
@action(detail=False, methods=["get"], url_name="issue-types")
def issue_types(self, request, integration_pk=None):
integration = get_object_or_404(Integration, pk=integration_pk)
integration = self.get_integration(integration_pk)
project_key = request.query_params.get("project_key")
if not project_key:
@@ -6862,23 +6915,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
@action(detail=False, methods=["post"], url_name="dispatches")
def dispatches(self, request, integration_pk=None):
get_object_or_404(Integration, pk=integration_pk)
self.get_integration(integration_pk)
serializer = self.get_serializer(
data=request.data, context={"integration_id": integration_pk}
)
serializer.is_valid(raise_exception=True)
if self.filter_queryset(self.get_queryset()).count() == 0:
raise ValidationError(
{"findings": "No findings match the provided filters"}
)
finding_ids = [
str(finding_id)
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
"id", flat=True
)
]
if not finding_ids:
raise ValidationError(
{"findings": "No findings match the provided filters"}
)
project_key = serializer.validated_data["project_key"]
issue_type = serializer.validated_data["issue_type"]
@@ -24,6 +24,12 @@ When enabled and configured:
1. Security findings can be manually sent to Jira from the Findings table.
2. Each finding creates a Jira work item with all the check's metadata, including guidance on how to remediate it.
## Prerequisites
<VersionBadge version="5.36.0" />
Configuring and using the Jira integration requires the **Manage Integrations** permission. The Jira integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group. Findings sent to Jira are still limited to the providers the role can access.
## Configuration
To configure Jira integration in Prowler Cloud:
@@ -128,7 +128,7 @@ To resend the invitation to the user, it is necessary to explicitly **delete the
## Managing Groups and Roles
Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, and compliance results the role can access.
Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, compliance results, and integrations the role can access.
<Note>
**Only users that have the _Manage Account_ or _admin_ permission can access this section.**
@@ -142,6 +142,12 @@ New roles have no provider visibility by default. Assign at least one Provider G
**Unlimited Visibility** grants organization-wide visibility across every provider, regardless of the Provider Groups assigned to the role. It does not grant administrative permissions.
#### Integration Visibility
<VersionBadge version="5.36.0" />
Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission.
#### Creating a Provider Group
Follow these steps to create a provider group in your account: