From 478ed0e5773bde8c56efb3f7b8c707a6614985c8 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Tue, 6 Oct 2026 14:55:24 +0200 Subject: [PATCH] fix(api): scope metadata endpoints to role provider groups --- ...adata-endpoints-provider-scope.security.md | 1 + api/src/backend/api/tests/test_views.py | 181 ++++++++++++++++++ api/src/backend/api/v1/views.py | 57 ++++-- 3 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 api/changelog.d/metadata-endpoints-provider-scope.security.md diff --git a/api/changelog.d/metadata-endpoints-provider-scope.security.md b/api/changelog.d/metadata-endpoints-provider-scope.security.md new file mode 100644 index 0000000000..64059c28ea --- /dev/null +++ b/api/changelog.d/metadata-endpoints-provider-scope.security.md @@ -0,0 +1 @@ +`GET /resources/metadata`, `GET /resources/metadata/latest`, `GET /findings/metadata` and `GET /findings/metadata/latest` now honour the provider groups of the caller role instead of aggregating every provider in the tenant diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b5c5d0fcf1..0ec8d5121b 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -97,6 +97,11 @@ from rest_framework_simplejwt.token_blacklist.models import ( OutstandingToken, ) from rest_framework_simplejwt.tokens import RefreshToken +from tasks.jobs.backfill import ( + aggregate_scan_category_summaries, + aggregate_scan_resource_group_summaries, + backfill_resource_scan_summaries, +) class TestViewSet: @@ -6949,6 +6954,99 @@ class TestAttackPathsScanViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST +def _client_restricted_to_provider(tenant, provider, factory): + """Authenticated client for a fresh member whose role only sees `provider`.""" + user = User.objects.create_user( + name=f"restricted-{uuid4()}", + email=f"restricted-{uuid4()}@prowler.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER + ) + role = Role.objects.create( + name=f"restricted-{uuid4()}", + tenant_id=tenant.id, + manage_users=False, + manage_account=False, + manage_billing=False, + manage_providers=False, + manage_integrations=False, + manage_scans=False, + unlimited_visibility=False, + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id) + provider_group = ProviderGroup.objects.create( + name=f"restricted-{uuid4()}", tenant_id=tenant.id + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, provider_group=provider_group, provider=provider + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, role=role, provider_group=provider_group + ) + return factory(user, tenant) + + +def _completed_scan_with_summaries( + tenant, provider, resources, *, categories, resource_group +): + """Completed scan for `provider` with one finding over `resources`, summary tables filled.""" + tenant_id = str(tenant.id) + scan = Scan.objects.create( + name=f"scan-{uuid4()}", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"finding-{uuid4()}", + scan=scan, + delta="new", + status=Status.FAIL, + status_extended="test status", + impact=Severity.critical, + impact_extended="test impact", + severity=Severity.critical, + raw_result={"status": Status.FAIL}, + check_id="scoped_check", + check_metadata={"CheckId": "scoped_check"}, + categories=categories, + resource_groups=resource_group, + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources(resources) + backfill_resource_scan_summaries(tenant_id, str(scan.id)) + aggregate_scan_category_summaries(tenant_id, str(scan.id)) + aggregate_scan_resource_group_summaries(tenant_id, str(scan.id)) + return scan + + +@pytest.fixture +def scans_per_provider_fixture(tenants_fixture, aws_provider_pair, resources_fixture): + """One completed scan per provider; provider 2 only owns the `test` typed resource.""" + tenant = tenants_fixture[0] + provider1, provider2 = aws_provider_pair + resource1, resource2, resource3 = resources_fixture + _completed_scan_with_summaries( + tenant, + provider1, + [resource1, resource2], + categories=["iam"], + resource_group="identity", + ) + _completed_scan_with_summaries( + tenant, + provider2, + [resource3], + categories=["data-protection"], + resource_group="data", + ) + return tenant, provider2 + + @pytest.mark.django_db class TestResourceViewSet: def test_resources_list_none(self, authenticated_client): @@ -7320,6 +7418,42 @@ class TestResourceViewSet: assert attributes["types"] == [latest_scan_resource.type] assert "groups" in attributes + def test_resources_metadata_restricted_to_provider_group( + self, authenticated_client_for_tenant_factory, scans_per_provider_fixture + ): + tenant, provider2 = scans_per_provider_fixture + client = _client_restricted_to_provider( + tenant, provider2, authenticated_client_for_tenant_factory + ) + + response = client.get( + reverse("resource-metadata"), {"filter[updated_at]": TODAY} + ) + + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert set(attributes["services"]) == {"ec2"} + assert set(attributes["regions"]) == {"us-east-1"} + assert set(attributes["types"]) == {"test"} + assert set(attributes["groups"]) == {"compute"} + + def test_resources_metadata_latest_restricted_to_provider_group( + self, authenticated_client_for_tenant_factory, scans_per_provider_fixture + ): + tenant, provider2 = scans_per_provider_fixture + client = _client_restricted_to_provider( + tenant, provider2, authenticated_client_for_tenant_factory + ) + + response = client.get(reverse("resource-metadata_latest")) + + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert set(attributes["services"]) == {"ec2"} + assert set(attributes["regions"]) == {"us-east-1"} + assert set(attributes["types"]) == {"test"} + assert set(attributes["groups"]) == {"compute"} + def test_resources_latest_filter_by_provider_id( self, authenticated_client, latest_scan_resource ): @@ -8952,6 +9086,53 @@ class TestFindingViewSet: assert "groups" in attributes assert "ai_ml" in attributes["groups"] + @patch("api.v1.views.backfill_scan_resource_summaries_task.apply_async") + def test_findings_metadata_restricted_to_provider_group( + self, + mock_backfill, + authenticated_client_for_tenant_factory, + scans_per_provider_fixture, + ): + tenant, provider2 = scans_per_provider_fixture + client = _client_restricted_to_provider( + tenant, provider2, authenticated_client_for_tenant_factory + ) + + response = client.get( + reverse("finding-metadata"), {"filter[inserted_at]": TODAY} + ) + + assert response.status_code == status.HTTP_200_OK + mock_backfill.assert_not_called() + attributes = response.json()["data"]["attributes"] + assert set(attributes["services"]) == {"ec2"} + assert set(attributes["regions"]) == {"us-east-1"} + assert set(attributes["resource_types"]) == {"test"} + assert set(attributes["categories"]) == {"data-protection"} + + @patch("api.v1.views.backfill_scan_resource_summaries_task.apply_async") + def test_findings_metadata_latest_restricted_to_provider_group( + self, + mock_backfill, + authenticated_client_for_tenant_factory, + scans_per_provider_fixture, + ): + tenant, provider2 = scans_per_provider_fixture + client = _client_restricted_to_provider( + tenant, provider2, authenticated_client_for_tenant_factory + ) + + response = client.get(reverse("finding-metadata_latest")) + + assert response.status_code == status.HTTP_200_OK + mock_backfill.assert_not_called() + attributes = response.json()["data"]["attributes"] + assert set(attributes["services"]) == {"ec2"} + assert set(attributes["regions"]) == {"us-east-1"} + assert set(attributes["resource_types"]) == {"test"} + assert set(attributes["categories"]) == {"data-protection"} + assert set(attributes["groups"]) == {"data"} + def test_findings_filter_by_category( self, authenticated_client, findings_with_categories ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 5dc175dd42..ccdfa18e0f 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -351,6 +351,15 @@ from tasks.tasks import ( logger = logging.getLogger(BackendLogger.API) + +def _scans_visible_to(role, tenant_id): + """Tenant scans, narrowed to the role's provider groups unless it has unlimited visibility.""" + scans = Scan.all_objects.filter(tenant_id=tenant_id) + if role.unlimited_visibility: + return scans + return scans.filter(provider__in=get_providers(role)) + + CACHE_DECORATOR = cache_control( max_age=django_settings.CACHE_MAX_AGE, stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE, @@ -3544,8 +3553,15 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): tenant_id = request.tenant_id query_params = request.query_params + user_roles = get_role(request.user, tenant_id) queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id) + if not user_roles.unlimited_visibility: + queryset = queryset.filter( + scan_id__in=_scans_visible_to(user_roles, tenant_id).values_list( + "id", flat=True + ) + ) if scans := query_params.get("filter[scan__in]") or query_params.get( "filter[scan]" @@ -3623,7 +3639,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): all_groups = Resource.objects.filter( tenant_id=tenant_id, groups__isnull=False, - ).values_list("groups", flat=True) + ) + if not user_roles.unlimited_visibility: + all_groups = all_groups.filter(provider__in=get_providers(user_roles)) + all_groups = all_groups.values_list("groups", flat=True) groups = sorted( {g for groups_list in all_groups if groups_list for g in groups_list} ) @@ -3648,9 +3667,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): def metadata_latest(self, request): tenant_id = request.tenant_id query_params = request.query_params + user_roles = get_role(request.user, tenant_id) - latest_scans_queryset = Scan.all_objects.filter( - tenant_id=tenant_id + latest_scans_queryset = _scans_visible_to( + user_roles, tenant_id ).latest_per_provider() queryset = ResourceScanSummary.objects.filter( @@ -3691,7 +3711,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): all_groups = Resource.objects.filter( tenant_id=tenant_id, groups__isnull=False, - ).values_list("groups", flat=True) + ) + if not user_roles.unlimited_visibility: + all_groups = all_groups.filter(provider__in=get_providers(user_roles)) + all_groups = all_groups.values_list("groups", flat=True) groups = sorted( {g for groups_list in all_groups if groups_list for g in groups_list} ) @@ -4102,8 +4125,14 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): tenant_id = request.tenant_id query_params = request.query_params + user_roles = get_role(request.user, tenant_id) + visible_scan_ids = _scans_visible_to(user_roles, tenant_id).values_list( + "id", flat=True + ) queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id) + if not user_roles.unlimited_visibility: + queryset = queryset.filter(scan_id__in=visible_scan_ids) scan_based_filters = {} category_scan_filters = {} # Filters for ScanCategorySummary @@ -4161,7 +4190,10 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): if not queryset.exists(): raw_scans_ids = Scan.objects.filter( tenant_id=tenant_id, **scan_based_filters - ).values_list("id", "unique_resource_count") + ) + if not user_roles.unlimited_visibility: + raw_scans_ids = raw_scans_ids.filter(id__in=visible_scan_ids) + raw_scans_ids = raw_scans_ids.values_list("id", "unique_resource_count") scan_ids = [ scan_id for scan_id, count in raw_scans_ids if count and count > 0 ] @@ -4203,11 +4235,13 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): ) # Get categories from ScanCategorySummary using same scan filters + category_summaries = ScanCategorySummary.objects.filter( + tenant_id=tenant_id, **category_scan_filters + ) + if not user_roles.unlimited_visibility: + category_summaries = category_summaries.filter(scan_id__in=visible_scan_ids) categories = list( - ScanCategorySummary.objects.filter( - tenant_id=tenant_id, **category_scan_filters - ) - .values_list("category", flat=True) + category_summaries.values_list("category", flat=True) .distinct() .order_by("category") ) @@ -4262,9 +4296,10 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): def metadata_latest(self, request): tenant_id = request.tenant_id query_params = request.query_params + user_roles = get_role(request.user, tenant_id) - latest_scans_queryset = Scan.all_objects.filter( - tenant_id=tenant_id + latest_scans_queryset = _scans_visible_to( + user_roles, tenant_id ).latest_per_provider() raw_latest_scans_ids = list( latest_scans_queryset.values_list("id", "unique_resource_count")