diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index d1387a4216..19da4bc062 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -51,6 +51,7 @@ from api.db_utils import ( one_week_from_now, ) from api.exceptions import ModelValidationError +from api.provider_types import get_app_provider_types from api.rls import ( BaseSecurityConstraint, RowLevelSecurityConstraint, @@ -58,7 +59,6 @@ from api.rls import ( Tenant, ) from prowler.lib.check.models import Severity -from prowler.providers.common.provider import Provider as SDKProvider fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode()) @@ -502,7 +502,7 @@ class Provider(RowLevelSecurityProtectedModel): def clean(self): super().clean() - if self.provider not in SDKProvider.get_available_providers(): + if self.provider not in get_app_provider_types(): raise ModelValidationError( detail=f"{self.provider} is not a supported provider.", code="invalid", diff --git a/api/src/backend/api/provider_types.py b/api/src/backend/api/provider_types.py index ce909d59d6..9a6b32268b 100644 --- a/api/src/backend/api/provider_types.py +++ b/api/src/backend/api/provider_types.py @@ -4,12 +4,20 @@ from prowler.providers.common.provider import Provider as SDKProvider @lru_cache(maxsize=1) -def get_provider_type_choices(): - """Provider-type choices from the SDK's available providers, so they cover - external providers and not just a static enum. +def get_app_provider_types(): + """App-facing provider types: SDK providers with ``sdk_only = False``. - Cached for the process lifetime; hot-installing a provider needs - coordinated cache invalidation (tracked separately) to show up here without - a restart. Shared by the filters and the provider serializer. + Single source of truth for which provider types the API exposes (filter + choices, serializer field, creation validation). Cached for the process + lifetime; hot-installing a provider needs cache invalidation (tracked + separately) to show up without a restart. """ - return [(name, name) for name in SDKProvider.get_available_providers()] + return tuple(SDKProvider.get_app_providers()) + + +@lru_cache(maxsize=1) +def get_provider_type_choices(): + """Provider-type choices from the app-facing providers, hiding providers + the API does not expose (``sdk_only = True``). Shared by the filters and + the provider serializer.""" + return [(name, name) for name in get_app_provider_types()] diff --git a/api/src/backend/api/tests/test_filters.py b/api/src/backend/api/tests/test_filters.py index 7738d247bb..cf99c18415 100644 --- a/api/src/backend/api/tests/test_filters.py +++ b/api/src/backend/api/tests/test_filters.py @@ -3,21 +3,29 @@ from prowler.providers.common.provider import Provider as SDKProvider class TestProviderTypeChoices: - """Provider-type filter choices are driven by the SDK's available providers - so filtering covers external providers, not just a static enum.""" + """Provider-type filter choices are driven by the SDK's app-facing providers + (``sdk_only = False``) so filtering covers external providers that opt in, + while hiding providers the API does not expose (``sdk_only = True``).""" - def test_choices_track_sdk_available_providers(self): - available = set(SDKProvider.get_available_providers()) + def test_choices_track_sdk_app_providers(self): + app_providers = set(SDKProvider.get_app_providers()) choices = get_provider_type_choices() - assert {value for value, _ in choices} == available + assert {value for value, _ in choices} == app_providers - def test_choices_include_provider_absent_from_legacy_enum(self): - from api.models import Provider - - legacy = {value for value, _ in Provider.ProviderChoices.choices} + def test_choices_exclude_sdk_only_providers(self): choice_values = {value for value, _ in get_provider_type_choices()} - # `llm` is exposed by the SDK but is not part of the legacy static enum. - assert "llm" in choice_values - assert "llm" not in legacy + # These built-ins are not implemented in the API, so they keep the + # default ``sdk_only = True`` and must not appear as filter choices, + # even though the SDK still lists them in get_available_providers(). + available = set(SDKProvider.get_available_providers()) + for sdk_only_provider in ("llm", "nhn", "scaleway", "stackit"): + assert sdk_only_provider in available + assert sdk_only_provider not in choice_values + + def test_choices_include_app_exposed_builtins(self): + choice_values = {value for value, _ in get_provider_type_choices()} + + assert "aws" in choice_values + assert "azure" in choice_values diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index 4a71eba00e..7744455583 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from unittest.mock import patch import pytest from allauth.socialaccount.models import SocialApp @@ -531,16 +532,18 @@ class TestTenantComplianceSummaryModel: @pytest.mark.django_db class TestProviderDynamicValidation: - """Provider validity is driven by the SDK's available providers, not a - static enum. Providers the SDK exposes are accepted; for those without a - `validate__uid` method only the uid min-length floor applies.""" + """Provider validity is driven by the SDK's app-facing providers + (``sdk_only = False``). sdk_only and unknown providers are rejected; an + app provider without a ``validate__uid`` method only hits the + uid min-length floor.""" - def test_accepts_provider_without_uid_validator(self, tenants_fixture): + def test_rejects_sdk_only_builtin(self, tenants_fixture): + # `llm` is a built-in but sdk_only, so the API must not accept it. tenant = tenants_fixture[0] - provider = Provider.objects.create( - tenant_id=tenant.id, provider="llm", uid="my-llm-account" - ) - assert provider.provider == "llm" + with pytest.raises(ModelValidationError): + Provider.objects.create( + tenant_id=tenant.id, provider="llm", uid="my-llm-account" + ) def test_rejects_provider_not_available_in_sdk(self, tenants_fixture): tenant = tenants_fixture[0] @@ -549,7 +552,17 @@ class TestProviderDynamicValidation: tenant_id=tenant.id, provider="does-not-exist", uid="whatever" ) - def test_uid_floor_still_enforced_for_external_provider(self, tenants_fixture): + @patch("api.models.get_app_provider_types", return_value=("customext",)) + def test_accepts_app_provider_without_uid_validator(self, _mock, tenants_fixture): + # An external provider that opts into the app and has no uid validator. + tenant = tenants_fixture[0] + provider = Provider.objects.create( + tenant_id=tenant.id, provider="customext", uid="my-account" + ) + assert provider.provider == "customext" + + @patch("api.models.get_app_provider_types", return_value=("customext",)) + def test_uid_floor_still_enforced_for_app_provider(self, _mock, tenants_fixture): tenant = tenants_fixture[0] with pytest.raises(ValidationError): - Provider.objects.create(tenant_id=tenant.id, provider="llm", uid="ab") + Provider.objects.create(tenant_id=tenant.id, provider="customext", uid="ab") diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 0422ac4a01..cd85939da3 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -81,17 +81,19 @@ class TestExternalProviderSecretValidation: class TestProviderEnumSerializerField: - """The provider field accepts whatever the SDK exposes (built-in or - external) and rejects anything else with `invalid_choice`.""" + """The provider field accepts app-facing providers (``sdk_only = False``) + and rejects sdk_only and unknown providers with `invalid_choice`.""" - def test_accepts_sdk_available_provider(self): + def test_accepts_app_provider(self): field = ProviderEnumSerializerField() assert field.run_validation("aws") == "aws" - def test_accepts_external_provider_absent_from_static_enum(self): + def test_rejects_sdk_only_provider(self): field = ProviderEnumSerializerField() - # `llm` is exposed by the SDK but is not part of the legacy static enum. - assert field.run_validation("llm") == "llm" + # `llm` is exposed by the SDK but is sdk_only, so the API rejects it. + with pytest.raises(ValidationError) as exc: + field.run_validation("llm") + assert exc.value.detail[0].code == "invalid_choice" def test_rejects_unknown_provider(self): field = ProviderEnumSerializerField()