diff --git a/api/changelog.d/integrations-hidden-providers-disclosure.security.md b/api/changelog.d/integrations-hidden-providers-disclosure.security.md
new file mode 100644
index 0000000000..4ea169d4fe
--- /dev/null
+++ b/api/changelog.d/integrations-hidden-providers-disclosure.security.md
@@ -0,0 +1 @@
+Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers`
diff --git a/api/changelog.d/integrations-limited-visibility.fixed.md b/api/changelog.d/integrations-limited-visibility.fixed.md
new file mode 100644
index 0000000000..c8f7e2c450
--- /dev/null
+++ b/api/changelog.d/integrations-limited-visibility.fixed.md
@@ -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
diff --git a/api/changelog.d/integrations-object-scoping.security.md b/api/changelog.d/integrations-object-scoping.security.md
new file mode 100644
index 0000000000..877190fc1b
--- /dev/null
+++ b/api/changelog.d/integrations-object-scoping.security.md
@@ -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
diff --git a/api/changelog.d/integrations-provider-scoping.security.md b/api/changelog.d/integrations-provider-scoping.security.md
new file mode 100644
index 0000000000..eec84285a4
--- /dev/null
+++ b/api/changelog.d/integrations-provider-scoping.security.md
@@ -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
diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py
index e8dd728cb9..7a2c9c61c4 100644
--- a/api/src/backend/api/base_views.py
+++ b/api/src/backend/api/base_views.py
@@ -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):
diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py
index 3458346a5f..e2c209a990 100644
--- a/api/src/backend/api/rbac/permissions.py
+++ b/api/src/backend/api/rbac/permissions.py
@@ -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()
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index 538f7421fd..fe57fdad4b 100644
--- a/api/src/backend/api/specs/v1.yaml
+++ b/api/src/backend/api/specs/v1.yaml
@@ -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
diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py
index ed177138b2..92d19bd3c0 100644
--- a/api/src/backend/api/tests/test_rbac.py
+++ b/api/src/backend/api/tests/test_rbac.py
@@ -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,
diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py
index ac876d1d5b..aa941b6c2a 100644
--- a/api/src/backend/api/v1/serializer_utils/integrations.py
+++ b/api/src/backend/api/v1/serializer_utils/integrations.py
@@ -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)
diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py
index 0f08c8f4ba..1fb915f3ef 100644
--- a/api/src/backend/api/v1/serializers.py
+++ b/api/src/backend/api/v1/serializers.py
@@ -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(
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index e392505818..db7b76f01a 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -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"]
diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
index 83554ee8c0..6d725bb5d0 100644
--- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
+++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
@@ -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
+
+
+
+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:
diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx
index 4533e0423c..2598c6d9ec 100644
--- a/docs/user-guide/tutorials/prowler-app-rbac.mdx
+++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx
@@ -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.
**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
+
+
+
+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: