diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index a781e57043..85387bf9d7 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,11 +2,20 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.33.1] (Prowler UNRELEASED) +## [1.34.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Compliance PDF reports no longer require provider credentials: findings are enriched from the provider metadata stored in the database, so reports generate even after the provider secret is deleted or its credentials become invalid [(#11845)](https://github.com/prowler-cloud/prowler/pull/11845) + +--- + +## [1.33.1] (Prowler v5.32.1) ### 🐞 Fixed - Attack Paths: Scan rows now have database defaults for `is_migrated` and `sink_backend` so `scan-perform-scheduled` inserts survive deploy skew [(#11826)](https://github.com/prowler-cloud/prowler/pull/11826) +- Invited users now keep their invitation context when completing authentication with Google, GitHub, or SAML, so the invitation is accepted during login [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752) ### 🔐 Security diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index cbd6795731..fa0abacb20 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -9,6 +9,7 @@ from api.models import ( User, UserRoleRelationship, ) +from api.utils import accept_invitation_for_user from django.db import transaction @@ -20,6 +21,22 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): except User.DoesNotExist: return None + @staticmethod + def _get_invitation_token(request): + for source_name in ("data", "POST"): + data = getattr(request, source_name, None) or {} + if not hasattr(data, "get"): + continue + invitation_token = data.get("invitation_token") + if invitation_token: + return invitation_token + + wrapped_request = getattr(request, "_request", None) + if wrapped_request and wrapped_request is not request: + return ProwlerSocialAccountAdapter._get_invitation_token(wrapped_request) + + return None + def pre_social_login(self, request, sociallogin): # Link existing accounts with the same email address email = sociallogin.account.extra_data.get("email") @@ -83,29 +100,38 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): user.name = social_account_name user.save(using=MainRouter.admin_db) - tenant = Tenant.objects.using(MainRouter.admin_db).create( - name=f"{user.email.split('@')[0]} default tenant" - ) - with rls_transaction(str(tenant.id)): - Membership.objects.using(MainRouter.admin_db).create( - user=user, tenant=tenant, role=Membership.RoleChoices.OWNER - ) - role = Role.objects.using(MainRouter.admin_db).create( - name="admin", - tenant_id=tenant.id, - manage_users=True, - manage_account=True, - manage_billing=True, - manage_providers=True, - manage_integrations=True, - manage_scans=True, - unlimited_visibility=True, - ) - UserRoleRelationship.objects.using(MainRouter.admin_db).create( + invitation_token = self._get_invitation_token(request) + if invitation_token: + invitation, _ = accept_invitation_for_user( user=user, - role=role, - tenant_id=tenant.id, + invitation_token=invitation_token, ) + request.prowler_invitation_token = invitation_token + request.prowler_invitation_tenant_id = str(invitation.tenant_id) + else: + tenant = Tenant.objects.using(MainRouter.admin_db).create( + name=f"{user.email.split('@')[0]} default tenant" + ) + with rls_transaction(str(tenant.id)): + Membership.objects.using(MainRouter.admin_db).create( + user=user, tenant=tenant, role=Membership.RoleChoices.OWNER + ) + role = Role.objects.using(MainRouter.admin_db).create( + name="admin", + tenant_id=tenant.id, + manage_users=True, + manage_account=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) else: request.session["saml_user_created"] = str(user.id) diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 91d3bb054a..1df9908ee5 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -5,7 +5,7 @@ import pytest from allauth.socialaccount.models import SocialLogin from api.adapters import ProwlerSocialAccountAdapter from api.db_router import MainRouter -from api.models import SAMLConfiguration +from api.models import Invitation, Membership, SAMLConfiguration, Tenant from django.contrib.auth import get_user_model User = get_user_model() @@ -188,6 +188,44 @@ class TestProwlerSocialAccountAdapter: _, called_user = call_args[0] assert called_user.email == create_test_user.email + def test_save_user_social_with_invitation_joins_invited_tenant( + self, rf, create_test_user, tenants_fixture + ): + adapter = ProwlerSocialAccountAdapter() + invited_tenant = tenants_fixture[2] + invited_email = "frank-invited@example.com" + invitation = Invitation.objects.create( + tenant=invited_tenant, + email=invited_email, + inviter=create_test_user, + ) + request = rf.post("/", data={"invitation_token": invitation.token}) + request.session = {} + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.provider = MagicMock() + sociallogin.provider.id = "google" + sociallogin.account = MagicMock() + sociallogin.account.extra_data = {"name": "Frank"} + + real_user = User.objects.create_user( + name="Frank", email=invited_email, password="Secret123!" + ) + tenants_before = Tenant.objects.count() + + with patch("api.adapters.super") as mock_super: + mock_super.return_value.save_user.return_value = real_user + adapter.save_user(request, sociallogin) + + invitation.refresh_from_db() + assert invitation.state == Invitation.State.ACCEPTED + assert Tenant.objects.count() == tenants_before + assert Membership.objects.filter( + user=real_user, + tenant=invited_tenant, + role=Membership.RoleChoices.MEMBER, + ).exists() + def test_save_user_saml_sets_session_flag(self, rf): adapter = ProwlerSocialAccountAdapter() request = rf.get("/") diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b159a284d3..a4951d936e 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -59,7 +59,11 @@ from api.models import ( from api.rls import Tenant from api.uuid_utils import datetime_to_uuid7 from api.v1.serializers import TokenSerializer -from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView +from api.v1.views import ( + ComplianceOverviewViewSet, + CustomSAMLLoginView, + TenantFinishACSView, +) from botocore.exceptions import ClientError, NoCredentialsError from conftest import ( API_JSON_CONTENT_TYPE, @@ -8598,16 +8602,14 @@ class TestInvitationViewSet: expires_at=self.TOMORROW, ) - data = { - "invitation_token": invitation.token, - } + data = {"invitation_token": invitation.token} assert not Membership.objects.filter( user__email__iexact=user.email, tenant=tenant ).exists() response = authenticated_client.post( - reverse("invitation-accept"), data=data, format="json" + reverse("invitation-accept"), data=data, format="vnd.api+json" ) assert response.status_code == status.HTTP_201_CREATED invitation.refresh_from_db() @@ -8616,13 +8618,46 @@ class TestInvitationViewSet: ).exists() assert invitation.state == Invitation.State.ACCEPTED.value - def test_invitations_accept_invitation_invalid_token(self, authenticated_client): - data = { - "invitation_token": "invalid_token", - } + def test_invitations_accept_invitation_existing_membership( + self, + authenticated_client, + create_test_user, + tenants_fixture, + ): + *_, tenant = tenants_fixture + user = create_test_user + + invitation = Invitation.objects.create( + tenant=tenant, + email=TEST_USER, + inviter=user, + expires_at=self.TOMORROW, + ) + Membership.objects.create(user=user, tenant=tenant) + + data = {"invitation_token": invitation.token} response = authenticated_client.post( - reverse("invitation-accept"), data=data, format="json" + reverse("invitation-accept"), + data=data, + format="vnd.api+json", + ) + + assert response.status_code == status.HTTP_201_CREATED + invitation.refresh_from_db() + assert invitation.state == Invitation.State.ACCEPTED.value + assert ( + Membership.objects.filter( + user__email__iexact=user.email, tenant=tenant + ).count() + == 1 + ) + + def test_invitations_accept_invitation_invalid_token(self, authenticated_client): + data = {"invitation_token": "invalid_token"} + + response = authenticated_client.post( + reverse("invitation-accept"), data=data, format="vnd.api+json" ) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -8636,12 +8671,10 @@ class TestInvitationViewSet: invitation.email = TEST_USER invitation.save() - data = { - "invitation_token": invitation.token, - } + data = {"invitation_token": invitation.token} response = authenticated_client.post( - reverse("invitation-accept"), data=data, format="json" + reverse("invitation-accept"), data=data, format="vnd.api+json" ) assert response.status_code == status.HTTP_410_GONE @@ -8677,12 +8710,10 @@ class TestInvitationViewSet: invitation.email = TEST_USER invitation.save() - data = { - "invitation_token": invitation.token, - } + data = {"invitation_token": invitation.token} response = authenticated_client.post( - reverse("invitation-accept"), data=data, format="json" + reverse("invitation-accept"), data=data, format="vnd.api+json" ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -8700,12 +8731,10 @@ class TestInvitationViewSet: invitation.email = TEST_USER invitation.save() - data = { - "invitation_token": invitation.token, - } + data = {"invitation_token": invitation.token} response = authenticated_client.post( - reverse("invitation-accept"), data=data, format="json" + reverse("invitation-accept"), data=data, format="vnd.api+json" ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -13697,6 +13726,26 @@ class TestSAMLTokenValidation: assert response2.status_code == status.HTTP_404_NOT_FOUND +@pytest.mark.django_db +class TestCustomSAMLLoginView: + def test_dispatch_clears_stale_callback_url_when_request_has_none(self): + request = RequestFactory().get("/api/v1/saml/login/testtenant/") + request.session = { + "saml_callback_url": "/invitation/accept?invitation_token=old-token" + } + + with patch( + "allauth.socialaccount.providers.saml.views.LoginView.dispatch", + return_value=JsonResponse({}), + ): + response = CustomSAMLLoginView.as_view()( + request, organization_slug="testtenant" + ) + + assert response.status_code == status.HTTP_200_OK + assert "saml_callback_url" not in request.session + + @pytest.mark.django_db class TestSAMLInitiateAPIView: def test_valid_email_domain_and_certificates( @@ -13708,7 +13757,7 @@ class TestSAMLInitiateAPIView: url = reverse("api_saml_initiate") payload = {"email_domain": saml_setup["email"]} - response = authenticated_client.post(url, data=payload, format="json") + response = authenticated_client.post(url, data=payload, format="vnd.api+json") assert response.status_code == status.HTTP_302_FOUND assert ( @@ -13717,11 +13766,42 @@ class TestSAMLInitiateAPIView: ) assert "SAMLRequest" not in response.url + def test_valid_email_domain_preserves_safe_callback_url( + self, authenticated_client, saml_setup + ): + url = reverse("api_saml_initiate") + callback_url = "/invitation/accept?invitation_token=test-token" + payload = { + "email_domain": saml_setup["email"], + "callback_url": callback_url, + } + + response = authenticated_client.post(url, data=payload, format="vnd.api+json") + + assert response.status_code == status.HTTP_302_FOUND + query_params = parse_qs(urlparse(response.url).query) + assert query_params["callback_url"] == [callback_url] + + def test_valid_email_domain_rejects_external_callback_url( + self, authenticated_client, saml_setup + ): + url = reverse("api_saml_initiate") + payload = { + "email_domain": saml_setup["email"], + "callback_url": "https://attacker.example/invitation", + } + + response = authenticated_client.post(url, data=payload, format="vnd.api+json") + + assert response.status_code == status.HTTP_302_FOUND + query_params = parse_qs(urlparse(response.url).query) + assert "callback_url" not in query_params + def test_invalid_email_domain(self, authenticated_client): url = reverse("api_saml_initiate") payload = {"email_domain": "user@unauthorized.com"} - response = authenticated_client.post(url, data=payload, format="json") + response = authenticated_client.post(url, data=payload, format="vnd.api+json") assert response.status_code == status.HTTP_403_FORBIDDEN assert response.json()["errors"]["detail"] == "Unauthorized domain." @@ -13904,7 +13984,8 @@ class TestTenantFinishACSView: ) ) request.user = user - request.session = {} + callback_url = "/invitation/accept?invitation_token=test-token" + request.session = {"saml_callback_url": callback_url} with ( patch( @@ -13946,6 +14027,7 @@ class TestTenantFinishACSView: assert parsed_url.netloc == expected_callback_host query_params = parse_qs(parsed_url.query) assert "id" in query_params + assert query_params["callbackUrl"] == [callback_url] token_id = query_params["id"][0] token_obj = SAMLToken.objects.get(id=token_id) diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index ce1dc0f10d..bb636b1bfa 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -7,9 +7,19 @@ from allauth.socialaccount.providers.oauth2.client import OAuth2Client from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import InvitationTokenExpiredException -from api.models import Integration, Invitation, Processor, Provider, Resource +from api.models import ( + Integration, + Invitation, + Membership, + Processor, + Provider, + Resource, + Role, + UserRoleRelationship, +) from api.v1.serializers import FindingMetadataSerializer from django.contrib.postgres.aggregates import ArrayAgg +from django.db import transaction from django.db.models import Subquery from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError from prowler.providers.aws.lib.s3.s3 import S3 @@ -538,6 +548,35 @@ def validate_invitation( return invitation +def accept_invitation_for_user( + *, user, invitation_token: str, raise_not_found: bool = False +): + with transaction.atomic(using=MainRouter.admin_db): + invitation = validate_invitation( + invitation_token, user.email, raise_not_found=raise_not_found + ) + with rls_transaction(str(invitation.tenant_id), using=MainRouter.admin_db): + membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( + user=user, + tenant=invitation.tenant, + defaults={"role": Membership.RoleChoices.MEMBER}, + ) + invitation_roles = Role.objects.using(MainRouter.admin_db).filter( + invitations=invitation + ) + for role in invitation_roles: + UserRoleRelationship.objects.using(MainRouter.admin_db).get_or_create( + user=user, + role=role, + defaults={"tenant": invitation.tenant}, + ) + + invitation.state = Invitation.State.ACCEPTED + invitation.save(using=MainRouter.admin_db) + + return invitation, membership + + # ToRemove after removing the fallback mechanism in /findings/metadata def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset): filtered_ids = filtered_queryset.order_by().values("id") diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 1d160b4048..a88f53fc7d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -3147,6 +3147,9 @@ class ProcessorUpdateSerializer(BaseWriteSerializer): class SamlInitiateSerializer(BaseSerializerV1): email_domain = serializers.CharField() + callback_url = serializers.CharField( + required=False, allow_blank=True, max_length=2048 + ) class JSONAPIMeta: resource_name = "saml-initiate" diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 588c65c30d..e7907cf6f8 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -9,7 +9,7 @@ from collections import defaultdict from copy import deepcopy from datetime import UTC, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin import sentry_sdk from allauth.socialaccount.models import SocialAccount, SocialApp @@ -129,6 +129,7 @@ from api.renderers import APIJSONRenderer, PlainTextRenderer from api.rls import Tenant from api.utils import ( CustomOAuth2Client, + accept_invitation_for_user, get_findings_metadata_no_aggregations, initialize_prowler_integration, initialize_prowler_provider, @@ -542,6 +543,46 @@ class SchemaView(SpectacularAPIView): return super().get(request, *args, **kwargs) +SAML_CALLBACK_SESSION_KEY = "saml_callback_url" + + +def _safe_callback_path(value): + if not value or not isinstance(value, str): + return None + if not value.startswith("/") or value.startswith("//"): + return None + return value + + +def _get_request_invitation_token(request): + for source_name in ("data", "POST"): + data = getattr(request, source_name, None) or {} + if not hasattr(data, "get"): + continue + invitation_token = data.get("invitation_token") + if invitation_token: + return invitation_token + + wrapped_request = getattr(request, "_request", None) + if wrapped_request and wrapped_request is not request: + return _get_request_invitation_token(wrapped_request) + + return None + + +def _accept_social_invitation(request, user): + invitation_token = _get_request_invitation_token(request) + tenant_id = getattr(request, "prowler_invitation_tenant_id", None) + if invitation_token and not tenant_id: + invitation, _ = accept_invitation_for_user( + user=user, + invitation_token=invitation_token, + raise_not_found=True, + ) + tenant_id = str(invitation.tenant_id) + return tenant_id + + @extend_schema(exclude=True) class GoogleSocialLoginView(SocialLoginView): adapter_class = GoogleOAuth2Adapter @@ -552,7 +593,11 @@ class GoogleSocialLoginView(SocialLoginView): original_response = super().get_response() if self.user and self.user.is_authenticated: - serializer = TokenSocialLoginSerializer(data={"email": self.user.email}) + tenant_id = _accept_social_invitation(self.request, self.user) + serializer_data = {"email": self.user.email} + if tenant_id: + serializer_data["tenant_id"] = tenant_id + serializer = TokenSocialLoginSerializer(data=serializer_data) try: serializer.is_valid(raise_exception=True) except TokenError as e: @@ -577,7 +622,11 @@ class GithubSocialLoginView(SocialLoginView): original_response = super().get_response() if self.user and self.user.is_authenticated: - serializer = TokenSocialLoginSerializer(data={"email": self.user.email}) + tenant_id = _accept_social_invitation(self.request, self.user) + serializer_data = {"email": self.user.email} + if tenant_id: + serializer_data["tenant_id"] = tenant_id + serializer = TokenSocialLoginSerializer(data=serializer_data) try: serializer.is_valid(raise_exception=True) @@ -637,6 +686,10 @@ class CustomSAMLLoginView(LoginView): This approach maintains security while providing better UX. """ + callback_url = _safe_callback_path(request.GET.get("callback_url")) + request.session.pop(SAML_CALLBACK_SESSION_KEY, None) + if callback_url: + request.session[SAML_CALLBACK_SESSION_KEY] = callback_url if request.method == "GET": # Convert GET to POST while preserving parameters request.method = "POST" @@ -681,6 +734,11 @@ class SAMLInitiateAPIView(GenericAPIView): "saml_login", kwargs={"organization_slug": config.email_domain} ) login_url = urljoin(api_host, login_path) + callback_url = _safe_callback_path( + serializer.validated_data.get("callback_url") + ) + if callback_url: + login_url = f"{login_url}?{urlencode({'callback_url': callback_url})}" return redirect(login_url) @@ -896,7 +954,13 @@ class TenantFinishACSView(FinishACSView): token=token_data, user=user ) callback_url = env.str("SAML_SSO_CALLBACK_URL") - redirect_url = f"{callback_url}?id={saml_token.id}" + redirect_params = {"id": str(saml_token.id)} + saml_callback_url = _safe_callback_path( + request.session.pop(SAML_CALLBACK_SESSION_KEY, None) + ) + if saml_callback_url: + redirect_params["callbackUrl"] = saml_callback_url + redirect_url = f"{callback_url}?{urlencode(redirect_params)}" request.session.pop("saml_user_created", None) return redirect(redirect_url) @@ -4389,25 +4453,12 @@ class InvitationAcceptViewSet(BaseRLSViewSet): invitation_token = serializer.validated_data["invitation_token"] user_email = request.user.email - invitation = validate_invitation( - invitation_token, user_email, raise_not_found=True - ) - - # Proceed with accepting the invitation user = User.objects.using(MainRouter.admin_db).get(email=user_email) - membership = Membership.objects.using(MainRouter.admin_db).create( + invitation, membership = accept_invitation_for_user( user=user, - tenant=invitation.tenant, + invitation_token=invitation_token, + raise_not_found=True, ) - user_role = [] - for role in invitation.roles.all(): - user_role.append( - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, role=role, tenant=invitation.tenant - ) - ) - invitation.state = Invitation.State.ACCEPTED - invitation.save(using=MainRouter.admin_db) self.response_serializer_class = MembershipSerializer membership_serializer = self.get_serializer(membership) diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index b40516dadf..c9d63a63ac 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -11,7 +11,6 @@ from uuid import UUID from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import rls_transaction from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot -from api.utils import initialize_prowler_provider from celery.utils.log import get_task_logger from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY from prowler.lib.check.compliance_models import ( @@ -27,6 +26,7 @@ from tasks.jobs.reports import ( ENSReportGenerator, NIS2ReportGenerator, ThreatScoreReportGenerator, + build_provider_metadata, ) from tasks.jobs.threatscore import compute_threatscore_metrics from tasks.jobs.threatscore_utils import ( @@ -841,24 +841,12 @@ def generate_compliance_reports( tenant_id, scan_id ) - # Initialize the Prowler provider once for the whole report batch. Each - # generator used to re-init this in _load_compliance_data, paying the - # boto3/Azure-SDK construction cost 5 times per scan. The instance is - # only used by FindingOutput.transform_api_finding to enrich findings, - # so a single shared instance is correct. - logger.info("Initializing prowler_provider once for all reports (scan %s)", scan_id) - try: - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - prowler_provider = initialize_prowler_provider(provider_obj) - except Exception as init_error: - # If init fails the generators will fall back to lazy init in - # _load_compliance_data; we just log and continue. - logger.warning( - "Could not pre-initialize prowler_provider for scan %s: %s", - scan_id, - init_error, - ) - prowler_provider = None + # Build a credential-free provider metadata stub once for the whole + # report batch. FindingOutput.transform_api_finding only reads static + # attributes (type plus a few identity fields), so reports never decrypt + # the ProviderSecret nor construct a cloud SDK session — generation keeps + # working after credentials are deleted or invalidated (PROWLER-2145). + prowler_provider = build_provider_metadata(provider_obj) # Create shared findings cache up front so the eviction closure below # can reference it. Defined BEFORE the closure to avoid the UnboundLocalError diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py index a538416f59..94da8d6524 100644 --- a/api/src/backend/tasks/jobs/reports/__init__.py +++ b/api/src/backend/tasks/jobs/reports/__init__.py @@ -98,6 +98,7 @@ from .config import ( from .csa import CSAReportGenerator from .ens import ENSReportGenerator from .nis2 import NIS2ReportGenerator +from .provider_metadata import build_provider_metadata from .threatscore import ThreatScoreReportGenerator __all__ = [ @@ -105,6 +106,7 @@ __all__ = [ "BaseComplianceReportGenerator", "ComplianceData", "RequirementData", + "build_provider_metadata", "create_pdf_styles", "get_requirement_metadata", # Framework-specific generators diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py index f51319a846..574609280a 100644 --- a/api/src/backend/tasks/jobs/reports/base.py +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -11,7 +11,6 @@ from typing import Any from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction from api.models import Provider, StatusChoices -from api.utils import initialize_prowler_provider from celery.utils.log import get_task_logger from prowler.lib.check.compliance_models import ( Compliance, @@ -52,6 +51,7 @@ from .config import ( PADDING_SMALL, FrameworkConfig, ) +from .provider_metadata import build_provider_metadata logger = get_task_logger(__name__) @@ -178,7 +178,8 @@ class ComplianceData: attributes_by_requirement_id: Mapping of requirement IDs to their attributes findings_by_check_id: Mapping of check IDs to their findings provider_obj: Provider model object - prowler_provider: Initialized Prowler provider + prowler_provider: Credential-free provider metadata stub (see + ``build_provider_metadata``) """ tenant_id: str @@ -439,10 +440,10 @@ class BaseComplianceReportGenerator(ABC): provider_obj: Optional pre-fetched Provider object requirement_statistics: Optional pre-aggregated statistics findings_cache: Optional pre-loaded findings cache - prowler_provider: Optional pre-initialized Prowler provider. When - generating multiple reports for the same scan the master - function initializes this once and passes it in to avoid - re-running boto3/Azure-SDK setup per framework. + prowler_provider: Optional provider metadata stub (see + ``build_provider_metadata``). When generating multiple + reports for the same scan the master function builds it + once and passes it in. **kwargs: Additional framework-specific arguments """ framework = self.config.display_name @@ -896,9 +897,9 @@ class BaseComplianceReportGenerator(ABC): provider_obj: Optional pre-fetched Provider requirement_statistics: Optional pre-aggregated statistics findings_cache: Optional pre-loaded findings - prowler_provider: Optional pre-initialized Prowler provider. When - the master function initializes it once and passes it in, - we skip the per-report ``initialize_prowler_provider`` call. + prowler_provider: Optional provider metadata stub. When the + master function builds it once and passes it in, we skip + the per-report ``build_provider_metadata`` call. Returns: Aggregated ComplianceData object @@ -909,7 +910,7 @@ class BaseComplianceReportGenerator(ABC): provider_obj = Provider.objects.get(id=provider_id) if prowler_provider is None: - prowler_provider = initialize_prowler_provider(provider_obj) + prowler_provider = build_provider_metadata(provider_obj) provider_type = provider_obj.provider # Load compliance framework — fall back to the universal loader diff --git a/api/src/backend/tasks/jobs/reports/provider_metadata.py b/api/src/backend/tasks/jobs/reports/provider_metadata.py new file mode 100644 index 0000000000..632f15c424 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/provider_metadata.py @@ -0,0 +1,124 @@ +from types import SimpleNamespace + +from prowler.providers.github.models import GithubIdentityInfo + + +def build_provider_metadata(provider) -> SimpleNamespace: + """Build a credential-free stand-in for the Prowler SDK provider. + + ``FindingOutput.transform_api_finding`` only reads static attributes + from the provider (``type`` plus a few identity/metadata fields used to + label accounts), so compliance reports never need the decrypted + ``ProviderSecret`` nor a live cloud SDK session. This builds an object + exposing exactly those attributes from the ``Provider`` DB row, which + keeps report generation working when the provider secret has been + deleted or its credentials are no longer valid (PROWLER-2145). + + Args: + provider: The API ``Provider`` model instance (only ``provider``, + ``uid`` and ``alias`` are read). + + Returns: + A ``SimpleNamespace`` mimicking the SDK provider attributes consumed + by ``FindingOutput.transform_api_finding`` / ``generate_output``. + """ + provider_type = provider.provider + uid = provider.uid + display_name = provider.alias or uid + + # Defaults cover every attribute read unconditionally in + # FindingOutput.generate_output (``provider.auth_method`` is accessed + # directly for several provider types); identity lookups go through + # get_nested_attribute/getattr, which tolerate missing attributes. + stub = SimpleNamespace( + type=provider_type, + auth_method="", + identity=SimpleNamespace(), + ) + + if provider_type == "aws": + stub.identity = SimpleNamespace(account=uid) + elif provider_type == "azure": + stub.identity = SimpleNamespace( + identity_type="", + identity_id="", + tenant_ids=[""], + tenant_domain="", + subscriptions={uid: display_name}, + ) + elif provider_type == "gcp": + stub.identity = SimpleNamespace(profile="") + stub.projects = { + uid: SimpleNamespace( + id=uid, + name=display_name, + labels={}, + organization=None, + ) + } + elif provider_type == "kubernetes": + stub.identity = SimpleNamespace(context=uid, cluster=uid) + elif provider_type == "m365": + stub.identity = SimpleNamespace( + identity_type="", + identity_id="", + tenant_domain=uid, + tenant_id="", + ) + elif provider_type == "github": + # generate_output assigns account fields only inside + # isinstance(identity, Github*IdentityInfo) branches, so the stub + # must carry a real GithubIdentityInfo instance. + stub.identity = GithubIdentityInfo( + account_id=uid, + account_name=display_name, + account_url="", + ) + elif provider_type == "mongodbatlas": + stub.identity = SimpleNamespace( + organization_id=uid, + organization_name=display_name, + ) + elif provider_type == "iac": + stub.provider_uid = uid + elif provider_type == "oraclecloud": + stub.identity = SimpleNamespace( + tenancy_id=uid, + tenancy_name=display_name, + ) + elif provider_type == "alibabacloud": + stub.identity = SimpleNamespace( + identity_arn="", + account_id=uid, + account_name=display_name, + ) + elif provider_type == "cloudflare": + stub.identity = SimpleNamespace( + audited_accounts=[uid], + accounts=[], + ) + elif provider_type == "openstack": + stub.identity = SimpleNamespace( + username="", + project_id=uid, + project_name=display_name, + ) + elif provider_type == "googleworkspace": + stub.identity = SimpleNamespace( + delegated_user="", + customer_id=uid, + domain=display_name, + ) + elif provider_type == "vercel": + stub.identity = SimpleNamespace( + team=None, + user_id=uid, + username=display_name, + ) + elif provider_type == "okta": + stub.identity = SimpleNamespace( + org_domain=uid, + client_id="", + ) + + return stub diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 2e2fb87ba5..2f968749dd 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -178,7 +178,9 @@ def _load_findings_for_requirement_checks( tenant_id (str): The tenant ID for Row-Level Security context. scan_id (str): The ID of the scan to retrieve findings for. check_ids (list[str]): List of check IDs to load findings for. - prowler_provider: The initialized Prowler provider instance. + prowler_provider: Credential-free provider metadata stub (see + ``tasks.jobs.reports.build_provider_metadata``) consumed by + ``FindingOutput.transform_api_finding``. findings_cache (dict, optional): Cache of already loaded findings. If provided, checks are first looked up in cache before querying database. total_counts_out (dict, optional): If provided, populated with diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py index c290e6fe1d..5d156f9d81 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -1044,10 +1044,10 @@ class TestStaleCleanupProtectionHelpers: class TestGenerateThreatscoreReportFunction: """Test suite for generate_threatscore_report function.""" - @patch("tasks.jobs.reports.base.initialize_prowler_provider") + @patch("tasks.jobs.reports.base.build_provider_metadata") def test_generate_threatscore_report_exception_handling( self, - mock_initialize_provider, + mock_build_provider_metadata, tenants_fixture, scans_fixture, providers_fixture, @@ -1057,7 +1057,7 @@ class TestGenerateThreatscoreReportFunction: scan = scans_fixture[0] provider = providers_fixture[0] - mock_initialize_provider.side_effect = Exception("Test exception") + mock_build_provider_metadata.side_effect = Exception("Test exception") with pytest.raises(Exception) as exc_info: generate_threatscore_report( @@ -1167,7 +1167,6 @@ class TestGenerateComplianceReportsOptimized: assert result["cis"] == {"upload": False, "path": ""} mock_cis.assert_not_called() - @patch("api.utils.initialize_prowler_provider") @patch("tasks.jobs.report.rmtree") @patch("tasks.jobs.report._upload_to_s3") @patch("tasks.jobs.report.generate_cis_report") @@ -1194,7 +1193,6 @@ class TestGenerateComplianceReportsOptimized: mock_cis, mock_upload_to_s3, mock_rmtree, - mock_init_provider, ): """After each framework finishes, exclusive entries are evicted. @@ -1223,7 +1221,6 @@ class TestGenerateComplianceReportsOptimized: mock_aggregate_stats.return_value = {} mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out" mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf" - mock_init_provider.return_value = Mock(name="prowler_provider") # Seed the cache as if both frameworks had already loaded their # findings. We mutate it indirectly: each generator wrapper is a @@ -1266,7 +1263,7 @@ class TestGenerateComplianceReportsOptimized: "shared must remain in cache because ENS still needs it" ) - @patch("tasks.jobs.report.initialize_prowler_provider") + @patch("tasks.jobs.report.build_provider_metadata") @patch("tasks.jobs.report.rmtree") @patch("tasks.jobs.report._upload_to_s3") @patch("tasks.jobs.report.generate_cis_report") @@ -1279,7 +1276,7 @@ class TestGenerateComplianceReportsOptimized: @patch("tasks.jobs.report.Compliance.get_bulk") @patch("tasks.jobs.report.Provider.objects.get") @patch("tasks.jobs.report.ScanSummary.objects.filter") - def test_prowler_provider_initialized_once( + def test_provider_metadata_built_once( self, mock_scan_summary_filter, mock_provider_get, @@ -1293,11 +1290,11 @@ class TestGenerateComplianceReportsOptimized: mock_cis, mock_upload_to_s3, mock_rmtree, - mock_init_provider, + mock_build_metadata, ): - """``initialize_prowler_provider`` must be called exactly once for - the whole batch (PROWLER-1733). Previously each generator re-init'd - the SDK provider in ``_load_compliance_data`` → 5 inits per scan. + """``build_provider_metadata`` must be called exactly once for the + whole batch and its result shared across all 5 reports + (PROWLER-1733 / PROWLER-2145). """ mock_scan_summary_filter.return_value.exists.return_value = True mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws") @@ -1306,7 +1303,7 @@ class TestGenerateComplianceReportsOptimized: mock_aggregate_stats.return_value = {} mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out" mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf" - mock_init_provider.return_value = Mock(name="prowler_provider") + mock_build_metadata.return_value = Mock(name="prowler_provider") generate_compliance_reports( tenant_id=str(uuid.uuid4()), @@ -1325,14 +1322,14 @@ class TestGenerateComplianceReportsOptimized: mock_nis2.assert_called_once() mock_csa.assert_called_once() mock_cis.assert_called_once() - # …but the SDK provider was initialized only once. - assert mock_init_provider.call_count == 1, ( - f"expected 1 init, got {mock_init_provider.call_count} " + # …but the provider metadata stub was built only once. + assert mock_build_metadata.call_count == 1, ( + f"expected 1 build, got {mock_build_metadata.call_count} " f"(prowler_provider must be shared across reports)" ) # The shared instance must reach every wrapper as kwargs. - shared = mock_init_provider.return_value + shared = mock_build_metadata.return_value for mock_wrapper in ( mock_threatscore, mock_ens, diff --git a/api/src/backend/tasks/tests/test_reports_provider_metadata.py b/api/src/backend/tasks/tests/test_reports_provider_metadata.py new file mode 100644 index 0000000000..6fe1c32255 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_provider_metadata.py @@ -0,0 +1,193 @@ +"""Tests for the credential-free provider metadata stub (PROWLER-2145). + +Every provider object used here is a plain ``SimpleNamespace`` WITHOUT a +``secret`` attribute: any code path trying to read ``provider.secret`` (the +coupling these tests guard against) would raise ``AttributeError`` and fail +the test. No database is required. +""" + +from types import SimpleNamespace + +import pytest +from api.models import Provider +from prowler.lib.outputs.finding import Finding as FindingOutput +from prowler.providers.github.models import GithubIdentityInfo +from tasks.jobs.reports import build_provider_metadata + +PROVIDER_UID = "provider-uid-123" +PROVIDER_ALIAS = "my-provider-alias" + + +def _provider_row(provider_type: str, alias: str | None = PROVIDER_ALIAS): + """Mimic the Provider DB row attributes read by build_provider_metadata.""" + return SimpleNamespace(provider=provider_type, uid=PROVIDER_UID, alias=alias) + + +class TestBuildProviderMetadata: + @pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values) + def test_every_provider_type_gets_safe_defaults(self, provider_type): + stub = build_provider_metadata(_provider_row(provider_type)) + + assert stub.type == provider_type + assert isinstance(stub.auth_method, str) + assert hasattr(stub, "identity") + + def test_aws_identity_account_is_uid(self): + stub = build_provider_metadata(_provider_row("aws")) + assert stub.identity.account == PROVIDER_UID + + def test_azure_identity_covers_generate_output_accesses(self): + stub = build_provider_metadata(_provider_row("azure")) + + # generate_output indexes tenant_ids[0] and reads these directly. + assert stub.identity.tenant_ids + assert stub.identity.identity_type == "" + assert stub.identity.identity_id == "" + assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_ALIAS} + + def test_gcp_projects_keyed_by_uid(self): + stub = build_provider_metadata(_provider_row("gcp")) + + project = stub.projects[PROVIDER_UID] + assert project.id == PROVIDER_UID + assert project.name == PROVIDER_ALIAS + assert project.labels == {} + # generate_output calls getattr(project, "organization") without a + # default, so the attribute must exist (None skips the org branch). + assert project.organization is None + + def test_kubernetes_identity_context_and_cluster(self): + stub = build_provider_metadata(_provider_row("kubernetes")) + assert stub.identity.context == PROVIDER_UID + assert stub.identity.cluster == PROVIDER_UID + + def test_github_identity_is_real_identity_info(self): + # generate_output only assigns account fields inside + # isinstance(identity, Github*IdentityInfo) branches. + stub = build_provider_metadata(_provider_row("github")) + assert isinstance(stub.identity, GithubIdentityInfo) + assert stub.identity.account_id == PROVIDER_UID + assert stub.identity.account_name == PROVIDER_ALIAS + + def test_iac_provider_uid(self): + stub = build_provider_metadata(_provider_row("iac")) + assert stub.provider_uid == PROVIDER_UID + + def test_alias_falls_back_to_uid(self): + stub = build_provider_metadata(_provider_row("azure", alias=None)) + assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_UID} + + +def _check_metadata_dict(provider_type: str, check_id: str) -> dict: + return { + "provider": provider_type, + "checkid": check_id, + "checktitle": "Test check title", + "checktype": [], + # CheckMetadata validates ServiceName == check_id.split("_")[0] + "servicename": check_id.split("_")[0], + "subservicename": "", + "severity": "high", + "resourcetype": "resource-type", + "description": "", + "risk": "", + "relatedurl": "", + "remediation": { + "recommendation": {"text": "", "url": ""}, + "code": {"nativeiac": "", "terraform": "", "cli": "", "other": ""}, + }, + "resourceidtemplate": "", + "categories": [], + "dependson": [], + "relatedto": [], + "notes": "", + } + + +class _FakeFinding: + """Attribute-faithful Finding stand-in. + + A plain object instead of ``Mock``: only the attributes the Django model + exposes exist, so any new provider-attribute read in generate_output + (e.g. cloudflare's ``getattr(finding, "account_id", ...)``) hits the + same missing-attribute path it would hit in production instead of being + masked by Mock auto-created attributes. + """ + + +def _finding_model(provider_type: str, check_id: str, region: str): + """Mimic the Django Finding row attributes read by transform_api_finding.""" + resource = SimpleNamespace( + uid="resource-uid", + name="resource-name", + metadata="{}", + details="", + region=region, + tags=SimpleNamespace(all=lambda: []), + ) + finding = _FakeFinding() + finding.resources = SimpleNamespace(first=lambda: resource) + finding.check_metadata = _check_metadata_dict(provider_type, check_id) + finding.status = "FAIL" + finding.status_extended = "failed for testing" + finding.muted = False + return finding + + +_FINDING_REGION = "region-x" + +# Expected (account_uid, region) of the transformed finding per provider +# type, with resource.region = _FINDING_REGION. Keyed by every +# Provider.ProviderChoices value so that adding a new provider type without +# extending build_provider_metadata (and this table) fails the test below +# instead of breaking PDF generation at runtime. +_EXPECTED_TRANSFORM = { + "aws": (PROVIDER_UID, _FINDING_REGION), + "azure": (PROVIDER_UID, _FINDING_REGION), + "gcp": (PROVIDER_UID, _FINDING_REGION), + # transform_api_finding strips the "namespace: " prefix and + # generate_output re-adds it. + "kubernetes": (PROVIDER_UID, f"namespace: {_FINDING_REGION}"), + "m365": (PROVIDER_UID, _FINDING_REGION), + # For GitHub the owner comes from resource.region. + "github": (_FINDING_REGION, _FINDING_REGION), + "mongodbatlas": (PROVIDER_UID, _FINDING_REGION), + "iac": (PROVIDER_UID, _FINDING_REGION), + "oraclecloud": (PROVIDER_UID, _FINDING_REGION), + "alibabacloud": (PROVIDER_UID, _FINDING_REGION), + # Cloudflare uses the zone name (falls back to resource.name) as region. + "cloudflare": (PROVIDER_UID, "resource-name"), + "openstack": (PROVIDER_UID, _FINDING_REGION), + "image": ("image", _FINDING_REGION), + "googleworkspace": (PROVIDER_UID, _FINDING_REGION), + "vercel": (PROVIDER_UID, "global"), + "okta": (PROVIDER_UID, "global"), +} + + +class TestTransformApiFindingWithMetadataStub: + """transform_api_finding must work end-to-end with the stub — i.e. + without a credentialed SDK provider — for EVERY API provider type.""" + + @pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values) + def test_transform_with_stub(self, provider_type): + assert provider_type in _EXPECTED_TRANSFORM, ( + f"New provider type {provider_type!r}: add a branch to " + f"build_provider_metadata covering the attributes read by " + f"FindingOutput.generate_output, then add its expected " + f"(account_uid, region) here." + ) + expected_account_uid, expected_region = _EXPECTED_TRANSFORM[provider_type] + + stub = build_provider_metadata(_provider_row(provider_type)) + check_id = f"{provider_type}_test_check" + finding_model = _finding_model(provider_type, check_id, _FINDING_REGION) + + output = FindingOutput.transform_api_finding(finding_model, stub) + + assert output.check_id == check_id + assert output.status == "FAIL" + assert output.account_uid == expected_account_uid + assert output.region == expected_region + assert output.resource_name + assert output.resource_uid diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5730cc4a1e..6dd2054c1e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.32.1] (Prowler UNRELEASED) +## [5.32.2] (Prowler UNRELEASED) + +### 🐞 Fixed + +- Azure `postgresql_flexible_server_log_retention_days_greater_3` check now queries the `logfiles.retention_days` configuration parameter instead of `log_retention_days` (which only exists on the retired Single Server), fixing false `FAIL` results on every Flexible Server regardless of the actual retention value [(#11761)](https://github.com/prowler-cloud/prowler/pull/11761) + +--- + +## [5.32.1] (Prowler v5.32.1) ### 🐞 Fixed diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index e7fe98fe79..59881da09b 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -223,7 +223,7 @@ class PostgreSQL(AzureService): client = self.clients[subscription] try: log_retention_days = client.configurations.get( - resouce_group_name, server_name, "log_retention_days" + resouce_group_name, server_name, "logfiles.retention_days" ) log_retention_days = log_retention_days.value except Exception: diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index c9fea2b307..2e002bcfde 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -402,6 +402,42 @@ class Test_PostgreSQL_Service_Resilience: dev_server = next(s for s in servers[AZURE_SUBSCRIPTION_ID] if s.name == "dev") assert dev_server.connection_throttling == "ON" + def test_log_retention_reads_flexible_server_parameter_name(self): + # Azure Flexible Server exposes log retention under the parameter + # "logfiles.retention_days". The legacy Single Server name + # "log_retention_days" does not exist on Flexible Server (Azure raises + # ConfigurationNotExists), which previously left log_retention_days=None + # and made postgresql_flexible_server_log_retention_days_greater_3 always + # FAIL. Regression test for #11757. + dev = _make_server("dev") + + mock_client = MagicMock() + mock_client.servers.list.return_value = [dev] + server_details = MagicMock() + server_details.location = "westeurope" + mock_client.servers.get.return_value = server_details + mock_client.administrators.list_by_server.return_value = [] + mock_client.firewall_rules.list_by_server.return_value = [] + + def configurations_get(resource_group, server_name, key): + if key == "log_retention_days": + raise ResourceNotFoundError( + "(ConfigurationNotExists) The configuration " + "'log_retention_days' does not exist for dev server " + "version 18." + ) + if key == "logfiles.retention_days": + return MagicMock(value="5") + return MagicMock(value="ON") + + mock_client.configurations.get.side_effect = configurations_get + + postgresql = self._build_service_with_client(mock_client) + servers = postgresql._get_flexible_servers() + + dev_server = servers[AZURE_SUBSCRIPTION_ID][0] + assert dev_server.log_retention_days == "5" + def test_unexpected_throttling_error_is_not_silently_collected(self): # An unexpected failure reading "connection_throttle.enable" (e.g. a # permission, throttling, or transient SDK error) must NOT be turned diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 67e9d56232..9a3eaf3c63 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,12 +2,26 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.33.0] (Prowler UNRELEASED) +## [1.33.0] (Prowler v5.33.0) ### 🚀 Added +- Owners can delete their last organization from the profile page [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864) - Cross-provider compliance view for universal frameworks, with a per-provider breakdown, provider/account/group filters, and combined PDF report generation [(#11745)](https://github.com/prowler-cloud/prowler/pull/11745) + +### 🔄 Changed + +- Organization row actions in the profile page are aligned in fixed columns and the Active indicator now sits next to the organization name [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864) + +--- + +## [1.32.1] (Prowler v5.32.1) + +### 🐞 Fixed + +- Invitation callback paths are now preserved when invited users continue with Google, GitHub, or SAML authentication [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752) + --- ## [1.32.0] (Prowler v5.32.0) diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts index 05a58e9e5e..41e153a46a 100644 --- a/ui/actions/integrations/saml.ts +++ b/ui/actions/integrations/saml.ts @@ -166,8 +166,13 @@ export const deleteSamlConfig = async (id: string) => { } }; -export const initiateSamlAuth = async (email: string) => { +export const initiateSamlAuth = async (email: string, callbackUrl = "/") => { try { + const attributes = { + email_domain: email, + ...(callbackUrl !== "/" && { callback_url: callbackUrl }), + }; + const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, { method: "POST", headers: { @@ -176,9 +181,7 @@ export const initiateSamlAuth = async (email: string) => { body: JSON.stringify({ data: { type: "saml-initiate", - attributes: { - email_domain: email, - }, + attributes, }, }), redirect: "manual", diff --git a/ui/actions/lighthouse/index.ts b/ui/actions/lighthouse-v1/index.ts similarity index 100% rename from ui/actions/lighthouse/index.ts rename to ui/actions/lighthouse-v1/index.ts diff --git a/ui/actions/lighthouse/lighthouse.ts b/ui/actions/lighthouse-v1/lighthouse.ts similarity index 99% rename from ui/actions/lighthouse/lighthouse.ts rename to ui/actions/lighthouse-v1/lighthouse.ts index 5365517bbd..635571f69e 100644 --- a/ui/actions/lighthouse/lighthouse.ts +++ b/ui/actions/lighthouse-v1/lighthouse.ts @@ -4,17 +4,17 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; import { validateBaseUrl, validateCredentials, -} from "@/lib/lighthouse/validation"; +} from "@/lib/lighthouse-v1/validation"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { type LighthouseProvider, PROVIDER_DISPLAY_NAMES, -} from "@/types/lighthouse"; +} from "@/types/lighthouse-v1"; import type { BedrockCredentials, OpenAICompatibleCredentials, OpenAICredentials, -} from "@/types/lighthouse/credentials"; +} from "@/types/lighthouse-v1/credentials"; // API Response Types type ProviderCredentials = diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index b43b467d93..79723debaa 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { signOut } from "@/auth.config"; import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; @@ -287,6 +288,47 @@ export async function deleteTenant( } } +export async function deleteTenantThenSignOut( + _prevState: DeleteTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = deleteTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant ID" }; + } + + const { tenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to delete tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + } catch (error) { + return handleApiError(error); + } + + // Deleting the last tenant also removes the user account server-side, so + // there is no profile left to revalidate; close the session instead. + // signOut redirects by throwing, so it must stay outside the try/catch. + await signOut({ redirectTo: "/sign-in" }); + + // Unreachable: signOut always redirects. Present to satisfy the return type. + return { success: true }; +} + interface SwitchThenDeleteSuccess { success: true; accessToken: string; diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index b846c59a93..4854de012b 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -13,6 +13,7 @@ const SignUp = async ({ typeof resolvedSearchParams?.invitation_token === "string" ? resolvedSearchParams.invitation_token : null; + const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; const GOOGLE_AUTH_URL = getAuthUrl("google"); const GITHUB_AUTH_URL = getAuthUrl("github"); @@ -21,6 +22,7 @@ const SignUp = async ({ { + it("renders Toni copy and links to Lighthouse when connected", () => { + // Given / When + render(); + + // Then + const link = screen.getByRole("link", { + name: /Find and remediate which actually matters\./, + }); + expect(link).toHaveAttribute("href", "/lighthouse"); + expect(link).toHaveTextContent("Lighthouse AI"); + expect(link).toHaveTextContent( + "Find and remediate which actually matters.", + ); + }); + + it("links to Lighthouse settings when no connected configuration exists", () => { + // Given / When + render(); + + // Then + expect( + screen.getByRole("link", { + name: /Find and remediate which actually matters\./, + }), + ).toHaveAttribute("href", "/lighthouse/settings"); + }); +}); diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx new file mode 100644 index 0000000000..eb2e0539fb --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { useRef, useState } from "react"; + +import { LighthouseIcon } from "@/components/icons/Icons"; +import { Card, CardContent } from "@/components/shadcn"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { cn } from "@/lib/utils"; + +import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner"; + +interface LighthouseOverviewBannerProps { + href: LighthouseOverviewBannerHref; +} + +export function LighthouseOverviewBanner({ + href, +}: LighthouseOverviewBannerProps) { + const interactiveRef = useRef(null); + const curXRef = useRef(0); + const curYRef = useRef(0); + const tgXRef = useRef(0); + const tgYRef = useRef(0); + const [isSafari, setIsSafari] = useState(false); + + useMountEffect(() => { + setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent)); + }); + + useMountEffect(() => { + let animationFrameId: number; + + const move = () => { + if (!interactiveRef.current) return; + + curXRef.current += (tgXRef.current - curXRef.current) / 20; + curYRef.current += (tgYRef.current - curYRef.current) / 20; + + interactiveRef.current.style.transform = `translate(${Math.round(curXRef.current)}px, ${Math.round(curYRef.current)}px)`; + + animationFrameId = requestAnimationFrame(move); + }; + + animationFrameId = requestAnimationFrame(move); + + return () => { + cancelAnimationFrame(animationFrameId); + }; + }); + + const handleMouseMove = (event: React.MouseEvent) => { + if (interactiveRef.current) { + const rect = interactiveRef.current.getBoundingClientRect(); + tgXRef.current = event.clientX - rect.left; + tgYRef.current = event.clientY - rect.top; + } + }; + + return ( + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ + +
+ + + +
+

