From c114aa304b9b6822e59fb955deb866d9c26b0cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:16:13 +0200 Subject: [PATCH 01/21] fix(api): stop locking the API key row on every authenticated request (#12882) --- .../api-key-auth-no-row-lock.fixed.md | 1 + api/src/backend/api/authentication.py | 78 +++++---- .../tests/integration/test_authentication.py | 46 +++-- .../backend/api/tests/test_authentication.py | 161 +++++++++++++++--- 4 files changed, 215 insertions(+), 71 deletions(-) create mode 100644 api/changelog.d/api-key-auth-no-row-lock.fixed.md diff --git a/api/changelog.d/api-key-auth-no-row-lock.fixed.md b/api/changelog.d/api-key-auth-no-row-lock.fixed.md new file mode 100644 index 0000000000..3d0a40260c --- /dev/null +++ b/api/changelog.d/api-key-auth-no-row-lock.fixed.md @@ -0,0 +1 @@ +API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index af1a35c7c0..2c99edfbad 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -1,4 +1,5 @@ import logging +from datetime import timedelta from math import isfinite from uuid import UUID @@ -6,7 +7,7 @@ from api.db_router import MainRouter from api.models import TenantAPIKey, TenantAPIKeyManager from cryptography.fernet import InvalidToken from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction +from django.db.models import Q from django.utils import timezone from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth from drf_simple_apikey.crypto import get_crypto @@ -18,12 +19,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication logger = logging.getLogger(__name__) +# Writing on every request makes all requests of a busy key contend on one row +API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60 + class OrphanedAPIKeyError(Exception): """Raised when an API key outlived the user that owns it. - Handled by `authenticate`, which commits the revocation written while detecting it - and then rejects the request with `AuthenticationFailed`. + The revocation is written by a plain `update()` before this is raised, so it is + already persisted by the time `authenticate` catches it and rejects the request. """ @@ -37,8 +41,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): """ Override to use admin connection, bypassing RLS during authentication. - Returns the validated API key row, locked with `select_for_update`, so callers - must run inside `transaction.atomic(using=MainRouter.admin_db)`. + Returns the validated API key row from a single read. `authenticate` builds + the auth claims from that same row instead of looking it up again, so a key + revoked or orphaned right after validation can't still authenticate. """ try: payload = self.key_crypto.decrypt(key) @@ -67,9 +72,11 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): raise AuthenticationFailed("API Key has already expired.") try: + # Loading `entity` in the same query keeps a user deleted after this read + # from turning the later `api_key.entity` access into a 500 api_key = ( self.model.objects.using(MainRouter.admin_db) - .select_for_update() + .select_related("entity") .get(id=api_key_pk) ) except ObjectDoesNotExist: @@ -85,8 +92,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): # Revoke it as well, so it stops showing up as active and later attempts fail # the `revoked` check above like any other revoked key. if api_key.entity_id is None: - api_key.revoked = True - api_key.save(update_fields=["revoked"], using=MainRouter.admin_db) + self.model.objects.using(MainRouter.admin_db).filter( + id=api_key.id, revoked=False + ).update(revoked=True) logger.warning( "Revoked orphaned API key: prefix=%s tenant=%s", api_key.prefix, @@ -112,34 +120,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): except ValueError: raise AuthenticationFailed("Invalid API Key.") - # Validation, the `last_used_at` update and the auth claims all read the same - # row, locked until the transaction ends. Looking the key up a second time to - # build the claims used to leave a window where a key revoked or orphaned right - # after passing validation still authenticated. - with transaction.atomic(using=MainRouter.admin_db): - try: - api_key = self._authenticate_credentials(request, key) - except OrphanedAPIKeyError: - # Rejected below instead of here: leaving the block normally commits - # the revocation `_authenticate_credentials` wrote, while raising from - # inside would roll it back. - pass - else: - # The prefix used to be checked by the second lookup - if api_key.prefix != prefix: - raise AuthenticationFailed("Invalid API Key.") + try: + api_key = self._authenticate_credentials(request, key) + except OrphanedAPIKeyError: + raise AuthenticationFailed("No entity matching this api key.") - api_key.last_used_at = timezone.now() - api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db) + # The prefix used to be checked by the second lookup + if api_key.prefix != prefix: + raise AuthenticationFailed("Invalid API Key.") - entity = api_key.entity - return entity, { - "tenant_id": str(api_key.tenant_id), - "sub": str(entity.id), - "api_key_prefix": api_key.prefix, - } + self._throttled_touch_last_used_at(api_key) - raise AuthenticationFailed("No entity matching this api key.") + entity = api_key.entity + return entity, { + "tenant_id": str(api_key.tenant_id), + "sub": str(entity.id), + "api_key_prefix": api_key.prefix, + } + + @staticmethod + def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None: + """Write `last_used_at` at most once per throttle interval, without locking the row.""" + now = timezone.now() + stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS) + + if api_key.last_used_at is not None and api_key.last_used_at >= stale_before: + return + + TenantAPIKey.objects.using(MainRouter.admin_db).filter( + id=api_key.id, revoked=False + ).filter( + Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before) + ).update(last_used_at=now) class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 926ba3a133..8e9040e117 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -1,9 +1,9 @@ import json -import time from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest +from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS from api.db_router import MainRouter from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship from api.signals import revoke_membership_api_keys, revoke_user_api_keys @@ -11,6 +11,7 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header from django.db.utils import ConnectionDoesNotExist from django.urls import reverse from drf_simple_apikey.crypto import get_crypto +from freezegun import freeze_time from rest_framework.test import APIClient from rest_framework_simplejwt.token_blacklist.models import ( BlacklistedToken, @@ -527,7 +528,7 @@ class TestAPIKeyAuthentication: def test_last_used_at_tracking( self, create_test_user, tenants_fixture, api_keys_fixture ): - """Verify last_used_at timestamp updates on each authentication.""" + """Verify last_used_at timestamp is set on first use and throttled after that.""" client = APIClient() api_key = api_keys_fixture[0] @@ -536,7 +537,11 @@ class TestAPIKeyAuthentication: # Use API key to authenticate api_key_headers = get_api_key_header(api_key._raw_key) - first_response = client.get(reverse("provider-list"), headers=api_key_headers) + start = datetime.now(UTC) + with freeze_time(start): + first_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) assert first_response.status_code == 200 # Reload from database and check last_used_at is set @@ -544,17 +549,23 @@ class TestAPIKeyAuthentication: first_used_at = api_key.last_used_at assert first_used_at is not None - # Use the same key again after a small delay - time.sleep(0.1) - + # Using the same key again within the throttle interval does not rewrite it second_response = client.get(reverse("provider-list"), headers=api_key_headers) assert second_response.status_code == 200 - # Reload and verify last_used_at was updated api_key.refresh_from_db() - second_used_at = api_key.last_used_at - assert second_used_at is not None - assert second_used_at > first_used_at + assert api_key.last_used_at == first_used_at + + # Past the throttle interval, the next use refreshes it + later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1) + with freeze_time(later): + third_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) + assert third_response.status_code == 200 + + api_key.refresh_from_db() + assert api_key.last_used_at > first_used_at @pytest.mark.django_db @@ -1441,6 +1452,7 @@ class TestAPIKeyRLSBypass: The update to last_used_at during authentication must also use the admin database since it occurs before RLS context is established. + Past the throttle interval, using the key again refreshes the timestamp. """ client = APIClient() api_key = api_keys_fixture[0] @@ -1448,7 +1460,11 @@ class TestAPIKeyRLSBypass: assert api_key.last_used_at is None api_key_headers = get_api_key_header(api_key._raw_key) - first_response = client.get(reverse("provider-list"), headers=api_key_headers) + start = datetime.now(UTC) + with freeze_time(start): + first_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) assert first_response.status_code == 200 @@ -1456,9 +1472,11 @@ class TestAPIKeyRLSBypass: first_timestamp = api_key.last_used_at assert first_timestamp is not None - time.sleep(0.1) - - second_response = client.get(reverse("provider-list"), headers=api_key_headers) + later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1) + with freeze_time(later): + second_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) assert second_response.status_code == 200 api_key.refresh_from_db() diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py index 782bfe670e..b01b709146 100644 --- a/api/src/backend/api/tests/test_authentication.py +++ b/api/src/backend/api/tests/test_authentication.py @@ -5,16 +5,18 @@ from uuid import uuid4 import pytest from api.authentication import ( + API_KEY_LAST_USED_AT_THROTTLE_SECONDS, OrphanedAPIKeyError, SSEAuthentication, TenantAPIKeyAuthentication, ) from api.db_router import MainRouter -from api.models import TenantAPIKey +from api.models import TenantAPIKey, User from django.db import connections from django.db.models.query import QuerySet from django.test import RequestFactory from django.test.utils import CaptureQueriesContext +from freezegun import freeze_time from rest_framework.exceptions import AuthenticationFailed @@ -286,14 +288,15 @@ class TestTenantAPIKeyAuthentication: assert str(exc_info.value.detail) == "This API Key has been revoked." - def test_authenticate_reads_the_api_key_once_under_a_row_lock( + def test_authenticate_reads_the_api_key_once_without_a_row_lock( self, auth_backend, api_keys_fixture, request_factory ): - """Test the API key is read a single time and the row is locked. + """Test the API key is read a single time and no row is locked. Validation, the `last_used_at` update and the claims must all come from the - same authoritative row: a second, unlocked lookup would reopen the window - where a key revoked in between still authenticates. + same authoritative row: a second lookup would reopen the window where a key + revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized + every request for a hot key onto one locked row and is not used any more. """ api_key = api_keys_fixture[0] @@ -310,33 +313,40 @@ class TestTenantAPIKeyAuthentication: ] assert len(api_key_selects) == 1 - assert "FOR UPDATE" in api_key_selects[0] + assert "FOR UPDATE" not in api_key_selects[0] - def test_authenticate_ignores_revocation_after_the_locked_read( + def test_authenticate_ignores_revocation_after_the_single_read( self, auth_backend, api_keys_fixture, request_factory ): """Test the claims describe the row that was validated, not a later state. Regression test: the key used to be looked up again to build the auth dict, without rechecking `revoked` or `entity`. A key revoked or orphaned between - both reads still authenticated, and the claims came from that stale row. With - a single locked read the write below cannot land mid-authentication, and the - revocation only takes effect on the next request. + both reads still authenticated, and the claims came from that stale row. + There is now only a single read, so this race is closed by construction and + the revocation only takes effect on the next request. """ api_key = api_keys_fixture[0] entity_at_validation = api_key.entity - original_save = TenantAPIKey.save + original_authenticate_credentials = ( + TenantAPIKeyAuthentication._authenticate_credentials + ) - def revoke_and_orphan_before_saving(instance, *args, **kwargs): - # Runs after validation, right before the claims are built: the exact - # window a concurrent revocation or user deletion used to slip into + def revoke_and_orphan_after_reading(self, request, key): + # Runs right after the single read `authenticate` will use to build the + # claims: the exact window a concurrent revocation used to slip into + result = original_authenticate_credentials(self, request, key) TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None) - return original_save(instance, *args, **kwargs) + return result request = request_factory.get("/") request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}" - with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving): + with patch.object( + TenantAPIKeyAuthentication, + "_authenticate_credentials", + revoke_and_orphan_after_reading, + ): entity, auth_dict = auth_backend.authenticate(request) assert entity == entity_at_validation @@ -350,6 +360,43 @@ class TestTenantAPIKeyAuthentication: assert str(exc_info.value.detail) == "This API Key has been revoked." + def test_authenticate_survives_owner_deleted_after_the_single_read( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test a user deleted right after the read does not turn into a 500. + + Without the row lock a concurrent user deletion can land between the read + and building the claims. `entity` is loaded by the same query, so no later + lookup can raise `DoesNotExist`. + """ + api_key = api_keys_fixture[0] + owner_id = api_key.entity_id + original_authenticate_credentials = ( + TenantAPIKeyAuthentication._authenticate_credentials + ) + + def delete_owner_after_reading(self, request, key): + result = original_authenticate_credentials(self, request, key) + User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete() + return result + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}" + + with patch.object( + TenantAPIKeyAuthentication, + "_authenticate_credentials", + delete_owner_after_reading, + ): + entity, auth_dict = auth_backend.authenticate(request) + + assert auth_dict["sub"] == str(owner_id) + assert entity.id == owner_id + + # From the next request on, the orphaned key is rejected with a 401 + with pytest.raises(AuthenticationFailed): + auth_backend.authenticate(request) + def test_authenticate_expired_api_key( self, auth_backend, create_test_user, tenants_fixture, request_factory ): @@ -421,24 +468,90 @@ class TestTenantAPIKeyAuthentication: if original_last_used: assert api_key.last_used_at > original_last_used - def test_authenticate_saves_to_admin_database( + def test_authenticate_updates_last_used_at_on_admin_database( self, auth_backend, api_keys_fixture, request_factory ): - """Test that the API key save operation uses admin database.""" + """Test that the `last_used_at` update runs against the admin database.""" api_key = api_keys_fixture[0] raw_key = api_key._raw_key request = request_factory.get("/") request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" - # Mock the save method to verify it's called with using='admin' - with patch.object(TenantAPIKey, "save") as mock_save: + with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured: auth_backend.authenticate(request) - # Verify save was called with using=admin_db - mock_save.assert_called_once_with( - update_fields=["last_used_at"], using=MainRouter.admin_db - ) + api_key_updates = [ + query["sql"] + for query in captured.captured_queries + if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"] + ] + + assert len(api_key_updates) == 1 + assert "last_used_at" in api_key_updates[0] + + def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that a second authentication within the throttle interval is a no-op write.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # First call sets last_used_at + auth_backend.authenticate(request) + api_key.refresh_from_db() + first_used_at = api_key.last_used_at + assert first_used_at is not None + + # Second call, still within the throttle interval, must issue no UPDATE + with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured: + auth_backend.authenticate(request) + + api_key_updates = [ + query["sql"] + for query in captured.captured_queries + if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"] + ] + assert api_key_updates == [] + + api_key.refresh_from_db() + assert api_key.last_used_at == first_used_at + + def test_authenticate_rewrites_last_used_at_after_throttle_interval( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that `last_used_at` is refreshed once it is older than the throttle interval.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + start = datetime.now(UTC) + with freeze_time(start): + auth_backend.authenticate(request) + + api_key.refresh_from_db() + first_used_at = api_key.last_used_at + assert first_used_at is not None + + later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1) + with freeze_time(later): + with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured: + auth_backend.authenticate(request) + + api_key_updates = [ + query["sql"] + for query in captured.captured_queries + if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"] + ] + assert len(api_key_updates) == 1 + + api_key.refresh_from_db() + assert api_key.last_used_at > first_used_at def test_authenticate_returns_correct_auth_dict( self, auth_backend, api_keys_fixture, request_factory From 453c953f373fb093ff7b2b0828787f06d914b94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= <98956809+puchy22@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:39:36 +0200 Subject: [PATCH 02/21] fix(api): avoid field-named annotation in attack surface aggregation (#12889) --- api/src/backend/tasks/jobs/scan.py | 6 +- api/src/backend/tasks/tests/test_scan.py | 77 ++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 26b64993bd..cc6cec5372 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -2113,7 +2113,9 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str): .annotate( total=Count("id"), failed=Count("id", filter=Q(status="FAIL", muted=False)), - muted=Count("id", filter=Q(status="FAIL", muted=True)), + # Not `muted`: an annotation named after a model field comes + # back as `muted_new` from the psqlextra queryset. + muted_count=Count("id", filter=Q(status="FAIL", muted=True)), ) ) @@ -2124,7 +2126,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str): aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0 aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0 - aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0 + aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0 overview_objects = [] for attack_surface_type, counts in aggregated_counts.items(): diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index efe36341fc..213fac207d 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -12,6 +12,7 @@ from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import ProviderConnectionError, ProviderDeletedException from api.models import ( + AttackSurfaceOverview, Finding, MuteRule, Provider, @@ -5327,8 +5328,13 @@ class TestAggregateAttackSurface: mock_queryset = MagicMock() mock_queryset.values.return_value = mock_queryset mock_queryset.annotate.return_value = [ - {"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1}, - {"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0}, + { + "check_id": "check_internet_1", + "total": 10, + "failed": 3, + "muted_count": 1, + }, + {"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0}, ] ctx = MagicMock() @@ -5377,7 +5383,7 @@ class TestAggregateAttackSurface: mock_queryset = MagicMock() mock_queryset.values.return_value = mock_queryset mock_queryset.annotate.return_value = [ - {"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0}, + {"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0}, ] ctx = MagicMock() @@ -5460,8 +5466,13 @@ class TestAggregateAttackSurface: mock_queryset = MagicMock() mock_queryset.values.return_value = mock_queryset mock_queryset.annotate.return_value = [ - {"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1}, - {"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0}, + { + "check_id": "check_internet_1", + "total": 10, + "failed": 3, + "muted_count": 1, + }, + {"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0}, ] ctx = MagicMock() @@ -5482,6 +5493,62 @@ class TestAggregateAttackSurface: assert overview.failed_findings == 5 # 3 + 2 assert overview.muted_failed_findings == 1 # 1 + 0 + @patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider") + def test_aggregate_attack_surface_counts_real_findings( + self, mock_get_mapping, tenants_fixture, scans_fixture + ): + """Run the aggregation query against real Finding rows. + + The other tests mock the queryset, so they never execute the real + `annotate`. This one guards the row keys the query returns.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + mock_get_mapping.return_value = { + "privilege-escalation": {"check_privesc_1"}, + "secrets": {"check_secrets_1"}, + } + + def create_finding(uid, check_id, status, muted): + Finding.objects.create( + tenant_id=tenant.id, + uid=uid, + scan=scan, + status=status, + status_extended="status extended", + impact=Severity.high, + severity=Severity.high, + raw_result={"status": status}, + check_id=check_id, + check_metadata={"CheckId": check_id}, + muted=muted, + first_seen_at="2024-01-02T00:00:00Z", + ) + + create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False) + create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False) + create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True) + create_finding("privesc_pass", "check_privesc_1", Status.PASS, False) + create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True) + create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False) + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + overviews = { + overview.attack_surface_type: overview + for overview in AttackSurfaceOverview.objects.filter( + tenant_id=tenant.id, scan_id=scan.id + ) + } + + assert set(overviews) == {"privilege-escalation", "secrets"} + assert overviews["privilege-escalation"].total_findings == 4 + assert overviews["privilege-escalation"].failed_findings == 2 + assert overviews["privilege-escalation"].muted_failed_findings == 1 + assert overviews["secrets"].total_findings == 1 + assert overviews["secrets"].failed_findings == 0 + assert overviews["secrets"].muted_failed_findings == 0 + @patch("tasks.jobs.scan.Scan.all_objects.select_related") @patch("tasks.jobs.scan.rls_transaction") def test_aggregate_attack_surface_uses_select_related( From d5136f364c01e928339660ba7aca320df1d42dea Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:21:13 +0200 Subject: [PATCH 03/21] perf(ui): stream the findings page and load the Finding Group filter on open (#12891) --- ui/actions/finding-groups/finding-groups.ts | 20 ++ .../__tests__/alert-form-modal.test.tsx | 5 + .../_components/findings-filters-section.tsx | 103 ++++++++++ .../_components/findings-filters-skeleton.tsx | 17 ++ ui/app/(prowler)/findings/loading.tsx | 19 ++ ui/app/(prowler)/findings/page.tsx | 121 ++++-------- .../findings-page-streaming.changed.md | 1 + ui/components/findings/findings-filters.tsx | 21 ++- .../findings/findings-filters.utils.test.ts | 22 +++ .../findings/findings-filters.utils.ts | 14 +- .../use-finding-check-options.test.ts | 170 +++++++++++++++++ .../findings/use-finding-check-options.ts | 95 ++++++++++ ui/components/shadcn/select/multiselect.tsx | 16 ++ .../data-table-filter-custom-batch.test.tsx | 92 ++++++++- .../shadcn/table/data-table-filter-custom.tsx | 18 +- ui/lib/finding-group-filter-options.test.ts | 176 +++++++++++++++++- ui/lib/finding-group-filter-options.ts | 109 +++++++++-- ui/types/filters.ts | 3 + 18 files changed, 911 insertions(+), 111 deletions(-) create mode 100644 ui/app/(prowler)/findings/_components/findings-filters-section.tsx create mode 100644 ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx create mode 100644 ui/app/(prowler)/findings/loading.tsx create mode 100644 ui/changelog.d/findings-page-streaming.changed.md create mode 100644 ui/components/findings/use-finding-check-options.test.ts create mode 100644 ui/components/findings/use-finding-check-options.ts diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index faa697cf32..26182e4443 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -14,6 +14,7 @@ import { includesMutedFindings, splitCsvFilterValues, } from "@/lib"; +import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; @@ -151,6 +152,25 @@ export const getLatestFindingGroups = async ( params: FetchFindingGroupsParams = {}, ) => fetchFindingGroupsEndpoint("finding-groups/latest", params); +/** + * Options for the "Finding Group" filter. Walks every finding-group page on the + * server, so the browser issues a single request instead of one per page + * (client-side Server Action calls are dispatched sequentially). + */ +export const getFindingGroupCheckOptions = async ({ + filters, + hasHistoricalData, +}: { + filters: Record; + hasHistoricalData: boolean; +}) => + getFindingGroupFilterOptions({ + fetchFindingGroups: hasHistoricalData + ? getFindingGroups + : getLatestFindingGroups, + filters, + }); + interface FetchFindingGroupResourcesParams { checkId: string; page?: number; diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index 4fa46494ae..991ddf7879 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -48,6 +48,11 @@ vi.mock( vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks); +// The findings filters lazily load check options through this Server Action. +vi.mock("@/actions/finding-groups", () => ({ + getFindingGroupCheckOptions: vi.fn().mockResolvedValue([]), +})); + vi.mock( "@/components/compliance/compliance-header/compliance-scan-info", () => ({ diff --git a/ui/app/(prowler)/findings/_components/findings-filters-section.tsx b/ui/app/(prowler)/findings/_components/findings-filters-section.tsx new file mode 100644 index 0000000000..b1efa7535c --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-section.tsx @@ -0,0 +1,103 @@ +import { + getFindingGroups, + getLatestFindingGroups, +} from "@/actions/finding-groups"; +import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; +import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; +import { getAllProviders } from "@/actions/providers"; +import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components"; +import { FindingsFilters } from "@/components/findings/findings-filters"; +import { createScanDetailsMapping, splitCsvFilterValues } from "@/lib"; +import { getSelectedFindingCheckOptions } from "@/lib/finding-group-filter-options"; +import { isCloud } from "@/lib/shared/env"; +import { ScanEntity, ScanProps } from "@/types"; + +interface FindingsFiltersSectionProps { + filters: Record; + resolvedFilters: Record; + hasHistoricalData: boolean; + query: string; + encodedSort?: string; + completedScans: ScanProps[]; +} + +/** + * Streams behind its own Suspense boundary: everything the filter controls + * need is fetched here, in parallel, so the table never waits for it. + */ +export async function FindingsFiltersSection({ + filters, + resolvedFilters, + hasHistoricalData, + query, + encodedSort, + completedScans, +}: FindingsFiltersSectionProps) { + const selectedCheckIds = [ + ...splitCsvFilterValues(resolvedFilters["filter[check_id]"]), + ...splitCsvFilterValues(resolvedFilters["filter[check_id__in]"]), + ]; + + const [providersData, providerGroupsData, metadataInfoData, selectedChecks] = + await Promise.all([ + getAllProviders(), + getAllProviderGroups(), + (hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo)({ + query, + sort: encodedSort, + filters: resolvedFilters, + }), + getSelectedFindingCheckOptions({ + fetchFindingGroups: hasHistoricalData + ? getFindingGroups + : getLatestFindingGroups, + filters: resolvedFilters, + selectedCheckIds, + }), + ]); + + const attributes = metadataInfoData?.data?.attributes; + const uniqueRegions = attributes?.regions || []; + const uniqueServices = attributes?.services || []; + const uniqueResourceTypes = attributes?.resource_types || []; + const uniqueCategories = attributes?.categories || []; + const uniqueGroups = attributes?.groups || []; + + const providers = providersData?.data || []; + const scanDetails = createScanDetailsMapping( + completedScans, + providersData, + ) as { [uid: string]: ScanEntity }[]; + + return ( + scan.id)} + scanDetails={scanDetails} + uniqueRegions={uniqueRegions} + uniqueServices={uniqueServices} + uniqueResourceTypes={uniqueResourceTypes} + uniqueCategories={uniqueCategories} + uniqueGroups={uniqueGroups} + checkOptionsSource={{ + filters: resolvedFilters, + hasHistoricalData, + initialOptions: selectedChecks, + }} + trailingControls={ + + } + /> + ); +} diff --git a/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx b/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx new file mode 100644 index 0000000000..50e1cd63c3 --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx @@ -0,0 +1,17 @@ +import { FILTER_CONTROL_COLUMN_CLASS } from "@/components/findings/findings-filters.utils"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; + +const FILTER_CONTROL_PLACEHOLDERS = 5; + +export const FindingsFiltersSkeleton = () => { + return ( +
+ {Array.from({ length: FILTER_CONTROL_PLACEHOLDERS }, (_, index) => ( + + ))} +
+ ); +}; diff --git a/ui/app/(prowler)/findings/loading.tsx b/ui/app/(prowler)/findings/loading.tsx new file mode 100644 index 0000000000..383bdbd297 --- /dev/null +++ b/ui/app/(prowler)/findings/loading.tsx @@ -0,0 +1,19 @@ +import { SkeletonTableFindings } from "@/components/findings/table"; +import { ContentLayout } from "@/components/shadcn/content-layout"; + +import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton"; + +export default function FindingsLoading() { + return ( + +
+ +
+ +
+ ); +} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 4a7f7d2f80..9891929f9d 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -5,12 +5,7 @@ import { getFindingGroups, getLatestFindingGroups, } from "@/actions/finding-groups"; -import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; -import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups"; -import { getAllProviders } from "@/actions/providers"; import { getScan, getScans } from "@/actions/scans"; -import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components"; -import { FindingsFilters } from "@/components/findings/findings-filters"; import { FindingsGroupTable, SkeletonTableFindings, @@ -19,17 +14,17 @@ import { ContentLayout } from "@/components/shadcn/content-layout"; import { FilterTransitionWrapper } from "@/contexts"; import { applyDefaultMutedFilter, - createScanDetailsMapping, extractFiltersAndQuery, extractSortAndKey, hasDateOrScanFilter, } from "@/lib"; -import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options"; import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; -import { isCloud } from "@/lib/shared/env"; -import { ScanEntity, ScanProps } from "@/types"; +import { ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; +import { FindingsFiltersSection } from "./_components/findings-filters-section"; +import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton"; + export default async function Findings({ searchParams, }: { @@ -39,54 +34,37 @@ export default async function Findings({ const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); - const [providersData, providerGroupsData, scansData] = await Promise.all([ - getAllProviders(), - getAllProviderGroups(), - getScans({ pageSize: 50 }), + // The page shell awaits only what both the filters and the table depend on: + // the completed scans (onboarding + scan filter) and the scan date range. + const [scansData, filtersWithScanDates] = await Promise.all([ + getScans({ + pageSize: 50, + filters: { "filter[state]": "completed" }, + fields: { + scans: "name,state,unique_resource_count,completed_at,provider", + }, + }), + resolveFindingScanDateFilters({ + filters, + scans: [], + loadScan: async (scanId: string) => { + const response = await getScan(scanId); + return response?.data; + }, + }), ]); - - const filtersWithScanDates = await resolveFindingScanDateFilters({ - filters, - scans: scansData?.data || [], - loadScan: async (scanId: string) => { - const response = await getScan(scanId); - return response?.data; - }, - }); const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates); const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates); - const metadataInfoData = await ( - hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo - )({ - query, - sort: encodedSort, - filters: resolvedFilters, - }); - const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; - const uniqueServices = metadataInfoData?.data?.attributes?.services || []; - const uniqueResourceTypes = - metadataInfoData?.data?.attributes?.resource_types || []; - const uniqueCategories = metadataInfoData?.data?.attributes?.categories || []; - const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; - const fetchFindingGroupFilterOptions = hasHistoricalData - ? getFindingGroups - : getLatestFindingGroups; - const checkOptions = await getFindingGroupFilterOptions({ - fetchFindingGroups: fetchFindingGroupFilterOptions, - filters: resolvedFilters, - }); + const completedScans: ScanProps[] = + scansData?.data?.filter( + (scan: ScanProps) => + scan.attributes.state === "completed" && + scan.attributes.unique_resource_count > 1, + ) || []; - const completedScans = scansData?.data?.filter( - (scan: ScanProps) => - scan.attributes.state === "completed" && - scan.attributes.unique_resource_count > 1, - ); - - const completedScanIds = - completedScans?.map((scan: ScanProps) => scan.id) || []; const onboardingAction = - completedScanIds.length > 0 + completedScans.length > 0 ? { flowId: "explore-findings" } : { flowId: "explore-findings", @@ -94,12 +72,6 @@ export default async function Findings({ useFallback: true, }; - const scanDetails = createScanDetailsMapping( - completedScans || [], - providersData, - ) as { [uid: string]: ScanEntity }[]; - const alertsEnabled = isCloud(); - return (
- - } - /> + }> + +
}> ): number => return true; }).length; -const FILTER_CONTROL_COLUMN_CLASS = - "min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]"; const FILTER_GRID_ITEM_CLASS = "min-w-0"; const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"]; @@ -89,7 +92,7 @@ export const FindingsFilterBatchControls = ({ uniqueResourceTypes, uniqueCategories, uniqueGroups, - checkOptions = [], + checkOptionsSource, trailingControls, appliedFilters, pendingFilters, @@ -107,6 +110,11 @@ export const FindingsFilterBatchControls = ({ }: FindingsFilterBatchControlsProps) => { const [isExpanded, setIsExpanded] = useState(false); const isAlertsEdit = variant === "alerts-edit"; + const { + options: checkOptions, + isLoading: isLoadingCheckOptions, + loadAll: loadCheckOptions, + } = useFindingCheckOptions({ source: checkOptionsSource }); const checkTitles = Object.fromEntries( checkOptions.map(({ checkId, checkTitle }) => [ checkId, @@ -118,6 +126,9 @@ export const FindingsFilterBatchControls = ({ selectedCheckIds: getFilterValue("filter[check_id]"), selectedCheckIdsIn: getFilterValue("filter[check_id__in]"), checkTitles, + lazy: checkOptionsSource + ? { onOpen: loadCheckOptions, isLoading: isLoadingCheckOptions } + : undefined, }); const customFilters = [ diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index 5b0fe99cb2..00d6b1d361 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -431,4 +431,26 @@ describe("buildFindingGroupFilterOption", () => { }), ).toBeNull(); }); + + it("keeps the Finding Group filter visible with lazy loading hooks when nothing is loaded yet", () => { + // Given + const onOpen = () => undefined; + + // When + const filter = buildFindingGroupFilterOption({ + checkOptions: [], + selectedCheckIds: [], + selectedCheckIdsIn: [], + checkTitles: {}, + lazy: { onOpen, isLoading: true }, + }); + + // Then + expect(filter).toMatchObject({ + key: "check_id__in", + values: [], + onOpen, + isLoading: true, + }); + }); }); diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 09188e65e6..830a68426f 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -17,6 +17,14 @@ export interface FindingCheckFilterOption { checkTitle?: string; } +export interface FindingGroupLazyOptions { + onOpen: () => void; + isLoading: boolean; +} + +export const FILTER_CONTROL_COLUMN_CLASS = + "min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]"; + interface GetFindingsFilterDisplayValueOptions { providers?: ProviderProps[]; scans?: Array<{ [scanId: string]: ScanEntity }>; @@ -122,11 +130,14 @@ export function buildFindingGroupFilterOption({ selectedCheckIds, selectedCheckIdsIn, checkTitles, + lazy, }: { checkOptions: FindingCheckFilterOption[]; selectedCheckIds: string[]; selectedCheckIdsIn: string[]; checkTitles: Record; + /** Keeps the dropdown visible with no values so they can load on open. */ + lazy?: FindingGroupLazyOptions; }): FilterOption | null { const values = uniqueNonEmptyValues([ ...checkOptions.map((option) => option.checkId), @@ -134,7 +145,7 @@ export function buildFindingGroupFilterOption({ ...selectedCheckIdsIn, ]); - if (values.length === 0) { + if (values.length === 0 && !lazy) { return null; } @@ -147,6 +158,7 @@ export function buildFindingGroupFilterOption({ checkTitles, }), index: 3, + ...(lazy && { onOpen: lazy.onOpen, isLoading: lazy.isLoading }), }; } diff --git a/ui/components/findings/use-finding-check-options.test.ts b/ui/components/findings/use-finding-check-options.test.ts new file mode 100644 index 0000000000..082624bc5c --- /dev/null +++ b/ui/components/findings/use-finding-check-options.test.ts @@ -0,0 +1,170 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getFindingGroupCheckOptions: vi.fn(), +})); + +vi.mock("@/actions/finding-groups", () => ({ + getFindingGroupCheckOptions: mocks.getFindingGroupCheckOptions, +})); + +import { useFindingCheckOptions } from "./use-finding-check-options"; + +const filters: Record = { + "filter[severity__in]": "high", + "filter[check_id__in]": "check-a", +}; +const selected = [{ checkId: "check-a", checkTitle: "Check A" }]; +const source = { filters, hasHistoricalData: false, initialOptions: selected }; + +describe("useFindingCheckOptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getFindingGroupCheckOptions.mockResolvedValue([]); + }); + + it("should do nothing without a source", () => { + // When + const { result } = renderHook(() => + useFindingCheckOptions({ source: undefined }), + ); + act(() => result.current.loadAll()); + + // Then + expect(mocks.getFindingGroupCheckOptions).not.toHaveBeenCalled(); + expect(result.current.options).toEqual([]); + }); + + it("should expose the selected titles without fetching anything", () => { + // When + const { result } = renderHook(() => useFindingCheckOptions({ source })); + + // Then + expect(result.current.options).toEqual(selected); + expect(mocks.getFindingGroupCheckOptions).not.toHaveBeenCalled(); + }); + + it("should load every option in one request on first open and keep the selected titles", async () => { + // Given + mocks.getFindingGroupCheckOptions.mockResolvedValue([ + { checkId: "check-b", checkTitle: "Check B" }, + ]); + const { result } = renderHook(() => useFindingCheckOptions({ source })); + + // When + act(() => result.current.loadAll()); + expect(result.current.isLoading).toBe(true); + act(() => result.current.loadAll()); + + // Then + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(1); + expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledWith({ + filters, + hasHistoricalData: false, + }); + expect(result.current.options).toEqual([ + ...selected, + { checkId: "check-b", checkTitle: "Check B" }, + ]); + }); + + it("should not reload when only the check selection changes", async () => { + // Given + const { result, rerender } = renderHook( + ({ filters }) => + useFindingCheckOptions({ + source: { filters, hasHistoricalData: false, initialOptions: [] }, + }), + { initialProps: { filters } }, + ); + act(() => result.current.loadAll()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // When + rerender({ filters: { ...filters, "filter[check_id__in]": "check-b" } }); + act(() => result.current.loadAll()); + + // Then + expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(1); + }); + + it("should forget loaded options when the surrounding filters change", async () => { + // Given + mocks.getFindingGroupCheckOptions.mockResolvedValue([ + { checkId: "check-b", checkTitle: "Check B" }, + ]); + const { result, rerender } = renderHook( + ({ filters }) => + useFindingCheckOptions({ + source: { filters, hasHistoricalData: false, initialOptions: [] }, + }), + { initialProps: { filters } }, + ); + act(() => result.current.loadAll()); + await waitFor(() => expect(result.current.options).toHaveLength(1)); + + // When + rerender({ filters: { "filter[severity__in]": "low" } }); + + // Then + expect(result.current.options).toEqual([]); + act(() => result.current.loadAll()); + await waitFor(() => + expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2), + ); + }); + + it("should drop a load that finishes after the filters changed", async () => { + // Given + let resolveStale: (options: unknown) => void = () => undefined; + mocks.getFindingGroupCheckOptions.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ filters }) => + useFindingCheckOptions({ + source: { filters, hasHistoricalData: false, initialOptions: [] }, + }), + { initialProps: { filters } }, + ); + act(() => result.current.loadAll()); + rerender({ filters: { "filter[severity__in]": "low" } }); + + // When + await act(async () => { + resolveStale([{ checkId: "stale", checkTitle: "Stale" }]); + }); + + // Then + expect(result.current.options).toEqual([]); + expect(result.current.isLoading).toBe(false); + act(() => result.current.loadAll()); + await waitFor(() => + expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2), + ); + }); + + it("should allow retrying after a failed load", async () => { + // Given + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + mocks.getFindingGroupCheckOptions.mockRejectedValueOnce(new Error("boom")); + const { result } = renderHook(() => useFindingCheckOptions({ source })); + + // When + act(() => result.current.loadAll()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + act(() => result.current.loadAll()); + + // Then + expect(consoleError).toHaveBeenCalledTimes(1); + expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2); + consoleError.mockRestore(); + }); +}); diff --git a/ui/components/findings/use-finding-check-options.ts b/ui/components/findings/use-finding-check-options.ts new file mode 100644 index 0000000000..0a779eae30 --- /dev/null +++ b/ui/components/findings/use-finding-check-options.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; + +import { getFindingGroupCheckOptions } from "@/actions/finding-groups"; +import { excludeFindingGroupOwnFilters } from "@/lib/finding-group-filter-options"; + +import type { FindingCheckFilterOption } from "./findings-filters.utils"; + +const LOAD_STATUS = { + IDLE: "idle", + LOADING: "loading", + LOADED: "loaded", +} as const; + +type LoadStatus = (typeof LOAD_STATUS)[keyof typeof LOAD_STATUS]; + +export interface FindingCheckOptionsSource { + /** Applied filters; the check filter itself is ignored when loading options. */ + filters: Record; + hasHistoricalData: boolean; + /** Titles of the checks already selected, so their chips read well before the list loads. */ + initialOptions: FindingCheckFilterOption[]; +} + +interface LoadState { + /** The filters the options were loaded for; a mismatch means they are stale. */ + key: string; + status: LoadStatus; + options: FindingCheckFilterOption[]; +} + +interface UseFindingCheckOptionsParams { + /** Undefined disables lazy loading. */ + source?: FindingCheckOptionsSource; +} + +const idleState = (key: string): LoadState => ({ + key, + status: LOAD_STATUS.IDLE, + options: [], +}); + +const toFiltersKey = (filters: Record) => + JSON.stringify(Object.entries(excludeFindingGroupOwnFilters(filters)).sort()); + +function mergeOptions( + current: FindingCheckFilterOption[], + incoming: FindingCheckFilterOption[], +): FindingCheckFilterOption[] { + const byId = new Map(current.map((option) => [option.checkId, option])); + for (const option of incoming) byId.set(option.checkId, option); + return Array.from(byId.values()); +} + +/** Loads the check filter options the first time the dropdown opens. */ +export function useFindingCheckOptions({ + source, +}: UseFindingCheckOptionsParams) { + const filtersKey = source ? toFiltersKey(source.filters) : ""; + // Local state needed: options load on demand, after the first open. + const [load, setLoad] = useState(() => idleState(filtersKey)); + // Derived: a load for other filters counts as nothing loaded. + const current = load.key === filtersKey ? load : idleState(filtersKey); + + const loadAll = () => { + if (!source || current.status !== LOAD_STATUS.IDLE) return; + + const requestKey = filtersKey; + setLoad({ key: requestKey, status: LOAD_STATUS.LOADING, options: [] }); + getFindingGroupCheckOptions({ + filters: source.filters, + hasHistoricalData: source.hasHistoricalData, + }) + .then((options) => { + setLoad((previous) => + previous.key === requestKey + ? { key: requestKey, status: LOAD_STATUS.LOADED, options } + : previous, + ); + }) + .catch((error) => { + console.error("Error fetching finding group filter options:", error); + setLoad((previous) => + previous.key === requestKey ? idleState(requestKey) : previous, + ); + }); + }; + + return { + options: mergeOptions(source?.initialOptions ?? [], current.options), + isLoading: current.status === LOAD_STATUS.LOADING, + loadAll, + }; +} diff --git a/ui/components/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index 45c91fa6ef..2ddbaadaee 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -384,6 +384,22 @@ export function MultiSelectContent({ ); } +/** Status row shown under the items while more values are still loading. */ +export function MultiSelectLoading({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + export function MultiSelectItem({ value, children, diff --git a/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx b/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx index 21e8f384a5..e1465be8bb 100644 --- a/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx +++ b/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx @@ -31,12 +31,23 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ children, values, onValuesChange, + open, + onOpenChange, }: { children: React.ReactNode; values?: string[]; onValuesChange?: (values: string[]) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) => ( -
+
+ {children} {/* expose a select to drive value changes in tests */} -
@@ -242,7 +242,10 @@ export const SelectModel = ({ {model.name} {isRecommended(model.id) && ( - + )} diff --git a/ui/components/providers/workflow/forms/fields/wizard-input-field.tsx b/ui/components/providers/workflow/forms/fields/wizard-input-field.tsx index bcfd945807..aa3c06c73f 100644 --- a/ui/components/providers/workflow/forms/fields/wizard-input-field.tsx +++ b/ui/components/providers/workflow/forms/fields/wizard-input-field.tsx @@ -1,6 +1,6 @@ "use client"; -import { Icon } from "@iconify/react"; +import { Eye, EyeOff } from "lucide-react"; import { InputHTMLAttributes, useState } from "react"; import { Control, FieldPath, FieldValues } from "react-hook-form"; @@ -149,16 +149,19 @@ export const WizardInputField = ({ : "Hide password" } > - + {(password && isPasswordVisible) || + (confirmPassword && isConfirmPasswordVisible) || + (type === "password" && isPasswordVisible) ? ( +
diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx index a0beb2cb4c..8e9a6e7958 100644 --- a/ui/components/providers/workflow/forms/test-connection-form.tsx +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -1,8 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Icon } from "@iconify/react"; -import { Loader2 } from "lucide-react"; +import { CircleAlert, Loader2 } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -176,8 +175,7 @@ export const TestConnectionForm = ({ className="border-border-error flex items-start gap-4 rounded-lg border p-4" >
-
diff --git a/ui/components/registry/registry-explorer.integration.test.tsx b/ui/components/registry/registry-explorer.integration.test.tsx index ec19034181..4641c873c3 100644 --- a/ui/components/registry/registry-explorer.integration.test.tsx +++ b/ui/components/registry/registry-explorer.integration.test.tsx @@ -752,10 +752,31 @@ describe("RegistryExplorer", () => { expect(document.body.textContent).not.toContain( "preserved tenant artifact", ); - expect(document.body.textContent).toContain("Explore Prowler Registry"); + expect(document.body.textContent).not.toContain( + "Explore Prowler Registry", + ); expect(document.body.textContent).not.toContain("Search artifacts"); }); + it("links the banner to the configured Registry", async () => { + // Given + const screen = await render( + , + ); + + // Then + await expect + .element( + screen.getByRole("link", { + name: "Explore Prowler Registry (opens in a new tab)", + }), + ) + .toHaveAttribute("href", "https://registry.internal.test/"); + }); + it("lets a replacement key supersede a pending validation from the banner", async () => { // Given: a validation that never settled must not dead-end the user submitRegistryCredentialMock.mockResolvedValue(submittedResult(true)); @@ -825,7 +846,7 @@ describe("RegistryExplorer", () => { const screen = await render( , ); @@ -840,7 +861,7 @@ describe("RegistryExplorer", () => { .toBeVisible(); await expect .element(screen.getByRole("link", { name: "Where do I find my key?" })) - .toHaveAttribute("href", "https://registry.private.test/keys"); + .toHaveAttribute("href", "https://registry.private.test/"); // When await screen.getByRole("button", { name: "Cancel", exact: true }).click(); diff --git a/ui/components/registry/registry-explorer.tsx b/ui/components/registry/registry-explorer.tsx index 84cad6e893..8ac02f93ec 100644 --- a/ui/components/registry/registry-explorer.tsx +++ b/ui/components/registry/registry-explorer.tsx @@ -107,12 +107,12 @@ function mutationFailureMessage(result: RegistryMutationResult) { interface RegistryExplorerProps { initialState: RegistryBootstrapState; - registryKeyUrl?: string; + registryUrl?: string; } export function RegistryExplorer({ initialState, - registryKeyUrl, + registryUrl, }: RegistryExplorerProps) { // The API is the sole access authority: a denied action result routes to // Profile once, and the navigation unmounts this component with its state. @@ -464,7 +464,7 @@ export function RegistryExplorer({ } const accessDialogProps = { - registryKeyUrl, + registryUrl, errorMessage: operationMessage, onOpenChange: (open: boolean) => { if (!open && pendingOperation !== REGISTRY_PENDING_OPERATION.CREDENTIAL) { @@ -504,6 +504,7 @@ export function RegistryExplorer({ )} setAccessDialogMode(REGISTRY_ACCESS_DIALOG_MODE.CONNECT) } diff --git a/ui/components/shadcn/action-card/ActionCard.tsx b/ui/components/shadcn/action-card/ActionCard.tsx index 5359cc176c..3c00913216 100644 --- a/ui/components/shadcn/action-card/ActionCard.tsx +++ b/ui/components/shadcn/action-card/ActionCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { Icon } from "@iconify/react"; +import type { ReactElement } from "react"; import { Card, @@ -40,7 +40,7 @@ const COLOR_STYLES = { } as const; export type ActionCardProps = CardProps & { - icon: string; + icon: ReactElement; title: string; color?: "success" | "secondary" | "warning" | "fail"; description: string; @@ -76,7 +76,12 @@ export const ActionCard = ({ colors.iconWrapper, )} > - +

{title}

diff --git a/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.test.tsx b/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.test.tsx index 5d85f092ec..33b37d7304 100644 --- a/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.test.tsx +++ b/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.test.tsx @@ -12,10 +12,6 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), })); -vi.mock("@iconify/react", () => ({ - Icon: ({ icon }: { icon: string }) => , -})); - describe("BreadcrumbNavigation", () => { afterEach(() => { navigationMock.pathname = "/findings"; @@ -40,22 +36,36 @@ describe("BreadcrumbNavigation", () => { ).toBeInTheDocument(); }); - it("does not render icons for secondary breadcrumb items", () => { - // Given - navigationMock.pathname = "/scans/config"; - - // When + it("renders the page icon next to a top-level title", () => { + // Given / When render( } />, ); // Then - expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument(); - expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument(); + expect(screen.getByTestId("page-icon")).toBeInTheDocument(); + }); + + it("shows the bundled section icon only on the first breadcrumb", () => { + // Given + navigationMock.pathname = "/scans/config"; + + // When + const { container } = render( + } + />, + ); + + // Then + expect(container.querySelector("svg.lucide-timer")).toBeInTheDocument(); + expect(screen.queryByTestId("page-icon")).not.toBeInTheDocument(); expect( screen.getByRole("heading", { name: "Configuration" }), ).toBeInTheDocument(); diff --git a/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx index 6121ee6f42..302dd0c571 100644 --- a/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx +++ b/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx @@ -1,9 +1,23 @@ "use client"; -import { Icon } from "@iconify/react"; +import { + BellRing, + Cloud, + Database, + GitBranch, + Key, + Layers, + Puzzle, + Search, + Server, + ShieldCheck, + Timer, + Users, + UsersRound, +} from "lucide-react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; -import { ReactNode } from "react"; +import type { ReactElement, ReactNode } from "react"; import { LighthouseIcon } from "@/components/icons/Icons"; import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url"; @@ -12,7 +26,7 @@ import { cn } from "@/lib/utils"; export interface CustomBreadcrumbItem { name: string; path?: string; - icon?: string | ReactNode; + icon?: ReactElement; isLast?: boolean; isClickable?: boolean; onClick?: () => void; @@ -21,7 +35,7 @@ export interface CustomBreadcrumbItem { interface BreadcrumbNavigationProps { mode?: "auto" | "custom" | "hybrid"; title?: string; - icon?: string | ReactNode; + icon?: ReactElement; titleAction?: ReactNode; customItems?: CustomBreadcrumbItem[]; className?: string; @@ -43,21 +57,21 @@ export function BreadcrumbNavigation({ const searchParams = useSearchParams(); const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => { - const pathIconMapping: Record = { - "/integrations": "lucide:puzzle", - "/alerts": "lucide:bell-ring", - "/providers": "lucide:cloud", - "/users": "lucide:users", - "/compliance": "lucide:shield-check", - "/findings": "lucide:search", - "/scans": "lucide:timer", - "/roles": "lucide:key", - "/resources": "lucide:database", + const pathIconMapping: Record = { + "/integrations": , + "/alerts": , + "/providers": , + "/users": , + "/compliance": , + "/findings": , + "/scans": , + "/roles": , + "/resources": , "/lighthouse": , - "/manage-groups": "lucide:users-2", - "/services": "lucide:server", - "/workloads": "lucide:layers", - "/attack-paths": "lucide:git-branch", + "/manage-groups": , + "/services": , + "/workloads": , + "/attack-paths": , }; const pathSegments = pathname @@ -116,15 +130,8 @@ export function BreadcrumbNavigation({ showIcon: boolean = true, ) => (
- {showIcon && typeof icon === "string" ? ( - - ) : showIcon && icon ? ( -
+ {showIcon && icon ? ( +
{icon}
) : null} @@ -170,20 +177,10 @@ export function BreadcrumbNavigation({ href={buildNavigationUrl(breadcrumb.path)} className="flex cursor-pointer items-center gap-2" > - {index === 0 && - breadcrumb.icon && - typeof breadcrumb.icon === "string" ? ( -
diff --git a/ui/components/shadcn/headers/navigation-header.tsx b/ui/components/shadcn/headers/navigation-header.tsx index 99acb2b368..3418d88adf 100644 --- a/ui/components/shadcn/headers/navigation-header.tsx +++ b/ui/components/shadcn/headers/navigation-header.tsx @@ -1,12 +1,12 @@ -import { Icon } from "@iconify/react"; import Link from "next/link"; +import type { ReactElement } from "react"; import { Button } from "@/components/shadcn/button/button"; import { Separator } from "@/components/shadcn/separator/separator"; interface NavigationHeaderProps { title: string; - icon: string; + icon: ReactElement; href?: string; } @@ -25,8 +25,8 @@ export const NavigationHeader = ({ size="icon" asChild > - - + + {icon} diff --git a/ui/dependency-log.json b/ui/dependency-log.json index af0cf7a367..8db88c384b 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -663,14 +663,6 @@ "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, - { - "section": "devDependencies", - "name": "@iconify/react", - "from": "5.2.1", - "to": "5.2.1", - "strategy": "installed", - "generatedAt": "2025-10-22T12:36:37.962Z" - }, { "section": "devDependencies", "name": "@next/eslint-plugin-next", diff --git a/ui/eslint.config.ts b/ui/eslint.config.ts index dc0658649f..f57c2fa486 100644 --- a/ui/eslint.config.ts +++ b/ui/eslint.config.ts @@ -105,6 +105,21 @@ export default tseslint.config( "security/detect-object-injection": "off", + // Icons must ship in the bundle: air-gapped deployments cannot reach + // runtime icon APIs. + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@iconify/*"], + message: + "Iconify loads icons over the network. Use lucide-react or components/icons.", + }, + ], + }, + ], + "eol-last": ["error", "always"], "import-x/order": [ diff --git a/ui/lib/csp.ts b/ui/lib/csp.ts index 8f037482e8..854d94bef7 100644 --- a/ui/lib/csp.ts +++ b/ui/lib/csp.ts @@ -67,7 +67,7 @@ export function getCspHeader({ return ` default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}${toolbarUiSource}; - connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource}; + connect-src 'self' https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource}; img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${registryImageOrigins.map((origin) => ` ${origin}`).join("")}${posthogSource}${toolbarUiSource}; font-src 'self'${toolbarPosthogSource}; style-src 'self' 'unsafe-inline'${toolbarPosthogSource}; diff --git a/ui/lib/registry/presentation.test.ts b/ui/lib/registry/presentation.test.ts index 847fe1dc99..8cf103679f 100644 --- a/ui/lib/registry/presentation.test.ts +++ b/ui/lib/registry/presentation.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { getRegistryPresentation } from "./presentation"; +import { + getRegistryPresentation, + readRegistryPresentation, +} from "./presentation"; describe("Registry presentation configuration", () => { const urlWithCredentials = new URL("https://registry.test"); @@ -10,11 +13,11 @@ describe("Registry presentation configuration", () => { it("uses the configured Registry and media origins", () => { expect( getRegistryPresentation( - "https://registry.private.test/keys", + "https://registry.private.test/", "https://assets.private.test/media/", ), ).toEqual({ - keyUrl: "https://registry.private.test/keys", + registryUrl: "https://registry.private.test/", imageOrigins: [ "https://registry.private.test", "https://assets.private.test", @@ -24,7 +27,7 @@ describe("Registry presentation configuration", () => { it("does not guess a Registry environment when configuration is missing", () => { expect(getRegistryPresentation()).toEqual({ - keyUrl: undefined, + registryUrl: undefined, imageOrigins: [], }); }); @@ -36,7 +39,41 @@ describe("Registry presentation configuration", () => { "invalid", ])("rejects unsafe configuration: %s", (value) => { expect(getRegistryPresentation(value, value)).toEqual({ - keyUrl: undefined, + registryUrl: undefined, + imageOrigins: [], + }); + }); +}); + +describe("Registry presentation from the runtime environment", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("links to the Registry the backend installs from", () => { + // Given + vi.stubEnv("PROWLER_REGISTRY_INDEX_URL", "https://registry.internal.test"); + vi.stubEnv("UI_REGISTRY_MEDIA_URL", "https://media.internal.test"); + + // When / Then + expect(readRegistryPresentation()).toEqual({ + registryUrl: "https://registry.internal.test/", + imageOrigins: [ + "https://registry.internal.test", + "https://media.internal.test", + ], + }); + }); + + it("ignores the retired UI_REGISTRY_URL variable", () => { + // Given + vi.stubEnv("PROWLER_REGISTRY_INDEX_URL", ""); + vi.stubEnv("UI_REGISTRY_MEDIA_URL", ""); + vi.stubEnv("UI_REGISTRY_URL", "https://registry.prowler.com"); + + // When / Then + expect(readRegistryPresentation()).toEqual({ + registryUrl: undefined, imageOrigins: [], }); }); diff --git a/ui/lib/registry/presentation.ts b/ui/lib/registry/presentation.ts index 91a8ac5656..19547eeb12 100644 --- a/ui/lib/registry/presentation.ts +++ b/ui/lib/registry/presentation.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + /** Accept public HTTP URLs only; never expose URL credentials or CSP syntax. */ function parsePublicUrl(value?: string | null): URL | undefined { if (!value || /[\s;]/.test(value)) return; @@ -21,7 +23,7 @@ export function getRegistryPresentation( const registry = parsePublicUrl(registryUrl); const media = parsePublicUrl(mediaUrl); return { - keyUrl: registry?.href, + registryUrl: registry?.href, imageOrigins: Array.from( new Set( [registry?.origin, media?.origin].filter((origin): origin is string => @@ -31,3 +33,11 @@ export function getRegistryPresentation( ), }; } + +/** Same Registry base URL the backend installs artifacts from. */ +export function readRegistryPresentation() { + return getRegistryPresentation( + readEnv("PROWLER_REGISTRY_INDEX_URL"), + readEnv("UI_REGISTRY_MEDIA_URL"), + ); +} diff --git a/ui/next.config.test.ts b/ui/next.config.test.ts index a464bb3602..90f58dde5d 100644 --- a/ui/next.config.test.ts +++ b/ui/next.config.test.ts @@ -33,9 +33,6 @@ const BASELINE_CSP = { ], "connect-src": [ "'self'", - "https://api.iconify.design", - "https://api.simplesvg.com", - "https://api.unisvg.com", "https://js.stripe.com", "https://www.googletagmanager.com", "https://*.sentry.io", diff --git a/ui/package.json b/ui/package.json index c0778f307d..bc72f8f318 100644 --- a/ui/package.json +++ b/ui/package.json @@ -121,7 +121,6 @@ "zustand": "5.0.8" }, "devDependencies": { - "@iconify/react": "5.2.1", "@next/eslint-plugin-next": "16.2.9", "@playwright/test": "1.56.1", "@testing-library/jest-dom": "6.9.1", diff --git a/ui/playwright.registry.config.ts b/ui/playwright.registry.config.ts index 28df0c7d66..2ba76d136e 100644 --- a/ui/playwright.registry.config.ts +++ b/ui/playwright.registry.config.ts @@ -25,7 +25,7 @@ const registryFixtureUiServer = ( UI_API_BASE_URL: registryFixtureApiUrl, UI_CLOUD_ENABLED: String(cloudEnabled), UI_REGISTRY_ENABLED: String(registryEnabled), - UI_REGISTRY_URL: "https://registry.dev.prowler.com", + PROWLER_REGISTRY_INDEX_URL: "https://registry.dev.prowler.com", UI_REGISTRY_MEDIA_URL: "https://media.registry.dev.prowler.com", CLOUD_BILLING_ENABLED: "false", }, diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 6ae1c07bdf..088af80569 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -301,9 +301,6 @@ importers: specifier: 5.0.8 version: 5.0.8(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: - '@iconify/react': - specifier: 5.2.1 - version: 5.2.1(react@19.2.7) '@next/eslint-plugin-next': specifier: 16.2.9 version: 16.2.9 @@ -1094,11 +1091,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iconify/react@5.2.1': - resolution: {integrity: sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==} - peerDependencies: - react: '>=16' - '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -8308,11 +8300,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iconify/react@5.2.1(react@19.2.7)': - dependencies: - '@iconify/types': 2.0.0 - react: 19.2.7 - '@iconify/types@2.0.0': {} '@iconify/utils@3.1.0': diff --git a/ui/proxy.ts b/ui/proxy.ts index b26008ddfc..24c15623a6 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -14,7 +14,7 @@ import { } from "@/lib/integrations/slack-connect-status"; import { REGISTRY_ACCESS } from "@/lib/registry/access"; import { evaluateRegistryAccess } from "@/lib/registry/access.server"; -import { getRegistryPresentation } from "@/lib/registry/presentation"; +import { readRegistryPresentation } from "@/lib/registry/presentation"; import { readEnv } from "@/lib/runtime-env"; import { isCloud } from "@/lib/shared/env"; import { copyAttributionParams } from "@/lib/utm"; @@ -38,10 +38,7 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => { "Content-Security-Policy", getCspHeader({ cloudEnabled: isCloud(), - registryImageOrigins: getRegistryPresentation( - readEnv("UI_REGISTRY_URL"), - readEnv("UI_REGISTRY_MEDIA_URL"), - ).imageOrigins, + registryImageOrigins: readRegistryPresentation().imageOrigins, posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog), posthogKey: readGatedEnv( "UI_POSTHOG_ENABLED", diff --git a/ui/tests/registry/validation.md b/ui/tests/registry/validation.md index e0e9835e22..e73d51c049 100644 --- a/ui/tests/registry/validation.md +++ b/ui/tests/registry/validation.md @@ -31,14 +31,14 @@ See [the scenario catalog](registry.md) and [the Add Provider tour report](add-p Set these runtime variables on the UI service to match the Registry used by the backend: -| Variable | Purpose | Development Example | -| ----------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `UI_REGISTRY_URL` | Public Registry website or key-management page. Supplies the help link and permits images from its origin. | `https://registry.dev.prowler.com` | -| `UI_REGISTRY_MEDIA_URL` | Registry media service. Only its HTTP(S) origin is added to `img-src`. | `https://media.registry.dev.prowler.com` | +| Variable | Purpose | Development Example | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `PROWLER_REGISTRY_INDEX_URL` | Registry base URL shared with the backend installer. Supplies the help links and permits images from its origin. | `https://registry.dev.prowler.com` | +| `UI_REGISTRY_MEDIA_URL` | Registry media service. Only its HTTP(S) origin is added to `img-src`. | `https://media.registry.dev.prowler.com` | -For production, use `https://registry.prowler.com` and `https://media.registry.prowler.com`. For a private Registry, use its website and media service URLs. These settings do not change the backend's Registry API endpoint. Keep both services aligned in deployment configuration: the current backend contract does not expose its Registry website URL to the UI. +For production, use `https://registry.prowler.com` and `https://media.registry.prowler.com`. For a private Registry, use its website and media service URLs. `PROWLER_REGISTRY_INDEX_URL` is the same variable the backend reads, so one value in the shared `.env` configures both services. -The help link is hidden when its URL is missing or invalid, so the UI cannot send a private Registry user to production by default. URLs containing credentials, non-HTTP schemes, or CSP separators are rejected. Unconfigured external images fall back to the owner initial. +The help links are hidden when the URL is missing or invalid, so the UI cannot send a private Registry user to production by default. URLs containing credentials, non-HTTP schemes, or CSP separators are rejected. Unconfigured external images fall back to the owner initial. Acceptance profiles and fixture servers live in `playwright.registry.config.ts`, with common defaults in `playwright.base.ts`. The existing `pnpm run test:e2e:registry` command selects that configuration. The general Playwright configuration runs the ordinary suites without Registry fixtures. diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts index 1f0315284c..b2532f6b95 100644 --- a/ui/types/env.d.ts +++ b/ui/types/env.d.ts @@ -31,6 +31,9 @@ declare global { // Prowler Cloud deployment flag — runtime read (server env, client island). UI_CLOUD_ENABLED?: "true" | "false"; UI_REGISTRY_ENABLED?: "true" | "false"; + // Registry base URL, shared with the backend installer. + PROWLER_REGISTRY_INDEX_URL?: string; + UI_REGISTRY_MEDIA_URL?: string; CLOUD_BILLING_ENABLED?: "legacy" | "metronome" | "false"; diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 39d01398a2..d4485dc474 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -147,7 +147,6 @@ export default defineConfig(() => { "next-themes", // App component lib - "@iconify/react", "react-day-picker", "posthog-js", "posthog-js/react", From 03502c24265b5eb898190282d2d92d69b863139a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 28 Sep 2026 17:15:39 +0200 Subject: [PATCH 07/21] fix(api): require operation permission to revoke tasks (#12893) --- .../task-revoke-permissions.security.md | 1 + api/src/backend/api/rbac/permissions.py | 77 ++++- api/src/backend/api/specs/v1.yaml | 11 +- api/src/backend/api/tests/test_views.py | 301 ++++++++++++++++++ api/src/backend/api/v1/views.py | 45 ++- .../user-guide/tutorials/prowler-app-rbac.mdx | 6 + 6 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 api/changelog.d/task-revoke-permissions.security.md diff --git a/api/changelog.d/task-revoke-permissions.security.md b/api/changelog.d/task-revoke-permissions.security.md new file mode 100644 index 0000000000..2497621e99 --- /dev/null +++ b/api/changelog.d/task-revoke-permissions.security.md @@ -0,0 +1 @@ +`DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index e2c209a990..4d3eeb64f9 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -1,7 +1,7 @@ from enum import Enum from api.db_router import MainRouter -from api.models import Integration, Provider, Role, User +from api.models import Integration, Provider, Role, Task, User from django.db.models import Q, QuerySet from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission @@ -17,6 +17,50 @@ class Permissions(Enum): UNLIMITED_VISIBILITY = "unlimited_visibility" +# Revoking a task needs the permission of the operation that queued it. +# None and unmapped names are not revocable; a revoked provider deletion +# would leave the provider soft-deleted with nothing re-queuing the cleanup. +TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = { + "provider-connection-check": [Permissions.MANAGE_PROVIDERS], + "provider-deletion": None, + "integration-connection-check": [Permissions.MANAGE_INTEGRATIONS], + "integration-s3": [Permissions.MANAGE_INTEGRATIONS], + "integration-security-hub": [Permissions.MANAGE_INTEGRATIONS], + "integration-jira": [Permissions.MANAGE_INTEGRATIONS], + "scan-perform": [Permissions.MANAGE_SCANS], + "scan-perform-scheduled": [Permissions.MANAGE_SCANS], + "scan-compliance-overviews": [Permissions.MANAGE_SCANS], + "scan-compliance-reports": [Permissions.MANAGE_SCANS], + "scan-finding-group-summaries": [Permissions.MANAGE_SCANS], + "scan-report": [Permissions.MANAGE_SCANS], + "attack-paths-scan-perform": [Permissions.MANAGE_SCANS], + "findings-mute-latest-scans": [Permissions.MANAGE_SCANS], + "lighthouse-connection-check": [], + "lighthouse-provider-connection-check": [], + "lighthouse-provider-models-refresh": [], +} + + +def get_user_roles(user: User, tenant_id: str) -> list[Role]: + """Return every role assigned to the user in the tenant.""" + return list( + User.objects.using(MainRouter.admin_db) + .get(id=user.id) + .roles.using(MainRouter.admin_db) + .filter(tenant_id=tenant_id) + ) + + +def roles_have_permissions( + roles: list[Role], required_permissions: list[Permissions] +) -> bool: + """Return True when every required permission is granted by at least one role.""" + return all( + any(getattr(role, permission.value, False) for role in roles) + for permission in required_permissions + ) + + class HasPermissions(BasePermission): """ Custom permission to check if the user's role has the required permissions. @@ -34,19 +78,11 @@ class HasPermissions(BasePermission): if not tenant_id: return False - user_roles = list( - User.objects.using(MainRouter.admin_db) - .get(id=request.user.id) - .roles.using(MainRouter.admin_db) - .filter(tenant_id=tenant_id) - ) + user_roles = get_user_roles(request.user, tenant_id) if not user_roles: return False - return all( - any(getattr(role, permission.value, False) for role in user_roles) - for permission in required_permissions - ) + return roles_have_permissions(user_roles, required_permissions) def get_role(user: User, tenant_id: str) -> Role: @@ -85,6 +121,25 @@ def get_providers(role: Role) -> QuerySet[Provider]: ).distinct() +def get_tasks(role: Role) -> QuerySet[Task]: + """Return the tasks visible to the role: tenant-wide ones and those of its providers.""" + queryset = Task.objects.filter(tenant_id=role.tenant_id) + if role.unlimited_visibility: + return queryset + + # Task has no provider FK, so match provider ids inside the stored kwargs. + # all_objects keeps a soft-deleted provider visible to its own groups, so the + # role that queued its deletion can still follow the task. + hidden = Q() + for provider_id in ( + Provider.all_objects.filter(tenant_id=role.tenant_id) + .exclude(provider_groups__in=role.provider_groups.all()) + .values_list("id", flat=True) + ): + hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id)) + return queryset.exclude(hidden) if hidden else queryset + + def get_integrations( role: Role, providers: QuerySet[Provider] | None = None ) -> QuerySet[Integration]: diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 78b46029bf..b1a66fee23 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -14823,7 +14823,9 @@ paths: get: operationId: api_v1_tasks_list description: Retrieve a list of all tasks with options for filtering by name, - state, and other criteria. + state, and other criteria. Tasks that reference a provider are only returned + when the role can access it; tasks without a provider reference are returned + for every role. summary: List all tasks parameters: - in: query @@ -14922,7 +14924,8 @@ paths: /api/v1/tasks/{id}: get: operationId: api_v1_tasks_retrieve - description: Fetch detailed information about a specific task by its ID. + description: Fetch detailed information about a specific task by its ID. Tasks + tied to a provider outside the visibility of the role are not found. summary: Retrieve data from a specific task parameters: - in: query @@ -14963,7 +14966,9 @@ paths: delete: operationId: api_v1_tasks_destroy description: Try to revoke a task using its ID. Only tasks that are not yet - in progress can be revoked. + in progress can be revoked, and the caller needs the same permission as the + operation that queued the task (for example MANAGE_SCANS for a scan). Provider + deletions cannot be revoked. summary: Revoke a task parameters: - in: path diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 64a2250082..55ef891386 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -60,6 +60,7 @@ from api.models import ( User, UserRoleRelationship, ) +from api.rbac.permissions import TASK_REVOKE_PERMISSIONS from api.rls import Tenant from api.uuid_utils import datetime_to_uuid7 from api.v1.views import ( @@ -5239,6 +5240,7 @@ class TestTaskViewSet: @patch("api.v1.views.AsyncResult", return_value=Mock()) def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture): _, task2 = tasks_fixture + self._set_task_name(task2, "scan-perform") response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": task2.id}) ) @@ -5254,12 +5256,311 @@ class TestTaskViewSet: def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture): task1, _ = tasks_fixture + self._set_task_name(task1, "scan-perform") response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": task1.id}) ) # Task status is SUCCESS assert response.status_code == status.HTTP_400_BAD_REQUEST + @staticmethod + def _set_task_name(task, name): + task.task_runner_task.task_name = name + task.task_runner_task.save(update_fields=["task_name"]) + + @staticmethod + def _set_task_kwargs(task, kwargs): + task.task_runner_task.task_kwargs = json.dumps(repr(kwargs)) + task.task_runner_task.save(update_fields=["task_kwargs"]) + + @staticmethod + def _client_with_role(tenant, factory, **permissions): + user = User.objects.create_user( + name=f"revoker-{uuid4()}", + email=f"revoker-{uuid4()}@prowler.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER + ) + flags = { + "manage_users": False, + "manage_account": False, + "manage_billing": False, + "manage_providers": False, + "manage_integrations": False, + "manage_scans": False, + "unlimited_visibility": True, + **permissions, + } + role = Role.objects.create( + name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id) + return factory(user, tenant) + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_without_permission_is_forbidden( + self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture + ): + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "provider-connection-check") + + response = authenticated_client_no_permissions_rbac.delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_async_result.return_value.revoke.assert_not_called() + + @pytest.mark.parametrize( + "task_name, permissions, expected_status", + [ + ( + "provider-connection-check", + {"manage_providers": True}, + status.HTTP_202_ACCEPTED, + ), + ( + "provider-connection-check", + {"manage_scans": True}, + status.HTTP_403_FORBIDDEN, + ), + ("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED), + ( + "scan-perform-scheduled", + {"manage_providers": True}, + status.HTTP_403_FORBIDDEN, + ), + ( + "integration-jira", + {"manage_integrations": True}, + status.HTTP_202_ACCEPTED, + ), + ("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN), + ("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED), + ], + ) + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_requires_originating_operation_permission( + self, + mock_async_result, + authenticated_client_for_tenant_factory, + tenants_fixture, + tasks_fixture, + task_name, + permissions, + expected_status, + ): + tenant, *_ = tenants_fixture + _, pending_task = tasks_fixture + self._set_task_name(pending_task, task_name) + client = self._client_with_role( + tenant, authenticated_client_for_tenant_factory, **permissions + ) + + response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id})) + + assert response.status_code == expected_status + if expected_status == status.HTTP_202_ACCEPTED: + mock_async_result.return_value.revoke.assert_called_once() + else: + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin( + self, mock_async_result, authenticated_client, tasks_fixture + ): + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "provider-deletion") + + response = authenticated_client.delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_unmapped_task_is_forbidden( + self, mock_async_result, authenticated_client, tasks_fixture + ): + _, pending_task = tasks_fixture + assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS + + response = authenticated_client.delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_async_result.return_value.revoke.assert_not_called() + + def test_every_rls_task_has_revoke_permissions(self): + from config.celery import RLSTask, celery_app + + rls_task_names = { + name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask) + } + assert rls_task_names + assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS) + + @patch("api.v1.views.AsyncResult") + def test_tasks_hidden_for_providers_outside_role_visibility( + self, + mock_async_result, + authenticated_client_no_permissions_rbac, + tasks_fixture, + aws_provider_pair, + ): + client = authenticated_client_no_permissions_rbac + limited_user = client.user + tenant = Membership.objects.filter(user=limited_user).first().tenant + allowed_provider, denied_provider = aws_provider_pair + allowed_task, denied_task = tasks_fixture + self._set_task_kwargs( + allowed_task, + {"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)}, + ) + self._set_task_name(denied_task, "provider-deletion") + self._set_task_kwargs( + denied_task, + {"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)}, + ) + provider_group = ProviderGroup.objects.create( + name="limited-task-group", tenant_id=tenant.id + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_user.roles.first(), + provider_group=provider_group, + ) + + response = client.get(reverse("task-list")) + assert response.status_code == status.HTTP_200_OK + assert [item["id"] for item in response.json()["data"]] == [ + str(allowed_task.id) + ] + + response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id})) + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id})) + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups( + self, + mock_async_result, + authenticated_client_for_tenant_factory, + tenants_fixture, + tasks_fixture, + aws_provider_pair, + ): + tenant, *_ = tenants_fixture + provider, _ = aws_provider_pair + finished_task, pending_task = tasks_fixture + client = self._client_with_role( + tenant, + authenticated_client_for_tenant_factory, + manage_providers=True, + unlimited_visibility=False, + ) + provider_group = ProviderGroup.objects.create( + name="own-group", 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=client.user.roles.first(), + provider_group=provider_group, + ) + for task, name in ( + (finished_task, "provider-deletion"), + (pending_task, "provider-connection-check"), + ): + self._set_task_name(task, name) + self._set_task_kwargs( + task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)} + ) + provider.is_deleted = True + provider.save() + + response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id})) + assert response.status_code == status.HTTP_200_OK + + response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id})) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_async_result.return_value.revoke.assert_called_once() + + def test_tasks_without_provider_stay_visible_for_limited_roles( + self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair + ): + response = authenticated_client_no_permissions_rbac.get(reverse("task-list")) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(tasks_fixture) + + def test_tasks_list_without_role_is_forbidden( + self, authenticated_client_rbac_noroles, tasks_fixture + ): + response = authenticated_client_rbac_noroles.get(reverse("task-list")) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_tasks_revoke_without_permission_hides_task_status( + self, authenticated_client_no_permissions_rbac, tasks_fixture + ): + finished_task, _ = tasks_fixture + self._set_task_name(finished_task, "provider-connection-check") + + response = authenticated_client_no_permissions_rbac.delete( + reverse("task-detail", kwargs={"pk": finished_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_unauthenticated_returns_401( + self, mock_async_result, tasks_fixture + ): + from rest_framework.test import APIClient + + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "scan-perform") + + response = APIClient().delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_foreign_tenant_task_returns_404( + self, + mock_async_result, + authenticated_client_for_tenant_factory, + tenants_fixture, + tasks_fixture, + ): + _, foreign_tenant, *_ = tenants_fixture + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "scan-perform") + client = self._client_with_role( + foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True + ) + + response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id})) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_async_result.return_value.revoke.assert_not_called() + @pytest.mark.django_db class TestAttackPathsScanViewSet: diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 9642b36d9d..5dc175dd42 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -125,10 +125,14 @@ from api.models import ( ) from api.pagination import ComplianceOverviewPagination from api.rbac.permissions import ( + TASK_REVOKE_PERMISSIONS, Permissions, get_integrations, get_providers, get_role, + get_tasks, + get_user_roles, + roles_have_permissions, ) from api.renderers import APIJSONRenderer, PlainTextRenderer from api.rls import Tenant @@ -2858,17 +2862,29 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet): list=extend_schema( tags=["Task"], summary="List all tasks", - description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.", + description=( + "Retrieve a list of all tasks with options for filtering by name, state, and other " + "criteria. Tasks that reference a provider are only returned when the role can " + "access it; tasks without a provider reference are returned for every role." + ), ), retrieve=extend_schema( tags=["Task"], summary="Retrieve data from a specific task", - description="Fetch detailed information about a specific task by its ID.", + description=( + "Fetch detailed information about a specific task by its ID. Tasks tied to a provider " + "outside the visibility of the role are not found." + ), ), destroy=extend_schema( tags=["Task"], summary="Revoke a task", - description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.", + description=( + "Try to revoke a task using its ID. Only tasks that are not yet in progress can be " + "revoked, and the caller needs the same permission as the operation that queued " + "the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be " + "revoked." + ), responses={202: OpenApiResponse(response=TaskSerializer)}, ), ) @@ -2884,13 +2900,26 @@ class TaskViewSet(BaseRLSViewSet): required_permissions = [] def get_queryset(self): - return Task.objects.annotate( - name=F("task_runner_task__task_name"), - state=F("task_runner_task__status"), - ).select_related("task_runner_task") + return ( + get_tasks(self.user_role) + .annotate( + name=F("task_runner_task__task_name"), + state=F("task_runner_task__status"), + ) + .select_related("task_runner_task") + ) def destroy(self, request, *args, pk=None, **kwargs): - task = get_object_or_404(Task, pk=pk) + task = self.get_object() + required_permissions = TASK_REVOKE_PERMISSIONS.get( + task.task_runner_task.task_name + ) + # Same multi-role semantics as HasPermissions. + if required_permissions is None or not roles_have_permissions( + get_user_roles(request.user, request.tenant_id), required_permissions + ): + raise PermissionDenied("You do not have permission to revoke this task.") + if task.task_runner_task.status not in ["PENDING", "RECEIVED"]: serializer = TaskSerializer(task) return Response( diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index f589245b06..65b5a54fbc 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -148,6 +148,12 @@ New roles have no provider visibility by default. Assign at least one Provider G 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. +#### Task Visibility and Revocation + + + +Background tasks, such as provider deletions, connection checks and scans, follow the visibility of the provider they belong to: a role can see a task when it can access its provider. Tasks that carry no provider reference are treated as tenant-wide and are visible to every role. Revoking a pending task requires the same permission as the operation that queued it, for example **Manage Scans** for a scan. Provider deletions cannot be revoked. + #### Creating a Provider Group Follow these steps to create a provider group in your account: From 60b936005cc109c611ad8b7f7c41cadb586fb4b6 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:32:55 +0200 Subject: [PATCH 08/21] test(ui): scope E2E delete dialog and scans table locators (#12898) --- ui/tests/helpers.ts | 12 ++++-------- ui/tests/scans/scans-page.ts | 6 ++++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 45d0195142..04e67d2d42 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -188,14 +188,10 @@ export async function deleteProviderIfExists( await expect(deleteMenuItem).toBeVisible({ timeout: 5000 }); await deleteMenuItem.click(); - // Wait for confirmation modal to appear. Exclude the Next.js dev error - // overlay, which is also role="dialog" and would otherwise be matched first, - // making the assertion wait on the wrong (hidden) element. - const modal = page.page - .locator( - '[role="dialog"]:not([data-nextjs-dialog="true"]), .modal, [data-testid*="modal"]', - ) - .first(); + // Match the delete dialog by name; other dialogs (Lighthouse callout, Next.js overlay) may be open + const modal = page.page.getByRole("dialog", { + name: "Are you absolutely sure?", + }); await expect(modal).toBeVisible({ timeout: 10000 }); diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts index 9a18d341c0..6b283e0d4f 100644 --- a/ui/tests/scans/scans-page.ts +++ b/ui/tests/scans/scans-page.ts @@ -32,7 +32,8 @@ export class ScansPage extends BasePage { this.launchScanButton = page .getByRole("group", { name: /scan tabs/i }) .getByRole("button", { name: /^Launch Scan$/i }); - this.launchScanDialog = page.getByRole("dialog"); + // By name: in Cloud the Lighthouse callout is also a dialog + this.launchScanDialog = page.getByRole("dialog", { name: "Launch A Scan" }); // The modal renders the providers picker as the shared MultiSelect-based // AccountsSelector (used in single-select mode via closeOnSelect). Scoping // to the dialog avoids matching the search combobox that appears in the @@ -68,7 +69,8 @@ export class ScansPage extends BasePage { }); // Main content elements - this.scanTable = page.locator("table"); + // getByRole skips the hidden shells React leaves while streaming rows + this.scanTable = page.getByRole("table"); // The scans view renders each tab with its own empty state, so a
// is NOT guaranteed (an empty tab shows a NoScansEmptyState card instead). // The tabs group is always present once providers exist, so it is the From ea020ed46e161b85921835df6ebee0a69baa6c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 29 Sep 2026 13:32:13 +0200 Subject: [PATCH 09/21] chore(changelog): v5.44.0 highlights (#12897) --- docs/changelog.mdx | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 280453485f..ecef9d41bd 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,71 @@ description: "New features and improvements in each Prowler release" rss: true --- + + ### 🔁 Findings — Re-check a Resource with a Partial Scan + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + A resource that has just been fixed can be confirmed from the Findings page without waiting for the next full scan. **Re-check resource** is available in the actions menu of every resource row and in the resource detail drawer, and a hint icon next to **Last seen** opens it directly. It launches a partial scan that runs again only the checks that last reported on that resource; its findings update when the scan completes, and every other resource keeps the results of the latest full scan. Roles need the Manage Scans permission, and a re-check is refused while the provider has a scan running or queued. + + Re-checked resources that now pass drop out of the finding groups list and its drill-down instead of opening a detail panel that still reports `FAIL`. Partial scans do not change overviews or compliance until the next full scan and produce no report files, so the Scans table marks them as **Partial**, offers no report download for them, and the per-scan Compliance selector leaves them out. + + Partial scans can also be launched outside the Findings page: + + - **API:** `POST /api/v1/scans` accepts up to 10 resources in `resource_uids`, and scans expose `is_partial` with a `filter[is_partial]` filter. + - **MCP Server:** `prowler_trigger_scan` takes a `resource_uids` argument, and `prowler_list_scans` and `prowler_get_scan` return `is_partial`, with an `is_partial` filter on `prowler_list_scans`. + - **Lighthouse AI:** can launch a partial scan to re-check specific resources, such as confirming a remediation. + + ### ☁️ AWS — Connect an Account in One Step + + The Add Provider wizard connects an AWS account in a single step. The account ID is read from the role ARN (or typed when using static access keys), the role is assumed with Prowler's own credentials, and the account, its credentials and the connection test are handled by one submit. A confirmed connection goes straight to the launch step. A refused connection stays on the form with the reason the API returned, so the fields can be fixed and retried without registering the account twice. + + New tenants without providers now land on this wizard on their first sign-in instead of a welcome modal, and the sidebar action reads **Add Provider** until the first provider is connected. + + Read more in the [Getting Started with AWS documentation](https://docs.prowler.com/user-guide/providers/aws/getting-started-aws). + + ### 🔌 Connection Tests No Longer Give Up Early + + The provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable. The UI now waits for the full time limit of the backend task, and if that is still exhausted it shows the provider's current connection state instead of a failure. + + On the SDK side, STS calls after a role assumption reuse the region that answered, so an unreachable partition region is waited on once instead of twice. The new `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable sets the Boto3 retries for deployments that build the AWS provider without CLI flags, next to the existing timeout variables; `0` disables retries. + + Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration#retries-configuration). + + ### 🗄️ Self-Hosted — S3-Compatible Storage and Air-Gapped Deployments + + - `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL` points scan output uploads and downloads at S3-compatible object storage such as MinIO. Previously the only way to reach it was exporting process-wide AWS environment variables, which also hijacked unrelated AWS API calls such as role assumption for AWS providers. + - Report downloads from a bucket with default SSE-KMS encryption no longer fail with `InvalidArgument`: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, download URLs are signed with Signature Version 4 for that region. + - Icons ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly without internet access. + - Celery worker fatal errors are logged instead of silenced, and every long-running service in `docker-compose.yml` restarts automatically after an unexpected crash. + + ### 📊 Consistent Latest Scan Across Endpoints + + Every endpoint now resolves a provider's latest completed scan the same way, so overlapping scans no longer make findings, compliance and mute rules read from different scans. Providers whose latest completed scan has no `completed_at` timestamp are no longer missing from those endpoints, and resources no longer keep a stale failed findings count when a scoped or imported scan completes after a full scan. + + ### 🛠️ Prowler App Fixes + + - API key authentication no longer locks the key row on every request, so a heavily used key no longer serializes all its requests; `last_used_at` is updated at most once per minute. + - `POST /api/v1/scans` returns the new scan ID in `task_args` again. + - Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j. + - A periodic sweep drops orphaned Attack Paths temporary Neo4j databases left behind when a worker or Neo4j crashes mid-scan. + - **Prowler Cloud:** imported findings no longer stay stuck in `pending` when the ingestion worker picks up the job before it is committed, and a failed enqueue marks the ingestion as failed. + - **Prowler Cloud:** the Lighthouse AI connection check reports a network failure as one, naming the endpoint it could not reach, instead of hitting a time limit that looked the same as a bad key. + - **Prowler Cloud:** the finding groups endpoints no longer query Manual Pass triages once per finding, and skip that overlay for tenants with no active Manual Pass. + - The Findings page renders a skeleton at once and streams the table before the filters, and the **Finding Group** options load when the dropdown opens. + - Mute rule creation errors show the API error message instead of the raw response body. + - The sidebar no longer throws a hydration error on full page loads for users who last used the chat mode. + + ### 🔐 Security Updates + + - `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the role's visibility. + - The UI E2E workflow receives its AWS credentials through environment variables instead of template expansion. + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.44.0) for the complete list of changes. + + ### 🏛️ Compliance — FedRAMP 20x Consolidated Rules 2026 From 3ec379a75ae601cd8bc5d084068111956f5d3b03 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 29 Sep 2026 13:32:41 +0200 Subject: [PATCH 10/21] chore(changelog): v5.44.0 (#12900) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- api/CHANGELOG.md | 27 +++++++++++++++++++ .../api-key-auth-no-row-lock.fixed.md | 1 - .../attack-paths-tmp-db-reaper.fixed.md | 1 - .../ephemeral-resource-count-reset.fixed.md | 1 - .../latest-scan-null-completed-at.fixed.md | 1 - .../latest-scan-selector.changed.md | 1 - ...ovider-deletion-unconfigured-sink.fixed.md | 1 - .../s3-output-internal-endpoint.added.md | 1 - .../s3-report-download-sigv4.fixed.md | 1 - .../scan-create-task-args.fixed.md | 1 - .../task-revoke-permissions.security.md | 1 - .../worker-logging-restart-policy.fixed.md | 1 - prowler/CHANGELOG.md | 16 +++++++++++ .../aws-retries-max-attempts-env.added.md | 1 - .../aws-sts-reuse-answering-region.fixed.md | 1 - .../ui-e2e-secrets-via-env.security.md | 1 - ui/CHANGELOG.md | 21 +++++++++++++++ .../aws-one-step-connect.changed.md | 1 - ui/changelog.d/bundled-icons-offline.fixed.md | 1 - .../findings-page-streaming.changed.md | 1 - .../first-run-add-provider.changed.md | 1 - .../mute-rule-error-toast-raw-json.fixed.md | 1 - .../provider-connection-check-wait.fixed.md | 1 - .../sidebar-add-provider-action.added.md | 1 - .../sidebar-mode-hydration.fixed.md | 1 - 25 files changed, 64 insertions(+), 22 deletions(-) delete mode 100644 api/changelog.d/api-key-auth-no-row-lock.fixed.md delete mode 100644 api/changelog.d/attack-paths-tmp-db-reaper.fixed.md delete mode 100644 api/changelog.d/ephemeral-resource-count-reset.fixed.md delete mode 100644 api/changelog.d/latest-scan-null-completed-at.fixed.md delete mode 100644 api/changelog.d/latest-scan-selector.changed.md delete mode 100644 api/changelog.d/provider-deletion-unconfigured-sink.fixed.md delete mode 100644 api/changelog.d/s3-output-internal-endpoint.added.md delete mode 100644 api/changelog.d/s3-report-download-sigv4.fixed.md delete mode 100644 api/changelog.d/scan-create-task-args.fixed.md delete mode 100644 api/changelog.d/task-revoke-permissions.security.md delete mode 100644 api/changelog.d/worker-logging-restart-policy.fixed.md delete mode 100644 prowler/changelog.d/aws-retries-max-attempts-env.added.md delete mode 100644 prowler/changelog.d/aws-sts-reuse-answering-region.fixed.md delete mode 100644 prowler/changelog.d/ui-e2e-secrets-via-env.security.md delete mode 100644 ui/changelog.d/aws-one-step-connect.changed.md delete mode 100644 ui/changelog.d/bundled-icons-offline.fixed.md delete mode 100644 ui/changelog.d/findings-page-streaming.changed.md delete mode 100644 ui/changelog.d/first-run-add-provider.changed.md delete mode 100644 ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md delete mode 100644 ui/changelog.d/provider-connection-check-wait.fixed.md delete mode 100644 ui/changelog.d/sidebar-add-provider-action.added.md delete mode 100644 ui/changelog.d/sidebar-mode-hydration.fixed.md diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 1a927baa9c..afd6c2d86d 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.45.0] (Prowler v5.44.0) + +### 🚀 Added + +- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871) + +### 🔄 Changed + +- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858) + +### 🐞 Fixed + +- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465) +- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746) +- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832) +- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858) +- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858) +- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878) +- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882) +- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894) + +### 🔐 Security + +- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893) + +--- + ## [1.44.0] (Prowler v5.43.0) ### 🐞 Fixed diff --git a/api/changelog.d/api-key-auth-no-row-lock.fixed.md b/api/changelog.d/api-key-auth-no-row-lock.fixed.md deleted file mode 100644 index 3d0a40260c..0000000000 --- a/api/changelog.d/api-key-auth-no-row-lock.fixed.md +++ /dev/null @@ -1 +0,0 @@ -API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row diff --git a/api/changelog.d/attack-paths-tmp-db-reaper.fixed.md b/api/changelog.d/attack-paths-tmp-db-reaper.fixed.md deleted file mode 100644 index b165fc6d40..0000000000 --- a/api/changelog.d/attack-paths-tmp-db-reaper.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded diff --git a/api/changelog.d/ephemeral-resource-count-reset.fixed.md b/api/changelog.d/ephemeral-resource-count-reset.fixed.md deleted file mode 100644 index 0c56ae2e5d..0000000000 --- a/api/changelog.d/ephemeral-resource-count-reset.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup diff --git a/api/changelog.d/latest-scan-null-completed-at.fixed.md b/api/changelog.d/latest-scan-null-completed-at.fixed.md deleted file mode 100644 index 9932210055..0000000000 --- a/api/changelog.d/latest-scan-null-completed-at.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider diff --git a/api/changelog.d/latest-scan-selector.changed.md b/api/changelog.d/latest-scan-selector.changed.md deleted file mode 100644 index 417cf9e997..0000000000 --- a/api/changelog.d/latest-scan-selector.changed.md +++ /dev/null @@ -1 +0,0 @@ -Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans diff --git a/api/changelog.d/provider-deletion-unconfigured-sink.fixed.md b/api/changelog.d/provider-deletion-unconfigured-sink.fixed.md deleted file mode 100644 index 93f5e2bc73..0000000000 --- a/api/changelog.d/provider-deletion-unconfigured-sink.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j diff --git a/api/changelog.d/s3-output-internal-endpoint.added.md b/api/changelog.d/s3-output-internal-endpoint.added.md deleted file mode 100644 index 88159f1ef8..0000000000 --- a/api/changelog.d/s3-output-internal-endpoint.added.md +++ /dev/null @@ -1 +0,0 @@ -Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls diff --git a/api/changelog.d/s3-report-download-sigv4.fixed.md b/api/changelog.d/s3-report-download-sigv4.fixed.md deleted file mode 100644 index 7c11870c11..0000000000 --- a/api/changelog.d/s3-report-download-sigv4.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region diff --git a/api/changelog.d/scan-create-task-args.fixed.md b/api/changelog.d/scan-create-task-args.fixed.md deleted file mode 100644 index 26d42cf0c4..0000000000 --- a/api/changelog.d/scan-create-task-args.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit diff --git a/api/changelog.d/task-revoke-permissions.security.md b/api/changelog.d/task-revoke-permissions.security.md deleted file mode 100644 index 2497621e99..0000000000 --- a/api/changelog.d/task-revoke-permissions.security.md +++ /dev/null @@ -1 +0,0 @@ -`DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role diff --git a/api/changelog.d/worker-logging-restart-policy.fixed.md b/api/changelog.d/worker-logging-restart-policy.fixed.md deleted file mode 100644 index dad995fdd9..0000000000 --- a/api/changelog.d/worker-logging-restart-policy.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 3a7bc157bf..d424abdd01 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.44.0] (Prowler v5.44.0) + +### 🚀 Added + +- `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870) + +### 🐞 Fixed + +- STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870) + +### 🔐 Security + +- Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion [(#12864)](https://github.com/prowler-cloud/prowler/pull/12864) + +--- + ## [5.43.0] (Prowler v5.43.0) ### 🚀 Added diff --git a/prowler/changelog.d/aws-retries-max-attempts-env.added.md b/prowler/changelog.d/aws-retries-max-attempts-env.added.md deleted file mode 100644 index d88cc59507..0000000000 --- a/prowler/changelog.d/aws-retries-max-attempts-env.added.md +++ /dev/null @@ -1 +0,0 @@ -`PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags diff --git a/prowler/changelog.d/aws-sts-reuse-answering-region.fixed.md b/prowler/changelog.d/aws-sts-reuse-answering-region.fixed.md deleted file mode 100644 index 431426522a..0000000000 --- a/prowler/changelog.d/aws-sts-reuse-answering-region.fixed.md +++ /dev/null @@ -1 +0,0 @@ -STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region diff --git a/prowler/changelog.d/ui-e2e-secrets-via-env.security.md b/prowler/changelog.d/ui-e2e-secrets-via-env.security.md deleted file mode 100644 index 476f690e3c..0000000000 --- a/prowler/changelog.d/ui-e2e-secrets-via-env.security.md +++ /dev/null @@ -1 +0,0 @@ -Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9ea71d69e9..5d62b9da6f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.44.0] (Prowler v5.44.0) + +### 🚀 Added + +- Sidebar action reads Add Provider while the tenant has no providers [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852) + +### 🔄 Changed + +- AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852) +- New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852) +- Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens [(#12891)](https://github.com/prowler-cloud/prowler/pull/12891) + +### 🐞 Fixed + +- Mute rule creation errors show the API error message instead of the raw JSON:API response body [(#12853)](https://github.com/prowler-cloud/prowler/pull/12853) +- Provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable; the wait now covers the backend task's full time limit and falls back to the provider's current connection state if it is still exhausted [(#12869)](https://github.com/prowler-cloud/prowler/pull/12869) +- Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode [(#12873)](https://github.com/prowler-cloud/prowler/pull/12873) +- Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments [(#12892)](https://github.com/prowler-cloud/prowler/pull/12892) + +--- + ## [1.43.0] (Prowler v5.43.0) ### 🚀 Added diff --git a/ui/changelog.d/aws-one-step-connect.changed.md b/ui/changelog.d/aws-one-step-connect.changed.md deleted file mode 100644 index 924f32b1bb..0000000000 --- a/ui/changelog.d/aws-one-step-connect.changed.md +++ /dev/null @@ -1 +0,0 @@ -AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account diff --git a/ui/changelog.d/bundled-icons-offline.fixed.md b/ui/changelog.d/bundled-icons-offline.fixed.md deleted file mode 100644 index 52bfe6d1c6..0000000000 --- a/ui/changelog.d/bundled-icons-offline.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments diff --git a/ui/changelog.d/findings-page-streaming.changed.md b/ui/changelog.d/findings-page-streaming.changed.md deleted file mode 100644 index ea82b76707..0000000000 --- a/ui/changelog.d/findings-page-streaming.changed.md +++ /dev/null @@ -1 +0,0 @@ -Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens diff --git a/ui/changelog.d/first-run-add-provider.changed.md b/ui/changelog.d/first-run-add-provider.changed.md deleted file mode 100644 index a40ae414e3..0000000000 --- a/ui/changelog.d/first-run-add-provider.changed.md +++ /dev/null @@ -1 +0,0 @@ -New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal diff --git a/ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md b/ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md deleted file mode 100644 index d59b218727..0000000000 --- a/ui/changelog.d/mute-rule-error-toast-raw-json.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Mute rule creation errors show the API error message instead of the raw JSON:API response body diff --git a/ui/changelog.d/provider-connection-check-wait.fixed.md b/ui/changelog.d/provider-connection-check-wait.fixed.md deleted file mode 100644 index 779efa5e74..0000000000 --- a/ui/changelog.d/provider-connection-check-wait.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable; the wait now covers the backend task's full time limit and falls back to the provider's current connection state if it is still exhausted diff --git a/ui/changelog.d/sidebar-add-provider-action.added.md b/ui/changelog.d/sidebar-add-provider-action.added.md deleted file mode 100644 index 29cc8aec45..0000000000 --- a/ui/changelog.d/sidebar-add-provider-action.added.md +++ /dev/null @@ -1 +0,0 @@ -Sidebar action reads Add Provider while the tenant has no providers diff --git a/ui/changelog.d/sidebar-mode-hydration.fixed.md b/ui/changelog.d/sidebar-mode-hydration.fixed.md deleted file mode 100644 index c495c3071d..0000000000 --- a/ui/changelog.d/sidebar-mode-hydration.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode From 5ea363d582edbc405ff596e67f74e867ff16a6d3 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 29 Sep 2026 16:31:16 +0200 Subject: [PATCH 11/21] chore(release): Bump versions to v5.45.0 (#12905) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- .env | 2 +- api/pyproject.toml | 2 +- api/src/backend/api/specs/v1.yaml | 2 +- api/uv.lock | 2 +- docs/getting-started/installation/prowler-app.mdx | 4 ++-- prowler/config/config.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.env b/.env index 3f80a2460b..99ec6b55d2 100644 --- a/.env +++ b/.env @@ -174,7 +174,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.45.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/api/pyproject.toml b/api/pyproject.toml index a349bd72ea..0c4eed3616 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -71,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.45.0" +version = "1.46.0" # Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). # target-version tracks this project's lowest supported Python. diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index b1a66fee23..220afcb7d8 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.45.0 + version: 1.46.0 description: |- Prowler API specification. diff --git a/api/uv.lock b/api/uv.lock index 7f6151d6d1..448ddd58f2 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4938,7 +4938,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.45.0" +version = "1.46.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index c1a00312aa..c768d078c4 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.43.0" -PROWLER_API_VERSION="5.43.0" +PROWLER_UI_VERSION="5.44.0" +PROWLER_API_VERSION="5.44.0" ``` diff --git a/prowler/config/config.py b/prowler/config/config.py index 928f7fd520..aa85d98502 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -52,7 +52,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.44.0" +prowler_version = "5.45.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/pyproject.toml b/pyproject.toml index ac90a11d60..0d40d8e6a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.44.0" +version = "5.45.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/uv.lock b/uv.lock index 57e4816cd1..d231b44bd4 100644 --- a/uv.lock +++ b/uv.lock @@ -3764,7 +3764,7 @@ wheels = [ [[package]] name = "prowler" -version = "5.44.0" +version = "5.45.0" source = { editable = "." } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, From 65fb146e7618fffddcd3a3a50de0dc895e9f0399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 29 Sep 2026 17:04:46 +0200 Subject: [PATCH 12/21] chore(trivy): suppress fast-uri CVE-2026-84292 (#12907) --- .trivyignore.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.trivyignore.yaml b/.trivyignore.yaml index f572065625..27e6ccf24b 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -68,7 +68,7 @@ vulnerabilities: expired_at: 2026-11-30 # Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module - # (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that + # (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that # SBOM and reports what it declares, which is not the same as what the image contains: # there is no Node runtime and no node_modules anywhere in the image, and the .NET # assemblies target net472, a Windows-only framework. Nothing here is reachable, and none @@ -129,6 +129,10 @@ vulnerabilities: purls: - "pkg:npm/fast-uri" expired_at: 2027-01-31 + - id: CVE-2026-84292 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 - id: CVE-2026-69192 purls: - "pkg:npm/ip-address" From f418b32c815c7bf8fe313cae753ec05fb8d41047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 29 Sep 2026 17:50:54 +0200 Subject: [PATCH 13/21] fix(oci): use home region for identity bootstrap (#12865) --- .github/workflows/sdk-refresh-oci-regions.yml | 2 +- .../oci-home-region-bootstrap.fixed.md | 1 + api/src/backend/api/tests/test_serializers.py | 44 ++++++------ api/src/backend/api/tests/test_utils.py | 42 ++++++++--- api/src/backend/api/tests/test_views.py | 12 ++-- api/src/backend/api/utils.py | 21 ++++-- .../api/v1/serializer_utils/providers.py | 3 +- api/src/backend/api/v1/serializers.py | 17 +++-- .../providers/oci/getting-started-oci.mdx | 15 +++- .../oci-home-region-bootstrap.fixed.md | 1 + .../oraclecloud/oraclecloud_provider.py | 4 +- .../oraclecloud/oraclecloud_provider_test.py | 52 ++++++++++++++ .../oraclecloud/ui_regions_sync_test.py | 20 ++++++ .../oci-home-region-bootstrap.fixed.md | 1 + .../oraclecloud-credentials-form.tsx | 31 ++++++++ ui/hooks/use-credentials-form.ts | 1 + .../provider-credentials/build-credentials.ts | 4 ++ ui/lib/provider-credentials/oci-regions.ts | 72 +++++++++++++++++++ ui/tests/providers/providers-page.ts | 13 ++++ ui/tests/providers/providers.md | 10 +-- ui/tests/providers/providers.spec.ts | 4 ++ ui/types/components.ts | 1 + ui/types/env.d.ts | 1 + ui/types/formSchemas.test.ts | 24 ++++++- ui/types/formSchemas.ts | 9 +++ util/update_oci_regions.py | 38 ++++++++++ 26 files changed, 383 insertions(+), 60 deletions(-) create mode 100644 api/changelog.d/oci-home-region-bootstrap.fixed.md create mode 100644 prowler/changelog.d/oci-home-region-bootstrap.fixed.md create mode 100644 tests/providers/oraclecloud/ui_regions_sync_test.py create mode 100644 ui/changelog.d/oci-home-region-bootstrap.fixed.md create mode 100644 ui/lib/provider-credentials/oci-regions.ts diff --git a/.github/workflows/sdk-refresh-oci-regions.yml b/.github/workflows/sdk-refresh-oci-regions.yml index 17b4205620..f0e3372ac9 100644 --- a/.github/workflows/sdk-refresh-oci-regions.yml +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -76,7 +76,7 @@ jobs: ### Changes - This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`). + This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`). - Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged - DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged diff --git a/api/changelog.d/oci-home-region-bootstrap.fixed.md b/api/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..0d28492c2b --- /dev/null +++ b/api/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 78a3e14c4f..0d7f042434 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -215,36 +215,34 @@ class TestOracleCloudProviderSecret: assert serializer.is_valid(), serializer.errors assert "region" not in serializer.validated_data - def test_accepts_and_ignores_region_field(self): - secret = self.valid_secret(region="us-phoenix-1") - serializer = OracleCloudProviderSecret(data=secret) - - assert serializer.is_valid(), serializer.errors - - assert "region" not in serializer.validated_data - - @pytest.mark.parametrize( - "legacy_field, legacy_value", - [ - ("region", None), - ("region", ""), - ("region", {"name": "us-ashburn-1"}), - ], - ) - def test_accepts_and_ignores_any_legacy_region_value( - self, legacy_field, legacy_value - ): + def test_keeps_region_as_home_region(self): serializer = OracleCloudProviderSecret( - data=self.valid_secret(**{legacy_field: legacy_value}) + data=self.valid_secret(region=" me-abudhabi-1 ") ) assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["region"] == "me-abudhabi-1" - assert legacy_field not in serializer.validated_data + def test_rejects_unknown_region(self): + serializer = OracleCloudProviderSecret( + data=self.valid_secret(region="mars-north-1") + ) + + assert not serializer.is_valid() + assert "region" in serializer.errors + + @pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}]) + def test_drops_blank_or_non_string_region(self, legacy_value): + serializer = OracleCloudProviderSecret( + data=self.valid_secret(region=legacy_value) + ) + + assert serializer.is_valid(), serializer.errors + assert "region" not in serializer.validated_data class TestProviderSecretFieldSchema: - def test_oraclecloud_schema_includes_legacy_region_field(self): + def test_oraclecloud_schema_region_is_not_deprecated(self): schema = ProviderSecretField._spectacular_annotation["field"] oraclecloud_schema = next( credential_schema @@ -253,7 +251,7 @@ class TestProviderSecretFieldSchema: == "Oracle Cloud Infrastructure (OCI) API Key Credentials" ) - assert oraclecloud_schema["properties"]["region"]["deprecated"] is True + assert "deprecated" not in oraclecloud_schema["properties"]["region"] class TestKubernetesProviderSecret: diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 4b5e8e1694..a6d2c10f24 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -172,7 +172,7 @@ class TestInitializeProwlerProvider: ) @patch("api.utils.return_prowler_provider") - def test_initialize_oraclecloud_provider_removes_region_string( + def test_initialize_oraclecloud_provider_passes_region_as_home_region( self, mock_return_prowler_provider ): provider = MagicMock() @@ -182,7 +182,7 @@ class TestInitializeProwlerProvider: "fingerprint": "00:11:22:33:44:55:66:77", "key_content": "fake-base64-key-content", "tenancy": "ocid1.tenancy.oc1..fake", - "region": "us-ashburn-1", + "region": "me-abudhabi-1", } mock_return_prowler_provider.return_value = MagicMock() @@ -193,6 +193,7 @@ class TestInitializeProwlerProvider: fingerprint="00:11:22:33:44:55:66:77", key_content="fake-base64-key-content", tenancy="ocid1.tenancy.oc1..fake", + home_region="me-abudhabi-1", ) @patch("api.utils.return_prowler_provider") @@ -254,11 +255,35 @@ class TestProwlerProviderConnectionTest: fingerprint="00:11:22:33:44:55:66:77", key_content="fake-base64-key-content", tenancy="ocid1.tenancy.oc1..aaaaaaaexample", - region=getattr( - OraclecloudProvider, - "_bootstrap_region", - OraclecloudProvider._home_region, - ), + region=OraclecloudProvider._bootstrap_region, + provider_id="ocid1.tenancy.oc1..aaaaaaaexample", + raise_on_exception=False, + ) + + @patch("api.utils.return_prowler_provider") + def test_oraclecloud_connection_test_uses_stored_region_for_identity( + self, mock_return_prowler_provider + ): + provider = MagicMock() + provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample" + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret.secret = { + "user": "ocid1.user.oc1..aaaaaaaexample", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "fake-base64-key-content", + "tenancy": "ocid1.tenancy.oc1..aaaaaaaexample", + "region": "me-abudhabi-1", + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + user="ocid1.user.oc1..aaaaaaaexample", + fingerprint="00:11:22:33:44:55:66:77", + key_content="fake-base64-key-content", + tenancy="ocid1.tenancy.oc1..aaaaaaaexample", + region="me-abudhabi-1", provider_id="ocid1.tenancy.oc1..aaaaaaaexample", raise_on_exception=False, ) @@ -434,7 +459,7 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result - def test_get_prowler_provider_kwargs_oraclecloud_removes_region( + def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region( self, ): secret_dict = { @@ -461,6 +486,7 @@ class TestGetProwlerProviderKwargs: "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", "tenancy": "ocid1.tenancy.oc1..fake", "pass_phrase": "fake-passphrase", + "home_region": "us-ashburn-1", } def test_get_prowler_provider_kwargs_with_mutelist(self): diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 55ef891386..b5c5d0fcf1 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -3363,7 +3363,7 @@ current-context: test-context provider_secret = ProviderSecret.objects.get() assert "region" not in provider_secret.secret - def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region( + def test_provider_secrets_create_oraclecloud_stores_region( self, authenticated_client, oraclecloud_provider, @@ -3372,14 +3372,14 @@ current-context: test-context authenticated_client, oraclecloud_provider, self._oraclecloud_secret( - key_content=" test-key-content ", region=" us-ashburn-1 " + key_content=" test-key-content ", region=" me-abudhabi-1 " ), ) assert response.status_code == status.HTTP_201_CREATED provider_secret = ProviderSecret.objects.get() assert provider_secret.secret["key_content"] == "test-key-content" - assert "region" not in provider_secret.secret + assert provider_secret.secret["region"] == "me-abudhabi-1" def test_provider_secrets_update_oraclecloud_without_region_stores_no_region( self, @@ -3412,7 +3412,7 @@ current-context: test-context provider_secret.refresh_from_db() assert "region" not in provider_secret.secret - def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region( + def test_provider_secrets_update_oraclecloud_stores_region( self, authenticated_client, oraclecloud_provider, @@ -3430,7 +3430,7 @@ current-context: test-context "type": "provider-secrets", "id": str(provider_secret.id), "attributes": { - "secret": self._oraclecloud_secret(region=" us-ashburn-1 ") + "secret": self._oraclecloud_secret(region=" me-abudhabi-1 ") }, } } @@ -3443,7 +3443,7 @@ current-context: test-context assert response.status_code == status.HTTP_200_OK provider_secret.refresh_from_db() - assert "region" not in provider_secret.secret + assert provider_secret.secret["region"] == "me-abudhabi-1" @pytest.mark.parametrize( "attributes, error_code, error_pointer", diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8e73b96a39..a48c8c13a4 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -302,17 +302,26 @@ def get_prowler_provider_kwargs( def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict: """Normalize external OCI secret fields into SDK provider kwargs.""" prowler_provider_kwargs = secret.copy() - prowler_provider_kwargs.pop("region", None) + home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None)) + if home_region: + prowler_provider_kwargs["home_region"] = home_region return prowler_provider_kwargs +def _oraclecloud_home_region(region) -> str | None: + """Return the stored OCI region as a home region, ignoring blank or non-string legacy values.""" + if isinstance(region, str) and region.strip(): + return region.strip() + return None + + def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict: """Normalize external OCI secret fields into test_connection kwargs.""" from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider prowler_provider_kwargs = secret.copy() - prowler_provider_kwargs.pop("region", None) + home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None)) if ( prowler_provider_kwargs.get("user") @@ -323,11 +332,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict: or prowler_provider_kwargs.get("key_file") ) ): - # Connection validation needs one OCI endpoint, but scans remain unfiltered. - prowler_provider_kwargs["region"] = getattr( - OraclecloudProvider, - "_bootstrap_region", - OraclecloudProvider._home_region, + # Identity calls only succeed in a region the tenancy is subscribed to. + prowler_provider_kwargs["region"] = ( + home_region or OraclecloudProvider._bootstrap_region ) return prowler_provider_kwargs diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 0d80b47c0a..92ce6d7d7b 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -301,8 +301,7 @@ from rest_framework_json_api import serializers }, "region": { "type": "string", - "deprecated": True, - "description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.", + "description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.", }, }, "required": ["user", "fingerprint", "tenancy"], diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 271d3a9b47..5b7e061561 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction from drf_spectacular.utils import extend_schema_field from jwt.exceptions import InvalidKeyError from prowler.lib.mutelist.mutelist import Mutelist +from prowler.providers.oraclecloud.config import OCI_REGIONS from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from rest_framework_json_api import serializers @@ -1917,9 +1918,16 @@ class IacProviderSecret(serializers.Serializer): resource_name = "provider-secrets" -class LegacyOCIRegionField(serializers.Field): +class OCIHomeRegionField(serializers.Field): + """Optional OCI home region; blank or non-string legacy values are dropped.""" + def to_internal_value(self, data): - return data + if not isinstance(data, str) or not data.strip(): + return None + region = data.strip() + if region not in OCI_REGIONS: + raise serializers.ValidationError(f"Invalid OCI region: {region}") + return region def to_representation(self, value): return value @@ -1932,10 +1940,11 @@ class OracleCloudProviderSecret(serializers.Serializer): key_content = serializers.CharField(required=False) tenancy = serializers.CharField() pass_phrase = serializers.CharField(required=False) - region = LegacyOCIRegionField(required=False, allow_null=True) + region = OCIHomeRegionField(required=False, allow_null=True) def validate(self, attrs): - attrs.pop("region", None) + if not attrs.get("region"): + attrs.pop("region", None) if "key_file" not in attrs and "key_content" not in attrs: raise serializers.ValidationError( diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 6a8c8de3e9..950661ff28 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -12,7 +12,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server. 1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID. 2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**. 3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint. -4. Note the **Region** identifier to scan (for example, `us-ashburn-1`). +4. In **Tenancy Details**, note the **Home Region** identifier (for example, `me-abudhabi-1`). Any other region the tenancy is subscribed to also works. ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). @@ -26,12 +26,18 @@ Prowler Cloud connects to OCI with API key credentials. Provide: - **User OCID** for the API key owner - **Fingerprint** of the API key -- **Region** (for example, `us-ashburn-1`) +- **Home Region**: select it from the list (for example, `me-abudhabi-1`) - **Private Key Content** (paste the full PEM value) - **Passphrase (Optional)** if the private key is encrypted Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission. + +The home region is used only to validate the credentials and discover the regions the tenancy is subscribed to. It does not limit the scan: Prowler audits every subscribed region. OCI Identity and Access Management (IAM) only answers in subscribed regions, so a tenancy that is not subscribed to the selected region cannot be validated. + + +Providers created without a region keep using `us-ashburn-1` for validation. If such a provider fails with `401 NotAuthenticated`, update its credentials and select the home region. + ![Add OCI API Key Credentials](./images/oci-add-api-key-credentials.png) --- @@ -334,6 +340,11 @@ prowler oci \ #### Region Issues +**Error: "OCI credential validation failed" with `401 NotAuthenticated` on `get_tenancy`** +- The tenancy is not subscribed to the region used for validation (by default `us-ashburn-1`) +- In Prowler Cloud or Prowler Local Server, update the provider credentials and select the tenancy home region +- In Prowler CLI, set `region` in `~/.oci/config` to the home region. All subscribed regions are still scanned; `--region` also works but limits the scan to that region + **Error: "Invalid region"** - Check available regions: `prowler oci --list-regions` - Verify your tenancy is subscribed to the region diff --git a/prowler/changelog.d/oci-home-region-bootstrap.fixed.md b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..de2997773d --- /dev/null +++ b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index a4794f6269..932333ee74 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -89,6 +89,7 @@ class OraclecloudProvider(Provider): key_content: str = None, tenancy: str = None, pass_phrase: str = None, + home_region: str = None, ): """ Initializes the OCI provider. @@ -110,6 +111,7 @@ class OraclecloudProvider(Provider): - key_content: Content of the private key (base64 encoded). - tenancy: The OCID of the tenancy. - pass_phrase: The passphrase for the private key, if encrypted. + - home_region: Region used to bootstrap identity calls with API key credentials; it does not filter the audited regions. Raises: - OCISetUpSessionError: If an error occurs during the setup process. @@ -140,7 +142,7 @@ class OraclecloudProvider(Provider): ) has_direct_credentials = user and fingerprint and tenancy bootstrap_region = single_region or ( - self._bootstrap_region if has_direct_credentials else None + (home_region or self._bootstrap_region) if has_direct_credentials else None ) # Setup OCI Session diff --git a/tests/providers/oraclecloud/oraclecloud_provider_test.py b/tests/providers/oraclecloud/oraclecloud_provider_test.py index 7deb649d8e..dce02eeb78 100644 --- a/tests/providers/oraclecloud/oraclecloud_provider_test.py +++ b/tests/providers/oraclecloud/oraclecloud_provider_test.py @@ -543,6 +543,58 @@ class TestOraclecloudProviderInit: assert mock_get_regions_to_audit.call_args_list[0].args == (None,) assert provider.regions == all_subscribed_regions + def test_init_with_home_region_bootstraps_there_without_scan_filter(self): + mock_session = OCISession( + config={"region": "me-abudhabi-1"}, signer=None, profile=None + ) + mock_identity = OCIIdentityInfo( + tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample", + tenancy_name="test-tenancy", + user_id="ocid1.user.oc1..aaaaaaaexample", + region="me-abudhabi-1", + profile=None, + audited_regions=set(), + audited_compartments=[], + ) + all_subscribed_regions = [ + OCIRegion(key="me-abudhabi-1", name="me-abudhabi-1", is_home_region=True), + OCIRegion(key="me-dubai-1", name="me-dubai-1", is_home_region=False), + ] + + with ( + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session", + return_value=mock_session, + ) as mock_setup_session, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity", + return_value=mock_identity, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit", + return_value=all_subscribed_regions, + ) as mock_get_regions_to_audit, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit", + return_value=["ocid1.compartment.oc1..aaaaaaaexample"], + ), + patch("prowler.providers.common.provider.Provider.set_global_provider"), + ): + provider = OraclecloudProvider( + user="ocid1.user.oc1..aaaaaaaexample", + fingerprint="aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99", + key_content="fake-base64-key-content", + tenancy="ocid1.tenancy.oc1..aaaaaaaexample", + home_region="me-abudhabi-1", + config_content={"dummy": True}, + mutelist_content={"Accounts": {}}, + ) + + assert mock_setup_session.call_args.kwargs["region"] == "me-abudhabi-1" + assert mock_get_regions_to_audit.call_args_list[0].args == (None,) + assert provider.regions == all_subscribed_regions + assert provider.home_region == "me-abudhabi-1" + def test_init_with_config_file_auth_without_region_uses_session_config_region_for_identity( self, ): diff --git a/tests/providers/oraclecloud/ui_regions_sync_test.py b/tests/providers/oraclecloud/ui_regions_sync_test.py new file mode 100644 index 0000000000..3e38914f24 --- /dev/null +++ b/tests/providers/oraclecloud/ui_regions_sync_test.py @@ -0,0 +1,20 @@ +import re +from pathlib import Path + +from prowler.providers.oraclecloud.config import OCI_REGIONS + +UI_REGIONS_FILE = ( + Path(__file__).resolve().parents[3] + / "ui" + / "lib" + / "provider-credentials" + / "oci-regions.ts" +) + + +def test_ui_home_region_list_matches_sdk_regions(): + ui_regions = set( + re.findall(r'"([a-z]{2,3}-[a-z-]+-\d+)"', UI_REGIONS_FILE.read_text()) + ) + + assert ui_regions == set(OCI_REGIONS) diff --git a/ui/changelog.d/oci-home-region-bootstrap.fixed.md b/ui/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..3982d0b7d1 --- /dev/null +++ b/ui/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +Required home region selector in the OCI credentials form, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx index ec989868ea..f25dd49356 100644 --- a/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/oraclecloud-credentials-form.tsx @@ -4,6 +4,9 @@ import { WizardInputField, WizardTextareaField, } from "@/components/providers/workflow/forms/fields"; +import { Combobox } from "@/components/shadcn/combobox"; +import { FormControl, FormField, FormMessage } from "@/components/shadcn/form"; +import { OCI_REGION_GROUPS } from "@/lib/provider-credentials/oci-regions"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { OCICredentials } from "@/types"; @@ -48,6 +51,34 @@ export const OracleCloudCredentialsForm = ({ variant="bordered" isRequired /> + ( +
+ + Home Region* + + + + + + Shown in the OCI Console under Tenancy Details. Used only to + validate the credentials: all subscribed regions are scanned. + + +
+ )} + /> ({ + value: region, + label: region, + })), + }, + { heading: "Government", options: OCI_GOVERNMENT_REGIONS }, +]; + +export const OCI_REGION_VALUES = OCI_REGION_GROUPS.flatMap((group) => + group.options.map((option) => option.value), +); diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index ecb8755ccd..9777c1b61b 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -224,6 +224,7 @@ export interface OCIProviderCredential { userId?: string; fingerprint?: string; keyContent?: string; + homeRegion?: string; } // AlibabaCloud credential options @@ -365,6 +366,7 @@ export class ProvidersPage extends BasePage { readonly ociUserIdInput: Locator; readonly ociFingerprintInput: Locator; readonly ociKeyContentInput: Locator; + readonly ociHomeRegionCombobox: Locator; // AlibabaCloud provider form elements readonly alibabacloudAccountIdInput: Locator; @@ -510,6 +512,9 @@ export class ProvidersPage extends BasePage { this.ociKeyContentInput = page.getByRole("textbox", { name: /Private Key Content/i, }); + this.ociHomeRegionCombobox = page.getByRole("combobox", { + name: /Home Region/i, + }); // AlibabaCloud provider form inputs this.alibabacloudAccountIdInput = page.getByRole("textbox", { @@ -1284,6 +1289,12 @@ export class ProvidersPage extends BasePage { if (credentials.keyContent) { await this.ociKeyContentInput.fill(credentials.keyContent); } + if (credentials.homeRegion) { + await this.ociHomeRegionCombobox.click(); + await this.page + .locator(`[role="option"][data-value="${credentials.homeRegion}"]`) + .click(); + } } async verifyOCICredentialsPageLoaded(): Promise { @@ -1294,6 +1305,7 @@ export class ProvidersPage extends BasePage { await expect(this.ociUserIdInput).toBeVisible(); await expect(this.ociFingerprintInput).toBeVisible(); await expect(this.ociKeyContentInput).toBeVisible(); + await expect(this.ociHomeRegionCombobox).toBeVisible(); } async verifyOCIUpdateCredentialsPageLoaded(): Promise { @@ -1304,6 +1316,7 @@ export class ProvidersPage extends BasePage { await expect(this.ociUserIdInput).toBeVisible(); await expect(this.ociFingerprintInput).toBeVisible(); await expect(this.ociKeyContentInput).toBeVisible(); + await expect(this.ociHomeRegionCombobox).toBeVisible(); } async selectAlibabaCloudProvider(): Promise { diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 0bc1b9d47d..049c3fcbe3 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -611,7 +611,7 @@ **Preconditions:** - Admin user authentication required (admin.auth.setup setup) -- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT +- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION (optional, defaults to us-ashburn-1) - Remove any existing provider with the same Tenancy ID before starting the test - This test must be run serially and never in parallel with other tests, as it requires the Tenancy ID not to be already registered beforehand. @@ -622,7 +622,7 @@ 3. Select OCI provider type 4. Fill provider details (tenancy ID and alias) 5. Verify OCI credentials page is loaded -6. Fill OCI credentials (user ID, fingerprint, key content) +6. Fill OCI credentials (user ID, fingerprint, key content, home region) 7. Confirm provider connection without launching a scan 8. Verify return to Providers page 9. Verify provider exists in Providers table @@ -640,7 +640,7 @@ - Connect account page displays OCI option - Provider details form accepts tenancy ID and alias - OCI credentials page loads -- Credentials form accepts all required fields (user ID, fingerprint, key content) +- Credentials form accepts all required fields (user ID, fingerprint, key content, home region) - Launch step appears - Successful return to Providers page after closing the launch step - Provider exists in Providers table (verified by tenancy ID) @@ -670,7 +670,7 @@ **Preconditions:** - Admin user authentication required (admin.auth.setup setup) -- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT +- Environment variables configured: E2E_OCI_TENANCY_ID, E2E_OCI_USER_ID, E2E_OCI_FINGERPRINT, E2E_OCI_KEY_CONTENT, E2E_OCI_REGION (optional, defaults to us-ashburn-1) - An OCI provider with the specified Tenancy ID must already exist (run PROVIDER-E2E-012 first) - This test must be run serially and never in parallel with other tests @@ -682,7 +682,7 @@ 4. Click "Update Credentials" option 5. Verify update credentials page is loaded 6. Verify OCI credentials form fields are visible (confirms providerUid is loaded) -7. Fill OCI credentials (user ID, fingerprint, key content) +7. Fill OCI credentials (user ID, fingerprint, key content, home region) 8. Click Next to submit 9. Verify successful navigation to test connection page diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 4052a1d4c0..4cdd0aa63c 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -954,6 +954,7 @@ test.describe("Add Provider", () => { const userId = process.env.E2E_OCI_USER_ID ?? ""; const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? ""; const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? ""; + const homeRegion = process.env.E2E_OCI_REGION ?? "us-ashburn-1"; // Setup before each test test.beforeEach(async ({ page }) => { @@ -995,6 +996,7 @@ test.describe("Add Provider", () => { userId: userId, fingerprint: fingerprint, keyContent: keyContent, + homeRegion: homeRegion, }; // Navigate to providers page @@ -1439,6 +1441,7 @@ test.describe("Update Provider Credentials", () => { const userId = process.env.E2E_OCI_USER_ID ?? ""; const fingerprint = process.env.E2E_OCI_FINGERPRINT ?? ""; const keyContent = process.env.E2E_OCI_KEY_CONTENT ?? ""; + const homeRegion = process.env.E2E_OCI_REGION ?? "us-ashburn-1"; // Setup before each test test.beforeEach(async ({ page }) => { @@ -1465,6 +1468,7 @@ test.describe("Update Provider Credentials", () => { userId: userId, fingerprint: fingerprint, keyContent: keyContent, + homeRegion: homeRegion, }; // Navigate to providers page diff --git a/ui/types/components.ts b/ui/types/components.ts index 4053984241..340f0c77d9 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -281,6 +281,7 @@ export type OCICredentials = { [ProviderCredentialFields.OCI_FINGERPRINT]: string; [ProviderCredentialFields.OCI_KEY_CONTENT]: string; [ProviderCredentialFields.OCI_TENANCY]: string; + [ProviderCredentialFields.OCI_REGION]: string; [ProviderCredentialFields.OCI_PASS_PHRASE]?: string; [ProviderCredentialFields.PROVIDER_ID]: string; }; diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts index b2532f6b95..2fb81bf73e 100644 --- a/ui/types/env.d.ts +++ b/ui/types/env.d.ts @@ -151,6 +151,7 @@ declare global { E2E_OCI_USER_ID?: string; E2E_OCI_FINGERPRINT?: string; E2E_OCI_KEY_CONTENT?: string; + E2E_OCI_REGION?: string; // E2E Alibaba Cloud E2E_ALIBABACLOUD_ACCOUNT_ID?: string; diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts index ddb5f4e301..8206ed3311 100644 --- a/ui/types/formSchemas.test.ts +++ b/ui/types/formSchemas.test.ts @@ -307,11 +307,33 @@ describe("addCredentialsFormSchema - oraclecloud", () => { [ProviderCredentialFields.OCI_TENANCY]: "ocid1.tenancy.oc1..example", } as const; - it("accepts OCI API key credentials without region", () => { + it("rejects OCI API key credentials without a home region", () => { const schema = addCredentialsFormSchema("oraclecloud"); const result = schema.safeParse(BASE_OCI_VALUES); + expect(result.success).toBe(false); + }); + + it("rejects an unknown OCI home region", () => { + const schema = addCredentialsFormSchema("oraclecloud"); + + const result = schema.safeParse({ + ...BASE_OCI_VALUES, + [ProviderCredentialFields.OCI_REGION]: "mars-north-1", + }); + + expect(result.success).toBe(false); + }); + + it("accepts OCI API key credentials with a home region", () => { + const schema = addCredentialsFormSchema("oraclecloud"); + + const result = schema.safeParse({ + ...BASE_OCI_VALUES, + [ProviderCredentialFields.OCI_REGION]: "me-abudhabi-1", + }); + expect(result.success).toBe(true); }); }); diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index d34a1c84ef..11c8bef03e 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -1,6 +1,7 @@ import yaml from "js-yaml"; import { z } from "zod"; +import { OCI_REGION_VALUES } from "@/lib/provider-credentials/oci-regions"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { validateMutelistYaml, validateYaml } from "@/lib/yaml"; import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml"; @@ -325,6 +326,14 @@ export const addCredentialsFormSchema = ( [ProviderCredentialFields.OCI_TENANCY]: z .string() .min(1, "Tenancy OCID is required"), + [ProviderCredentialFields.OCI_REGION]: z + .string() + .refine( + (region) => OCI_REGION_VALUES.includes(region), + { + error: "Home region is required", + }, + ), [ProviderCredentialFields.OCI_PASS_PHRASE]: z .union([z.string(), z.literal("")]) .optional(), diff --git a/util/update_oci_regions.py b/util/update_oci_regions.py index cb012bec58..acd858a7ec 100644 --- a/util/update_oci_regions.py +++ b/util/update_oci_regions.py @@ -178,6 +178,34 @@ def update_config_file(regions, config_file_path): logging.info(f"Updated OCI_COMMERCIAL_REGIONS with {len(regions)} regions") +def update_ui_regions_file(regions, ui_file_path): + """Rewrite OCI_COMMERCIAL_REGIONS in the UI region list used by the credentials form.""" + logging.info(f"Updating UI regions file: {ui_file_path}") + + with open(ui_file_path, "r") as f: + ui_content = f.read() + + new_regions_array = "const OCI_COMMERCIAL_REGIONS = [\n" + for region_id in regions.keys(): + new_regions_array += f' "{region_id}",\n' + new_regions_array += "];" + + pattern = r"const OCI_COMMERCIAL_REGIONS = \[[^\]]*\];" + if not re.search(pattern, ui_content): + raise Exception( + "Validation failed: OCI_COMMERCIAL_REGIONS not found in the UI regions file." + ) + updated_content = re.sub(pattern, new_regions_array, ui_content) + + if updated_content == ui_content: + logging.warning("No changes detected in UI regions file") + return + + with open(ui_file_path, "w") as f: + f.write(updated_content) + logging.info("Successfully updated UI regions file") + + def main(): """ Main execution function for OCI regions updater. @@ -201,6 +229,16 @@ def main(): update_config_file(commercial_regions, config_file_path) + ui_file_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "..", + "ui", + "lib", + "provider-credentials", + "oci-regions.ts", + ) + update_ui_regions_file(commercial_regions, ui_file_path) + logging.info("OCI regions update completed successfully") return 0 From 04511f339e2cc13857f8d876d689857429f65edf Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:42:19 +0200 Subject: [PATCH 14/21] test(ui): stabilize attack-paths refit integration test (#12896) --- .../browser-harness.integration.test.ts | 22 +++++++++++++ ui/__tests__/browser-harness.ts | 25 +++++++++++++++ .../attack-paths-page.harness.ts | 31 ++++++++++++++++--- .../attack-paths-page.integration.test.tsx | 11 +++++-- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/ui/__tests__/browser-harness.integration.test.ts b/ui/__tests__/browser-harness.integration.test.ts index a1befbc1ec..19005d0c08 100644 --- a/ui/__tests__/browser-harness.integration.test.ts +++ b/ui/__tests__/browser-harness.integration.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from "vitest"; import { BrowserHarness } from "./browser-harness"; +const QUIET_MS = 50; + /** Exposes the protected waiting helpers; no fixture or DOM is involved. */ class WaitingHarness extends BrowserHarness { constructor() { @@ -21,6 +23,10 @@ class WaitingHarness extends BrowserHarness { probeOrNull(fn: () => T | null | undefined | false): Promise { return this.waitForOrNull(fn, 200, "probe"); } + + probeStable(read: () => T): Promise { + return this.waitForStable(read, QUIET_MS, 1000, "probe"); + } } describe("BrowserHarness waiting helpers", () => { @@ -58,4 +64,20 @@ describe("BrowserHarness waiting helpers", () => { }), ).resolves.toBe("ready"); }); + + it("resolves with a value only once it has held for the quiet window", async () => { + const harness = new WaitingHarness(); + let reads = 0; + let settledAt = 0; + + // Changes on each of the first reads, then holds at 4. + const settled = await harness.probeStable(() => { + reads += 1; + if (reads === 4) settledAt = performance.now(); + return Math.min(reads, 4); + }); + + expect(settled).toBe(4); + expect(performance.now() - settledAt).toBeGreaterThanOrEqual(QUIET_MS); + }); }); diff --git a/ui/__tests__/browser-harness.ts b/ui/__tests__/browser-harness.ts index 1aa44e2946..8c4345ecf1 100644 --- a/ui/__tests__/browser-harness.ts +++ b/ui/__tests__/browser-harness.ts @@ -211,6 +211,31 @@ export abstract class BrowserHarness { } } + /** Wait until `read` returns the same value for `quietMs`, and return it. */ + protected async waitForStable( + read: () => T, + quietMs: number, + timeoutMs = 5000, + label?: string, + ): Promise { + let value = read(); + let since = performance.now(); + const settled = await this.waitFor( + () => { + const next = read(); + if (!Object.is(next, value)) { + value = next; + since = performance.now(); + return null; + } + return performance.now() - since >= quietMs ? { value } : null; + }, + timeoutMs, + label ?? `a value stable for ${quietMs}ms`, + ); + return settled.value; + } + protected async waitForText( pattern: RegExp, timeoutMs = 5000, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index 6e846dcf29..e358d9287e 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -18,6 +18,8 @@ export class AttackPathPageHarness extends BrowserHarness { private static readonly VIEWPORT_SEL = ".react-flow__viewport"; private static readonly MINIMAP_SEL = ".react-flow__minimap"; private static readonly BACKGROUND_SEL = ".react-flow__background"; + // Matches the graph's auto-fit duration; a pause this long means no fit is mid-flight. + private static readonly FIT_ANIMATION_MS = 300; private static isFindingElement(el: Element): boolean { return ( @@ -255,17 +257,31 @@ export class AttackPathPageHarness extends BrowserHarness { /** Wait until the React Flow viewport transform changes from `previous`. */ async waitForViewportChange( previous: string, - timeoutMs = 2000, + timeoutMs?: number, ): Promise { - await this.waitFor(() => this.viewportTransform !== previous, timeoutMs); + await this.waitFor( + () => this.viewportTransform !== previous, + timeoutMs, + "the viewport transform to change", + ); + } + + /** Wait until the viewport stops moving and return its settled transform. */ + async waitForViewportSettled(): Promise { + return this.waitForStable( + () => this.viewportTransform, + AttackPathPageHarness.FIT_ANIMATION_MS, + undefined, + "the viewport to settle", + ); } /** Wait until every requested node is fully contained in the graph canvas. */ async waitForNodesInViewport( nodeIds: string[], - timeoutMs = 2000, + timeoutMs?: number, ): Promise { - await this.waitFor(() => { + const allInViewport = () => { const canvas = this.q(AttackPathPageHarness.FLOW_SEL); if (!canvas) return false; @@ -282,7 +298,12 @@ export class AttackPathPageHarness extends BrowserHarness { nodeRect.bottom <= canvasRect.bottom ); }); - }, timeoutMs); + }; + await this.waitFor( + allInViewport, + timeoutMs, + `nodes ${nodeIds.join(", ")} to be in the viewport`, + ); } /** Wait until exactly `count` edges are highlighted. */ diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx index d081a58e97..d15733db06 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx @@ -407,19 +407,24 @@ describe("exploring the graph", () => { const graph = await mountWith(); await graph.executeQuery(); await graph.waitForGraphStable(3); - - const initialViewport = graph.viewportTransform; + // Settle before each capture so the next change can only come from the + // action under test, not the tail of the previous fit animation. + const initialViewport = await graph.waitForViewportSettled(); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); await graph.waitForViewportChange(initialViewport); - const contextualViewport = graph.viewportTransform; + const contextualViewport = await graph.waitForViewportSettled(); + const visibleNodeIds = graph.renderedNodeIds; await graph.fit(); await graph.waitForViewportChange(contextualViewport); + // The fit must end with the whole visible graph on screen, not just move + await graph.waitForViewportSettled(); + await graph.waitForNodesInViewport(visibleNodeIds); }); test("clicking an expanded resource re-fits the remaining visible graph", async ({ mountWith, From ed510e217dd74a8e50ebd491c02e82e015af34db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 30 Sep 2026 10:21:11 +0200 Subject: [PATCH 15/21] chore(deps): bump pyjwt to 2.14.0 for osv-scanner (#12911) --- api/changelog.d/pyjwt-2-14-0.security.md | 1 + api/pyproject.toml | 8 ++++---- api/uv.lock | 10 +++++----- mcp_server/changelog.d/pyjwt-2-14-0.security.md | 1 + mcp_server/pyproject.toml | 1 + mcp_server/uv.lock | 11 ++++++----- prowler/changelog.d/pyjwt-2-14-0.security.md | 1 + pyproject.toml | 2 +- uv.lock | 8 ++++---- 9 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 api/changelog.d/pyjwt-2-14-0.security.md create mode 100644 mcp_server/changelog.d/pyjwt-2-14-0.security.md create mode 100644 prowler/changelog.d/pyjwt-2-14-0.security.md diff --git a/api/changelog.d/pyjwt-2-14-0.security.md b/api/changelog.d/pyjwt-2-14-0.security.md new file mode 100644 index 0000000000..fab78f2937 --- /dev/null +++ b/api/changelog.d/pyjwt-2-14-0.security.md @@ -0,0 +1 @@ +`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/api/pyproject.toml b/api/pyproject.toml index 0c4eed3616..4d1b3be070 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -375,7 +375,7 @@ constraint-dependencies = [ "pydantic-core==2.41.5", "pygithub==2.8.0", "pygments==2.20.0", - "pyjwt==2.13.0", + "pyjwt==2.14.0", "pylint==3.2.5", "pymsalruntime==0.18.1", "pynacl==1.6.2", @@ -476,8 +476,8 @@ constraint-dependencies = [ # to 1.9.10 until the SDK bump propagates to the pinned master rev. # # prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies]. -# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0 -# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these +# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.14.0 +# patches GHSA-ffc3-869f-jxw9 (HMAC/PEM key-confusion); a constraint cannot satisfy these # against the SDK's hard pins, so override them to the patched versions until the SDK # bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an # override replaces the whole requirement; bare pyjwt would drop it from the consumers @@ -500,5 +500,5 @@ override-dependencies = [ "microsoft-kiota-serialization-multipart==1.9.10", "microsoft-kiota-serialization-text==1.9.10", "dulwich==1.2.5", - "pyjwt[crypto]==2.13.0" + "pyjwt[crypto]==2.14.0" ] diff --git a/api/uv.lock b/api/uv.lock index 448ddd58f2..022bdf0632 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -291,7 +291,7 @@ constraints = [ { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygithub", specifier = "==2.8.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.13.0" }, + { name = "pyjwt", specifier = "==2.14.0" }, { name = "pylint", specifier = "==3.2.5" }, { name = "pymsalruntime", specifier = "==0.18.1" }, { name = "pynacl", specifier = "==1.6.2" }, @@ -387,7 +387,7 @@ overrides = [ { name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" }, { name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" }, { name = "okta", specifier = "==3.4.2" }, - { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" }, ] [[package]] @@ -5332,11 +5332,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] diff --git a/mcp_server/changelog.d/pyjwt-2-14-0.security.md b/mcp_server/changelog.d/pyjwt-2-14-0.security.md new file mode 100644 index 0000000000..fab78f2937 --- /dev/null +++ b/mcp_server/changelog.d/pyjwt-2-14-0.security.md @@ -0,0 +1 @@ +`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 30016bf5cb..e6ae26fe32 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -81,5 +81,6 @@ constraint-dependencies = [ "cryptography==50.0.0", "joserfc==1.6.8", "mcp==1.28.1", + "pyjwt==2.14.0", "python-multipart==0.0.30" ] diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index 125087fb20..e529c9c3c4 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -13,6 +13,7 @@ constraints = [ { name = "cryptography", specifier = "==50.0.0" }, { name = "joserfc", specifier = "==1.6.8" }, { name = "mcp", specifier = "==1.28.1" }, + { name = "pyjwt", specifier = "==2.14.0" }, { name = "python-multipart", specifier = "==0.0.30" }, ] @@ -977,11 +978,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -1292,8 +1293,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/prowler/changelog.d/pyjwt-2-14-0.security.md b/prowler/changelog.d/pyjwt-2-14-0.security.md new file mode 100644 index 0000000000..fab78f2937 --- /dev/null +++ b/prowler/changelog.d/pyjwt-2-14-0.security.md @@ -0,0 +1 @@ +`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/pyproject.toml b/pyproject.toml index 0d40d8e6a9..4285938e70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -349,7 +349,7 @@ constraint-dependencies = [ "pydash==8.0.6", "pyflakes==3.2.0", "pygments==2.20.0", - "pyjwt==2.13.0", + "pyjwt==2.14.0", "pylint==3.3.4", "pynacl==1.6.2", "pyopenssl==26.4.0", diff --git a/uv.lock b/uv.lock index d231b44bd4..c8e524b709 100644 --- a/uv.lock +++ b/uv.lock @@ -181,7 +181,7 @@ constraints = [ { name = "pydash", specifier = "==8.0.6" }, { name = "pyflakes", specifier = "==3.2.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.13.0" }, + { name = "pyjwt", specifier = "==2.14.0" }, { name = "pylint", specifier = "==3.3.4" }, { name = "pynacl", specifier = "==1.6.2" }, { name = "pyopenssl", specifier = "==26.4.0" }, @@ -4296,14 +4296,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] From a006525e7847c53348d65e218acecaf7492a39ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:09:21 +0200 Subject: [PATCH 16/21] fix(api): release providers blocked by scans whose worker died (#12899) --- api/changelog.d/scan-release-stale.fixed.md | 1 + .../0101_scan_release_stale_periodic_task.py | 48 ++ .../api/tests/test_scan_dead_release_view.py | 148 +++++ api/src/backend/api/v1/views.py | 2 + api/src/backend/config/django/base.py | 6 + api/src/backend/tasks/jobs/dead_scans.py | 69 +++ api/src/backend/tasks/jobs/orphan_recovery.py | 1 + api/src/backend/tasks/tasks.py | 142 ++++- .../tasks/tests/test_scan_release_stale.py | 519 ++++++++++++++++++ 9 files changed, 918 insertions(+), 18 deletions(-) create mode 100644 api/changelog.d/scan-release-stale.fixed.md create mode 100644 api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py create mode 100644 api/src/backend/api/tests/test_scan_dead_release_view.py create mode 100644 api/src/backend/tasks/jobs/dead_scans.py create mode 100644 api/src/backend/tasks/tests/test_scan_release_stale.py diff --git a/api/changelog.d/scan-release-stale.fixed.md b/api/changelog.d/scan-release-stale.fixed.md new file mode 100644 index 0000000000..c7a986d4ee --- /dev/null +++ b/api/changelog.d/scan-release-stale.fixed.md @@ -0,0 +1 @@ +Scans whose worker was killed mid-run no longer block their provider: a scan whose task already failed, whose worker no longer answers, or that shows no progress for 12 hours is marked failed and the next queued scan runs diff --git a/api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py b/api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py new file mode 100644 index 0000000000..24f67e1c2b --- /dev/null +++ b/api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py @@ -0,0 +1,48 @@ +from django.db import migrations + +TASK_NAME = "scan-release-stale" +INTERVAL_MINUTES = 5 + + +def create_periodic_task(apps, _schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + schedule, _ = IntervalSchedule.objects.get_or_create( + every=INTERVAL_MINUTES, + period="minutes", + ) + + PeriodicTask.objects.update_or_create( + name=TASK_NAME, + defaults={ + "task": TASK_NAME, + "interval": schedule, + "enabled": True, + }, + ) + + +def delete_periodic_task(apps, _schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + PeriodicTask.objects.filter(name=TASK_NAME).delete() + + # Clean up the schedule if no other task references it + IntervalSchedule.objects.filter( + every=INTERVAL_MINUTES, + period="minutes", + periodictask__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0100_attack_paths_tmp_db_reap_periodic_task"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_periodic_task, delete_periodic_task), + ] diff --git a/api/src/backend/api/tests/test_scan_dead_release_view.py b/api/src/backend/api/tests/test_scan_dead_release_view.py new file mode 100644 index 0000000000..a55fb41e33 --- /dev/null +++ b/api/src/backend/api/tests/test_scan_dead_release_view.py @@ -0,0 +1,148 @@ +import uuid +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest +from api.models import Scan, StateChoices, Task +from celery import states +from django.urls import reverse +from django_celery_results.models import TaskResult +from rest_framework import status + +API_JSON_CONTENT_TYPE = "application/vnd.api+json" + + +def _task(tenant_id, task_status): + task_result = TaskResult.objects.create( + task_id=str(uuid.uuid4()), task_name="scan-perform", status=task_status + ) + return Task.objects.create( + id=task_result.task_id, task_runner_task=task_result, tenant_id=tenant_id + ) + + +def _dead_executing_scan(tenant, provider): + return Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Killed scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.EXECUTING, + started_at=datetime.now(UTC) - timedelta(hours=2), + task=_task(tenant.id, states.FAILURE), + ) + + +def _post_scan(client, provider): + return client.post( + reverse("scan-list"), + data={ + "data": { + "type": "scans", + "attributes": {"name": "New Scan"}, + "relationships": { + "provider": {"data": {"type": "providers", "id": str(provider.id)}} + }, + } + }, + content_type=API_JSON_CONTENT_TYPE, + ) + + +@pytest.mark.django_db +class TestScanCreateReleasesDeadScan: + @patch("tasks.tasks.perform_scan_task.apply_async") + def test_dead_scan_does_not_block_new_scan( + self, + mock_apply_async, + authenticated_client, + tenants_fixture, + aws_provider, + django_capture_on_commit_callbacks, + ): + dead = _dead_executing_scan(tenants_fixture[0], aws_provider) + + with ( + patch("tasks.jobs.dead_scans.ping_workers") as ping, + django_capture_on_commit_callbacks(execute=True), + ): + response = _post_scan(authenticated_client, aws_provider) + + ping.assert_not_called() + + assert response.status_code == status.HTTP_202_ACCEPTED + dead.refresh_from_db() + assert dead.state == StateChoices.FAILED + new_scan = Scan.objects.exclude(id=dead.id).get() + assert new_scan.task.task_runner_task.status == states.PENDING + mock_apply_async.assert_called_once() + assert mock_apply_async.call_args.kwargs["kwargs"]["scan_id"] == str( + new_scan.id + ) + + @patch("tasks.tasks.perform_scan_task.apply_async") + def test_queued_scan_behind_dead_scan_runs_first_and_new_scan_queues( + self, + mock_apply_async, + authenticated_client, + tenants_fixture, + aws_provider, + django_capture_on_commit_callbacks, + ): + tenant = tenants_fixture[0] + dead = _dead_executing_scan(tenant, aws_provider) + queued = Scan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + name="Queued scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + task=_task(tenant.id, "QUEUED"), + ) + + with ( + patch("tasks.jobs.dead_scans.ping_workers") as ping, + django_capture_on_commit_callbacks(execute=True), + ): + response = _post_scan(authenticated_client, aws_provider) + + ping.assert_not_called() + + assert response.status_code == status.HTTP_202_ACCEPTED + dead.refresh_from_db() + queued.task.task_runner_task.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert queued.task.task_runner_task.status == states.PENDING + mock_apply_async.assert_called_once() + assert mock_apply_async.call_args.kwargs["kwargs"]["scan_id"] == str(queued.id) + new_scan = Scan.objects.exclude(id__in=(dead.id, queued.id)).get() + assert new_scan.task.task_runner_task.status == "QUEUED" + + @patch("tasks.tasks.perform_scan_task.apply_async") + def test_live_scan_still_queues_new_scan( + self, + mock_apply_async, + authenticated_client, + tenants_fixture, + aws_provider, + django_capture_on_commit_callbacks, + ): + live = _dead_executing_scan(tenants_fixture[0], aws_provider) + TaskResult.objects.filter(pk=live.task.task_runner_task.pk).update( + status=states.STARTED + ) + + with ( + patch("tasks.jobs.dead_scans.ping_workers") as ping, + django_capture_on_commit_callbacks(execute=True), + ): + response = _post_scan(authenticated_client, aws_provider) + + ping.assert_not_called() + + assert response.status_code == status.HTTP_202_ACCEPTED + live.refresh_from_db() + assert live.state == StateChoices.EXECUTING + new_scan = Scan.objects.exclude(id=live.id).get() + assert new_scan.task.task_runner_task.status == "QUEUED" + mock_apply_async.assert_not_called() diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 5dc175dd42..dab9be431c 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -333,6 +333,7 @@ from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.export import get_s3_client, get_s3_presign_client from tasks.tasks import ( QUEUED_SCAN_TASK_STATE, + _release_provider_scan_slot, backfill_compliance_summaries_task, backfill_scan_resource_summaries_task, check_integration_connection_task, @@ -2808,6 +2809,7 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet): tenant_id=self.request.tenant_id, id__in=self.get_provider_queryset().values("id"), ) + _release_provider_scan_slot(self.request.tenant_id, provider.id) active_scan = get_active_provider_scan( self.request.tenant_id, provider.id ) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 664fcd1c4a..f5ec7356a8 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -321,6 +321,12 @@ UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/") CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True +# A scan is dead when its task already finished, its worker stopped answering, or it +# shows no progress (`updated_at`) for the backstop; a dispatched scan that never +# started is dead after the dispatch age. +SCAN_STALE_BACKSTOP_HOURS = env.int("SCAN_STALE_BACKSTOP_HOURS", 12) +SCAN_DISPATCH_STALE_HOURS = env.int("SCAN_DISPATCH_STALE_HOURS", 24) + # Attack Paths ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30 diff --git a/api/src/backend/tasks/jobs/dead_scans.py b/api/src/backend/tasks/jobs/dead_scans.py new file mode 100644 index 0000000000..9b6f785975 --- /dev/null +++ b/api/src/backend/tasks/jobs/dead_scans.py @@ -0,0 +1,69 @@ +from datetime import UTC, datetime, timedelta + +from api.db_router import MainRouter +from api.models import Scan, StateChoices +from celery import states +from celery.utils.log import get_task_logger +from config.django.base import SCAN_DISPATCH_STALE_HOURS, SCAN_STALE_BACKSTOP_HOURS +from django.db.models import Q +from django_celery_results.models import TaskResult +from tasks.jobs.attack_paths.cleanup import _ping_workers as ping_workers + +logger = get_task_logger(__name__) + +DISPATCHED_SCAN_TASK_STATES = (states.PENDING, states.STARTED, "PROGRESS") + +_TASK_STATUS = "task__task_runner_task__status" + + +def dead_scan_q(now: datetime) -> Q: + """Single DB-only definition of a dead scan: it will never finish on its own.""" + backstop = now - timedelta(hours=SCAN_STALE_BACKSTOP_HOURS) + dispatch_cutoff = now - timedelta(hours=SCAN_DISPATCH_STALE_HOURS) + + executing = Q(state=StateChoices.EXECUTING) & ( + Q(**{f"{_TASK_STATUS}__in": states.READY_STATES}) | Q(updated_at__lt=backstop) + ) + never_started = Q( + state__in=(StateChoices.AVAILABLE, StateChoices.SCHEDULED), + task__isnull=False, + **{f"{_TASK_STATUS}__in": DISPATCHED_SCAN_TASK_STATES}, + task__task_runner_task__date_created__lt=dispatch_cutoff, + ) + return executing | never_started + + +def fail_unresponsive_scan_tasks() -> int: + """Mark the task of every executing scan whose worker no longer answers as failed. + + Workers with unknown liveness (ping error), responsive workers and scans with no + recorded worker are left alone; the latter fall to the staleness backstop. + """ + rows = list( + Scan.all_objects.using(MainRouter.admin_db) + .filter(state=StateChoices.EXECUTING, task__task_runner_task__isnull=False) + .exclude(**{f"{_TASK_STATUS}__in": states.READY_STATES}) + .exclude(task__task_runner_task__worker__isnull=True) + .exclude(task__task_runner_task__worker="") + .values_list("task__task_runner_task_id", "task__task_runner_task__worker") + ) + workers = {worker for _, worker in rows} + if not workers: + return 0 + + _, unresponsive = ping_workers(workers) + if not unresponsive: + return 0 + + updated = ( + TaskResult.objects.using(MainRouter.admin_db) + .filter(id__in=[task_id for task_id, worker in rows if worker in unresponsive]) + .exclude(status__in=states.READY_STATES) + .update(status=states.FAILURE, date_done=datetime.now(UTC)) + ) + logger.warning( + "Marked %s task(s) failed for %s unresponsive worker(s)", + updated, + len(unresponsive), + ) + return updated diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py index 7290fb313f..f184317bcf 100644 --- a/api/src/backend/tasks/jobs/orphan_recovery.py +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -86,6 +86,7 @@ _SKIP_RECOVERY = { "attack-paths-cleanup-stale-scans", "attack-paths-reap-orphaned-tmp-databases", "reconcile-orphan-tasks", + "scan-release-stale", } diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 9ec6526963..e5393a347a 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -9,7 +9,7 @@ from api.compliance import ( get_compliance_frameworks, get_prowler_provider_compliance, ) -from api.db_router import READ_REPLICA_ALIAS +from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import delete_related_daily_task, rls_transaction from api.decorators import handle_provider_deletion, set_tenant from api.exceptions import ProviderDeletedException @@ -29,6 +29,7 @@ from celery.utils.log import get_task_logger from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from django.db import transaction +from django.db.models import Q from django_celery_beat.models import PeriodicTask from django_celery_results.models import TaskResult from prowler.lib.check.compliance_models import Compliance @@ -58,6 +59,11 @@ from tasks.jobs.connection import ( check_lighthouse_connection, check_provider_connection, ) +from tasks.jobs.dead_scans import ( + DISPATCHED_SCAN_TASK_STATES, + dead_scan_q, + fail_unresponsive_scan_tasks, +) from tasks.jobs.deletion import delete_provider, delete_tenant from tasks.jobs.export import ( COMPLIANCE_CLASS_MAP, @@ -103,18 +109,19 @@ from tasks.utils import ( logger = get_task_logger(__name__) QUEUED_SCAN_TASK_STATE = "QUEUED" -DISPATCHED_SCAN_TASK_STATES = (states.PENDING, states.STARTED, "PROGRESS") def _get_dispatched_provider_scan(tenant_id: str, provider_id: str): - """Return a scan that has already been dispatched for a provider.""" + """Return a live scan that has already been dispatched for a provider.""" + dead = dead_scan_q(datetime.now(UTC)) executing_scan = ( - Scan.objects.select_for_update() + Scan.objects.select_for_update(of=("self",)) .filter( tenant_id=tenant_id, provider_id=provider_id, state=StateChoices.EXECUTING, ) + .exclude(dead) .order_by("-inserted_at") .first() ) @@ -131,6 +138,7 @@ def _get_dispatched_provider_scan(tenant_id: str, provider_id: str): task__isnull=False, task__task_runner_task__status__in=DISPATCHED_SCAN_TASK_STATES, ) + .exclude(dead) .order_by("-inserted_at") .first() ) @@ -258,28 +266,64 @@ def _get_or_create_queued_scheduled_scan( ) +def _dispatch_queued_provider_scan_locked(tenant_id: str, provider_id: str): + """Dispatch the oldest queued scan; the caller holds the provider lock.""" + if _get_dispatched_provider_scan(tenant_id, provider_id): + return None + + queued_scan = _get_queued_provider_scan(tenant_id, provider_id) + if not queued_scan or not queued_scan.task: + return None + + task_result = queued_scan.task.task_runner_task + task_result.status = states.PENDING + task_result.task_name = "scan-perform" + task_result.save(update_fields=["status", "task_name"]) + enqueue_scan_execution_on_commit( + tenant_id=tenant_id, + scan=queued_scan, + task_id=str(queued_scan.task_id), + ) + return queued_scan + + def _dispatch_next_queued_provider_scan(tenant_id: str, provider_id: str): with rls_transaction(tenant_id): if not Provider.objects.select_for_update().filter(pk=provider_id).exists(): return None - if _get_dispatched_provider_scan(tenant_id, provider_id): - return None + return _dispatch_queued_provider_scan_locked(tenant_id, provider_id) - queued_scan = _get_queued_provider_scan(tenant_id, provider_id) - if not queued_scan or not queued_scan.task: - return None - task_result = queued_scan.task.task_runner_task - task_result.status = states.PENDING - task_result.task_name = "scan-perform" - task_result.save(update_fields=["status", "task_name"]) - enqueue_scan_execution_on_commit( - tenant_id=tenant_id, - scan=queued_scan, - task_id=str(queued_scan.task_id), +def _release_provider_scan_slot(tenant_id: str, provider_id: str): + """Fail the provider's dead scans and dispatch the next queued one. + + Must run inside a transaction that already holds the provider lock. + Returns the dispatched scan, or None. + """ + now = datetime.now(UTC) + dead_scans = list( + Scan.objects.select_for_update(of=("self",)) + .select_related("task__task_runner_task") + .filter(tenant_id=tenant_id, provider_id=provider_id) + .filter(dead_scan_q(now)) + ) + for scan in dead_scans: + logger.warning( + "Scan %s of provider %s has no live worker; marking it failed", + scan.id, + provider_id, ) - return queued_scan + scan.state = StateChoices.FAILED + scan.completed_at = now + scan.save(update_fields=["state", "completed_at", "updated_at"]) + task_result = scan.task.task_runner_task if scan.task else None + if task_result and task_result.status not in states.READY_STATES: + task_result.status = states.FAILURE + task_result.date_done = now + task_result.save(update_fields=["status", "date_done"]) + + return _dispatch_queued_provider_scan_locked(tenant_id, provider_id) def _dispatch_next_queued_provider_scan_best_effort( @@ -293,6 +337,51 @@ def _dispatch_next_queued_provider_scan_best_effort( ) +def release_stale_scans() -> dict: + """Run the per-provider healer for every provider with a dead or queued scan.""" + dispatched = failed = 0 + try: + unresponsive_tasks = fail_unresponsive_scan_tasks() + except Exception: + unresponsive_tasks = 0 + logger.exception("Failed to check scan workers for liveness") + + now = datetime.now(UTC) + queued = Q( + state=StateChoices.AVAILABLE, + task__isnull=False, + task__task_runner_task__status=QUEUED_SCAN_TASK_STATE, + ) + candidates = list( + Scan.all_objects.using(MainRouter.admin_db) + .filter(dead_scan_q(now) | queued) + .values_list("tenant_id", "provider_id") + .distinct() + ) + for tenant_id, provider_id in candidates: + try: + with rls_transaction(str(tenant_id)): + if ( + not Provider.objects.select_for_update() + .filter(pk=provider_id) + .exists() + ): + continue + if _release_provider_scan_slot(str(tenant_id), str(provider_id)): + dispatched += 1 + except Exception: + failed += 1 + logger.exception( + "Failed to release stale scans for provider %s", provider_id + ) + return { + "providers_checked": len(candidates), + "unresponsive_tasks": unresponsive_tasks, + "dispatched": dispatched, + "failed": failed, + } + + def _get_or_create_next_scheduled_scan( tenant_id: str, provider_id: str, @@ -621,6 +710,17 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): scheduler_task_id=periodic_task_instance.id, ) + released_scan = _release_provider_scan_slot(tenant_id, provider_id) + if released_scan and released_scan.trigger == Scan.TriggerChoices.SCHEDULED: + # The released queued scan is this tick's run. + _get_or_create_next_scheduled_scan( + tenant_id=tenant_id, + provider_id=provider_id, + periodic_task_instance=periodic_task_instance, + next_scan_datetime=next_scan_datetime, + ) + return ScanTaskSerializer(instance=released_scan).data + active_scan = get_active_provider_scan(tenant_id, provider_id) if active_scan: logger.warning( @@ -735,6 +835,12 @@ def reap_orphaned_attack_paths_tmp_databases_task(): return reap_orphaned_tmp_databases() +@shared_task(name="scan-release-stale", queue="celery") +def release_stale_scans_task(): + """Periodic watchdog: fail dead scans and unblock providers nobody requests.""" + return release_stale_scans() + + @shared_task(name="reconcile-orphan-tasks", queue="celery") def reconcile_orphan_tasks_task(): """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes).""" diff --git a/api/src/backend/tasks/tests/test_scan_release_stale.py b/api/src/backend/tasks/tests/test_scan_release_stale.py new file mode 100644 index 0000000000..ec1be61372 --- /dev/null +++ b/api/src/backend/tasks/tests/test_scan_release_stale.py @@ -0,0 +1,519 @@ +import uuid +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest +from api.models import Scan, StateChoices, Task +from celery import states +from django_celery_beat.models import IntervalSchedule, PeriodicTask +from django_celery_results.models import TaskResult +from tasks.jobs.dead_scans import dead_scan_q +from tasks.tasks import ( + _release_provider_scan_slot, + perform_scheduled_scan_task, + release_stale_scans, +) + +PING = "tasks.jobs.dead_scans.ping_workers" +BACKSTOP_OVER = timedelta(hours=12, minutes=5) +BACKSTOP_UNDER = timedelta(hours=11, minutes=55) +DISPATCH_OVER = timedelta(hours=24, minutes=5) +DISPATCH_UNDER = timedelta(hours=23, minutes=55) + + +def _task(tenant_id, status, worker=None, date_created=None): + task_result = TaskResult.objects.create( + task_id=str(uuid.uuid4()), + task_name="scan-perform", + status=status, + worker=worker, + ) + if date_created: + TaskResult.objects.filter(pk=task_result.pk).update(date_created=date_created) + return Task.objects.create( + id=task_result.task_id, task_runner_task=task_result, tenant_id=tenant_id + ) + + +def _executing( + tenant, + provider, + task_status=states.FAILURE, + worker=None, + idle=None, + trigger=Scan.TriggerChoices.MANUAL, +): + scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Executing scan", + trigger=trigger, + state=StateChoices.EXECUTING, + started_at=datetime.now(UTC) - timedelta(hours=1), + task=_task(tenant.id, task_status, worker=worker), + ) + if idle: + Scan.objects.filter(pk=scan.pk).update(updated_at=datetime.now(UTC) - idle) + return scan + + +def _queued(tenant, provider, trigger=Scan.TriggerChoices.MANUAL): + return Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Queued scan", + trigger=trigger, + state=StateChoices.AVAILABLE, + task=_task(tenant.id, "QUEUED"), + ) + + +def _dispatched(tenant, provider, task_status, task_age): + return Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + task=_task(tenant.id, task_status, date_created=datetime.now(UTC) - task_age), + ) + + +def _dead_ids(tenant_id): + return set( + Scan.objects.filter(tenant_id=tenant_id) + .filter(dead_scan_q(datetime.now(UTC))) + .values_list("id", flat=True) + ) + + +@pytest.mark.django_db +class TestDeadScanQ: + @pytest.mark.parametrize("task_status", sorted(states.READY_STATES)) + def test_executing_with_finished_task_is_dead( + self, task_status, tenants_fixture, aws_provider + ): + scan = _executing(tenants_fixture[0], aws_provider, task_status=task_status) + assert _dead_ids(tenants_fixture[0].id) == {scan.id} + + def test_executing_with_running_task_is_alive(self, tenants_fixture, aws_provider): + _executing(tenants_fixture[0], aws_provider, task_status=states.STARTED) + assert _dead_ids(tenants_fixture[0].id) == set() + + def test_backstop_over_and_under_twelve_hours(self, tenants_fixture, aws_provider): + tenant = tenants_fixture[0] + over = _executing( + tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_OVER + ) + _executing( + tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_UNDER + ) + assert _dead_ids(tenant.id) == {over.id} + + def test_dispatched_never_started_over_and_under_twenty_four_hours( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + over = _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_OVER) + _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_UNDER) + assert _dead_ids(tenant.id) == {over.id} + + def test_queued_and_completed_scans_are_not_dead( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + _queued(tenant, aws_provider) + Scan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + ) + assert _dead_ids(tenant.id) == set() + + +@pytest.mark.django_db +class TestReleaseProviderScanSlot: + def test_fails_dead_scan_and_dispatches_queued( + self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks + ): + tenant = tenants_fixture[0] + dead = _executing(tenant, aws_provider, task_status=states.STARTED) + TaskResult.objects.filter(pk=dead.task.task_runner_task.pk).update( + status=states.FAILURE + ) + queued = _queued(tenant, aws_provider) + + with patch("tasks.tasks.perform_scan_task.apply_async") as publish: + with django_capture_on_commit_callbacks(execute=True): + released = _release_provider_scan_slot( + str(tenant.id), str(aws_provider.id) + ) + + assert released.id == queued.id + publish.assert_called_once() + dead.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert dead.completed_at is not None + queued.task.task_runner_task.refresh_from_db() + assert queued.task.task_runner_task.status == states.PENDING + + def test_fails_dead_scan_without_queue(self, tenants_fixture, aws_provider): + tenant = tenants_fixture[0] + dead = _executing(tenant, aws_provider) + + assert _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) is None + dead.refresh_from_db() + assert dead.state == StateChoices.FAILED + + def test_backstop_scan_gets_task_marked_failed(self, tenants_fixture, aws_provider): + tenant = tenants_fixture[0] + dead = _executing( + tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_OVER + ) + + _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) + + dead.refresh_from_db() + dead.task.task_runner_task.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert dead.task.task_runner_task.status == states.FAILURE + assert dead.task.task_runner_task.date_done is not None + + def test_live_scan_is_kept_and_queue_stays(self, tenants_fixture, aws_provider): + tenant = tenants_fixture[0] + live = _executing( + tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_UNDER + ) + queued = _queued(tenant, aws_provider) + + assert _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) is None + live.refresh_from_db() + queued.task.task_runner_task.refresh_from_db() + assert live.state == StateChoices.EXECUTING + assert queued.task.task_runner_task.status == "QUEUED" + + def test_stale_dispatched_scan_is_failed_and_recent_one_kept( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + old = _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_OVER) + recent = _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_UNDER) + + _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) + + old.refresh_from_db() + recent.refresh_from_db() + assert old.state == StateChoices.FAILED + assert recent.state == StateChoices.AVAILABLE + + +@pytest.mark.django_db +class TestScheduledScanHealsDeadScan: + def _periodic_task(self, provider_id, tenant_id): + interval, _ = IntervalSchedule.objects.get_or_create(every=24, period="hours") + return PeriodicTask.objects.create( + name=f"scan-perform-scheduled-{provider_id}", + task="scan-perform-scheduled", + interval=interval, + kwargs=f'{{"tenant_id": "{tenant_id}", "provider_id": "{provider_id}"}}', + enabled=True, + ) + + def _run(self, tenant, provider, task_id): + task_result = TaskResult.objects.create( + task_id=task_id, + task_name="scan-perform-scheduled", + status="STARTED", + date_created=datetime.now(UTC), + ) + Task.objects.create( + id=task_id, task_runner_task=task_result, tenant_id=tenant.id + ) + request = perform_scheduled_scan_task.request + previous = getattr(request, "id", None) + request.id = task_id + try: + with ( + patch("tasks.tasks.perform_prowler_scan") as scan, + patch("tasks.tasks.reconcile_scan_mute_rules"), + patch("tasks.tasks._perform_scan_complete_tasks"), + patch(PING) as ping, + ): + result = perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=str(provider.id) + ) + ping.assert_not_called() + return result, scan + finally: + request.id = previous + + def test_runs_scheduled_scan_when_dead_scan_blocks_provider( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + self._periodic_task(aws_provider.id, tenant.id) + dead = _executing(tenant, aws_provider) + + result, scan = self._run(tenant, aws_provider, str(uuid.uuid4())) + + dead.refresh_from_db() + assert dead.state == StateChoices.FAILED + scan.assert_called_once() + assert result is not None + + def test_dead_scan_with_queued_scheduled_scan_returns_that_scan( + self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks + ): + tenant = tenants_fixture[0] + self._periodic_task(aws_provider.id, tenant.id) + _executing(tenant, aws_provider) + queued = _queued(tenant, aws_provider, trigger=Scan.TriggerChoices.SCHEDULED) + + with patch("tasks.tasks.perform_scan_task.apply_async") as publish: + with django_capture_on_commit_callbacks(execute=True): + result, scan = self._run(tenant, aws_provider, str(uuid.uuid4())) + + assert result["id"] == str(queued.id) + publish.assert_called_once() + scan.assert_not_called() + assert ( + Scan.objects.filter( + provider=aws_provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.AVAILABLE, + ).count() + == 1 + ) + assert Scan.objects.filter( + provider=aws_provider, state=StateChoices.SCHEDULED + ).exists() + + def test_dead_scan_with_queued_manual_scan_queues_scheduled_run( + self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks + ): + tenant = tenants_fixture[0] + self._periodic_task(aws_provider.id, tenant.id) + _executing(tenant, aws_provider) + manual = _queued(tenant, aws_provider) + + with patch("tasks.tasks.perform_scan_task.apply_async") as publish: + with django_capture_on_commit_callbacks(execute=True): + result, scan = self._run(tenant, aws_provider, str(uuid.uuid4())) + + publish.assert_called_once() + scan.assert_not_called() + assert result["id"] != str(manual.id) + queued_scheduled = Scan.objects.get(id=result["id"]) + assert queued_scheduled.task.task_runner_task.status == "QUEUED" + + +@pytest.mark.django_db +class TestReleaseStaleScansSweeper: + def test_fails_dead_scan_without_queue(self, tenants_fixture, aws_provider): + tenant = tenants_fixture[0] + dead = _executing(tenant, aws_provider) + + with patch(PING) as ping: + counts = release_stale_scans() + + ping.assert_not_called() + dead.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert counts["dispatched"] == 0 + assert counts["failed"] == 0 + + def test_dispatches_queued_scan_behind_dead_scan( + self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks + ): + tenant = tenants_fixture[0] + dead = _executing(tenant, aws_provider) + queued = _queued(tenant, aws_provider) + + with patch("tasks.tasks.perform_scan_task.apply_async") as publish: + with django_capture_on_commit_callbacks(execute=True): + counts = release_stale_scans() + + dead.refresh_from_db() + queued.task.task_runner_task.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert queued.task.task_runner_task.status == states.PENDING + assert counts["dispatched"] == 1 + publish.assert_called_once() + + def test_unresponsive_worker_scan_is_released_in_the_same_run( + self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks + ): + tenant = tenants_fixture[0] + dead = _executing( + tenant, aws_provider, task_status=states.STARTED, worker="w-dead@host" + ) + queued = _queued(tenant, aws_provider) + + with ( + patch(PING, return_value=(set(), {"w-dead@host"})) as ping, + patch("tasks.tasks.perform_scan_task.apply_async") as publish, + ): + with django_capture_on_commit_callbacks(execute=True): + counts = release_stale_scans() + + ping.assert_called_once_with({"w-dead@host"}) + dead.refresh_from_db() + dead.task.task_runner_task.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert dead.task.task_runner_task.status == states.FAILURE + assert dead.task.task_runner_task.date_done is not None + assert counts["unresponsive_tasks"] == 1 + assert counts["dispatched"] == 1 + publish.assert_called_once() + queued.task.task_runner_task.refresh_from_db() + assert queued.task.task_runner_task.status == states.PENDING + + def test_other_tasks_of_an_unresponsive_worker_are_left_to_orphan_recovery( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + _executing( + tenant, aws_provider, task_status=states.STARTED, worker="w-dead@host" + ) + summary = TaskResult.objects.create( + task_id=str(uuid.uuid4()), + task_name="scan-summary", + status=states.STARTED, + worker="w-dead@host", + ) + + with patch(PING, return_value=(set(), {"w-dead@host"})): + counts = release_stale_scans() + + summary.refresh_from_db() + assert summary.status == states.STARTED + assert counts["unresponsive_tasks"] == 1 + + def test_responsive_worker_scan_is_preserved(self, tenants_fixture, aws_provider): + live = _executing( + tenants_fixture[0], + aws_provider, + task_status=states.STARTED, + worker="w-live@host", + ) + + with patch(PING, return_value=({"w-live@host"}, set())): + release_stale_scans() + + live.refresh_from_db() + live.task.task_runner_task.refresh_from_db() + assert live.state == StateChoices.EXECUTING + assert live.task.task_runner_task.status == states.STARTED + + def test_unknown_liveness_is_preserved(self, tenants_fixture, aws_provider): + scan = _executing( + tenants_fixture[0], + aws_provider, + task_status=states.STARTED, + worker="w-unknown@host", + ) + + with patch(PING, return_value=(set(), None)): + release_stale_scans() + + scan.refresh_from_db() + assert scan.state == StateChoices.EXECUTING + + def test_scan_without_worker_is_left_to_the_backstop( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + no_worker = _executing(tenant, aws_provider, task_status=states.STARTED) + + with patch(PING) as ping: + release_stale_scans() + + ping.assert_not_called() + no_worker.refresh_from_db() + assert no_worker.state == StateChoices.EXECUTING + + Scan.objects.filter(pk=no_worker.pk).update( + updated_at=datetime.now(UTC) - BACKSTOP_OVER + ) + with patch(PING) as ping: + release_stale_scans() + + ping.assert_not_called() + no_worker.refresh_from_db() + assert no_worker.state == StateChoices.FAILED + + def test_ping_failure_does_not_stop_the_release( + self, tenants_fixture, aws_provider + ): + dead = _executing(tenants_fixture[0], aws_provider) + alive = _executing( + tenants_fixture[0], + aws_provider, + task_status=states.STARTED, + worker="w@host", + ) + + with patch(PING, side_effect=RuntimeError("broker down")): + counts = release_stale_scans() + + dead.refresh_from_db() + alive.refresh_from_db() + assert dead.state == StateChoices.FAILED + assert alive.state == StateChoices.EXECUTING + assert counts["unresponsive_tasks"] == 0 + + def test_backstop_scan_is_reaped_and_recent_one_kept( + self, tenants_fixture, aws_provider + ): + tenant = tenants_fixture[0] + over = _executing( + tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_OVER + ) + under = _executing( + tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_UNDER + ) + + release_stale_scans() + + over.refresh_from_db() + under.refresh_from_db() + assert over.state == StateChoices.FAILED + assert under.state == StateChoices.EXECUTING + + def test_handles_two_tenants(self, tenants_fixture, aws_provider, provider_factory): + tenant_a, tenant_b = tenants_fixture[0], tenants_fixture[1] + provider_b = provider_factory(tenant=tenant_b) + dead_a = _executing(tenant_a, aws_provider) + dead_b = _executing( + tenant_b, provider_b, task_status=states.STARTED, worker="w-b@host" + ) + + with patch(PING, return_value=(set(), {"w-b@host"})): + counts = release_stale_scans() + + dead_a.refresh_from_db() + dead_b.refresh_from_db() + assert dead_a.state == StateChoices.FAILED + assert dead_b.state == StateChoices.FAILED + assert counts["providers_checked"] == 2 + + def test_one_provider_failure_does_not_stop_the_rest( + self, tenants_fixture, aws_provider, provider_factory + ): + tenant_a, tenant_b = tenants_fixture[0], tenants_fixture[1] + provider_b = provider_factory(tenant=tenant_b) + _executing(tenant_a, aws_provider) + dead_b = _executing(tenant_b, provider_b) + real = _release_provider_scan_slot + + def flaky(tenant_id, provider_id): + if provider_id == str(aws_provider.id): + raise RuntimeError("boom") + return real(tenant_id, provider_id) + + with patch("tasks.tasks._release_provider_scan_slot", side_effect=flaky): + counts = release_stale_scans() + + dead_b.refresh_from_db() + assert dead_b.state == StateChoices.FAILED + assert counts["failed"] == 1 From b8ca30400b240fa1667e082a43c22a9333b31fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:28:42 +0200 Subject: [PATCH 17/21] fix(api): stop sending personal data to Sentry (#12912) --- .../sentry-no-personal-data.security.md | 1 + api/src/backend/api/tests/test_sentry.py | 15 +++++++++++++++ api/src/backend/config/settings/sentry.py | 6 +++--- 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 api/changelog.d/sentry-no-personal-data.security.md diff --git a/api/changelog.d/sentry-no-personal-data.security.md b/api/changelog.d/sentry-no-personal-data.security.md new file mode 100644 index 0000000000..b0c7d6d5fe --- /dev/null +++ b/api/changelog.d/sentry-no-personal-data.security.md @@ -0,0 +1 @@ +Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials diff --git a/api/src/backend/api/tests/test_sentry.py b/api/src/backend/api/tests/test_sentry.py index 14308f0cb6..c83861a2e4 100644 --- a/api/src/backend/api/tests/test_sentry.py +++ b/api/src/backend/api/tests/test_sentry.py @@ -30,6 +30,21 @@ def test_initialize_sentry_uses_configured_dsn(): assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send +def test_initialize_sentry_sends_no_personal_data(): + with ( + patch.object( + sentry_settings.env, + "str", + return_value="https://fake-public-key@sentry.example.invalid/1", + ), + patch.object(sentry_settings.sentry_sdk, "init") as mock_init, + ): + sentry_settings.initialize_sentry() + + assert mock_init.call_args.kwargs["send_default_pii"] is False + assert mock_init.call_args.kwargs["max_request_body_size"] == "never" + + def _make_log_record(msg, level=logging.ERROR, name="test", args=None): """Build a real LogRecord so getMessage() works like in production.""" record = logging.LogRecord( diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index f5a593601e..1d283f9041 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -193,10 +193,10 @@ def initialize_sentry(): sentry_sdk.init( dsn=sentry_dsn, - # Add data like request headers and IP for users, - # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info before_send=before_send, - send_default_pii=True, + # No user identity, IPs, cookies, headers or request bodies: bodies carry emails and provider details. + send_default_pii=False, + max_request_body_size="never", traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02), _experiments={ # Set continuous_profiling_auto_start to True From a44a7255079a6c419e11cda8d174869691a67ceb Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:34:52 +0200 Subject: [PATCH 18/21] fix(ui): retry the first-run redirect until the add-provider wizard opens (#12914) --- ...t-run-redirect-until-wizard-opens.fixed.md | 1 + .../__tests__/onboarding-gate.test.tsx | 120 +++++++++++++++--- ui/components/onboarding/onboarding-gate.tsx | 33 ++++- ui/lib/onboarding/first-run-marker.ts | 46 ++++++- 4 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md diff --git a/ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md b/ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md new file mode 100644 index 0000000000..e2d6c268ee --- /dev/null +++ b/ui/changelog.d/ui-first-run-redirect-until-wizard-opens.fixed.md @@ -0,0 +1 @@ +First-login redirect to the add-provider wizard is retried on the next load when the navigation was cut short, on Cloud and self-hosted alike, instead of being written off after a single attempt diff --git a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx index f83c8a0385..a6796dd005 100644 --- a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx @@ -1,7 +1,15 @@ -import { render, waitFor } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { isFirstRunHandled } from "@/lib/onboarding/first-run-marker"; +import { + FIRST_RUN_MAX_ATTEMPTS, + isFirstRunHandled, +} from "@/lib/onboarding/first-run-marker"; +import { + dispatchProviderFunnel, + PROVIDER_FUNNEL_STEP, + WIZARD_OPEN_SOURCE, +} from "@/lib/provider-funnel/provider-funnel-events"; import { addProviderTour } from "@/lib/tours/add-provider.tour"; import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter"; @@ -111,7 +119,7 @@ describe("OnboardingGate", () => { expect(armMock).toHaveBeenCalledOnce(); }); - it("happens only once per tenant on this browser", async () => { + it("tries again on the next load when the wizard never opened (the navigation was cut short)", async () => { // Given const { unmount } = render( , @@ -124,28 +132,83 @@ describe("OnboardingGate", () => { render(); // Then - expect(isFirstRunHandled(TENANT_A)).toBe(true); - expect(replaceMock).not.toHaveBeenCalled(); + await waitFor(() => + expect(replaceMock).toHaveBeenCalledExactlyOnceWith( + CLOUD_FIRST_RUN_HREF, + ), + ); + expect(isFirstRunHandled(TENANT_A)).toBe(false); }); - it("honours a browser-wide marker written before markers were tenant-scoped", () => { - // Given: e2e storage state and pre-existing browsers set the bare key. - window.localStorage.setItem("prowler.onboarding.first-run", "true"); - - // When - render(); - - // Then - expect(replaceMock).not.toHaveBeenCalled(); - expect(isFirstRunHandled(TENANT_A)).toBe(true); - }); - - it("still runs for a different empty tenant on the same browser", async () => { + it("is resolved once the add-provider wizard opens, so later loads leave the user alone", async () => { // Given const { unmount } = render( , ); await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + act(() => { + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED, + source: WIZARD_OPEN_SOURCE.FIRST_RUN, + }); + }); + unmount(); + replaceMock.mockClear(); + + // When + render(); + + // Then + expect(isFirstRunHandled(TENANT_A)).toBe(true); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("gives up after a few attempts that never reached the wizard, so no browser is trapped", async () => { + // Given: three loads whose navigation never completed. + for (let attempt = 0; attempt < FIRST_RUN_MAX_ATTEMPTS; attempt++) { + const { unmount } = render( + , + ); + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + unmount(); + replaceMock.mockClear(); + } + + // When + render(); + + // Then + expect(isFirstRunHandled(TENANT_A)).toBe(true); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it.each(["true", "1legacy", "-1"])( + "honours a browser-wide marker holding %s, written before markers counted attempts", + (value) => { + // Given: e2e storage state and pre-existing browsers set the bare key. + window.localStorage.setItem("prowler.onboarding.first-run", value); + + // When + render(); + + // Then + expect(replaceMock).not.toHaveBeenCalled(); + expect(isFirstRunHandled(TENANT_A)).toBe(true); + }, + ); + + it("still runs for a different empty tenant on the same browser", async () => { + // Given: tenant A went through its first run on this browser. + const { unmount } = render( + , + ); + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + act(() => { + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED, + source: WIZARD_OPEN_SOURCE.FIRST_RUN, + }); + }); unmount(); replaceMock.mockClear(); @@ -154,7 +217,8 @@ describe("OnboardingGate", () => { // Then await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); - expect(isFirstRunHandled(TENANT_B)).toBe(true); + expect(isFirstRunHandled(TENANT_A)).toBe(true); + expect(isFirstRunHandled(TENANT_B)).toBe(false); }); }); @@ -172,6 +236,24 @@ describe("OnboardingGate", () => { ); expect(armMock).not.toHaveBeenCalled(); }); + + it("tries again on the next load when the wizard never opened, with no tenant id available", async () => { + // Given: self-hosted layouts mount the gate without a tenant id. + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const { unmount } = render(); + await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce()); + unmount(); + replaceMock.mockClear(); + + // When + render(); + + // Then + await waitFor(() => + expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF), + ); + expect(isFirstRunHandled()).toBe(false); + }); }); describe("when the user cannot add providers", () => { diff --git a/ui/components/onboarding/onboarding-gate.tsx b/ui/components/onboarding/onboarding-gate.tsx index 6f76318415..f2a56cdaac 100644 --- a/ui/components/onboarding/onboarding-gate.tsx +++ b/ui/components/onboarding/onboarding-gate.tsx @@ -12,8 +12,14 @@ import { import { isFirstRunHandled, markFirstRunHandled, + recordFirstRunAttempt, } from "@/lib/onboarding/first-run-marker"; -import { WIZARD_OPEN_SOURCE } from "@/lib/provider-funnel/provider-funnel-events"; +import { + PROVIDER_FUNNEL_EVENT, + PROVIDER_FUNNEL_STEP, + type ProviderFunnelDetail, + WIZARD_OPEN_SOURCE, +} from "@/lib/provider-funnel/provider-funnel-events"; import { buildAddProviderHref } from "@/lib/providers-navigation"; import { isCloud } from "@/lib/shared/env"; import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter"; @@ -28,7 +34,8 @@ interface OnboardingGateProps { } // New-tenant gate. Mounted once in the layout: an empty tenant is sent straight to -// the add-provider wizard, once per tenant and browser. Renders nothing. +// the add-provider wizard, retried per load until the wizard opens once for that +// tenant on this browser (bounded attempts). Renders nothing. export function OnboardingGate({ hasProviders, tenantId = null, @@ -77,17 +84,29 @@ function FirstRunRedirect({ flow, tenantId }: FirstRunRedirectProps) { return; } - markFirstRunHandled(tenantId); + // The wizard opening resolves the first run, whether this redirect got there + // or the user opened it on their own. Until then each load retries, bounded + // by the attempt count, so a navigation cut short is not the end of it. + const resolveOnWizardOpened = (event: Event) => { + const { detail } = event as CustomEvent; + if (detail?.step === PROVIDER_FUNNEL_STEP.WIZARD_OPENED) { + markFirstRunHandled(tenantId); + } + }; + window.addEventListener(PROVIDER_FUNNEL_EVENT, resolveOnWizardOpened); + recordFirstRunAttempt(tenantId); const addProviderHref = buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN); if (!isCloud()) { router.replace(addProviderHref); - return; + } else { + // Tours and the post-connect checkpoint are Cloud-only. + useOnboardingCheckpointStore.getState().arm(); + router.replace(`${addProviderHref}&onboarding=${flow.id}`); } - // Tours and the post-connect checkpoint are Cloud-only. - useOnboardingCheckpointStore.getState().arm(); - router.replace(`${addProviderHref}&onboarding=${flow.id}`); + return () => + window.removeEventListener(PROVIDER_FUNNEL_EVENT, resolveOnWizardOpened); }); return null; diff --git a/ui/lib/onboarding/first-run-marker.ts b/ui/lib/onboarding/first-run-marker.ts index 12e4a52ec9..0b57262602 100644 --- a/ui/lib/onboarding/first-run-marker.ts +++ b/ui/lib/onboarding/first-run-marker.ts @@ -3,11 +3,20 @@ // written there; without this marker an empty tenant would be redirected on // every page load. // +// The first run is resolved when the add-provider wizard actually opens, not +// when the redirect is issued: a navigation cut short (a second login, a tab +// closed mid-flight) must be retried on the next load. Each redirect counts as +// an attempt; after a few attempts that never reached the wizard the marker +// resolves anyway, so a browser can never be trapped in the redirect. +// // Scoped per tenant, like the other onboarding markers: going through the first // run in one tenant must not silence it for another one on the same browser. // The bare key is a browser-wide opt-out: written before markers were scoped, // by e2e storage state, or when no usable tenant id exists. const FIRST_RUN_MARKER_KEY = "prowler.onboarding.first-run"; +const HANDLED_VALUE = "true"; + +export const FIRST_RUN_MAX_ATTEMPTS = 3; // Tenant ids are UUIDs; anything else is refused rather than concatenated // into a storage key. @@ -21,23 +30,50 @@ export function firstRunMarkerKey(tenantId?: string | null): string { return `${FIRST_RUN_MARKER_KEY}.${tenantId.toLowerCase()}`; } +// A stored value is either an attempt count (digits only) or `HANDLED_VALUE`; +// anything else (a legacy or hand-written marker) is read as resolved. +const ATTEMPT_COUNT_PATTERN = /^\d+$/; + +function readAttempts(value: string | null): number | null { + if (value === null) return 0; + return ATTEMPT_COUNT_PATTERN.test(value) ? Number(value) : null; +} + export function isFirstRunHandled(tenantId?: string | null): boolean { if (typeof window === "undefined") return true; try { - return ( - window.localStorage.getItem(FIRST_RUN_MARKER_KEY) !== null || - window.localStorage.getItem(firstRunMarkerKey(tenantId)) !== null + // The bare key opts the whole browser out, unless it merely holds the + // attempt count of a deployment that mounts the gate without a tenant id. + const bareAttempts = readAttempts( + window.localStorage.getItem(FIRST_RUN_MARKER_KEY), ); + if (bareAttempts === null) return true; + const attempts = readAttempts( + window.localStorage.getItem(firstRunMarkerKey(tenantId)), + ); + return attempts === null || attempts >= FIRST_RUN_MAX_ATTEMPTS; } catch { // Unreadable storage must not redirect forever: treat as handled. return true; } } -export function markFirstRunHandled(tenantId?: string | null): void { +export function recordFirstRunAttempt(tenantId?: string | null): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(firstRunMarkerKey(tenantId), "true"); + const key = firstRunMarkerKey(tenantId); + const attempts = readAttempts(window.localStorage.getItem(key)); + if (attempts === null) return; + window.localStorage.setItem(key, String(attempts + 1)); + } catch { + // Non-fatal: a repeated redirect beats a thrown render. + } +} + +export function markFirstRunHandled(tenantId?: string | null): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(firstRunMarkerKey(tenantId), HANDLED_VALUE); } catch { // Non-fatal: a repeated redirect beats a thrown render. } From f0da33f4515e7a069b1243a1f0cceb48049547a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:00:50 +0200 Subject: [PATCH 19/21] revert(api): release providers blocked by scans whose worker died (#12915) --- api/changelog.d/scan-release-stale.fixed.md | 1 - .../0101_scan_release_stale_periodic_task.py | 48 -- .../api/tests/test_scan_dead_release_view.py | 148 ----- api/src/backend/api/v1/views.py | 2 - api/src/backend/config/django/base.py | 6 - api/src/backend/tasks/jobs/dead_scans.py | 69 --- api/src/backend/tasks/jobs/orphan_recovery.py | 1 - api/src/backend/tasks/tasks.py | 142 +---- .../tasks/tests/test_scan_release_stale.py | 519 ------------------ 9 files changed, 18 insertions(+), 918 deletions(-) delete mode 100644 api/changelog.d/scan-release-stale.fixed.md delete mode 100644 api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py delete mode 100644 api/src/backend/api/tests/test_scan_dead_release_view.py delete mode 100644 api/src/backend/tasks/jobs/dead_scans.py delete mode 100644 api/src/backend/tasks/tests/test_scan_release_stale.py diff --git a/api/changelog.d/scan-release-stale.fixed.md b/api/changelog.d/scan-release-stale.fixed.md deleted file mode 100644 index c7a986d4ee..0000000000 --- a/api/changelog.d/scan-release-stale.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Scans whose worker was killed mid-run no longer block their provider: a scan whose task already failed, whose worker no longer answers, or that shows no progress for 12 hours is marked failed and the next queued scan runs diff --git a/api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py b/api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py deleted file mode 100644 index 24f67e1c2b..0000000000 --- a/api/src/backend/api/migrations/0101_scan_release_stale_periodic_task.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.db import migrations - -TASK_NAME = "scan-release-stale" -INTERVAL_MINUTES = 5 - - -def create_periodic_task(apps, _schema_editor): - IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") - PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") - - schedule, _ = IntervalSchedule.objects.get_or_create( - every=INTERVAL_MINUTES, - period="minutes", - ) - - PeriodicTask.objects.update_or_create( - name=TASK_NAME, - defaults={ - "task": TASK_NAME, - "interval": schedule, - "enabled": True, - }, - ) - - -def delete_periodic_task(apps, _schema_editor): - IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") - PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") - - PeriodicTask.objects.filter(name=TASK_NAME).delete() - - # Clean up the schedule if no other task references it - IntervalSchedule.objects.filter( - every=INTERVAL_MINUTES, - period="minutes", - periodictask__isnull=True, - ).delete() - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0100_attack_paths_tmp_db_reap_periodic_task"), - ("django_celery_beat", "0019_alter_periodictasks_options"), - ] - - operations = [ - migrations.RunPython(create_periodic_task, delete_periodic_task), - ] diff --git a/api/src/backend/api/tests/test_scan_dead_release_view.py b/api/src/backend/api/tests/test_scan_dead_release_view.py deleted file mode 100644 index a55fb41e33..0000000000 --- a/api/src/backend/api/tests/test_scan_dead_release_view.py +++ /dev/null @@ -1,148 +0,0 @@ -import uuid -from datetime import UTC, datetime, timedelta -from unittest.mock import patch - -import pytest -from api.models import Scan, StateChoices, Task -from celery import states -from django.urls import reverse -from django_celery_results.models import TaskResult -from rest_framework import status - -API_JSON_CONTENT_TYPE = "application/vnd.api+json" - - -def _task(tenant_id, task_status): - task_result = TaskResult.objects.create( - task_id=str(uuid.uuid4()), task_name="scan-perform", status=task_status - ) - return Task.objects.create( - id=task_result.task_id, task_runner_task=task_result, tenant_id=tenant_id - ) - - -def _dead_executing_scan(tenant, provider): - return Scan.objects.create( - tenant_id=tenant.id, - provider=provider, - name="Killed scan", - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.EXECUTING, - started_at=datetime.now(UTC) - timedelta(hours=2), - task=_task(tenant.id, states.FAILURE), - ) - - -def _post_scan(client, provider): - return client.post( - reverse("scan-list"), - data={ - "data": { - "type": "scans", - "attributes": {"name": "New Scan"}, - "relationships": { - "provider": {"data": {"type": "providers", "id": str(provider.id)}} - }, - } - }, - content_type=API_JSON_CONTENT_TYPE, - ) - - -@pytest.mark.django_db -class TestScanCreateReleasesDeadScan: - @patch("tasks.tasks.perform_scan_task.apply_async") - def test_dead_scan_does_not_block_new_scan( - self, - mock_apply_async, - authenticated_client, - tenants_fixture, - aws_provider, - django_capture_on_commit_callbacks, - ): - dead = _dead_executing_scan(tenants_fixture[0], aws_provider) - - with ( - patch("tasks.jobs.dead_scans.ping_workers") as ping, - django_capture_on_commit_callbacks(execute=True), - ): - response = _post_scan(authenticated_client, aws_provider) - - ping.assert_not_called() - - assert response.status_code == status.HTTP_202_ACCEPTED - dead.refresh_from_db() - assert dead.state == StateChoices.FAILED - new_scan = Scan.objects.exclude(id=dead.id).get() - assert new_scan.task.task_runner_task.status == states.PENDING - mock_apply_async.assert_called_once() - assert mock_apply_async.call_args.kwargs["kwargs"]["scan_id"] == str( - new_scan.id - ) - - @patch("tasks.tasks.perform_scan_task.apply_async") - def test_queued_scan_behind_dead_scan_runs_first_and_new_scan_queues( - self, - mock_apply_async, - authenticated_client, - tenants_fixture, - aws_provider, - django_capture_on_commit_callbacks, - ): - tenant = tenants_fixture[0] - dead = _dead_executing_scan(tenant, aws_provider) - queued = Scan.objects.create( - tenant_id=tenant.id, - provider=aws_provider, - name="Queued scan", - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.AVAILABLE, - task=_task(tenant.id, "QUEUED"), - ) - - with ( - patch("tasks.jobs.dead_scans.ping_workers") as ping, - django_capture_on_commit_callbacks(execute=True), - ): - response = _post_scan(authenticated_client, aws_provider) - - ping.assert_not_called() - - assert response.status_code == status.HTTP_202_ACCEPTED - dead.refresh_from_db() - queued.task.task_runner_task.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert queued.task.task_runner_task.status == states.PENDING - mock_apply_async.assert_called_once() - assert mock_apply_async.call_args.kwargs["kwargs"]["scan_id"] == str(queued.id) - new_scan = Scan.objects.exclude(id__in=(dead.id, queued.id)).get() - assert new_scan.task.task_runner_task.status == "QUEUED" - - @patch("tasks.tasks.perform_scan_task.apply_async") - def test_live_scan_still_queues_new_scan( - self, - mock_apply_async, - authenticated_client, - tenants_fixture, - aws_provider, - django_capture_on_commit_callbacks, - ): - live = _dead_executing_scan(tenants_fixture[0], aws_provider) - TaskResult.objects.filter(pk=live.task.task_runner_task.pk).update( - status=states.STARTED - ) - - with ( - patch("tasks.jobs.dead_scans.ping_workers") as ping, - django_capture_on_commit_callbacks(execute=True), - ): - response = _post_scan(authenticated_client, aws_provider) - - ping.assert_not_called() - - assert response.status_code == status.HTTP_202_ACCEPTED - live.refresh_from_db() - assert live.state == StateChoices.EXECUTING - new_scan = Scan.objects.exclude(id=live.id).get() - assert new_scan.task.task_runner_task.status == "QUEUED" - mock_apply_async.assert_not_called() diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index dab9be431c..5dc175dd42 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -333,7 +333,6 @@ from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.export import get_s3_client, get_s3_presign_client from tasks.tasks import ( QUEUED_SCAN_TASK_STATE, - _release_provider_scan_slot, backfill_compliance_summaries_task, backfill_scan_resource_summaries_task, check_integration_connection_task, @@ -2809,7 +2808,6 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet): tenant_id=self.request.tenant_id, id__in=self.get_provider_queryset().values("id"), ) - _release_provider_scan_slot(self.request.tenant_id, provider.id) active_scan = get_active_provider_scan( self.request.tenant_id, provider.id ) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index f5ec7356a8..664fcd1c4a 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -321,12 +321,6 @@ UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/") CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True -# A scan is dead when its task already finished, its worker stopped answering, or it -# shows no progress (`updated_at`) for the backstop; a dispatched scan that never -# started is dead after the dispatch age. -SCAN_STALE_BACKSTOP_HOURS = env.int("SCAN_STALE_BACKSTOP_HOURS", 12) -SCAN_DISPATCH_STALE_HOURS = env.int("SCAN_DISPATCH_STALE_HOURS", 24) - # Attack Paths ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30 diff --git a/api/src/backend/tasks/jobs/dead_scans.py b/api/src/backend/tasks/jobs/dead_scans.py deleted file mode 100644 index 9b6f785975..0000000000 --- a/api/src/backend/tasks/jobs/dead_scans.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import UTC, datetime, timedelta - -from api.db_router import MainRouter -from api.models import Scan, StateChoices -from celery import states -from celery.utils.log import get_task_logger -from config.django.base import SCAN_DISPATCH_STALE_HOURS, SCAN_STALE_BACKSTOP_HOURS -from django.db.models import Q -from django_celery_results.models import TaskResult -from tasks.jobs.attack_paths.cleanup import _ping_workers as ping_workers - -logger = get_task_logger(__name__) - -DISPATCHED_SCAN_TASK_STATES = (states.PENDING, states.STARTED, "PROGRESS") - -_TASK_STATUS = "task__task_runner_task__status" - - -def dead_scan_q(now: datetime) -> Q: - """Single DB-only definition of a dead scan: it will never finish on its own.""" - backstop = now - timedelta(hours=SCAN_STALE_BACKSTOP_HOURS) - dispatch_cutoff = now - timedelta(hours=SCAN_DISPATCH_STALE_HOURS) - - executing = Q(state=StateChoices.EXECUTING) & ( - Q(**{f"{_TASK_STATUS}__in": states.READY_STATES}) | Q(updated_at__lt=backstop) - ) - never_started = Q( - state__in=(StateChoices.AVAILABLE, StateChoices.SCHEDULED), - task__isnull=False, - **{f"{_TASK_STATUS}__in": DISPATCHED_SCAN_TASK_STATES}, - task__task_runner_task__date_created__lt=dispatch_cutoff, - ) - return executing | never_started - - -def fail_unresponsive_scan_tasks() -> int: - """Mark the task of every executing scan whose worker no longer answers as failed. - - Workers with unknown liveness (ping error), responsive workers and scans with no - recorded worker are left alone; the latter fall to the staleness backstop. - """ - rows = list( - Scan.all_objects.using(MainRouter.admin_db) - .filter(state=StateChoices.EXECUTING, task__task_runner_task__isnull=False) - .exclude(**{f"{_TASK_STATUS}__in": states.READY_STATES}) - .exclude(task__task_runner_task__worker__isnull=True) - .exclude(task__task_runner_task__worker="") - .values_list("task__task_runner_task_id", "task__task_runner_task__worker") - ) - workers = {worker for _, worker in rows} - if not workers: - return 0 - - _, unresponsive = ping_workers(workers) - if not unresponsive: - return 0 - - updated = ( - TaskResult.objects.using(MainRouter.admin_db) - .filter(id__in=[task_id for task_id, worker in rows if worker in unresponsive]) - .exclude(status__in=states.READY_STATES) - .update(status=states.FAILURE, date_done=datetime.now(UTC)) - ) - logger.warning( - "Marked %s task(s) failed for %s unresponsive worker(s)", - updated, - len(unresponsive), - ) - return updated diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py index f184317bcf..7290fb313f 100644 --- a/api/src/backend/tasks/jobs/orphan_recovery.py +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -86,7 +86,6 @@ _SKIP_RECOVERY = { "attack-paths-cleanup-stale-scans", "attack-paths-reap-orphaned-tmp-databases", "reconcile-orphan-tasks", - "scan-release-stale", } diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e5393a347a..9ec6526963 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -9,7 +9,7 @@ from api.compliance import ( get_compliance_frameworks, get_prowler_provider_compliance, ) -from api.db_router import READ_REPLICA_ALIAS, MainRouter +from api.db_router import READ_REPLICA_ALIAS from api.db_utils import delete_related_daily_task, rls_transaction from api.decorators import handle_provider_deletion, set_tenant from api.exceptions import ProviderDeletedException @@ -29,7 +29,6 @@ from celery.utils.log import get_task_logger from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from django.db import transaction -from django.db.models import Q from django_celery_beat.models import PeriodicTask from django_celery_results.models import TaskResult from prowler.lib.check.compliance_models import Compliance @@ -59,11 +58,6 @@ from tasks.jobs.connection import ( check_lighthouse_connection, check_provider_connection, ) -from tasks.jobs.dead_scans import ( - DISPATCHED_SCAN_TASK_STATES, - dead_scan_q, - fail_unresponsive_scan_tasks, -) from tasks.jobs.deletion import delete_provider, delete_tenant from tasks.jobs.export import ( COMPLIANCE_CLASS_MAP, @@ -109,19 +103,18 @@ from tasks.utils import ( logger = get_task_logger(__name__) QUEUED_SCAN_TASK_STATE = "QUEUED" +DISPATCHED_SCAN_TASK_STATES = (states.PENDING, states.STARTED, "PROGRESS") def _get_dispatched_provider_scan(tenant_id: str, provider_id: str): - """Return a live scan that has already been dispatched for a provider.""" - dead = dead_scan_q(datetime.now(UTC)) + """Return a scan that has already been dispatched for a provider.""" executing_scan = ( - Scan.objects.select_for_update(of=("self",)) + Scan.objects.select_for_update() .filter( tenant_id=tenant_id, provider_id=provider_id, state=StateChoices.EXECUTING, ) - .exclude(dead) .order_by("-inserted_at") .first() ) @@ -138,7 +131,6 @@ def _get_dispatched_provider_scan(tenant_id: str, provider_id: str): task__isnull=False, task__task_runner_task__status__in=DISPATCHED_SCAN_TASK_STATES, ) - .exclude(dead) .order_by("-inserted_at") .first() ) @@ -266,64 +258,28 @@ def _get_or_create_queued_scheduled_scan( ) -def _dispatch_queued_provider_scan_locked(tenant_id: str, provider_id: str): - """Dispatch the oldest queued scan; the caller holds the provider lock.""" - if _get_dispatched_provider_scan(tenant_id, provider_id): - return None - - queued_scan = _get_queued_provider_scan(tenant_id, provider_id) - if not queued_scan or not queued_scan.task: - return None - - task_result = queued_scan.task.task_runner_task - task_result.status = states.PENDING - task_result.task_name = "scan-perform" - task_result.save(update_fields=["status", "task_name"]) - enqueue_scan_execution_on_commit( - tenant_id=tenant_id, - scan=queued_scan, - task_id=str(queued_scan.task_id), - ) - return queued_scan - - def _dispatch_next_queued_provider_scan(tenant_id: str, provider_id: str): with rls_transaction(tenant_id): if not Provider.objects.select_for_update().filter(pk=provider_id).exists(): return None - return _dispatch_queued_provider_scan_locked(tenant_id, provider_id) + if _get_dispatched_provider_scan(tenant_id, provider_id): + return None + queued_scan = _get_queued_provider_scan(tenant_id, provider_id) + if not queued_scan or not queued_scan.task: + return None -def _release_provider_scan_slot(tenant_id: str, provider_id: str): - """Fail the provider's dead scans and dispatch the next queued one. - - Must run inside a transaction that already holds the provider lock. - Returns the dispatched scan, or None. - """ - now = datetime.now(UTC) - dead_scans = list( - Scan.objects.select_for_update(of=("self",)) - .select_related("task__task_runner_task") - .filter(tenant_id=tenant_id, provider_id=provider_id) - .filter(dead_scan_q(now)) - ) - for scan in dead_scans: - logger.warning( - "Scan %s of provider %s has no live worker; marking it failed", - scan.id, - provider_id, + task_result = queued_scan.task.task_runner_task + task_result.status = states.PENDING + task_result.task_name = "scan-perform" + task_result.save(update_fields=["status", "task_name"]) + enqueue_scan_execution_on_commit( + tenant_id=tenant_id, + scan=queued_scan, + task_id=str(queued_scan.task_id), ) - scan.state = StateChoices.FAILED - scan.completed_at = now - scan.save(update_fields=["state", "completed_at", "updated_at"]) - task_result = scan.task.task_runner_task if scan.task else None - if task_result and task_result.status not in states.READY_STATES: - task_result.status = states.FAILURE - task_result.date_done = now - task_result.save(update_fields=["status", "date_done"]) - - return _dispatch_queued_provider_scan_locked(tenant_id, provider_id) + return queued_scan def _dispatch_next_queued_provider_scan_best_effort( @@ -337,51 +293,6 @@ def _dispatch_next_queued_provider_scan_best_effort( ) -def release_stale_scans() -> dict: - """Run the per-provider healer for every provider with a dead or queued scan.""" - dispatched = failed = 0 - try: - unresponsive_tasks = fail_unresponsive_scan_tasks() - except Exception: - unresponsive_tasks = 0 - logger.exception("Failed to check scan workers for liveness") - - now = datetime.now(UTC) - queued = Q( - state=StateChoices.AVAILABLE, - task__isnull=False, - task__task_runner_task__status=QUEUED_SCAN_TASK_STATE, - ) - candidates = list( - Scan.all_objects.using(MainRouter.admin_db) - .filter(dead_scan_q(now) | queued) - .values_list("tenant_id", "provider_id") - .distinct() - ) - for tenant_id, provider_id in candidates: - try: - with rls_transaction(str(tenant_id)): - if ( - not Provider.objects.select_for_update() - .filter(pk=provider_id) - .exists() - ): - continue - if _release_provider_scan_slot(str(tenant_id), str(provider_id)): - dispatched += 1 - except Exception: - failed += 1 - logger.exception( - "Failed to release stale scans for provider %s", provider_id - ) - return { - "providers_checked": len(candidates), - "unresponsive_tasks": unresponsive_tasks, - "dispatched": dispatched, - "failed": failed, - } - - def _get_or_create_next_scheduled_scan( tenant_id: str, provider_id: str, @@ -710,17 +621,6 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): scheduler_task_id=periodic_task_instance.id, ) - released_scan = _release_provider_scan_slot(tenant_id, provider_id) - if released_scan and released_scan.trigger == Scan.TriggerChoices.SCHEDULED: - # The released queued scan is this tick's run. - _get_or_create_next_scheduled_scan( - tenant_id=tenant_id, - provider_id=provider_id, - periodic_task_instance=periodic_task_instance, - next_scan_datetime=next_scan_datetime, - ) - return ScanTaskSerializer(instance=released_scan).data - active_scan = get_active_provider_scan(tenant_id, provider_id) if active_scan: logger.warning( @@ -835,12 +735,6 @@ def reap_orphaned_attack_paths_tmp_databases_task(): return reap_orphaned_tmp_databases() -@shared_task(name="scan-release-stale", queue="celery") -def release_stale_scans_task(): - """Periodic watchdog: fail dead scans and unblock providers nobody requests.""" - return release_stale_scans() - - @shared_task(name="reconcile-orphan-tasks", queue="celery") def reconcile_orphan_tasks_task(): """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes).""" diff --git a/api/src/backend/tasks/tests/test_scan_release_stale.py b/api/src/backend/tasks/tests/test_scan_release_stale.py deleted file mode 100644 index ec1be61372..0000000000 --- a/api/src/backend/tasks/tests/test_scan_release_stale.py +++ /dev/null @@ -1,519 +0,0 @@ -import uuid -from datetime import UTC, datetime, timedelta -from unittest.mock import patch - -import pytest -from api.models import Scan, StateChoices, Task -from celery import states -from django_celery_beat.models import IntervalSchedule, PeriodicTask -from django_celery_results.models import TaskResult -from tasks.jobs.dead_scans import dead_scan_q -from tasks.tasks import ( - _release_provider_scan_slot, - perform_scheduled_scan_task, - release_stale_scans, -) - -PING = "tasks.jobs.dead_scans.ping_workers" -BACKSTOP_OVER = timedelta(hours=12, minutes=5) -BACKSTOP_UNDER = timedelta(hours=11, minutes=55) -DISPATCH_OVER = timedelta(hours=24, minutes=5) -DISPATCH_UNDER = timedelta(hours=23, minutes=55) - - -def _task(tenant_id, status, worker=None, date_created=None): - task_result = TaskResult.objects.create( - task_id=str(uuid.uuid4()), - task_name="scan-perform", - status=status, - worker=worker, - ) - if date_created: - TaskResult.objects.filter(pk=task_result.pk).update(date_created=date_created) - return Task.objects.create( - id=task_result.task_id, task_runner_task=task_result, tenant_id=tenant_id - ) - - -def _executing( - tenant, - provider, - task_status=states.FAILURE, - worker=None, - idle=None, - trigger=Scan.TriggerChoices.MANUAL, -): - scan = Scan.objects.create( - tenant_id=tenant.id, - provider=provider, - name="Executing scan", - trigger=trigger, - state=StateChoices.EXECUTING, - started_at=datetime.now(UTC) - timedelta(hours=1), - task=_task(tenant.id, task_status, worker=worker), - ) - if idle: - Scan.objects.filter(pk=scan.pk).update(updated_at=datetime.now(UTC) - idle) - return scan - - -def _queued(tenant, provider, trigger=Scan.TriggerChoices.MANUAL): - return Scan.objects.create( - tenant_id=tenant.id, - provider=provider, - name="Queued scan", - trigger=trigger, - state=StateChoices.AVAILABLE, - task=_task(tenant.id, "QUEUED"), - ) - - -def _dispatched(tenant, provider, task_status, task_age): - return Scan.objects.create( - tenant_id=tenant.id, - provider=provider, - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.AVAILABLE, - task=_task(tenant.id, task_status, date_created=datetime.now(UTC) - task_age), - ) - - -def _dead_ids(tenant_id): - return set( - Scan.objects.filter(tenant_id=tenant_id) - .filter(dead_scan_q(datetime.now(UTC))) - .values_list("id", flat=True) - ) - - -@pytest.mark.django_db -class TestDeadScanQ: - @pytest.mark.parametrize("task_status", sorted(states.READY_STATES)) - def test_executing_with_finished_task_is_dead( - self, task_status, tenants_fixture, aws_provider - ): - scan = _executing(tenants_fixture[0], aws_provider, task_status=task_status) - assert _dead_ids(tenants_fixture[0].id) == {scan.id} - - def test_executing_with_running_task_is_alive(self, tenants_fixture, aws_provider): - _executing(tenants_fixture[0], aws_provider, task_status=states.STARTED) - assert _dead_ids(tenants_fixture[0].id) == set() - - def test_backstop_over_and_under_twelve_hours(self, tenants_fixture, aws_provider): - tenant = tenants_fixture[0] - over = _executing( - tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_OVER - ) - _executing( - tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_UNDER - ) - assert _dead_ids(tenant.id) == {over.id} - - def test_dispatched_never_started_over_and_under_twenty_four_hours( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - over = _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_OVER) - _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_UNDER) - assert _dead_ids(tenant.id) == {over.id} - - def test_queued_and_completed_scans_are_not_dead( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - _queued(tenant, aws_provider) - Scan.objects.create( - tenant_id=tenant.id, - provider=aws_provider, - trigger=Scan.TriggerChoices.MANUAL, - state=StateChoices.COMPLETED, - ) - assert _dead_ids(tenant.id) == set() - - -@pytest.mark.django_db -class TestReleaseProviderScanSlot: - def test_fails_dead_scan_and_dispatches_queued( - self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks - ): - tenant = tenants_fixture[0] - dead = _executing(tenant, aws_provider, task_status=states.STARTED) - TaskResult.objects.filter(pk=dead.task.task_runner_task.pk).update( - status=states.FAILURE - ) - queued = _queued(tenant, aws_provider) - - with patch("tasks.tasks.perform_scan_task.apply_async") as publish: - with django_capture_on_commit_callbacks(execute=True): - released = _release_provider_scan_slot( - str(tenant.id), str(aws_provider.id) - ) - - assert released.id == queued.id - publish.assert_called_once() - dead.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert dead.completed_at is not None - queued.task.task_runner_task.refresh_from_db() - assert queued.task.task_runner_task.status == states.PENDING - - def test_fails_dead_scan_without_queue(self, tenants_fixture, aws_provider): - tenant = tenants_fixture[0] - dead = _executing(tenant, aws_provider) - - assert _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) is None - dead.refresh_from_db() - assert dead.state == StateChoices.FAILED - - def test_backstop_scan_gets_task_marked_failed(self, tenants_fixture, aws_provider): - tenant = tenants_fixture[0] - dead = _executing( - tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_OVER - ) - - _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) - - dead.refresh_from_db() - dead.task.task_runner_task.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert dead.task.task_runner_task.status == states.FAILURE - assert dead.task.task_runner_task.date_done is not None - - def test_live_scan_is_kept_and_queue_stays(self, tenants_fixture, aws_provider): - tenant = tenants_fixture[0] - live = _executing( - tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_UNDER - ) - queued = _queued(tenant, aws_provider) - - assert _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) is None - live.refresh_from_db() - queued.task.task_runner_task.refresh_from_db() - assert live.state == StateChoices.EXECUTING - assert queued.task.task_runner_task.status == "QUEUED" - - def test_stale_dispatched_scan_is_failed_and_recent_one_kept( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - old = _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_OVER) - recent = _dispatched(tenant, aws_provider, states.PENDING, DISPATCH_UNDER) - - _release_provider_scan_slot(str(tenant.id), str(aws_provider.id)) - - old.refresh_from_db() - recent.refresh_from_db() - assert old.state == StateChoices.FAILED - assert recent.state == StateChoices.AVAILABLE - - -@pytest.mark.django_db -class TestScheduledScanHealsDeadScan: - def _periodic_task(self, provider_id, tenant_id): - interval, _ = IntervalSchedule.objects.get_or_create(every=24, period="hours") - return PeriodicTask.objects.create( - name=f"scan-perform-scheduled-{provider_id}", - task="scan-perform-scheduled", - interval=interval, - kwargs=f'{{"tenant_id": "{tenant_id}", "provider_id": "{provider_id}"}}', - enabled=True, - ) - - def _run(self, tenant, provider, task_id): - task_result = TaskResult.objects.create( - task_id=task_id, - task_name="scan-perform-scheduled", - status="STARTED", - date_created=datetime.now(UTC), - ) - Task.objects.create( - id=task_id, task_runner_task=task_result, tenant_id=tenant.id - ) - request = perform_scheduled_scan_task.request - previous = getattr(request, "id", None) - request.id = task_id - try: - with ( - patch("tasks.tasks.perform_prowler_scan") as scan, - patch("tasks.tasks.reconcile_scan_mute_rules"), - patch("tasks.tasks._perform_scan_complete_tasks"), - patch(PING) as ping, - ): - result = perform_scheduled_scan_task.run( - tenant_id=str(tenant.id), provider_id=str(provider.id) - ) - ping.assert_not_called() - return result, scan - finally: - request.id = previous - - def test_runs_scheduled_scan_when_dead_scan_blocks_provider( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - self._periodic_task(aws_provider.id, tenant.id) - dead = _executing(tenant, aws_provider) - - result, scan = self._run(tenant, aws_provider, str(uuid.uuid4())) - - dead.refresh_from_db() - assert dead.state == StateChoices.FAILED - scan.assert_called_once() - assert result is not None - - def test_dead_scan_with_queued_scheduled_scan_returns_that_scan( - self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks - ): - tenant = tenants_fixture[0] - self._periodic_task(aws_provider.id, tenant.id) - _executing(tenant, aws_provider) - queued = _queued(tenant, aws_provider, trigger=Scan.TriggerChoices.SCHEDULED) - - with patch("tasks.tasks.perform_scan_task.apply_async") as publish: - with django_capture_on_commit_callbacks(execute=True): - result, scan = self._run(tenant, aws_provider, str(uuid.uuid4())) - - assert result["id"] == str(queued.id) - publish.assert_called_once() - scan.assert_not_called() - assert ( - Scan.objects.filter( - provider=aws_provider, - trigger=Scan.TriggerChoices.SCHEDULED, - state=StateChoices.AVAILABLE, - ).count() - == 1 - ) - assert Scan.objects.filter( - provider=aws_provider, state=StateChoices.SCHEDULED - ).exists() - - def test_dead_scan_with_queued_manual_scan_queues_scheduled_run( - self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks - ): - tenant = tenants_fixture[0] - self._periodic_task(aws_provider.id, tenant.id) - _executing(tenant, aws_provider) - manual = _queued(tenant, aws_provider) - - with patch("tasks.tasks.perform_scan_task.apply_async") as publish: - with django_capture_on_commit_callbacks(execute=True): - result, scan = self._run(tenant, aws_provider, str(uuid.uuid4())) - - publish.assert_called_once() - scan.assert_not_called() - assert result["id"] != str(manual.id) - queued_scheduled = Scan.objects.get(id=result["id"]) - assert queued_scheduled.task.task_runner_task.status == "QUEUED" - - -@pytest.mark.django_db -class TestReleaseStaleScansSweeper: - def test_fails_dead_scan_without_queue(self, tenants_fixture, aws_provider): - tenant = tenants_fixture[0] - dead = _executing(tenant, aws_provider) - - with patch(PING) as ping: - counts = release_stale_scans() - - ping.assert_not_called() - dead.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert counts["dispatched"] == 0 - assert counts["failed"] == 0 - - def test_dispatches_queued_scan_behind_dead_scan( - self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks - ): - tenant = tenants_fixture[0] - dead = _executing(tenant, aws_provider) - queued = _queued(tenant, aws_provider) - - with patch("tasks.tasks.perform_scan_task.apply_async") as publish: - with django_capture_on_commit_callbacks(execute=True): - counts = release_stale_scans() - - dead.refresh_from_db() - queued.task.task_runner_task.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert queued.task.task_runner_task.status == states.PENDING - assert counts["dispatched"] == 1 - publish.assert_called_once() - - def test_unresponsive_worker_scan_is_released_in_the_same_run( - self, tenants_fixture, aws_provider, django_capture_on_commit_callbacks - ): - tenant = tenants_fixture[0] - dead = _executing( - tenant, aws_provider, task_status=states.STARTED, worker="w-dead@host" - ) - queued = _queued(tenant, aws_provider) - - with ( - patch(PING, return_value=(set(), {"w-dead@host"})) as ping, - patch("tasks.tasks.perform_scan_task.apply_async") as publish, - ): - with django_capture_on_commit_callbacks(execute=True): - counts = release_stale_scans() - - ping.assert_called_once_with({"w-dead@host"}) - dead.refresh_from_db() - dead.task.task_runner_task.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert dead.task.task_runner_task.status == states.FAILURE - assert dead.task.task_runner_task.date_done is not None - assert counts["unresponsive_tasks"] == 1 - assert counts["dispatched"] == 1 - publish.assert_called_once() - queued.task.task_runner_task.refresh_from_db() - assert queued.task.task_runner_task.status == states.PENDING - - def test_other_tasks_of_an_unresponsive_worker_are_left_to_orphan_recovery( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - _executing( - tenant, aws_provider, task_status=states.STARTED, worker="w-dead@host" - ) - summary = TaskResult.objects.create( - task_id=str(uuid.uuid4()), - task_name="scan-summary", - status=states.STARTED, - worker="w-dead@host", - ) - - with patch(PING, return_value=(set(), {"w-dead@host"})): - counts = release_stale_scans() - - summary.refresh_from_db() - assert summary.status == states.STARTED - assert counts["unresponsive_tasks"] == 1 - - def test_responsive_worker_scan_is_preserved(self, tenants_fixture, aws_provider): - live = _executing( - tenants_fixture[0], - aws_provider, - task_status=states.STARTED, - worker="w-live@host", - ) - - with patch(PING, return_value=({"w-live@host"}, set())): - release_stale_scans() - - live.refresh_from_db() - live.task.task_runner_task.refresh_from_db() - assert live.state == StateChoices.EXECUTING - assert live.task.task_runner_task.status == states.STARTED - - def test_unknown_liveness_is_preserved(self, tenants_fixture, aws_provider): - scan = _executing( - tenants_fixture[0], - aws_provider, - task_status=states.STARTED, - worker="w-unknown@host", - ) - - with patch(PING, return_value=(set(), None)): - release_stale_scans() - - scan.refresh_from_db() - assert scan.state == StateChoices.EXECUTING - - def test_scan_without_worker_is_left_to_the_backstop( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - no_worker = _executing(tenant, aws_provider, task_status=states.STARTED) - - with patch(PING) as ping: - release_stale_scans() - - ping.assert_not_called() - no_worker.refresh_from_db() - assert no_worker.state == StateChoices.EXECUTING - - Scan.objects.filter(pk=no_worker.pk).update( - updated_at=datetime.now(UTC) - BACKSTOP_OVER - ) - with patch(PING) as ping: - release_stale_scans() - - ping.assert_not_called() - no_worker.refresh_from_db() - assert no_worker.state == StateChoices.FAILED - - def test_ping_failure_does_not_stop_the_release( - self, tenants_fixture, aws_provider - ): - dead = _executing(tenants_fixture[0], aws_provider) - alive = _executing( - tenants_fixture[0], - aws_provider, - task_status=states.STARTED, - worker="w@host", - ) - - with patch(PING, side_effect=RuntimeError("broker down")): - counts = release_stale_scans() - - dead.refresh_from_db() - alive.refresh_from_db() - assert dead.state == StateChoices.FAILED - assert alive.state == StateChoices.EXECUTING - assert counts["unresponsive_tasks"] == 0 - - def test_backstop_scan_is_reaped_and_recent_one_kept( - self, tenants_fixture, aws_provider - ): - tenant = tenants_fixture[0] - over = _executing( - tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_OVER - ) - under = _executing( - tenant, aws_provider, task_status=states.STARTED, idle=BACKSTOP_UNDER - ) - - release_stale_scans() - - over.refresh_from_db() - under.refresh_from_db() - assert over.state == StateChoices.FAILED - assert under.state == StateChoices.EXECUTING - - def test_handles_two_tenants(self, tenants_fixture, aws_provider, provider_factory): - tenant_a, tenant_b = tenants_fixture[0], tenants_fixture[1] - provider_b = provider_factory(tenant=tenant_b) - dead_a = _executing(tenant_a, aws_provider) - dead_b = _executing( - tenant_b, provider_b, task_status=states.STARTED, worker="w-b@host" - ) - - with patch(PING, return_value=(set(), {"w-b@host"})): - counts = release_stale_scans() - - dead_a.refresh_from_db() - dead_b.refresh_from_db() - assert dead_a.state == StateChoices.FAILED - assert dead_b.state == StateChoices.FAILED - assert counts["providers_checked"] == 2 - - def test_one_provider_failure_does_not_stop_the_rest( - self, tenants_fixture, aws_provider, provider_factory - ): - tenant_a, tenant_b = tenants_fixture[0], tenants_fixture[1] - provider_b = provider_factory(tenant=tenant_b) - _executing(tenant_a, aws_provider) - dead_b = _executing(tenant_b, provider_b) - real = _release_provider_scan_slot - - def flaky(tenant_id, provider_id): - if provider_id == str(aws_provider.id): - raise RuntimeError("boom") - return real(tenant_id, provider_id) - - with patch("tasks.tasks._release_provider_scan_slot", side_effect=flaky): - counts = release_stale_scans() - - dead_b.refresh_from_db() - assert dead_b.state == StateChoices.FAILED - assert counts["failed"] == 1 From 4605d9a770eda4fc123275428540e5e41b5c2e58 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:50:10 +0200 Subject: [PATCH 20/21] feat(ui): invite a teammate from the AWS connect step (#12917) --- docs/user-guide/tutorials/prowler-app.mdx | 4 + .../invitations/invitation.adapter.test.ts | 51 +++ ui/actions/invitations/invitation.adapter.ts | 25 ++ .../invite.ts => invitations/roles.ts} | 8 +- ui/changelog.d/aws-invite-teammate.added.md | 1 + .../invitations/invitation-details.tsx | 3 +- .../forms/send-invitation-form.test.tsx | 8 +- .../workflow/forms/send-invitation-form.tsx | 106 +------ .../workflow/forms/use-send-invitation.ts | 106 +++++++ .../__tests__/onboarding-invite-step.test.tsx | 12 +- .../onboarding/onboarding-invite-dialog.tsx | 18 +- .../onboarding/onboarding-invite-step.tsx | 32 +- .../wizard/provider-wizard-modal.test.tsx | 77 ++++- .../wizard/provider-wizard-modal.tsx | 1 + .../steps/aws/aws-connect-step.test.tsx | 118 ++++++- .../wizard/steps/aws/aws-connect-step.tsx | 78 ++++- .../providers/wizard/steps/aws/types.ts | 11 + .../providers/wizard/steps/connect-step.tsx | 19 +- .../invite-teammate/invite-teammate-form.tsx | 124 ++++++++ .../invite-teammate-panel.test.tsx | 294 ++++++++++++++++++ .../invite-teammate/invite-teammate-panel.tsx | 121 +++++++ .../invite-teammate/invite-teammate-sent.tsx | 68 ++++ ui/hooks/use-invitation-roles.test.ts | 66 ++++ ui/hooks/use-invitation-roles.ts | 38 +++ ui/lib/invitations/accept-link.test.ts | 19 ++ ui/lib/invitations/accept-link.ts | 9 + ui/lib/invitations/order-roles.test.ts | 29 ++ ui/lib/invitations/order-roles.ts | 15 + .../provider-funnel/provider-funnel-events.ts | 2 + ui/store/provider-wizard/store.ts | 2 + ui/tests/providers/providers-page.ts | 40 +++ ui/tests/providers/providers.md | 49 +++ ui/tests/providers/providers.spec.ts | 39 +++ ui/types/onboarding-invite.ts | 9 + ui/types/provider-wizard.ts | 10 + 35 files changed, 1449 insertions(+), 163 deletions(-) create mode 100644 ui/actions/invitations/invitation.adapter.test.ts create mode 100644 ui/actions/invitations/invitation.adapter.ts rename ui/actions/{onboarding/invite.ts => invitations/roles.ts} (67%) create mode 100644 ui/changelog.d/aws-invite-teammate.added.md create mode 100644 ui/components/invitations/workflow/forms/use-send-invitation.ts create mode 100644 ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx create mode 100644 ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx create mode 100644 ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx create mode 100644 ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx create mode 100644 ui/hooks/use-invitation-roles.test.ts create mode 100644 ui/hooks/use-invitation-roles.ts create mode 100644 ui/lib/invitations/accept-link.test.ts create mode 100644 ui/lib/invitations/accept-link.ts create mode 100644 ui/lib/invitations/order-roles.test.ts create mode 100644 ui/lib/invitations/order-roles.ts diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index db0916e4a4..f42a4db8f8 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -93,6 +93,10 @@ After adding your cloud account credentials, click the `Check connection` button For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan.
+ +To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account. + + ## Step 6: Scan Started After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress: diff --git a/ui/actions/invitations/invitation.adapter.test.ts b/ui/actions/invitations/invitation.adapter.test.ts new file mode 100644 index 0000000000..f0edc02918 --- /dev/null +++ b/ui/actions/invitations/invitation.adapter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { toSentInvitation } from "./invitation.adapter"; + +const created = { + data: { + id: "inv-1", + type: "invitations", + attributes: { + email: "teammate@company.com", + token: "abc123DEF45678", + state: "pending", + expires_at: "2026-10-07T10:00:00Z", + }, + }, +}; + +describe("toSentInvitation", () => { + it("reads the id, email and token of a created invitation", () => { + expect(toSentInvitation(created)).toEqual({ + id: "inv-1", + email: "teammate@company.com", + token: "abc123DEF45678", + }); + }); + + it("returns null when the action resolved without a value", () => { + // A 5xx makes `sendInvite` resolve undefined. + expect(toSentInvitation(undefined)).toBeNull(); + }); + + it("returns null on a rejection, with or without an errors array", () => { + expect( + toSentInvitation({ errors: [{ detail: "Invalid email" }] }), + ).toBeNull(); + expect(toSentInvitation({ error: "Something went wrong" })).toBeNull(); + }); + + it("returns null when the record is missing any of the fields the link needs", () => { + expect( + toSentInvitation({ + data: { id: "inv-1", attributes: { email: "a@b.com" } }, + }), + ).toBeNull(); + expect( + toSentInvitation({ + data: { id: "inv-1", attributes: { token: "abc123DEF45678" } }, + }), + ).toBeNull(); + }); +}); diff --git a/ui/actions/invitations/invitation.adapter.ts b/ui/actions/invitations/invitation.adapter.ts new file mode 100644 index 0000000000..41fccefbb8 --- /dev/null +++ b/ui/actions/invitations/invitation.adapter.ts @@ -0,0 +1,25 @@ +import type { SentInvitation } from "@/types/onboarding-invite"; + +const readString = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +/** + * The created record out of `sendInvite`'s JSON:API response. Null for every + * failure shape: `undefined` (a 5xx makes the action resolve without a value), + * `{ errors }`, a bare `{ error }`, or a record missing what the link needs. + */ +export function toSentInvitation(response: unknown): SentInvitation | null { + if (!response || typeof response !== "object") return null; + const { data } = response as { data?: unknown }; + if (!data || typeof data !== "object") return null; + const { id, attributes } = data as { id?: unknown; attributes?: unknown }; + const fields = + attributes && typeof attributes === "object" + ? (attributes as Record) + : {}; + const invitationId = readString(id); + const email = readString(fields.email); + const token = readString(fields.token); + if (!invitationId || !email || !token) return null; + return { id: invitationId, email, token }; +} diff --git a/ui/actions/onboarding/invite.ts b/ui/actions/invitations/roles.ts similarity index 67% rename from ui/actions/onboarding/invite.ts rename to ui/actions/invitations/roles.ts index d7e9221056..d90695ef91 100644 --- a/ui/actions/onboarding/invite.ts +++ b/ui/actions/invitations/roles.ts @@ -5,11 +5,9 @@ import type { InvitationRoleOption } from "@/types/onboarding-invite"; const ROLES_PAGE_SIZE = 50; -// Roles the onboarding invite step can offer; empty when the read fails so -// the step can fall back to skipping rather than blocking the checkpoint. -export const getOnboardingInviteRoles = async (): Promise< - InvitationRoleOption[] -> => { +// Roles an invitation can grant; empty when the read fails so a caller can +// fall back (skip, disable) rather than block. +export const getInvitationRoles = async (): Promise => { const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE }); const roles: unknown = rolesData?.data; if (!Array.isArray(roles)) return []; diff --git a/ui/changelog.d/aws-invite-teammate.added.md b/ui/changelog.d/aws-invite-teammate.added.md new file mode 100644 index 0000000000..150bec96c1 --- /dev/null +++ b/ui/changelog.d/aws-invite-teammate.added.md @@ -0,0 +1 @@ +Option to invite a teammate from the AWS connect step when the user cannot access the account credentials diff --git a/ui/components/invitations/invitation-details.tsx b/ui/components/invitations/invitation-details.tsx index 259a1de0a4..55f1878140 100644 --- a/ui/components/invitations/invitation-details.tsx +++ b/ui/components/invitations/invitation-details.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { DateWithTime } from "@/components/shadcn/entities"; +import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link"; import { AddIcon } from "../icons"; import { Button, Card, CardContent, CardHeader } from "../shadcn"; @@ -54,7 +55,7 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => { ? window.location.origin : "http://localhost:3000"; - const invitationLink = `${baseUrl}/invitation/accept?invitation_token=${attributes.token}`; + const invitationLink = buildInvitationAcceptLink(attributes.token, baseUrl); return (
diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx index a653a31692..1bb81d44b8 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx @@ -66,7 +66,13 @@ const fillAndSubmit = async (user: ReturnType) => { describe("SendInvitationForm", () => { beforeEach(() => { pushMock.mockReset(); - sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } }); + // The API answers with the created record, token included. + sendInviteMock.mockReset().mockResolvedValue({ + data: { + id: "inv-1", + attributes: { email: "teammate@company.com", token: "abc123DEF45678" }, + }, + }); }); it("navigates to the invitation details by default", async () => { diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index cbbbdac225..8401f3c5d1 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -1,13 +1,10 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; import { SaveIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { Controller, useForm } from "react-hook-form"; -import * as z from "zod"; +import { Controller } from "react-hook-form"; -import { sendInvite } from "@/actions/invitations/invitation"; -import { Button, useToast } from "@/components/shadcn"; +import { Button } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { @@ -17,15 +14,9 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn/select/select"; -import { ApiError } from "@/types"; import type { InvitationRoleOption } from "@/types/onboarding-invite"; -const sendInvitationFormSchema = z.object({ - email: z.email({ error: "Please enter a valid email" }), - roleId: z.string().min(1, "Role is required"), -}); - -export type FormValues = z.infer; +import { useSendInvitation } from "./use-send-invitation"; interface SendInvitationFormProps { roles: InvitationRoleOption[]; @@ -45,90 +36,23 @@ export const SendInvitationForm = ({ source, onSuccess, }: SendInvitationFormProps) => { - const { toast } = useToast(); const router = useRouter(); - const form = useForm({ - resolver: zodResolver(sendInvitationFormSchema), - defaultValues: { - email: "", - roleId: isSelectorDisabled ? defaultRole : "", + const { form, onSubmit, isSubmitting } = useSendInvitation({ + source, + defaultRoleId: isSelectorDisabled ? defaultRole : "", + onSuccess: (invitation) => { + if (onSuccess) { + onSuccess(invitation.id); + return; + } + router.push(`/invitations/check-details/?id=${invitation.id}`); }, }); - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: FormValues) => { - const formData = new FormData(); - formData.append("email", values.email); - formData.append("role", values.roleId); - if (source) formData.append("source", source); - - try { - const data = await sendInvite(formData); - - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - const pointer = error.source?.pointer; - switch (pointer) { - case "/data/attributes/email": - form.setError("email", { - type: "server", - message: errorMessage, - }); - break; - case "/data/relationships/roles": - form.setError("roleId", { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } - }); - } else { - const invitationId = data?.data?.id; - if (!invitationId) { - // A transport failure returns nothing and a rejection can come - // back as a bare `error` without an `errors` array; neither - // created an invitation, so neither is a success. - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: - typeof data?.error === "string" - ? data.error - : "The invitation could not be sent. Please try again.", - }); - return; - } - if (onSuccess) { - onSuccess(invitationId); - return; - } - router.push(`/invitations/check-details/?id=${invitationId}`); - } - } catch (_error) { - toast({ - variant: "destructive", - title: "Error", - description: "An unexpected error occurred. Please try again.", - }); - } - }; - return (
- + {/* Email Field */} - {isLoading ? ( + {isSubmitting ? ( <>Loading ) : ( <> diff --git a/ui/components/invitations/workflow/forms/use-send-invitation.ts b/ui/components/invitations/workflow/forms/use-send-invitation.ts new file mode 100644 index 0000000000..3abd9003fc --- /dev/null +++ b/ui/components/invitations/workflow/forms/use-send-invitation.ts @@ -0,0 +1,106 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useFormState, type UseFormProps } from "react-hook-form"; +import * as z from "zod"; + +import { sendInvite } from "@/actions/invitations/invitation"; +import { toSentInvitation } from "@/actions/invitations/invitation.adapter"; +import { useToast } from "@/components/shadcn"; +import { ApiError } from "@/types"; +import type { SentInvitation } from "@/types/onboarding-invite"; + +export const sendInvitationFormSchema = z.object({ + email: z.email({ error: "Please enter a valid email" }), + roleId: z.string().min(1, "Role is required"), +}); + +export type SendInvitationFormValues = z.infer; + +const EMAIL_ERROR_POINTER = "/data/attributes/email"; +const ROLES_ERROR_POINTER = "/data/relationships/roles"; + +interface UseSendInvitationOptions { + // Where the invitation is sent from, forwarded to the API as `?source=` + // so the origin can be told apart (e.g. the onboarding invite step). + source?: string; + defaultRoleId?: string; + // `onChange` lets a caller gate its submit button on `isValid`. + mode?: UseFormProps["mode"]; + onSuccess: (invitation: SentInvitation) => void; +} + +/** Owns an invitation form: schema, submit, API error mapping and toasts. */ +export function useSendInvitation({ + source, + defaultRoleId = "", + mode = "onSubmit", + onSuccess, +}: UseSendInvitationOptions) { + const { toast } = useToast(); + const form = useForm({ + resolver: zodResolver(sendInvitationFormSchema), + mode, + defaultValues: { email: "", roleId: defaultRoleId }, + }); + // A hook, not `form.formState` read inline: the React Compiler keys its memo + // on the stable `form` object and would freeze a proxy read. + const { isSubmitting, isValid } = useFormState({ control: form.control }); + + const onSubmit = form.handleSubmit(async (values) => { + const formData = new FormData(); + formData.append("email", values.email); + formData.append("role", values.roleId); + if (source) formData.append("source", source); + + try { + const data = await sendInvite(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const message = error.detail; + switch (error.source?.pointer) { + case EMAIL_ERROR_POINTER: + form.setError("email", { type: "server", message }); + break; + case ROLES_ERROR_POINTER: + form.setError("roleId", { type: "server", message }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: message, + }); + } + }); + return; + } + + const invitation = toSentInvitation(data); + if (!invitation) { + // A transport failure returns nothing and a rejection can come back + // as a bare `error` without an `errors` array; neither created an + // invitation, so neither is a success. + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: + typeof data?.error === "string" + ? data.error + : "The invitation could not be sent. Please try again.", + }); + return; + } + onSuccess(invitation); + } catch { + toast({ + variant: "destructive", + title: "Error", + description: "An unexpected error occurred. Please try again.", + }); + } + }); + + return { form, onSubmit, isSubmitting, isValid }; +} diff --git a/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx index 403bee8e63..aecb35fee2 100644 --- a/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx @@ -19,8 +19,8 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock("@/actions/onboarding/invite", () => ({ - getOnboardingInviteRoles: getRolesMock, +vi.mock("@/actions/invitations/roles", () => ({ + getInvitationRoles: getRolesMock, })); vi.mock("@/actions/invitations/invitation", () => ({ @@ -85,7 +85,13 @@ describe("OnboardingInviteStep", () => { outcomes.length = 0; window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome); getRolesMock.mockReset().mockResolvedValue(ROLES); - sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } }); + // The API answers with the created record, token included. + sendInviteMock.mockReset().mockResolvedValue({ + data: { + id: "inv-1", + attributes: { email: "teammate@company.com", token: "abc123DEF45678" }, + }, + }); toastMock.mockReset(); }); diff --git a/ui/components/onboarding/onboarding-invite-dialog.tsx b/ui/components/onboarding/onboarding-invite-dialog.tsx index 7431d7c829..f8ac0f5771 100644 --- a/ui/components/onboarding/onboarding-invite-dialog.tsx +++ b/ui/components/onboarding/onboarding-invite-dialog.tsx @@ -4,6 +4,7 @@ import { SendInvitationForm } from "@/components/invitations/workflow/forms/send import { Button } from "@/components/shadcn"; import { DialogFooter } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal/modal"; +import { orderRolesAdminFirst } from "@/lib/invitations/order-roles"; import { INVITATION_SOURCE, type InvitationRoleOption, @@ -16,19 +17,6 @@ interface OnboardingInviteDialogProps { onSkip: () => void; } -const DEFAULT_ROLE_NAME = "admin"; - -// Roles are listed with the admin one first so it is the natural pick for a -// first teammate; the form itself keeps the selection required. -const orderRoles = (roles: InvitationRoleOption[]) => - [...roles].sort((a, b) => - a.name.toLowerCase() === DEFAULT_ROLE_NAME - ? -1 - : b.name.toLowerCase() === DEFAULT_ROLE_NAME - ? 1 - : 0, - ); - // "Invite your team", offered once right after the first provider is // connected: permissions on the cloud were just granted and the value of // sharing the first scan is fresh. Reuses the members-page form, tagged as an @@ -55,7 +43,9 @@ export function OnboardingInviteDialog({
{hasRoles ? ( void; } -// Roles that have not arrived by then count as unavailable, so a request -// that never answers cannot hold the checkpoint behind an empty step. -const ROLES_TIMEOUT_MS = 5_000; - // Mounted only while the step is showing: loads the roles once, announces // the impression once, and resolves through a sent invitation or a skip. export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) { // `null` until the roles settle: the invitation form takes its default // role from the list at mount, so the dialog renders once the list is known. - const [roles, setRoles] = useState(null); + // Without roles the dialog offers only the skip, so the checkpoint is never + // blocked: not by a failed read, not by one that never answers. + const roles = useInvitationRoles(); useMountEffect(() => { dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN }); - let active = true; - let timer: ReturnType | undefined; - // First answer wins: a late response or a timer after it is ignored. - const settle = (loaded: InvitationRoleOption[]) => { - if (!active) return; - active = false; - clearTimeout(timer); - setRoles(loaded); - }; - // Without roles the dialog offers only the skip, so the checkpoint is - // never blocked: not by a failed read, not by one that never answers. - timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS); - getOnboardingInviteRoles() - .then(settle) - .catch(() => settle([])); - return () => { - active = false; - clearTimeout(timer); - }; }); if (roles === null) return null; diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index 5a1115dbd9..450c712b77 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -36,12 +36,23 @@ const { vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), })); -vi.mock("next-auth/react", () => ({ - useSession: () => ({ - data: { tenantId: "tenant-abc" }, - status: "authenticated", - }), +const { getInvitationRoles, sendInvite, session } = vi.hoisted(() => ({ + getInvitationRoles: vi.fn(), + sendInvite: vi.fn(), + // Mutable: only the permissions differ between cases. + session: { + data: { tenantId: "tenant-abc" } as { + tenantId: string; + user?: { permissions: Record }; + }, + }, })); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: session.data, status: "authenticated" }), +})); +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); +vi.mock("@/actions/invitations/invitation", () => ({ sendInvite })); vi.mock("@/actions/providers/providers", () => ({ addCredentialsProvider, addProvider, @@ -165,6 +176,7 @@ describe("provider wizard account creation", () => { afterEach(() => { vi.unstubAllEnvs(); + session.data = { tenantId: "tenant-abc" }; }); it("shows progress, blocks repeat clicks, and advances after creation", async () => { @@ -459,6 +471,61 @@ describe("provider wizard account creation", () => { expect(endActiveTour).toHaveBeenCalled(); }); + it("closes the wizard once a teammate has been invited to connect the account instead", async () => { + // Given: a user who can invite but cannot reach the account. + session.data = { + tenantId: "tenant-abc", + user: { permissions: { manage_account: true } }, + }; + getInvitationRoles.mockResolvedValue([ + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, + ]); + sendInvite.mockResolvedValue({ + data: { + id: "inv-1", + attributes: { + email: "teammate@company.com", + token: "abc123DEF45678", + }, + }, + }); + const funnelSignals: ProviderFunnelDetail[] = []; + const recordFunnelSignal: EventListener = (event) => { + funnelSignals.push((event as CustomEvent).detail); + }; + window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render(); + await screen.findByRole("option", { name: "Acme Cloud Registry" }); + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + + // When + await user.click( + await screen.findByRole("radio", { name: /invite a teammate/i }), + ); + await user.type( + await screen.findByRole("textbox", { name: /Teammate email/ }), + "teammate@company.com", + ); + const send = screen.getByRole("button", { name: "Send invitation" }); + await waitFor(() => expect(send).toBeEnabled()); + await user.click(send); + await user.click(await screen.findByRole("button", { name: "Done" })); + window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + + // Then: no account was created, so the wizard closes instead of launching. + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.queryByText("Launch scan")).not.toBeInTheDocument(); + expect(funnelSignals.at(-1)).toEqual({ + step: "wizard_closed", + lastStep: "connect", + providerCreated: false, + }); + }); + it("goes back to the provider list", async () => { // Given const user = await pickAws(); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index f5bf93f789..45e9b9ca8d 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -185,6 +185,7 @@ export function ProviderWizardModal({ handleTestSuccess(); endActiveTour(); }} + onClose={handleClose} onSelectOrganizations={openOrganizationsFlow} onFooterChange={setFooterConfig} onProviderTypeChange={(providerType) => { diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx index 73cf42fea8..0d1aa2df61 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx +++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx @@ -23,6 +23,10 @@ const { updateCredentialsProvider, testProviderConnection, openCloudUpgradeMock, + endActiveTour, + getInvitationRoles, + sendInvite, + session, } = vi.hoisted(() => ({ addProvider: vi.fn(), addCredentialsProvider: vi.fn(), @@ -30,14 +34,24 @@ const { updateCredentialsProvider: vi.fn(), testProviderConnection: vi.fn(), openCloudUpgradeMock: vi.fn(), + endActiveTour: vi.fn(), + getInvitationRoles: vi.fn(), + sendInvite: vi.fn(), + // Mutable: only the permissions differ between suites. + session: { + data: { tenantId: "tenant-abc" } as { + tenantId: string; + user?: { permissions: Record }; + }, + }, })); vi.mock("next-auth/react", () => ({ - useSession: () => ({ - data: { tenantId: "tenant-abc" }, - status: "authenticated", - }), + useSession: () => ({ data: session.data, status: "authenticated" }), })); +vi.mock("@/lib/tours/use-driver-tour", () => ({ endActiveTour })); +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); +vi.mock("@/actions/invitations/invitation", () => ({ sendInvite })); vi.mock("@/actions/providers/providers", () => ({ addProvider, addCredentialsProvider, @@ -130,6 +144,7 @@ describe("AwsConnectStep", () => { afterEach(() => { window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); vi.unstubAllEnvs(); + session.data = { tenantId: "tenant-abc" }; }); describe("in Prowler Cloud", () => { @@ -754,4 +769,99 @@ describe("AwsConnectStep", () => { expect(secret).not.toHaveProperty("aws_access_key_id"); }); }); + + describe("inviting a teammate who can connect the account", () => { + const inviteRadio = () => + screen.queryByRole("radio", { name: /invite a teammate/i }); + + beforeEach(() => { + session.data = { + tenantId: "tenant-abc", + user: { permissions: { manage_account: true } }, + }; + getInvitationRoles.mockResolvedValue([ + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, + ]); + }); + + it("is not offered to a user who cannot invite", () => { + // Given: no `manage_account`, so the API would refuse the invitation. + session.data = { tenantId: "tenant-abc" }; + + // When + renderStep(); + + // Then + expect(inviteRadio()).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /IAM Role/ })).toBeChecked(); + }); + + it("swaps the AWS form for the invitation and signals the choice once", async () => { + // Given + const { user } = renderStep(); + + // When + await user.click(inviteRadio()!); + await user.click(inviteRadio()!); + + // Then: the teammate form takes the step and the footer, the tour steps aside. + expect(inviteRadio()).toBeChecked(); + expect( + screen.queryByRole("textbox", { name: /Role ARN/ }), + ).not.toBeInTheDocument(); + expect( + await screen.findByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Send invitation" }), + ).toBeInTheDocument(); + expect(endActiveTour).toHaveBeenCalled(); + expect( + funnelSignals.filter((signal) => signal.step === "method_selected"), + ).toEqual([ + { + step: "method_selected", + providerType: "aws", + method: "invite_teammate", + }, + ]); + }); + + it("comes back to the IAM Role form with what was typed", async () => { + // Given + const { user } = renderStep(); + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + await user.click(inviteRadio()!); + await screen.findByRole("textbox", { name: /Teammate email/ }); + + // When + await user.click(screen.getByRole("radio", { name: /IAM Role/ })); + + // Then + expect(screen.getByRole("textbox", { name: /Role ARN/ })).toHaveValue( + ROLE_ARN, + ); + expect(inviteRadio()).not.toBeChecked(); + }); + + it("restores the invitation panel when the step is reopened", async () => { + // Given: the user left for the organizations tab and came back. + const { user, unmount } = renderStep(); + await user.click(inviteRadio()!); + await screen.findByRole("textbox", { name: /Teammate email/ }); + unmount(); + + // When + renderStep(); + + // Then + expect(inviteRadio()).toBeChecked(); + expect( + await screen.findByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + }); + }); }); diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx index 9cca93a5dd..3456717d38 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx +++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx @@ -7,6 +7,7 @@ import { KeyRound, Loader2, ShieldCheck, + UserPlus, } from "lucide-react"; import { useSession } from "next-auth/react"; import { useEffect, useRef, useState } from "react"; @@ -35,6 +36,7 @@ import { CollapsibleTrigger, } from "@/components/shadcn/collapsible"; import { Form } from "@/components/shadcn/form"; +import { useAuth } from "@/hooks/use-auth"; import { useFormServerErrors } from "@/hooks/use-form-server-errors"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings"; @@ -43,14 +45,22 @@ import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-cr import { ACCOUNT_SUBMIT_OUTCOME, dispatchProviderFunnel, + PROVIDER_FUNNEL_METHOD, PROVIDER_FUNNEL_STEP, } from "@/lib/provider-funnel/provider-funnel-events"; import { testProviderConnection } from "@/lib/provider-helpers"; +import { endActiveTour } from "@/lib/tours/use-driver-tour"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import type { AWSCredentials, AWSCredentialsRole } from "@/types"; -import type { AwsConnectDraft } from "@/types/provider-wizard"; +import { + AWS_CONNECT_PANEL, + type AwsConnectDraft, + type AwsConnectPanel, +} from "@/types/provider-wizard"; import { CONNECTION_CHECK_STATUS } from "@/types/providers"; +import { InviteTeammatePanel } from "../invite-teammate/invite-teammate-panel"; + import { awsKeysConnectSchema, type AwsKeysConnectValues, @@ -84,6 +94,11 @@ const initialMethod = (): AwsAccessMethod => ? AWS_ACCESS_METHOD.CREDENTIALS : AWS_ACCESS_METHOD.ROLE; +const initialPanel = (): AwsConnectPanel => + readDraft()?.panel === AWS_CONNECT_PANEL.INVITE + ? AWS_CONNECT_PANEL.INVITE + : AWS_CONNECT_PANEL.ACCESS; + function useDraftValues( form: UseFormReturn, key: keyof Pick, @@ -112,14 +127,41 @@ export function AwsConnectStep({ }: AwsConnectStepProps) { // Local state needed: the access method only matters until the account is connected. const [method, setMethod] = useState(initialMethod); + // Local state needed: whether the step shows the access forms or hands the + // account over to a teammate. Separate from the method, which is the `via` + // an account gets connected with. + const [panel, setPanel] = useState(initialPanel); // Local state needed: the active form reports it so the method cannot change mid-submit. const [isBusy, setIsBusy] = useState(false); + const { permissions } = useAuth(); + // Inviting takes `manage_account`, which the API also asks of the roles list. + const canInvite = permissions.manage_account === true; - const isRole = method === AWS_ACCESS_METHOD.ROLE; + const isInvite = canInvite && panel === AWS_CONNECT_PANEL.INVITE; + const isRole = !isInvite && method === AWS_ACCESS_METHOD.ROLE; + const isKeys = !isInvite && method === AWS_ACCESS_METHOD.CREDENTIALS; const chooseMethod = (next: AwsAccessMethod) => { + setPanel(AWS_CONNECT_PANEL.ACCESS); setMethod(next); - useProviderWizardStore.getState().setAwsConnectDraft({ method: next }); + useProviderWizardStore + .getState() + .setAwsConnectDraft({ method: next, panel: AWS_CONNECT_PANEL.ACCESS }); + }; + + const chooseInvite = () => { + if (isInvite) return; + setPanel(AWS_CONNECT_PANEL.INVITE); + useProviderWizardStore + .getState() + .setAwsConnectDraft({ panel: AWS_CONNECT_PANEL.INVITE }); + // Delegating diverges from the path the tour guides toward. No-op off-onboarding. + endActiveTour(); + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED, + providerType: "aws", + method: PROVIDER_FUNNEL_METHOD.INVITE_TEAMMATE, + }); }; return ( @@ -133,11 +175,11 @@ export function AwsConnectStep({

- Choose how Prowler should access your account. + Choose how to connect this account.

chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)} /> + {canInvite && ( + + )}
- {isRole ? ( + {isInvite && ( + + )} + + {isRole && ( - ) : ( + )} + + {isKeys && ( void; /** AWS registers, stores and tests the account in this step, so it skips ahead. */ onCredentialsSaved: () => void; + /** AWS offers it as the footer action once a teammate has been invited instead. */ + onClose: () => void; onSelectOrganizations: (orgType: OrgFlowType) => void; onFooterChange: (config: WizardFooterConfig) => void; onProviderTypeChange: (providerType: ProviderType | null) => void; @@ -33,6 +36,7 @@ interface ConnectStepProps { export function ConnectStep({ onNext, onCredentialsSaved, + onClose, onSelectOrganizations, onFooterChange, onProviderTypeChange, @@ -41,9 +45,13 @@ export function ConnectStep({ const { setProvider, setVia, setSecretId, setMode } = useProviderWizardStore(); const backHandlerRef = useRef<(() => void) | null>(null); + // The modal hands over a fresh `onClose` every render; the footer effect + // keeps one closure and reads the latest through the ref, as LaunchStep does. + const closeHandlerRef = useRef(onClose); + closeHandlerRef.current = onClose; // Local state needed: AWS swaps the generic account form for its one-step form. const [isAwsFlow, setIsAwsFlow] = useState(initialProviderType === "aws"); - const [uiState, setUiState] = useState({ + const [uiState, setUiState] = useState({ showBack: false, showAction: false, actionLabel: "Next", @@ -74,6 +82,8 @@ export function ConnectStep({ if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) { endActiveTour(); } + // Nothing left to submit once a teammate has been invited: the action closes. + const closes = uiState.actionKind === AWS_CONNECT_ACTION_KIND.CLOSE; onFooterChange({ showBack: uiState.showBack, backLabel: "Back", @@ -86,8 +96,11 @@ export function ConnectStep({ actionLabel: uiState.actionLabel, actionLoading: uiState.isLoading, actionDisabled: uiState.actionDisabled || uiState.isLoading, - actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, - actionFormId: formId, + actionType: closes + ? WIZARD_FOOTER_ACTION_TYPE.BUTTON + : WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: closes ? undefined : formId, + onAction: closes ? () => closeHandlerRef.current() : undefined, }); }, [isAwsFlow, onFooterChange, uiState]); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx new file mode 100644 index 0000000000..6a7b0d9326 --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect } from "react"; +import { Controller } from "react-hook-form"; + +import { useSendInvitation } from "@/components/invitations/workflow/forms/use-send-invitation"; +import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { Form } from "@/components/shadcn/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { isAdminRole } from "@/lib/invitations/order-roles"; +import { + INVITATION_SOURCE, + type InvitationRoleOption, + type SentInvitation, +} from "@/types/onboarding-invite"; +import { getProviderDisplayName, type ProviderType } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +interface InviteTeammateFormProps { + roles: InvitationRoleOption[]; + providerType: ProviderType; + formId: string; + onSent: (invitation: SentInvitation) => void; + onUiStateChange: (state: AwsConnectUiState) => void; + onBusyChange: (isBusy: boolean) => void; +} + +/** Email and role for the teammate; the wizard footer submits it by `formId`. */ +export function InviteTeammateForm({ + roles, + providerType, + formId, + onSent, + onUiStateChange, + onBusyChange, +}: InviteTeammateFormProps) { + const { form, onSubmit, isSubmitting, isValid } = useSendInvitation({ + source: INVITATION_SOURCE.PROVIDER_CONNECT, + // Admin can finish the setup; the user may still pick another role. + defaultRoleId: roles.find(isAdminRole)?.id ?? "", + mode: "onChange", + onSuccess: onSent, + }); + + // Same contract the AWS forms use: the wizard footer lives outside the step. + // Both callbacks must be stable setters, or this effect would loop. + useEffect(() => { + onBusyChange(isSubmitting); + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: isSubmitting ? "Sending invitation..." : "Send invitation", + actionDisabled: !isValid || isSubmitting, + isLoading: isSubmitting, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }, [isSubmitting, isValid, onBusyChange, onUiStateChange]); + + return ( + + +

+ Invite someone from your team who can access the{" "} + {getProviderDisplayName(providerType)} account. They will join this + Prowler tenant and can connect it themselves. +

+ + + + ( +
+ +

+ Pick a role that can manage providers, such as admin. +

+ {fieldState.error && ( +

+ {fieldState.error.message} +

+ )} +
+ )} + /> + + + ); +} diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx new file mode 100644 index 0000000000..65139703eb --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx @@ -0,0 +1,294 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getProviderDisplayName } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +import { InviteTeammatePanel } from "./invite-teammate-panel"; + +const { getInvitationRoles, sendInvite, toastMock } = vi.hoisted(() => ({ + getInvitationRoles: vi.fn(), + sendInvite: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); +vi.mock("@/actions/invitations/invitation", () => ({ sendInvite })); +vi.mock("@/components/shadcn", async (importOriginal) => ({ + ...(await importOriginal()), + useToast: () => ({ toast: toastMock }), +})); + +// Radix Select does not open in jsdom; a native select keeps the test on the +// form's behaviour rather than the dropdown's. +vi.mock("@/components/shadcn/select/select", () => ({ + Select: ({ + value, + onValueChange, + disabled, + children, + }: { + value?: string; + onValueChange: (value: string) => void; + disabled?: boolean; + children: React.ReactNode; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => , +})); + +const FORM_ID = "invite-teammate-test-form"; +const ROLES = [ + { id: "11111111-1111-4111-8111-111111111111", name: "member" }, + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, +]; +const SENT = { + data: { + id: "inv-1", + attributes: { email: "teammate@company.com", token: "abc123DEF45678" }, + }, +}; + +// Stands in for the wizard footer: the panel only publishes its UI state. +function Harness({ + onUiState, + onBusyChange, +}: { + onUiState: (state: AwsConnectUiState) => void; + onBusyChange: (isBusy: boolean) => void; +}) { + const [uiState, setUiState] = useState(null); + // Stable like the wizard's own setter: the panel keys an effect on it. + const handleUiState = useRef((state: AwsConnectUiState) => { + setUiState(state); + onUiState(state); + }).current; + return ( + <> + + {uiState?.showAction && ( + + )} + + ); +} + +function renderPanel() { + const onUiState = vi.fn(); + const onBusyChange = vi.fn(); + render(); + return { onUiState, onBusyChange, user: userEvent.setup() }; +} + +const lastUiState = (onUiState: ReturnType) => + onUiState.mock.calls.at(-1)?.[0] as AwsConnectUiState; + +async function fillAndSend(user: ReturnType) { + await user.type( + await screen.findByRole("textbox", { name: /Teammate email/ }), + "teammate@company.com", + ); + const send = screen.getByRole("button", { name: "Send invitation" }); + await waitFor(() => expect(send).toBeEnabled()); + await user.click(send); +} + +describe("InviteTeammatePanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInvitationRoles.mockResolvedValue(ROLES); + sendInvite.mockResolvedValue(SENT); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("holds the footer on a disabled Send invitation while the roles load", () => { + // Given: roles that have not answered yet. + getInvitationRoles.mockReturnValue(new Promise(() => {})); + + // When + const { onUiState } = renderPanel(); + + // Then + expect(lastUiState(onUiState)).toMatchObject({ + showAction: true, + actionLabel: "Send invitation", + actionDisabled: true, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + expect(screen.getByRole("status")).toHaveTextContent(/Loading roles/); + }); + + it("offers the roles admin first, preselected, and gates Send on a valid email", async () => { + // When + const { onUiState } = renderPanel(); + + // Then + const select = await screen.findByRole("combobox", { + name: "Select a role", + }); + expect(select).toHaveValue(ROLES[1].id); + expect( + screen.getAllByRole("option").map((option) => option.textContent), + ).toEqual(["Select a role", "admin", "member"]); + expect(lastUiState(onUiState)).toMatchObject({ + actionLabel: "Send invitation", + actionDisabled: true, + }); + }); + + it("sends the invitation tagged as coming from the provider connection and shows the link", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const { onUiState, onBusyChange, user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then: the API got the form, the user gets the link to share. + const formData = sendInvite.mock.calls[0]?.[0] as FormData; + expect(formData.get("email")).toBe("teammate@company.com"); + expect(formData.get("role")).toBe(ROLES[1].id); + expect(formData.get("source")).toBe("provider_connect"); + expect( + await screen.findByText("Invitation sent to teammate@company.com"), + ).toBeInTheDocument(); + expect( + screen.getByText( + `${window.location.origin}/invitation/accept?invitation_token=abc123DEF45678`, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/Prowler does not send emails/), + ).toBeInTheDocument(); + expect( + screen.getByText( + new RegExp(`connect the ${getProviderDisplayName("aws")} account`), + ), + ).toBeInTheDocument(); + // The footer closes the wizard from here, and the step is no longer busy. + expect(lastUiState(onUiState)).toMatchObject({ + actionLabel: "Done", + actionDisabled: false, + actionKind: AWS_CONNECT_ACTION_KIND.CLOSE, + }); + expect(onBusyChange).toHaveBeenLastCalledWith(false); + expect(onBusyChange).toHaveBeenCalledWith(true); + }); + + it("tells a Cloud user the invitation was emailed too", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const { user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then + expect( + await screen.findByText(/We have emailed them the invitation/), + ).toBeInTheDocument(); + expect( + screen.queryByText(/Prowler does not send emails/), + ).not.toBeInTheDocument(); + }); + + it("keeps the form up with the field error when the API rejects the email", async () => { + // Given + sendInvite.mockResolvedValue({ + errors: [ + { + detail: "This email has already been invited.", + source: { pointer: "/data/attributes/email" }, + }, + ], + }); + const { onUiState, user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then + expect( + await screen.findByText("This email has already been invited."), + ).toBeInTheDocument(); + expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument(); + expect(lastUiState(onUiState)).toMatchObject({ + actionLabel: "Send invitation", + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }); + + it("stays on the form with a toast when the action resolves without an invitation", async () => { + // Given: a 5xx makes the action resolve undefined. + sendInvite.mockResolvedValue(undefined); + const { user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ variant: "destructive" }), + ), + ); + expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + }); + + it("explains and hides the action when the roles cannot be loaded", async () => { + // Given + getInvitationRoles.mockRejectedValue(new Error("roles unavailable")); + + // When + const { onUiState } = renderPanel(); + + // Then + expect( + await screen.findByText(/Roles could not be loaded right now/), + ).toBeInTheDocument(); + expect(lastUiState(onUiState)).toMatchObject({ showAction: false }); + expect( + screen.queryByRole("button", { name: "Send invitation" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx new file mode 100644 index 0000000000..fc93a68474 --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useState } from "react"; + +import { useInvitationRoles } from "@/hooks/use-invitation-roles"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { orderRolesAdminFirst } from "@/lib/invitations/order-roles"; +import type { SentInvitation } from "@/types/onboarding-invite"; +import type { ProviderType } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +import { InviteTeammateForm } from "./invite-teammate-form"; +import { InviteTeammateSent } from "./invite-teammate-sent"; + +const SEND_LABEL = "Send invitation"; + +interface InviteTeammatePanelProps { + providerType: ProviderType; + formId: string; + onUiStateChange: (state: AwsConnectUiState) => void; + onBusyChange: (isBusy: boolean) => void; +} + +/** + * The connect step's way out for a user who cannot connect the account: invite + * a teammate who can. Loads the roles, sends the invitation through the wizard + * footer and then shows the link to share. Provider-agnostic; AWS mounts it. + */ +export function InviteTeammatePanel({ + providerType, + formId, + onUiStateChange, + onBusyChange, +}: InviteTeammatePanelProps) { + const roles = useInvitationRoles(); + // Local state needed: the sent record belongs to this panel alone, never to + // the wizard draft or the store. + const [sent, setSent] = useState(null); + + if (sent) { + return ( + + ); + } + + if (roles === null) { + return ; + } + + if (roles.length === 0) { + return ; + } + + return ( + { + // The form unmounts mid-submit; release the step before it can. + onBusyChange(false); + setSent(invitation); + }} + onUiStateChange={onUiStateChange} + onBusyChange={onBusyChange} + /> + ); +} + +interface StaticStateProps { + onUiStateChange: (state: AwsConnectUiState) => void; +} + +function RolesLoading({ onUiStateChange }: StaticStateProps) { + useMountEffect(() => { + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: SEND_LABEL, + actionDisabled: true, + isLoading: false, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }); + + return ( +

+ + Loading roles... +

+ ); +} + +function RolesUnavailable({ onUiStateChange }: StaticStateProps) { + useMountEffect(() => { + onUiStateChange({ + showBack: true, + showAction: false, + actionLabel: SEND_LABEL, + actionDisabled: true, + isLoading: false, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }); + + return ( +

+ Roles could not be loaded right now. You can invite your team later from + the Invitations page. +

+ ); +} diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx new file mode 100644 index 0000000000..f725175f31 --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { CircleCheck } from "lucide-react"; + +import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link"; +import { isCloud } from "@/lib/shared/env"; +import type { SentInvitation } from "@/types/onboarding-invite"; +import { getProviderDisplayName, type ProviderType } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +interface InviteTeammateSentProps { + invitation: SentInvitation; + providerType: ProviderType; + onUiStateChange: (state: AwsConnectUiState) => void; +} + +/** The link to share once the invitation exists; the footer's "Done" closes the wizard. */ +export function InviteTeammateSent({ + invitation, + providerType, + onUiStateChange, +}: InviteTeammateSentProps) { + useMountEffect(() => { + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: "Done", + actionDisabled: false, + isLoading: false, + actionKind: AWS_CONNECT_ACTION_KIND.CLOSE, + }); + }); + + // Mounted after a click, so the window is there; the guard keeps SSR safe. + const origin = typeof window === "undefined" ? "" : window.location.origin; + const link = buildInvitationAcceptLink(invitation.token, origin); + + return ( +
+
+ +
+

+ Invitation sent to {invitation.email} +

+

+ {isCloud() + ? "We have emailed them the invitation. You can also share this link with them:" + : "Prowler does not send emails. Share this link with them:"} +

+
+
+ + + +

+ The link expires in 7 days. Once they accept, they can connect the{" "} + {getProviderDisplayName(providerType)} account from the Providers page. +

+
+ ); +} diff --git a/ui/hooks/use-invitation-roles.test.ts b/ui/hooks/use-invitation-roles.test.ts new file mode 100644 index 0000000000..50755be9f5 --- /dev/null +++ b/ui/hooks/use-invitation-roles.test.ts @@ -0,0 +1,66 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useInvitationRoles } from "./use-invitation-roles"; + +const { getInvitationRoles } = vi.hoisted(() => ({ + getInvitationRoles: vi.fn(), +})); + +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); + +const ROLES = [ + { id: "11111111-1111-4111-8111-111111111111", name: "member" }, + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, +]; + +describe("useInvitationRoles", () => { + beforeEach(() => { + getInvitationRoles.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("is unsettled until the roles arrive, then exposes them as loaded", async () => { + getInvitationRoles.mockResolvedValue(ROLES); + + const { result } = renderHook(() => useInvitationRoles()); + + expect(result.current).toBeNull(); + await waitFor(() => expect(result.current).toEqual(ROLES)); + }); + + it("settles empty when the read fails", async () => { + getInvitationRoles.mockRejectedValue(new Error("roles unavailable")); + + const { result } = renderHook(() => useInvitationRoles()); + + await waitFor(() => expect(result.current).toEqual([])); + }); + + it("settles empty when the read never answers, and ignores a late answer", async () => { + vi.useFakeTimers(); + let resolveLate: (roles: typeof ROLES) => void = () => {}; + getInvitationRoles.mockReturnValue( + new Promise((resolve) => { + resolveLate = resolve; + }), + ); + + const { result } = renderHook(() => useInvitationRoles()); + expect(result.current).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(result.current).toEqual([]); + + await act(async () => { + resolveLate(ROLES); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual([]); + }); +}); diff --git a/ui/hooks/use-invitation-roles.ts b/ui/hooks/use-invitation-roles.ts new file mode 100644 index 0000000000..8a098e1686 --- /dev/null +++ b/ui/hooks/use-invitation-roles.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; + +import { getInvitationRoles } from "@/actions/invitations/roles"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +// Roles that have not arrived by then count as unavailable, so a request +// that never answers cannot hold a form behind an empty list. +const ROLES_TIMEOUT_MS = 5_000; + +/** Roles an invitation can grant: `null` until the read settles, `[]` when it failed or timed out. */ +export function useInvitationRoles(): InvitationRoleOption[] | null { + const [roles, setRoles] = useState(null); + + useMountEffect(() => { + let active = true; + let timer: ReturnType | undefined; + // First answer wins: a late response or a timer after it is ignored. + const settle = (loaded: InvitationRoleOption[]) => { + if (!active) return; + active = false; + clearTimeout(timer); + setRoles(loaded); + }; + timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS); + getInvitationRoles() + .then(settle) + .catch(() => settle([])); + return () => { + active = false; + clearTimeout(timer); + }; + }); + + return roles; +} diff --git a/ui/lib/invitations/accept-link.test.ts b/ui/lib/invitations/accept-link.test.ts new file mode 100644 index 0000000000..d903b85e45 --- /dev/null +++ b/ui/lib/invitations/accept-link.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { buildInvitationAcceptLink } from "./accept-link"; + +describe("buildInvitationAcceptLink", () => { + it("points the invitee at the accept page of the given origin", () => { + expect( + buildInvitationAcceptLink("abc123DEF45678", "https://app.example.com"), + ).toBe( + "https://app.example.com/invitation/accept?invitation_token=abc123DEF45678", + ); + }); + + it("keeps the token safe inside the query string", () => { + expect(buildInvitationAcceptLink("a b&c", "https://app.example.com")).toBe( + "https://app.example.com/invitation/accept?invitation_token=a%20b%26c", + ); + }); +}); diff --git a/ui/lib/invitations/accept-link.ts b/ui/lib/invitations/accept-link.ts new file mode 100644 index 0000000000..4fa520b4de --- /dev/null +++ b/ui/lib/invitations/accept-link.ts @@ -0,0 +1,9 @@ +const INVITATION_ACCEPT_PATH = "/invitation/accept"; + +/** Link an invitee opens to join the tenant; the API only hands back the token. */ +export function buildInvitationAcceptLink( + token: string, + origin: string, +): string { + return `${origin}${INVITATION_ACCEPT_PATH}?invitation_token=${encodeURIComponent(token)}`; +} diff --git a/ui/lib/invitations/order-roles.test.ts b/ui/lib/invitations/order-roles.test.ts new file mode 100644 index 0000000000..64aefeea48 --- /dev/null +++ b/ui/lib/invitations/order-roles.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { orderRolesAdminFirst } from "./order-roles"; + +describe("orderRolesAdminFirst", () => { + it("moves the admin role to the front and keeps the rest in order", () => { + const roles = [ + { id: "1", name: "member" }, + { id: "2", name: "Admin" }, + { id: "3", name: "auditor" }, + ]; + + expect(orderRolesAdminFirst(roles).map((role) => role.name)).toEqual([ + "Admin", + "member", + "auditor", + ]); + }); + + it("leaves the list untouched when there is no admin role", () => { + const roles = [ + { id: "1", name: "member" }, + { id: "2", name: "auditor" }, + ]; + + expect(orderRolesAdminFirst(roles)).toEqual(roles); + expect(orderRolesAdminFirst(roles)).not.toBe(roles); + }); +}); diff --git a/ui/lib/invitations/order-roles.ts b/ui/lib/invitations/order-roles.ts new file mode 100644 index 0000000000..cd87c0d49d --- /dev/null +++ b/ui/lib/invitations/order-roles.ts @@ -0,0 +1,15 @@ +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +const ADMIN_ROLE_NAME = "admin"; + +export const isAdminRole = (role: InvitationRoleOption) => + role.name.toLowerCase() === ADMIN_ROLE_NAME; + +/** Admin first: the natural pick for a teammate who has to finish the setup. */ +export function orderRolesAdminFirst( + roles: InvitationRoleOption[], +): InvitationRoleOption[] { + return [...roles].sort( + (a, b) => Number(isAdminRole(b)) - Number(isAdminRole(a)), + ); +} diff --git a/ui/lib/provider-funnel/provider-funnel-events.ts b/ui/lib/provider-funnel/provider-funnel-events.ts index 2d485b9536..af2ad45be8 100644 --- a/ui/lib/provider-funnel/provider-funnel-events.ts +++ b/ui/lib/provider-funnel/provider-funnel-events.ts @@ -41,6 +41,8 @@ export type WizardOpenSource = export const PROVIDER_FUNNEL_METHOD = { SINGLE: "single", ORGANIZATION: "organization", + // The user cannot connect the account and hands it to a teammate instead. + INVITE_TEAMMATE: "invite_teammate", } as const; export type ProviderFunnelMethod = diff --git a/ui/store/provider-wizard/store.ts b/ui/store/provider-wizard/store.ts index 2d94eff7db..b401bf9803 100644 --- a/ui/store/provider-wizard/store.ts +++ b/ui/store/provider-wizard/store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { + AWS_CONNECT_PANEL, AwsConnectDraft, PROVIDER_WIZARD_MODE, ProviderWizardIdentity, @@ -39,6 +40,7 @@ const initialState = { const EMPTY_AWS_CONNECT_DRAFT: AwsConnectDraft = { method: "role", + panel: AWS_CONNECT_PANEL.ACCESS, roleValues: {}, keysValues: {}, }; diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 9777c1b61b..da52041e0d 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -708,6 +708,46 @@ export class ProvidersPage extends BasePage { await this.selectProviderRadio(this.githubProviderRadio); } + // Offered on the AWS step to a user who can invite but cannot reach the account. + async selectAwsInviteTeammate(): Promise { + const invite = this.wizardModal.getByRole("radio", { + name: /invite a teammate/i, + }); + await expect(invite).toBeVisible({ timeout: 10000 }); + await invite.click(); + await expect( + this.wizardModal.getByRole("textbox", { name: /Teammate email/i }), + ).toBeVisible({ timeout: 10000 }); + } + + // The admin role comes preselected; only the email is needed. + async sendTeammateInvitation(email: string): Promise { + await this.wizardModal + .getByRole("textbox", { name: /Teammate email/i }) + .fill(email); + const send = this.page.getByRole("button", { + name: "Send invitation", + exact: true, + }); + await expect(send).toBeEnabled({ timeout: 10000 }); + await send.click(); + } + + async verifyTeammateInvitationSent(email: string): Promise { + await expect( + this.wizardModal.getByText(`Invitation sent to ${email}`), + ).toBeVisible({ timeout: 15000 }); + await expect( + this.wizardModal.getByText(/\/invitation\/accept\?invitation_token=/), + ).toBeVisible(); + } + + // "Done" replaces the submit once the invitation exists and closes the wizard. + async finishTeammateInvitation(): Promise { + await this.page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(this.wizardModal).not.toBeVisible(); + } + // AWS picks its access method on the same step that registers the account. async selectAwsAccessMethod(type: AWSCredentialType): Promise { const name = diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 049c3fcbe3..53ba05f1f9 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -1075,3 +1075,52 @@ - Private Key is provided as base64-encoded PEM content and decoded before use (multi-line content) - Provider cleanup performed before each test to ensure clean state - Requires a valid Okta API Services app with a registered public key (JWK) matching the provided private key + +--- + +## Test Case: `PROVIDER-E2E-020` - Invite a Teammate From the AWS Connect Step + +**Priority:** `high` + +**Tags:** + +- type → @e2e +- feature → @providers +- provider → @aws + +**Description/Objective:** Validates that a user who cannot connect the AWS account can invite a teammate from the AWS connect step, without leaving the wizard, and gets the invitation link to share. + +**Preconditions:** + +- Admin user authentication required (admin.auth.setup setup): the option is only offered to users who can invite (`manage_account`) +- No environment variables required: no provider is created + +### Flow Steps + +1. Navigate to providers page +2. Click "Add Provider" button +3. Select AWS provider type +4. Select the "I don't have access, invite a teammate" option +5. Fill the teammate email (the admin role comes preselected) +6. Click "Send invitation" +7. Verify the confirmation and the invitation link +8. Click "Done" + +### Expected Result + +- The AWS access form is replaced by the invitation form +- The invitation is created and the confirmation shows the invited email and the accept link +- "Done" closes the wizard without creating a provider + +### Key verification points + +- The invite option is visible on the AWS step for the admin user +- "Send invitation" is enabled once a valid email is typed +- Confirmation text "Invitation sent to {email}" is visible +- The accept link contains `/invitation/accept?invitation_token=` +- The wizard modal is closed after "Done" + +### Notes + +- Uses a unique email per run so the invitation never collides with a pending one +- The invitation is left pending; it expires on its own after 7 days diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 4cdd0aa63c..8dd348e9c5 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -1,6 +1,7 @@ import { test } from "@playwright/test"; import { isCloud } from "@/lib/shared/env"; +import { makeSuffix } from "../helpers"; import { ProvidersPage, AWSProviderData, @@ -263,6 +264,44 @@ test.describe("Add Provider", () => { ); }); + test.describe("Invite a teammate from the AWS step", () => { + let providersPage: ProvidersPage; + + test.beforeEach(async ({ page }) => { + providersPage = new ProvidersPage(page); + }); + + // The admin user can invite (manage_account) and add providers. + test.use({ storageState: "playwright/.auth/admin_user.json" }); + + test( + "should invite a teammate to connect the AWS account instead", + { + tag: ["@high", "@e2e", "@providers", "@aws", "@PROVIDER-E2E-020"], + }, + async () => { + const uniqueEmail = `e2e+aws-${makeSuffix(10)}@prowler.com`; + + // Navigate to providers page + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + + // Start adding new provider and pick AWS + await providersPage.clickAddProvider(); + await providersPage.verifyConnectAccountPageLoaded(); + await providersPage.selectAWSProvider(); + + // Hand the account over to a teammate instead of connecting it + await providersPage.selectAwsInviteTeammate(); + await providersPage.sendTeammateInvitation(uniqueEmail); + + // The invitation exists and the link to share is shown; Done closes the wizard + await providersPage.verifyTeammateInvitationSent(uniqueEmail); + await providersPage.finishTeammateInvitation(); + }, + ); + }); + test.describe.serial("Add AZURE Provider", () => { // Providers page object let providersPage: ProvidersPage; diff --git a/ui/types/onboarding-invite.ts b/ui/types/onboarding-invite.ts index e1bcdec4d6..f0d8498dcf 100644 --- a/ui/types/onboarding-invite.ts +++ b/ui/types/onboarding-invite.ts @@ -3,9 +3,18 @@ export const INVITATION_SOURCE_PARAM = "source"; export const INVITATION_SOURCE = { ONBOARDING: "onboarding", + // Sent from the add-provider wizard by a user who cannot connect the account. + PROVIDER_CONNECT: "provider_connect", } as const; export interface InvitationRoleOption { id: string; name: string; } + +/** What a successful `sendInvite` yields: enough to show and share the accept link. */ +export interface SentInvitation { + id: string; + email: string; + token: string; +} diff --git a/ui/types/provider-wizard.ts b/ui/types/provider-wizard.ts index 8f8dd5c17a..2b927f25f0 100644 --- a/ui/types/provider-wizard.ts +++ b/ui/types/provider-wizard.ts @@ -27,9 +27,19 @@ export interface ProviderWizardIdentity { export type AwsConnectDraftValues = Record; +/** Which panel the AWS connect step shows: the access forms or the teammate invitation. */ +export const AWS_CONNECT_PANEL = { + ACCESS: "access", + INVITE: "invite", +} as const; + +export type AwsConnectPanel = + (typeof AWS_CONNECT_PANEL)[keyof typeof AWS_CONNECT_PANEL]; + /** What the AWS connect step typed so far; in memory only, gone with the wizard. */ export interface AwsConnectDraft { method: string; + panel: AwsConnectPanel; roleValues: AwsConnectDraftValues; keysValues: AwsConnectDraftValues; } From 383a9bf9032c503c6e920d9f94f7712d046d3a7b Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:54:44 +0200 Subject: [PATCH 21/21] fix(ui): bump Next.js to 16.3.6 to patch the next/og RCE advisory (#12922) --- .../next-og-image-response-rce.security.md | 1 + ui/dependency-log.json | 6 +- ui/package.json | 2 +- ui/pnpm-lock.yaml | 106 +++++++++--------- ui/pnpm-workspace.yaml | 2 +- 5 files changed, 59 insertions(+), 58 deletions(-) create mode 100644 ui/changelog.d/next-og-image-response-rce.security.md diff --git a/ui/changelog.d/next-og-image-response-rce.security.md b/ui/changelog.d/next-og-image-response-rce.security.md new file mode 100644 index 0000000000..d9f4eb4894 --- /dev/null +++ b/ui/changelog.d/next-og-image-response-rce.security.md @@ -0,0 +1 @@ +`next` to 16.3.6, patching a remote code execution in `next/og` `ImageResponse` (GHSA-vcvr-r3jv-pc5j) diff --git a/ui/dependency-log.json b/ui/dependency-log.json index 8db88c384b..15052e2374 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -498,10 +498,10 @@ { "section": "dependencies", "name": "next", - "from": "16.2.11", - "to": "16.3.3", + "from": "16.3.3", + "to": "16.3.6", "strategy": "installed", - "generatedAt": "2026-09-09T12:23:05.642Z" + "generatedAt": "2026-10-01T09:28:54.112Z" }, { "section": "dependencies", diff --git a/ui/package.json b/ui/package.json index bc72f8f318..6b8d50471e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -98,7 +98,7 @@ "marked": "15.0.12", "modern-screenshot": "4.7.0", "nanoid": "5.1.16", - "next": "16.3.3", + "next": "16.3.6", "next-auth": "5.0.0-beta.32", "next-themes": "0.2.1", "posthog-js": "1.407.2", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 088af80569..744f064f7b 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -92,7 +92,7 @@ importers: version: 1.2.3 '@next/third-parties': specifier: 16.2.9 - version: 16.2.9(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 16.2.9(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@radix-ui/react-alert-dialog': specifier: 1.1.14 version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -167,7 +167,7 @@ importers: version: 3.26.0(react@19.2.7) '@sentry/nextjs': specifier: 10.65.0 - version: 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.23)) + version: 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.23)) '@tailwindcss/postcss': specifier: 4.1.18 version: 4.1.18 @@ -238,14 +238,14 @@ importers: specifier: 5.1.16 version: 5.1.16 next: - specifier: 16.3.3 - version: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 16.3.6 + version: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 5.0.0-beta.32(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 0.2.1 - version: 0.2.1(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.2.1(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) posthog-js: specifier: 1.407.2 version: 1.407.2(preact-render-to-string@6.5.11(preact@10.24.3)) @@ -1414,60 +1414,60 @@ packages: '@ndaidong/bellajs@12.0.1': resolution: {integrity: sha512-1iY42uiHz0cxNMbde7O3zVN+ZX1viOOUOBRt6ht6lkRZbSjwOnFV34Zv4URp3hGzEe6L9Byk7BOq/41H0PzAOQ==} - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.6': + resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} '@next/eslint-plugin-next@16.2.9': resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.6': + resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.6': + resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.6': + resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.6': + resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.6': + resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.6': + resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.6': + resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.6': + resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -5801,8 +5801,8 @@ packages: react: '*' react-dom: '*' - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.6: + resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -8630,39 +8630,39 @@ snapshots: '@ndaidong/bellajs@12.0.1': {} - '@next/env@16.3.3': {} + '@next/env@16.3.6': {} '@next/eslint-plugin-next@16.2.9': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.6': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.6': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.6': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.6': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.6': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.6': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.6': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.6': optional: true - '@next/third-parties@16.2.9(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@next/third-parties@16.2.9(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 third-party-capital: 1.0.20 @@ -9829,7 +9829,7 @@ snapshots: dependencies: '@sentry/core': 10.65.0 - '@sentry/nextjs@10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.23))': + '@sentry/nextjs@10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.23))': dependencies: '@opentelemetry/api': 1.9.1 '@rollup/plugin-commonjs': 28.0.1(rollup@4.59.0) @@ -9842,7 +9842,7 @@ snapshots: '@sentry/react': 10.65.0(react@19.2.7) '@sentry/vercel-edge': 10.65.0 '@sentry/webpack-plugin': 5.4.0(rollup@4.59.0)(webpack@5.104.1(lightningcss@1.30.2)(postcss@8.5.23)) - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) rollup: 4.59.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -13437,21 +13437,21 @@ snapshots: neo-async@2.6.2: {} - next-auth@5.0.0-beta.32(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + next-auth@5.0.0-beta.32(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: '@auth/core': 0.41.3 - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 - next-themes@0.2.1(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next-themes@0.2.1(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - next: 16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(@types/node@24.10.8)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.6 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 @@ -13460,14 +13460,14 @@ snapshots: react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.6 + '@next/swc-darwin-x64': 16.3.6 + '@next/swc-linux-arm64-gnu': 16.3.6 + '@next/swc-linux-arm64-musl': 16.3.6 + '@next/swc-linux-x64-gnu': 16.3.6 + '@next/swc-linux-x64-musl': 16.3.6 + '@next/swc-win32-arm64-msvc': 16.3.6 + '@next/swc-win32-x64-msvc': 16.3.6 '@opentelemetry/api': 1.9.1 '@playwright/test': 1.56.1 babel-plugin-react-compiler: 1.0.0 diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index fbc178ce5a..ab3e084e51 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -18,7 +18,7 @@ overrides: "@react-aria/visually-hidden>react": "19.2.7" "@react-aria/interactions>react": "19.2.7" "lodash": "4.18.1" - # Next.js 16.3.3 requests sharp ^0.35.3; resolve 0.35.4 to fix + # Next.js 16.3.6 requests sharp ^0.35.4; pinned to fix # GHSA-rgj7-g3m4-5g8c (libheif). This override controls resolution; # keep it aligned with the direct dependency in package.json. "sharp": "0.35.4"