diff --git a/prowler/providers/supabase/services/organizations/__init__.py b/prowler/providers/supabase/services/organizations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/services/organizations/organizations_service.py b/prowler/providers/supabase/services/organizations/organizations_service.py new file mode 100644 index 0000000000..af50cc0bd0 --- /dev/null +++ b/prowler/providers/supabase/services/organizations/organizations_service.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel + +from prowler.providers.supabase.lib.service.service import SupabaseService + + +class Organizations(SupabaseService): + """Retrieve Supabase organization members.""" + + def __init__(self, provider): + super().__init__("Organizations", provider) + self.members: dict[str, SupabaseOrganizationMember] = {} + self._fetch_members() + + def _fetch_members(self) -> None: + """Collect members from every organization in the authenticated scope.""" + for organization in self.provider.identity.organizations: + members = self._get(f"/v1/organizations/{organization.slug}/members") + for member in members: + user_id = member["user_id"] + resource = SupabaseOrganizationMember( + id=user_id, + name=f"member {user_id}", + organization_slug=organization.slug, + organization_name=organization.name, + mfa_enabled=member["mfa_enabled"], + ) + self.members[f"{organization.slug}:{user_id}"] = resource + + +class SupabaseOrganizationMember(BaseModel): + """Security-relevant Supabase organization member attributes.""" + + id: str + name: str + organization_slug: str + organization_name: str + mfa_enabled: bool diff --git a/tests/providers/supabase/services/organizations/supabase_organizations_service_test.py b/tests/providers/supabase/services/organizations/supabase_organizations_service_test.py new file mode 100644 index 0000000000..f4d5491cdd --- /dev/null +++ b/tests/providers/supabase/services/organizations/supabase_organizations_service_test.py @@ -0,0 +1,147 @@ +from unittest import mock + +import pytest + +from prowler.providers.supabase.exceptions.exceptions import ( + SupabaseAuthenticationError, + SupabaseInsufficientPermissionsError, + SupabaseRateLimitError, +) +from prowler.providers.supabase.lib.service.service import _rate_limit_delay +from prowler.providers.supabase.services.organizations import ( + organizations_service, +) +from tests.providers.supabase.supabase_fixtures import ( + ORGANIZATION_SLUG, + USER_ID, + set_mocked_supabase_provider, +) + + +class TestOrganizationsService: + def test_fetches_members_without_storing_email(self): + provider = set_mocked_supabase_provider() + response = mock.MagicMock(status_code=200) + response.json.return_value = [ + { + "user_id": USER_ID, + "user_name": "Test User", + "email": "private@example.com", + "role_name": "Owner", + "mfa_enabled": False, + } + ] + provider.session.http_session.get.return_value = response + + service = organizations_service.Organizations(provider) + + member = service.members[f"{ORGANIZATION_SLUG}:{USER_ID}"] + assert member.id == USER_ID + assert member.name == f"member {USER_ID}" + assert member.organization_slug == ORGANIZATION_SLUG + assert member.mfa_enabled is False + assert "email" not in member.model_dump() + assert "private@example.com" not in repr(member) + + @pytest.mark.parametrize( + ("status_code", "exception"), + [ + (401, SupabaseAuthenticationError), + (403, SupabaseInsufficientPermissionsError), + (429, SupabaseRateLimitError), + ], + ) + def test_api_errors_abort_collection_instead_of_producing_empty_results( + self, status_code, exception + ): + provider = set_mocked_supabase_provider() + provider.session.http_session.get.return_value = mock.MagicMock( + status_code=status_code, + headers={"X-RateLimit-Reset": "0"}, + ) + + with pytest.raises(exception): + organizations_service.Organizations(provider) + + def test_rate_limit_retries_using_reset_header(self): + provider = set_mocked_supabase_provider() + provider.audit_config = {"max_retries": 1} + rate_limited = mock.MagicMock( + status_code=429, + headers={"X-RateLimit-Reset": "2"}, + ) + success = mock.MagicMock(status_code=200) + success.json.return_value = [] + provider.session.http_session.get.side_effect = [rate_limited, success] + + with mock.patch( + "prowler.providers.supabase.lib.service.service.time.sleep" + ) as sleep: + service = organizations_service.Organizations(provider) + + assert service.members == {} + sleep.assert_called_once_with(2) + + +class TestRateLimitDelay: + def test_relative_reset_seconds(self): + assert _rate_limit_delay({"X-RateLimit-Reset": "2"}) == 2 + + def test_retry_after_delta_seconds(self): + assert _rate_limit_delay({"Retry-After": "3"}) == 3 + + @mock.patch( + "prowler.providers.supabase.lib.service.service.time.time", + return_value=1_700_000_000, + ) + def test_absolute_reset_timestamp(self, _): + assert _rate_limit_delay({"X-RateLimit-Reset": "1700000005"}) == 5 + + @mock.patch( + "prowler.providers.supabase.lib.service.service.time.time", + return_value=1_445_412_475, + ) + def test_retry_after_http_date(self, _): + headers = {"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"} + + assert _rate_limit_delay(headers) == 5 + + @pytest.mark.parametrize( + "malformed_value", + ["not-a-timestamp", "inf", "nan"], + ) + def test_malformed_headers_use_fallback(self, malformed_value): + headers = { + "X-RateLimit-Reset": malformed_value, + "Retry-After": malformed_value, + } + + assert _rate_limit_delay(headers) == 1 + + def test_malformed_reset_uses_retry_after(self): + headers = { + "X-RateLimit-Reset": "not-a-timestamp", + "Retry-After": "4", + } + + assert _rate_limit_delay(headers) == 4 + + @pytest.mark.parametrize( + "headers", + [ + {"X-RateLimit-Reset": "-5"}, + {"Retry-After": "-5"}, + ], + ) + def test_negative_delay_is_clamped_to_zero(self, headers): + assert _rate_limit_delay(headers) == 0 + + @mock.patch( + "prowler.providers.supabase.lib.service.service.time.time", + return_value=1_700_000_000, + ) + def test_past_absolute_reset_is_clamped_to_zero(self, _): + assert _rate_limit_delay({"X-RateLimit-Reset": "1699999995"}) == 0 + + def test_relative_delay_is_bounded(self): + assert _rate_limit_delay({"X-RateLimit-Reset": "7200"}) == 3600