+ Lighthouse AI +

+

+ Find and remediate which actually matters. +

+
+
+ +
+ + + ); +} diff --git a/ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts new file mode 100644 index 0000000000..2ac8bcb2df --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + LighthouseV2Configuration, + LighthouseV2ProviderType, +} from "@/app/(prowler)/lighthouse/_types"; + +import { + getLighthouseOverviewBannerHref, + resolveLighthouseOverviewBannerHref, +} from "./lighthouse-banner"; + +describe("resolveLighthouseOverviewBannerHref", () => { + it("routes to Lighthouse chat when any v2 configuration is connected", () => { + // Given / When + const href = resolveLighthouseOverviewBannerHref([ + configuration("openai", false), + configuration("bedrock", true), + ]); + + // Then + expect(href).toBe("/lighthouse"); + }); + + it("routes to Lighthouse settings when no v2 configuration is connected", () => { + // Given / When + const href = resolveLighthouseOverviewBannerHref([ + configuration("openai", false), + configuration("openai-compatible", null), + ]); + + // Then + expect(href).toBe("/lighthouse/settings"); + }); +}); + +describe("getLighthouseOverviewBannerHref", () => { + it("hides the banner outside cloud without loading configurations", async () => { + // Given + const loadConfigurations = vi.fn(async () => ({ + data: [configuration("openai", true)], + })); + + // When + const href = await getLighthouseOverviewBannerHref( + false, + loadConfigurations, + ); + + // Then + expect(href).toBeNull(); + expect(loadConfigurations).not.toHaveBeenCalled(); + }); + + it("hides the banner when configurations fail to load", async () => { + // Given + const loadConfigurations = vi.fn(async () => ({ + error: "Unauthorized", + status: 401, + })); + + // When + const href = await getLighthouseOverviewBannerHref( + true, + loadConfigurations, + ); + + // Then + expect(href).toBeNull(); + }); + + it("resolves the banner href from loaded configurations in cloud", async () => { + // Given + const loadConfigurations = vi.fn(async () => ({ + data: [configuration("bedrock", true)], + })); + + // When + const href = await getLighthouseOverviewBannerHref( + true, + loadConfigurations, + ); + + // Then + expect(href).toBe("/lighthouse"); + }); +}); + +function configuration( + providerType: LighthouseV2ProviderType, + connected: LighthouseV2Configuration["connected"], +): LighthouseV2Configuration { + return { + id: `config-${providerType}`, + providerType, + baseUrl: + providerType === "openai-compatible" ? "https://example.com" : null, + defaultModel: null, + businessContext: "Production account", + connected, + connectionLastCheckedAt: null, + insertedAt: "2026-06-24T09:00:00Z", + updatedAt: "2026-06-24T10:00:00Z", + }; +} diff --git a/ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts new file mode 100644 index 0000000000..495c2d47d0 --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/lighthouse-banner.ts @@ -0,0 +1,38 @@ +import type { LighthouseV2Configuration } from "@/app/(prowler)/lighthouse/_types"; +import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; +import type { ServerActionResult } from "@/types/server-actions"; + +export const LIGHTHOUSE_OVERVIEW_BANNER_HREF = LIGHTHOUSE_ROUTE; + +export type LighthouseOverviewBannerHref = + (typeof LIGHTHOUSE_OVERVIEW_BANNER_HREF)[keyof typeof LIGHTHOUSE_OVERVIEW_BANNER_HREF]; + +type LoadLighthouseV2Configurations = () => Promise< + ServerActionResult +>; + +export function resolveLighthouseOverviewBannerHref( + configurations: LighthouseV2Configuration[], +): LighthouseOverviewBannerHref { + return configurations.some( + (configuration) => configuration.connected === true, + ) + ? LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT + : LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS; +} + +export async function getLighthouseOverviewBannerHref( + cloud: boolean, + loadConfigurations: LoadLighthouseV2Configurations, +): Promise { + if (!cloud) { + return null; + } + + const result = await loadConfigurations(); + if (!("data" in result)) { + return null; + } + + return resolveLighthouseOverviewBannerHref(result.data); +} diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 9a7a184a42..c2fa1e379b 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -1,7 +1,6 @@ "use server"; import { getLatestFindings } from "@/actions/findings/findings"; -import { LighthouseBanner } from "@/components/lighthouse/banner"; import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; @@ -62,7 +61,6 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { return (
- { + describe("when mapping Cloud JSON:API resources", () => { + it("should map configuration attributes to UI fields", () => { + // Given + const resource: Parameters[0] = { + id: "config-1", + type: "lighthouse-ai-configurations", + attributes: { + provider_type: "bedrock", + base_url: null, + default_model: "anthropic.claude", + business_context: "Production tenant", + connected: true, + connection_last_checked_at: "2026-06-24T10:00:00Z", + inserted_at: "2026-06-24T09:00:00Z", + updated_at: "2026-06-24T10:00:00Z", + }, + }; + + // When + const config = mapLighthouseV2Configuration(resource); + + // Then + expect(config).toEqual({ + id: "config-1", + providerType: "bedrock", + baseUrl: null, + defaultModel: "anthropic.claude", + businessContext: "Production tenant", + connected: true, + connectionLastCheckedAt: "2026-06-24T10:00:00Z", + insertedAt: "2026-06-24T09:00:00Z", + updatedAt: "2026-06-24T10:00:00Z", + }); + }); + + it("should map supported provider and model payloads", () => { + // Given + const provider = { + id: "openai_compatible", + type: "lighthouse-supported-providers", + attributes: { name: "OpenAI Compatible" }, + }; + const model = { + id: "gpt-5.5", + type: "lighthouse-supported-models", + attributes: { + model_name: "GPT 5.5", + max_input_tokens: 100000, + max_output_tokens: 8192, + supports_function_calling: true, + supports_vision: false, + supports_reasoning: true, + }, + }; + + // When / Then + expect(mapLighthouseV2Provider(provider)).toEqual({ + id: "openai-compatible", + name: "OpenAI Compatible", + }); + expect(mapLighthouseV2Model(model)).toEqual({ + id: "gpt-5.5", + name: "GPT 5.5", + maxInputTokens: 100000, + maxOutputTokens: 8192, + supportsFunctionCalling: true, + supportsVision: false, + supportsReasoning: true, + }); + }); + + it("should map message parts from backend names", () => { + // Given + const resource: Parameters[0] = { + id: "message-1", + type: "lighthouse-messages", + attributes: { + role: "assistant", + model: "gpt-5.5", + token_usage: { input: 10 }, + inserted_at: "2026-06-24T10:01:00Z", + parts: [ + { + id: "part-1", + type: "lighthouse-parts", + attributes: { + part_type: "text", + content: { text: "Done" }, + tool_call_outcome: null, + inserted_at: "2026-06-24T10:01:00Z", + updated_at: "2026-06-24T10:01:00Z", + }, + }, + ], + }, + }; + + // When + const message = mapLighthouseV2Message(resource); + + // Then + expect(message.parts[0]).toMatchObject({ + id: "part-1", + type: "text", + content: { text: "Done" }, + }); + }); + + it("should give id-less parts stable fallback keys instead of empty strings", () => { + // Given + const resource: Parameters[0] = { + id: "message-2", + type: "lighthouse-messages", + attributes: { + role: "assistant", + model: null, + token_usage: null, + inserted_at: "2026-06-24T10:02:00Z", + parts: [ + { part_type: "text", content: { text: "one" } }, + { part_type: "text", content: { text: "two" } }, + ], + }, + }; + + // When + const message = mapLighthouseV2Message(resource); + + // Then + expect(message.parts.map((part) => part.id)).toEqual([ + "part-0", + "part-1", + ]); + }); + + it("should reject unknown provider ids at the adapter boundary", () => { + // Given / When / Then + expect(() => + mapLighthouseV2Provider({ + id: "totally-unknown-provider", + type: "lighthouse-supported-providers", + attributes: { name: "Mystery" }, + }), + ).toThrow(/Unsupported Lighthouse v2 provider/); + }); + }); + + describe("when building Cloud payloads", () => { + it("should use Cloud Bedrock credential keys", () => { + // Given + const input = { + providerType: "bedrock" as const, + credentials: { + aws_access_key_id: "test-bedrock-access-key", + aws_secret_access_key: "a".repeat(40), + aws_region_name: "us-east-1", + }, + }; + + // When + const payload = buildLighthouseV2ConfigurationPayload(input); + + // Then + expect(payload.data).toMatchObject({ + type: "lighthouse-ai-configurations", + attributes: { + provider_type: "bedrock", + credentials: { + aws_access_key_id: "test-bedrock-access-key", + aws_secret_access_key: "a".repeat(40), + aws_region_name: "us-east-1", + }, + }, + }); + expect(payload.data.attributes).not.toHaveProperty("default_model"); + expect(payload.data.attributes).not.toHaveProperty("business_context"); + }); + + it("should serialize OpenAI-compatible configuration provider ids for the Cloud API", () => { + // Given + const input = { + providerType: "openai-compatible" as const, + credentials: { api_key: "provider-key" }, + baseUrl: "https://openrouter.ai/api/v1", + }; + + // When + const payload = buildLighthouseV2ConfigurationPayload(input); + + // Then + expect(payload.data.attributes).toMatchObject({ + provider_type: "openai_compatible", + credentials: { api_key: "provider-key" }, + base_url: "https://openrouter.ai/api/v1", + }); + }); + + it("should serialize OpenAI-compatible message provider ids for the Cloud API", () => { + // Given + const input = { + text: "Summarize critical findings", + provider: "openai-compatible" as const, + model: "openrouter/auto", + }; + + // When + const payload = buildLighthouseV2MessagePayload(input); + + // Then + expect(payload.data.attributes.provider).toBe("openai_compatible"); + }); + + it("should build per-provider update payloads with default_model and business_context", () => { + // When + const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", { + defaultModel: "anthropic.claude-4", + businessContext: "Production tenant", + }); + + // Then + expect(payload).toEqual({ + data: { + type: "lighthouse-ai-configurations", + id: "config-1", + attributes: { + default_model: "anthropic.claude-4", + business_context: "Production tenant", + }, + }, + }); + }); + + it("should omit untouched fields from the update payload", () => { + // When + const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", { + defaultModel: "gpt-5.1", + }); + + // Then + expect(payload.data.attributes).toEqual({ default_model: "gpt-5.1" }); + expect(payload.data.attributes).not.toHaveProperty("business_context"); + expect(payload.data.attributes).not.toHaveProperty("credentials"); + }); + + it("should require base_url for OpenAI-compatible configurations", () => { + // Given + const input = { + providerType: "openai-compatible" as const, + credentials: { api_key: "provider-key" }, + }; + + // When + const result = validateLighthouseV2ConfigurationInput(input); + + // Then + expect(result).toEqual({ + success: false, + error: "Base URL is required for OpenAI-compatible providers.", + }); + }); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts new file mode 100644 index 0000000000..5d9a93295c --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts @@ -0,0 +1,380 @@ +import { + LIGHTHOUSE_V2_PROVIDER_TYPE, + type LighthouseV2Configuration, + type LighthouseV2ConfigurationInput, + type LighthouseV2ConfigurationUpdateInput, + type LighthouseV2Credentials, + type LighthouseV2Message, + type LighthouseV2MessageRole, + type LighthouseV2Part, + type LighthouseV2PartType, + type LighthouseV2ProviderType, + type LighthouseV2Session, + type LighthouseV2SupportedModel, + type LighthouseV2SupportedProvider, + type LighthouseV2Task, +} from "@/app/(prowler)/lighthouse/_types"; +import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi"; +import type { + TaskAttributes as ApiTaskAttributes, + TaskState, +} from "@/types/tasks"; + +interface ConfigurationAttributes { + provider_type: string; + base_url: string | null; + default_model?: string | null; + business_context?: string | null; + connected: boolean | null; + connection_last_checked_at: string | null; + inserted_at: string; + updated_at: string; +} + +interface SupportedProviderAttributes { + name: string; +} + +interface SupportedModelAttributes { + model_name?: string | null; + name?: string | null; + max_input_tokens: number | null; + max_output_tokens: number | null; + supports_function_calling: boolean | null; + supports_vision: boolean | null; + supports_reasoning: boolean | null; +} + +interface SessionAttributes { + title: string | null; + is_archived: boolean; + inserted_at: string; + updated_at: string; +} + +interface MessageAttributes { + role: LighthouseV2MessageRole; + model: string | null; + token_usage: unknown; + inserted_at: string; + parts?: UnknownPartResource[]; +} + +interface PartAttributes { + id?: string; + part_type: LighthouseV2PartType; + content: unknown; + tool_call_outcome?: string | null; + inserted_at?: string | null; + updated_at?: string | null; +} + +type UnknownPartResource = + | JsonApiResource + | (PartAttributes & { id?: string }); + +// Extends the shared task attributes: the Lighthouse task resource always +// carries `state` plus scheduling metadata. +interface TaskAttributes extends ApiTaskAttributes { + state: TaskState; + inserted_at?: string; + completed_at?: string | null; + name?: string | null; + metadata?: unknown; +} + +interface ValidationSuccess { + success: true; +} + +interface ValidationFailure { + success: false; + error: string; +} + +type ValidationResult = ValidationSuccess | ValidationFailure; + +const LIGHTHOUSE_V2_API_PROVIDER_TYPE = { + OPENAI: "openai", + BEDROCK: "bedrock", + OPENAI_COMPATIBLE: "openai_compatible", +} as const; + +type LighthouseV2ApiProviderType = + (typeof LIGHTHOUSE_V2_API_PROVIDER_TYPE)[keyof typeof LIGHTHOUSE_V2_API_PROVIDER_TYPE]; + +export function getJsonApiArray( + document: JsonApiDocument, +): TResource[] { + return document.data ?? []; +} + +export function mapLighthouseV2Configuration( + resource: JsonApiResource, +): LighthouseV2Configuration { + return { + id: resource.id, + providerType: normalizeLighthouseV2ProviderType( + resource.attributes.provider_type, + ), + baseUrl: resource.attributes.base_url, + defaultModel: resource.attributes.default_model ?? null, + businessContext: resource.attributes.business_context ?? "", + connected: resource.attributes.connected, + connectionLastCheckedAt: resource.attributes.connection_last_checked_at, + insertedAt: resource.attributes.inserted_at, + updatedAt: resource.attributes.updated_at, + }; +} + +export function mapLighthouseV2Provider( + resource: JsonApiResource, +): LighthouseV2SupportedProvider { + return { + id: normalizeLighthouseV2ProviderType(resource.id), + name: resource.attributes.name, + }; +} + +export function mapLighthouseV2Model( + resource: JsonApiResource, +): LighthouseV2SupportedModel { + return { + id: resource.id, + name: + resource.attributes.model_name ?? resource.attributes.name ?? resource.id, + maxInputTokens: resource.attributes.max_input_tokens, + maxOutputTokens: resource.attributes.max_output_tokens, + supportsFunctionCalling: resource.attributes.supports_function_calling, + supportsVision: resource.attributes.supports_vision, + supportsReasoning: resource.attributes.supports_reasoning, + }; +} + +export function mapLighthouseV2Session( + resource: JsonApiResource, +): LighthouseV2Session { + return { + id: resource.id, + title: resource.attributes.title, + isArchived: resource.attributes.is_archived, + insertedAt: resource.attributes.inserted_at, + updatedAt: resource.attributes.updated_at, + }; +} + +export function mapLighthouseV2Message( + resource: JsonApiResource, +): LighthouseV2Message { + return { + id: resource.id, + role: resource.attributes.role, + model: resource.attributes.model, + tokenUsage: resource.attributes.token_usage, + insertedAt: resource.attributes.inserted_at, + parts: (resource.attributes.parts ?? []).map((part, index) => + mapLighthouseV2Part(part, index), + ), + }; +} + +export function mapLighthouseV2Task( + resource: JsonApiResource, +): LighthouseV2Task { + return { + id: resource.id, + name: resource.attributes.name ?? null, + state: resource.attributes.state, + insertedAt: resource.attributes.inserted_at, + completedAt: resource.attributes.completed_at, + metadata: resource.attributes.metadata, + result: resource.attributes.result, + }; +} + +export function buildLighthouseV2ConfigurationPayload( + input: LighthouseV2ConfigurationInput, +) { + return { + data: { + type: "lighthouse-ai-configurations", + attributes: filterUndefinedAttributes({ + provider_type: toLighthouseV2ApiProviderType(input.providerType), + credentials: input.credentials, + base_url: input.baseUrl ?? null, + }), + }, + }; +} + +export function buildLighthouseV2ConfigurationUpdatePayload( + configId: string, + input: LighthouseV2ConfigurationUpdateInput, +) { + return { + data: { + type: "lighthouse-ai-configurations", + id: configId, + attributes: filterUndefinedAttributes({ + credentials: input.credentials, + base_url: input.baseUrl, + default_model: input.defaultModel, + business_context: input.businessContext, + }), + }, + }; +} + +export function buildLighthouseV2SessionCreatePayload(title?: string | null) { + return { + data: { + type: "lighthouse-sessions", + attributes: { title: title || null }, + }, + }; +} + +export function buildLighthouseV2SessionUpdatePayload( + sessionId: string, + attributes: { title?: string | null; isArchived?: boolean }, +) { + return { + data: { + type: "lighthouse-sessions", + id: sessionId, + attributes: filterUndefinedAttributes({ + title: attributes.title, + is_archived: attributes.isArchived, + }), + }, + }; +} + +export function buildLighthouseV2MessagePayload(input: { + text: string; + provider: LighthouseV2ProviderType; + model?: string | null; +}) { + return { + data: { + type: "lighthouse-messages", + attributes: filterUndefinedAttributes({ + parts: [ + { + part_type: "text", + content: { text: input.text }, + }, + ], + provider: toLighthouseV2ApiProviderType(input.provider), + model: input.model || undefined, + }), + }, + }; +} + +export function validateLighthouseV2ConfigurationInput(input: { + providerType: LighthouseV2ProviderType; + credentials?: LighthouseV2Credentials; + baseUrl?: string | null; +}): ValidationResult { + if (!input.credentials) { + return { success: false, error: "Credentials are required." }; + } + + if ( + input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && + !input.baseUrl + ) { + return { + success: false, + error: "Base URL is required for OpenAI-compatible providers.", + }; + } + + if ( + input.providerType !== LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE && + input.baseUrl + ) { + return { + success: false, + error: "Base URL is only supported for OpenAI-compatible providers.", + }; + } + + if ( + input.providerType === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && + !hasBedrockRegion(input.credentials) + ) { + return { + success: false, + error: "AWS region is required for Bedrock providers.", + }; + } + + return { success: true }; +} + +export function toLighthouseV2ApiProviderType( + providerType: LighthouseV2ProviderType, +): LighthouseV2ApiProviderType { + switch (providerType) { + case LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI: + return LIGHTHOUSE_V2_API_PROVIDER_TYPE.OPENAI; + case LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK: + return LIGHTHOUSE_V2_API_PROVIDER_TYPE.BEDROCK; + case LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE: + return LIGHTHOUSE_V2_API_PROVIDER_TYPE.OPENAI_COMPATIBLE; + } +} + +function mapLighthouseV2Part( + resource: UnknownPartResource, + index: number, +): LighthouseV2Part { + const attributes = "attributes" in resource ? resource.attributes : resource; + // Persisted parts carry an id; streamed/id-less parts fall back to a stable + // per-message index so multiple id-less parts never collide on "" as a key. + const id = + ("id" in resource ? resource.id : attributes.id) ?? `part-${index}`; + + return { + id, + type: attributes.part_type, + content: attributes.content, + toolCallOutcome: attributes.tool_call_outcome ?? null, + insertedAt: attributes.inserted_at ?? null, + updatedAt: attributes.updated_at ?? null, + }; +} + +function filterUndefinedAttributes>( + attributes: T, +) { + return Object.fromEntries( + Object.entries(attributes).filter(([, value]) => value !== undefined), + ) as Partial; +} + +function hasBedrockRegion(credentials: LighthouseV2Credentials): boolean { + return ( + "aws_region_name" in credentials && Boolean(credentials.aws_region_name) + ); +} + +function normalizeLighthouseV2ProviderType( + providerType: string, +): LighthouseV2ProviderType { + const normalized = + providerType === "openai_compatible" + ? LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE + : providerType; + + // Validate at the adapter boundary so an unexpected backend id fails fast + // here instead of crossing into the UI as a bogus "valid" provider. + const allowed: readonly string[] = Object.values(LIGHTHOUSE_V2_PROVIDER_TYPE); + if (!allowed.includes(normalized)) { + throw new Error(`Unsupported Lighthouse v2 provider: ${providerType}`); + } + + return normalized as LighthouseV2ProviderType; +} diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts new file mode 100644 index 0000000000..bab81d7157 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { authMock, revalidatePathMock } = vi.hoisted(() => ({ + authMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock })); +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), + captureMessage: vi.fn(), +})); +// Provide the primitives the action AND the real handleApiResponse need, while +// keeping the revalidate gate (the behavior under test) running for real. +vi.mock("@/lib/helper", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: vi + .fn() + .mockResolvedValue({ Authorization: "Bearer token-123" }), + parseStringify: (value: unknown) => JSON.parse(JSON.stringify(value)), + getErrorMessage: (error: unknown) => String(error), + sanitizeErrorMessage: (message: string) => message, + GENERIC_SERVER_ERROR_MESSAGE: "Server error", +})); + +import { + createLighthouseV2Session, + getLighthouseV2SupportedModels, + updateLighthouseV2Configuration, + updateLighthouseV2Session, +} from "./lighthouse-v2"; + +function sessionResponse(id = "session-1") { + return Response.json( + { + data: { + id, + type: "lighthouse-sessions", + attributes: { + title: "Summarize findings", + is_archived: false, + inserted_at: "2026-06-25T10:00:00Z", + updated_at: "2026-06-25T10:00:00Z", + active_celery_task_id: null, + }, + }, + }, + { status: 201 }, + ); +} + +function configurationResponse(id = "config-1") { + return Response.json( + { + data: { + id, + type: "lighthouse-ai-configurations", + attributes: { + provider_type: "bedrock", + base_url: null, + default_model: "anthropic.claude-4", + business_context: "Production tenant", + connected: true, + connection_last_checked_at: "2026-06-25T10:00:00Z", + inserted_at: "2026-06-25T09:00:00Z", + updated_at: "2026-06-25T10:00:00Z", + }, + }, + }, + { status: 200 }, + ); +} + +function modelsResponse() { + return Response.json({ data: [] }, { status: 200 }); +} + +describe("Lighthouse v2 session write actions", () => { + beforeEach(() => { + authMock.mockResolvedValue({ accessToken: "token-123" }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("does NOT revalidate /lighthouse when creating a session (avoids chat remount)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(sessionResponse())); + + const result = await createLighthouseV2Session("Summarize findings"); + + expect("data" in result && result.data.id).toBe("session-1"); + // Revalidating the active force-dynamic route would remount the chat and + // kill the live EventSource — so it must stay off for session creation. + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("still revalidates /lighthouse for other session writes (regression contrast)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(sessionResponse())); + + await updateLighthouseV2Session("session-1", { title: "Renamed" }); + + // Proves the test harness would catch a revalidate being (re)introduced. + expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse"); + }); + + it("persists the chosen model as the provider default without remounting the active chat", async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(configurationResponse()); + vi.stubGlobal("fetch", fetchMock); + + // When + const result = await updateLighthouseV2Configuration("config-1", { + defaultModel: "anthropic.claude-4", + }); + + // Then + expect("data" in result && result.data.defaultModel).toBe( + "anthropic.claude-4", + ); + expect(fetchMock).toHaveBeenCalledWith( + new URL("https://api.example.com/api/v1/lighthouse/config/config-1"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "lighthouse-ai-configurations", + id: "config-1", + attributes: { default_model: "anthropic.claude-4" }, + }, + }), + }), + ); + // Revalidating the active force-dynamic chat route would remount it and kill + // the live EventSource — only the settings route may be revalidated. + expect(revalidatePathMock).not.toHaveBeenCalledWith("/lighthouse"); + expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse/settings"); + }); + + it("loads OpenAI-compatible models using the Cloud provider id", async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(modelsResponse()); + vi.stubGlobal("fetch", fetchMock); + + // When + const result = await getLighthouseV2SupportedModels("openai-compatible"); + + // Then + expect("data" in result && result.data).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/lighthouse/supported-providers/openai_compatible/models", + expect.objectContaining({ + method: "GET", + }), + ); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts new file mode 100644 index 0000000000..c78233b35f --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts @@ -0,0 +1,372 @@ +"use server"; + +import { pollTaskUntilSettled } from "@/actions/task/poll"; +import type { + LighthouseV2Configuration, + LighthouseV2ConfigurationInput, + LighthouseV2ConfigurationUpdateInput, + LighthouseV2Message, + LighthouseV2ProviderType, + LighthouseV2SendMessageInput, + LighthouseV2SendMessageResult, + LighthouseV2Session, + LighthouseV2SupportedModel, + LighthouseV2SupportedProvider, +} from "@/app/(prowler)/lighthouse/_types"; +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { JsonApiDocument } from "@/types/jsonapi"; +import type { ServerActionResult } from "@/types/server-actions"; + +import { + buildLighthouseV2ConfigurationPayload, + buildLighthouseV2ConfigurationUpdatePayload, + buildLighthouseV2MessagePayload, + buildLighthouseV2SessionCreatePayload, + buildLighthouseV2SessionUpdatePayload, + getJsonApiArray, + mapLighthouseV2Configuration, + mapLighthouseV2Message, + mapLighthouseV2Model, + mapLighthouseV2Provider, + mapLighthouseV2Session, + mapLighthouseV2Task, + toLighthouseV2ApiProviderType, + validateLighthouseV2ConfigurationInput, +} from "./lighthouse-v2.adapter"; + +type TaskResource = Parameters[0]; + +export type LighthouseV2ActionResult = ServerActionResult; + +const CONFIG_ENDPOINT = "/lighthouse/config"; +const SESSIONS_ENDPOINT = "/lighthouse/sessions"; +const SUPPORTED_PROVIDERS_ENDPOINT = "/lighthouse/supported-providers"; + +// 20 attempts x 3s: ~60s ceiling for the provider connection-check task. +const CONNECTION_TEST_MAX_ATTEMPTS = 20; +const CONNECTION_TEST_DELAY_MS = 3000; + +export async function getLighthouseV2Configurations(): Promise< + LighthouseV2ActionResult +> { + return getCollection(CONFIG_ENDPOINT, mapLighthouseV2Configuration); +} + +export async function createLighthouseV2Configuration( + input: LighthouseV2ConfigurationInput, +): Promise> { + const validation = validateLighthouseV2ConfigurationInput(input); + if (!validation.success) { + return { error: validation.error, status: 400 }; + } + + return mutateSingle( + CONFIG_ENDPOINT, + { + method: "POST", + body: JSON.stringify(buildLighthouseV2ConfigurationPayload(input)), + }, + mapLighthouseV2Configuration, + LIGHTHOUSE_ROUTE.SETTINGS, + ); +} + +export async function updateLighthouseV2Configuration( + configId: string, + input: LighthouseV2ConfigurationUpdateInput, +): Promise> { + return mutateSingle( + `${CONFIG_ENDPOINT}/${encodeURIComponent(configId)}`, + { + method: "PATCH", + body: JSON.stringify( + buildLighthouseV2ConfigurationUpdatePayload(configId, input), + ), + }, + mapLighthouseV2Configuration, + LIGHTHOUSE_ROUTE.SETTINGS, + ); +} + +export async function deleteLighthouseV2Configuration( + configId: string, +): Promise> { + return mutateEmpty( + `${CONFIG_ENDPOINT}/${encodeURIComponent(configId)}`, + { method: "DELETE" }, + LIGHTHOUSE_ROUTE.SETTINGS, + ); +} + +// Starts the backend connection-check task, polls it to completion (reusing the +// shared task poller), then returns the re-fetched configuration so the caller +// can render the authoritative `connected` / `connectionLastCheckedAt` status. +export async function testLighthouseV2ConfigurationConnection( + configId: string, +): Promise> { + try { + const response = await fetch( + buildApiUrl( + `${CONFIG_ENDPOINT}/${encodeURIComponent(configId)}/connection`, + ), + { method: "POST", headers: await getAuthHeaders({ contentType: false }) }, + ); + const document = (await handleApiResponse( + response, + )) as JsonApiDocument; + if (isErrorDocument(document) || !document.data) { + return toErrorResult(document); + } + + const settled = await pollTaskUntilSettled( + mapLighthouseV2Task(document.data).id, + { + maxAttempts: CONNECTION_TEST_MAX_ATTEMPTS, + delayMs: CONNECTION_TEST_DELAY_MS, + }, + ); + if (!settled.ok) { + return { error: settled.error || "Connection test timed out." }; + } + + const configurations = await getLighthouseV2Configurations(); + if ("error" in configurations) { + return configurations; + } + const updated = configurations.data.find( + (config) => config.id === configId, + ); + if (!updated) { + return { error: "Configuration not found after connection test." }; + } + return { data: updated }; + } catch (error) { + return handleApiError(error); + } +} + +export async function getLighthouseV2SupportedProviders(): Promise< + LighthouseV2ActionResult +> { + return getCollection(SUPPORTED_PROVIDERS_ENDPOINT, mapLighthouseV2Provider); +} + +export async function getLighthouseV2SupportedModels( + provider: LighthouseV2ProviderType, +): Promise> { + return getCollection( + `${SUPPORTED_PROVIDERS_ENDPOINT}/${encodeURIComponent(toLighthouseV2ApiProviderType(provider))}/models`, + mapLighthouseV2Model, + ); +} + +export async function getLighthouseV2Sessions(): Promise< + LighthouseV2ActionResult +> { + return getCollection(SESSIONS_ENDPOINT, mapLighthouseV2Session); +} + +export async function createLighthouseV2Session( + title?: string | null, +): Promise> { + // Intentionally NOT revalidating "/lighthouse": the page is force-dynamic + // (nothing to revalidate) and revalidating the active route mid-submit would + // re-run the server component and remount the chat, killing the live stream. + // The sidebar refreshes client-side via notifyLighthouseV2SessionsChanged(). + return mutateSingle( + SESSIONS_ENDPOINT, + { + method: "POST", + body: JSON.stringify(buildLighthouseV2SessionCreatePayload(title)), + }, + mapLighthouseV2Session, + "", + ); +} + +export async function updateLighthouseV2Session( + sessionId: string, + attributes: { title?: string | null; isArchived?: boolean }, +): Promise> { + return mutateSingle( + `${SESSIONS_ENDPOINT}/${encodeURIComponent(sessionId)}`, + { + method: "PATCH", + body: JSON.stringify( + buildLighthouseV2SessionUpdatePayload(sessionId, attributes), + ), + }, + mapLighthouseV2Session, + LIGHTHOUSE_ROUTE.CHAT, + ); +} + +export async function archiveLighthouseV2Session( + sessionId: string, +): Promise> { + return updateLighthouseV2Session(sessionId, { isArchived: true }); +} + +export async function getLighthouseV2Messages( + sessionId: string, +): Promise> { + return getCollection( + `${SESSIONS_ENDPOINT}/${encodeURIComponent(sessionId)}/messages`, + mapLighthouseV2Message, + ); +} + +export async function sendLighthouseV2Message( + input: LighthouseV2SendMessageInput, +): Promise> { + try { + const response = await fetch( + buildApiUrl( + `${SESSIONS_ENDPOINT}/${encodeURIComponent(input.sessionId)}/messages`, + ), + { + method: "POST", + headers: await getAuthHeaders({ contentType: true }), + body: JSON.stringify(buildLighthouseV2MessagePayload(input)), + }, + ); + const document = (await handleApiResponse( + response, + )) as JsonApiDocument; + + if (isErrorDocument(document) || !document.data) { + return toErrorResult(document); + } + + return { + data: { + task: mapLighthouseV2Task(document.data), + }, + meta: document.meta, + }; + } catch (error) { + return handleApiError(error); + } +} + +async function getCollection( + path: string, + mapper: (resource: TResource) => TOutput, +): Promise> { + return getCollectionFromUrl(buildApiUrl(path), mapper); +} + +async function getCollectionFromUrl( + url: URL, + mapper: (resource: TResource) => TOutput, +): Promise> { + try { + const headers = await getAuthHeaders({ contentType: false }); + const first = (await handleApiResponse( + await fetch(url.toString(), { + method: "GET", + headers, + cache: "no-store", + }), + )) as JsonApiDocument; + + if (isErrorDocument(first)) { + return toErrorResult(first); + } + + const resources = [...getJsonApiArray(first)]; + let nextUrl: string | undefined = first.links?.next ?? undefined; + while (nextUrl) { + const page = (await handleApiResponse( + await fetch(nextUrl, { method: "GET", headers, cache: "no-store" }), + )) as JsonApiDocument; + if (isErrorDocument(page)) { + return toErrorResult(page); + } + resources.push(...getJsonApiArray(page)); + nextUrl = page.links?.next ?? undefined; + } + + return { + data: resources.map(mapper), + meta: first.meta, + links: first.links, + }; + } catch (error) { + return handleApiError(error); + } +} + +async function mutateSingle( + path: string, + init: RequestInit, + mapper: (resource: TResource) => TOutput, + pathToRevalidate: string, + includeContentType = true, +): Promise> { + try { + const response = await fetch(buildApiUrl(path), { + ...init, + headers: await getAuthHeaders({ contentType: includeContentType }), + }); + const document = (await handleApiResponse( + response, + pathToRevalidate, + )) as JsonApiDocument; + if (isErrorDocument(document) || !document.data) { + return toErrorResult(document); + } + return { data: mapper(document.data), meta: document.meta }; + } catch (error) { + return handleApiError(error); + } +} + +async function mutateEmpty( + path: string, + init: RequestInit, + pathToRevalidate: string, +): Promise> { + try { + const response = await fetch(buildApiUrl(path), { + ...init, + headers: await getAuthHeaders({ contentType: false }), + }); + const document = await handleApiResponse(response, pathToRevalidate); + if (isErrorDocument(document)) { + return toErrorResult(document); + } + return { data: true, status: document.status }; + } catch (error) { + return handleApiError(error); + } +} + +function buildApiUrl(path: string): URL { + return new URL(`${getRequiredApiBaseUrl()}${path}`); +} + +function getRequiredApiBaseUrl(): string { + if (!apiBaseUrl) { + throw new Error("API base URL is not configured."); + } + return apiBaseUrl; +} + +function isErrorDocument( + document: JsonApiDocument | { error?: unknown }, +): document is JsonApiDocument & { error: string } { + return typeof document.error === "string"; +} + +function toErrorResult( + document: JsonApiDocument, +): Extract, { error: string }> { + return { + error: document.error ?? "Unexpected Lighthouse AI response.", + errors: document.errors, + status: document.status, + }; +} diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/README.md b/ui/app/(prowler)/lighthouse/_components/ai-elements/README.md new file mode 100644 index 0000000000..0030ddeb40 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/README.md @@ -0,0 +1,7 @@ +# AI Elements + +Components vendored from Vercel's AI Elements registry +(https://ai-sdk.dev/elements), adapted to this repo's shadcn imports and +lint rules. They live in their own folder — same idea as `components/shadcn/` +— so they can be diffed against upstream when updating instead of being mixed +with hand-written chat UI, which lives in `../chat/`. diff --git a/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx new file mode 100644 index 0000000000..474fed577c --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { + BrainIcon, + ChevronDownIcon, + DotIcon, + type LucideIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, useContext } from "react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/shadcn/collapsible"; +import { cn } from "@/lib/utils"; + +type ChainOfThoughtContextValue = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const ChainOfThoughtContext = createContext( + null, +); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error( + "ChainOfThought components must be used within ChainOfThought", + ); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export function ChainOfThought({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props +}: ChainOfThoughtProps) { + const [isOpen, setIsOpen] = useControllableState({ + prop: open, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const chainOfThoughtContext = { isOpen, setIsOpen }; + + // One Collapsible root wraps both header and content so CollapsibleTrigger and + // CollapsibleContent share Radix's ARIA/id wiring; the context only carries + // `isOpen` for presentational bits like the header chevron. + return ( + + + {children} + + + ); +} + +export type ChainOfThoughtHeaderProps = ComponentProps< + typeof CollapsibleTrigger +>; + +export function ChainOfThoughtHeader({ + className, + children, + ...props +}: ChainOfThoughtHeaderProps) { + const { isOpen } = useChainOfThought(); + + return ( + + + {children ?? "Chain of Thought"} + + + ); +} + +export const CHAIN_OF_THOUGHT_STATUS = { + COMPLETE: "complete", + ACTIVE: "active", + PENDING: "pending", +} as const; + +export type ChainOfThoughtStatus = + (typeof CHAIN_OF_THOUGHT_STATUS)[keyof typeof CHAIN_OF_THOUGHT_STATUS]; + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: ChainOfThoughtStatus; +}; + +export function ChainOfThoughtStep({ + className, + icon: Icon = DotIcon, + label, + description, + status = CHAIN_OF_THOUGHT_STATUS.COMPLETE, + children, + ...props +}: ChainOfThoughtStepProps) { + const statusStyles = { + complete: "text-muted-foreground", + active: "text-foreground", + pending: "text-muted-foreground/50", + }; + + return ( +
+
+ +
+
+
+
{label}
+ {description && ( +
{description}
+ )} + {children} +
+
+ ); +} + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export function ChainOfThoughtSearchResults({ + className, + ...props +}: ChainOfThoughtSearchResultsProps) { + return ( +
+ ); +} + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export function ChainOfThoughtSearchResult({ + className, + children, + ...props +}: ChainOfThoughtSearchResultProps) { + return ( + + {children} + + ); +} + +export type ChainOfThoughtContentProps = ComponentProps< + typeof CollapsibleContent +>; + +export function ChainOfThoughtContent({ + className, + children, + ...props +}: ChainOfThoughtContentProps) { + return ( + + {children} + + ); +} + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export function ChainOfThoughtImage({ + className, + children, + caption, + ...props +}: ChainOfThoughtImageProps) { + return ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ); +} diff --git a/ui/components/ai-elements/conversation.tsx b/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx similarity index 73% rename from ui/components/ai-elements/conversation.tsx rename to ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx index 44916f06d1..f1adfa3689 100644 --- a/ui/components/ai-elements/conversation.tsx +++ b/ui/app/(prowler)/lighthouse/_components/ai-elements/conversation.tsx @@ -19,19 +19,42 @@ export const Conversation = ({ className, ...props }: ConversationProps) => ( /> ); -export type ConversationContentProps = ComponentProps< - typeof StickToBottom.Content ->; +type ConversationContentChildren = + | ReactNode + | ((context: ReturnType) => ReactNode); + +export type ConversationContentProps = Omit< + ComponentProps<"div">, + "children" | "ref" +> & { + children?: ConversationContentChildren; + scrollClassName?: string; +}; export const ConversationContent = ({ + children, className, + scrollClassName, ...props -}: ConversationContentProps) => ( - -); +}: ConversationContentProps) => { + const context = useStickToBottomContext(); + const { contentRef, scrollRef } = context; + + return ( +
+
+ {typeof children === "function" ? children(context) : children} +
+
+ ); +}; export type ConversationEmptyStateProps = ComponentProps<"div"> & { title?: string; diff --git a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx new file mode 100644 index 0000000000..62d39cfb05 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { CornerDownLeft, Settings, TriangleAlert } from "lucide-react"; +import Link from "next/link"; +import { type ReactNode, type SubmitEvent, useRef } from "react"; + +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { Button } from "@/components/shadcn/button/button"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; + +interface ChatComposerPanelProps { + feedback: string | null; + canRetry: boolean; + onRetry: () => void; + onDismissFeedback: () => void; + canSend: boolean; + input: string; + isStreaming: boolean; + modelSelector: ReactNode; + selectedConfigurationConnected: boolean; + onInputChange: (value: string) => void; + onSubmit: (event: SubmitEvent) => void; + onSubmitText: (text: string) => Promise; +} + +// Feedback banner + input, shared by the empty and active chat layouts so the +// two branches can't drift apart. +export function ChatComposerPanel({ + feedback, + canRetry, + onRetry, + onDismissFeedback, + ...composerProps +}: ChatComposerPanelProps) { + return ( + <> + + + + ); +} + +function ChatFeedbackBar({ + feedback, + canRetry, + onRetry, + onDismiss, +}: { + feedback: string | null; + canRetry: boolean; + onRetry: () => void; + onDismiss: () => void; +}) { + if (!feedback) return null; + + return ( + + + + {feedback} + {canRetry && ( + + )} + + + ); +} + +interface ChatComposerProps { + canSend: boolean; + input: string; + isStreaming: boolean; + modelSelector: ReactNode; + selectedConfigurationConnected: boolean; + onInputChange: (value: string) => void; + onSubmit: (event: SubmitEvent) => void; + onSubmitText: (text: string) => Promise; +} + +function ChatComposer({ + canSend, + input, + isStreaming, + selectedConfigurationConnected, + onInputChange, + modelSelector, + onSubmit, + onSubmitText, +}: ChatComposerProps) { + const textareaRef = useRef(null); + + useMountEffect(() => { + textareaRef.current?.focus(); + }); + + return ( +
+