diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 4c85044f9d..d1387a4216 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -58,6 +58,7 @@ 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()) @@ -501,13 +502,24 @@ class Provider(RowLevelSecurityProtectedModel): def clean(self): super().clean() + if self.provider not in SDKProvider.get_available_providers(): + raise ModelValidationError( + detail=f"{self.provider} is not a supported provider.", + code="invalid", + pointer="/data/attributes/provider", + ) if self.provider == self.ProviderChoices.OKTA and self.uid: # Mirror the SDK, which lowercases the org domain before connecting. # Without this the API would reject Acme.okta.com even though the # SDK would accept it, and stored uids could disagree with the # authenticated org domain. self.uid = self.uid.strip().lower() - getattr(self, f"validate_{self.provider}_uid")(self.uid) + # Providers the SDK exposes but the API has no specific uid rule for + # (e.g. external providers) fall back to the field-level min-length + # check only, instead of failing on a missing validator. + uid_validator = getattr(self, f"validate_{self.provider}_uid", None) + if uid_validator is not None: + uid_validator(self.uid) def save(self, *args, **kwargs): self.full_clean() diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index b8b7f61dd1..4a71eba00e 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -6,7 +6,9 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from api.db_router import MainRouter +from api.exceptions import ModelValidationError from api.models import ( + Provider, ProviderComplianceScore, Resource, ResourceTag, @@ -525,3 +527,29 @@ class TestTenantComplianceSummaryModel: assert summary1.id != summary2.id assert summary1.requirements_passed != summary2.requirements_passed + + +@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.""" + + def test_accepts_provider_without_uid_validator(self, tenants_fixture): + tenant = tenants_fixture[0] + provider = Provider.objects.create( + tenant_id=tenant.id, provider="llm", uid="my-llm-account" + ) + assert provider.provider == "llm" + + def test_rejects_provider_not_available_in_sdk(self, tenants_fixture): + tenant = tenants_fixture[0] + with pytest.raises(ModelValidationError): + Provider.objects.create( + tenant_id=tenant.id, provider="does-not-exist", uid="whatever" + ) + + def test_uid_floor_still_enforced_for_external_provider(self, tenants_fixture): + tenant = tenants_fixture[0] + with pytest.raises(ValidationError): + Provider.objects.create(tenant_id=tenant.id, provider="llm", uid="ab") diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 5810a97b63..cdba488932 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -2,7 +2,27 @@ import pytest from rest_framework.exceptions import ValidationError from api.v1.serializer_utils.integrations import S3ConfigSerializer -from api.v1.serializers import ImageProviderSecret +from api.v1.serializers import ImageProviderSecret, ProviderEnumSerializerField + + +class TestProviderEnumSerializerField: + """The provider field accepts whatever the SDK exposes (built-in or + external) and rejects anything else with `invalid_choice`.""" + + def test_accepts_sdk_available_provider(self): + field = ProviderEnumSerializerField() + assert field.run_validation("aws") == "aws" + + def test_accepts_external_provider_absent_from_static_enum(self): + field = ProviderEnumSerializerField() + # `llm` is exposed by the SDK but is not part of the legacy static enum. + assert field.run_validation("llm") == "llm" + + def test_rejects_unknown_provider(self): + field = ProviderEnumSerializerField() + with pytest.raises(ValidationError) as exc: + field.run_validation("does-not-exist") + assert exc.value.detail[0].code == "invalid_choice" class TestS3ConfigSerializer: diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index d085edea0c..03c4a99e7e 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -73,6 +73,7 @@ from api.v1.serializer_utils.lighthouse import ( from api.v1.serializer_utils.processors import ProcessorConfigField from api.v1.serializer_utils.providers import ProviderSecretField from prowler.lib.mutelist.mutelist import Mutelist +from prowler.providers.common.provider import Provider as SDKProvider # Base @@ -854,7 +855,12 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer): # Providers class ProviderEnumSerializerField(serializers.ChoiceField): def __init__(self, **kwargs): - kwargs["choices"] = Provider.ProviderChoices.choices + # The SDK is the source of truth for which providers exist, so the + # accepted values track the installed providers (built-in or external) + # instead of a static enum. + kwargs["choices"] = [ + (name, name) for name in SDKProvider.get_available_providers() + ] super().__init__(**kwargs)