feat(sdk): add Supabase provider foundation

This commit is contained in:
Hugo P.Brito committed 2026-08-19 16:49:43 +01:00
1 parent 0b9791ffdc
commit 5a1430a49f
21 files changed
+873

No files matched your search

@@ -0,0 +1,34 @@
from unittest.mock import MagicMock
from prowler.providers.supabase.models import (
SupabaseIdentityInfo,
SupabaseOrganization,
SupabaseSession,
)
ACCESS_TOKEN = "sbp_test_token"
ORGANIZATION_ID = "org-id"
ORGANIZATION_NAME = "Test Organization"
ORGANIZATION_SLUG = "test-organization"
USER_ID = "user-id"
def set_mocked_supabase_provider():
provider = MagicMock()
provider.type = "supabase"
provider.session = SupabaseSession(
access_token=ACCESS_TOKEN,
http_session=MagicMock(),
)
provider.identity = SupabaseIdentityInfo(
organizations=[
SupabaseOrganization(
id=ORGANIZATION_ID,
name=ORGANIZATION_NAME,
slug=ORGANIZATION_SLUG,
)
]
)
provider.audit_config = {"max_retries": 0}
provider.fixer_config = {}
return provider
@@ -0,0 +1,40 @@
from unittest.mock import MagicMock
import pytest
from prowler.providers.supabase.lib.mutelist.mutelist import SupabaseMutelist
from tests.providers.supabase.supabase_fixtures import (
ORGANIZATION_SLUG,
USER_ID,
)
class TestSupabaseMutelist:
@pytest.mark.parametrize(
("resource_id", "expected"),
[(USER_ID, True), ("another-user", False)],
)
def test_matches_organization_check_and_member(self, resource_id, expected):
mutelist = SupabaseMutelist(
mutelist_content={
"Accounts": {
ORGANIZATION_SLUG: {
"Checks": {
"organizations_member_mfa_enabled": {
"Regions": ["global"],
"Resources": [USER_ID],
}
}
}
}
}
)
finding = MagicMock(
organization_slug=ORGANIZATION_SLUG,
resource_id=resource_id,
resource_name=f"member {resource_id}",
resource_tags=[],
)
finding.check_metadata.CheckID = "organizations_member_mfa_enabled"
assert mutelist.is_finding_muted(finding) is expected
@@ -0,0 +1,216 @@
import os
from argparse import Namespace
from unittest import mock
import pytest
from prowler.config.config import Provider as ProviderName
from prowler.lib.cli.parser import ProwlerArgumentParser
from prowler.lib.outputs.html.html import HTML
from prowler.providers.common.provider import Provider
from prowler.providers.supabase.exceptions.exceptions import (
SupabaseAuthenticationError,
SupabaseCredentialsError,
SupabaseInsufficientPermissionsError,
SupabaseRateLimitError,
)
from prowler.providers.supabase.models import SupabaseOrganization, SupabaseSession
from prowler.providers.supabase.supabase_provider import SupabaseProvider
from tests.providers.supabase.supabase_fixtures import (
ACCESS_TOKEN,
ORGANIZATION_ID,
ORGANIZATION_NAME,
ORGANIZATION_SLUG,
)
class TestSupabaseProvider:
def test_setup_session_uses_environment_token(self):
with mock.patch.dict(
os.environ, {"SUPABASE_ACCESS_TOKEN": ACCESS_TOKEN}, clear=True
):
session = SupabaseProvider.setup_session()
assert session.access_token == ACCESS_TOKEN
assert session.http_session.headers["Authorization"] == f"Bearer {ACCESS_TOKEN}"
def test_setup_session_requires_environment_token(self):
with mock.patch.dict(os.environ, {}, clear=True):
with pytest.raises(SupabaseCredentialsError):
SupabaseProvider.setup_session()
def test_access_token_is_not_serialized_or_represented(self):
session = SupabaseSession(access_token=ACCESS_TOKEN)
assert ACCESS_TOKEN not in repr(session)
assert ACCESS_TOKEN not in str(session)
assert ACCESS_TOKEN not in session.model_dump_json()
assert "access_token" not in session.model_dump()
def test_setup_identity_lists_organizations_without_member_pii(self):
session = SupabaseSession(
access_token=ACCESS_TOKEN,
http_session=mock.MagicMock(),
)
response = mock.MagicMock(status_code=200)
response.json.return_value = [
{
"id": ORGANIZATION_ID,
"name": ORGANIZATION_NAME,
"slug": ORGANIZATION_SLUG,
}
]
session.http_session.get.return_value = response
identity = SupabaseProvider.setup_identity(session, max_retries=0)
assert identity.organizations[0].slug == ORGANIZATION_SLUG
session.http_session.get.assert_called_once_with(
"https://api.supabase.com/v1/organizations", timeout=30
)
@pytest.mark.parametrize(
("status_code", "exception"),
[
(401, SupabaseAuthenticationError),
(403, SupabaseInsufficientPermissionsError),
(429, SupabaseRateLimitError),
],
)
def test_setup_identity_preserves_management_api_errors(
self, status_code, exception
):
session = SupabaseSession(
access_token=ACCESS_TOKEN,
http_session=mock.MagicMock(),
)
session.http_session.get.return_value = mock.MagicMock(
status_code=status_code,
headers={"X-RateLimit-Reset": "0"},
)
with pytest.raises(exception):
SupabaseProvider.setup_identity(session, max_retries=0)
def test_from_cli_args_uses_environment_only(self):
arguments = Namespace(
config_file=None,
mutelist_file=None,
)
with (
mock.patch.dict(
os.environ, {"SUPABASE_ACCESS_TOKEN": ACCESS_TOKEN}, clear=True
),
mock.patch.object(
SupabaseProvider,
"setup_identity",
return_value=mock.MagicMock(organizations=[]),
),
):
provider = SupabaseProvider.from_cli_args(arguments, fixer_config={})
assert provider.type == "supabase"
assert not hasattr(arguments, "supabase_access_token")
def test_parser_discovers_supabase_without_secret_argument(self):
arguments = ProwlerArgumentParser().parse(
["prowler", "supabase", "--list-checks"]
)
assert arguments.provider == "supabase"
assert not hasattr(arguments, "supabase_access_token")
def test_provider_registry_and_class_resolution(self):
assert ProviderName.SUPABASE.value == "supabase"
assert Provider.get_class("supabase") is SupabaseProvider
assert SupabaseProvider.sdk_only is True
class TestSupabaseProviderOutputHooks:
def test_finding_output_uses_organization_and_member_ids(self):
provider = SupabaseProvider.__new__(SupabaseProvider)
provider._identity = mock.MagicMock(organizations=[])
check_output = mock.MagicMock(
organization_slug=ORGANIZATION_SLUG,
organization_name=ORGANIZATION_NAME,
resource_name="member user-id",
resource_id="user-id",
)
output = provider.get_finding_output_data(check_output)
assert output == {
"auth_method": "personal_access_token",
"account_uid": ORGANIZATION_SLUG,
"account_name": ORGANIZATION_NAME,
"resource_name": "member user-id",
"resource_uid": "user-id",
"region": "global",
}
@pytest.mark.parametrize(
("output_filename", "expected"),
[
(None, f"prowler-output-{ORGANIZATION_SLUG}-"),
("custom-report", "custom-report"),
],
)
def test_output_options_use_organization_slug_or_explicit_name(
self, output_filename, expected
):
provider = SupabaseProvider.__new__(SupabaseProvider)
provider._identity = mock.MagicMock(
organizations=[
SupabaseOrganization(
id=ORGANIZATION_ID,
name=ORGANIZATION_NAME,
slug=ORGANIZATION_SLUG,
)
]
)
output_options = provider.get_output_options(
Namespace(output_filename=output_filename), {}
)
if output_filename:
assert output_options.output_filename == expected
else:
assert output_options.output_filename.startswith(expected)
def test_html_assessment_summary_uses_supabase_hook(self):
provider = SupabaseProvider.__new__(SupabaseProvider)
provider._identity = mock.MagicMock(
organizations=[
SupabaseOrganization(
id=ORGANIZATION_ID,
name=ORGANIZATION_NAME,
slug=ORGANIZATION_SLUG,
)
]
)
summary = HTML.get_assessment_summary(provider)
assert "Supabase Assessment Summary" in summary
assert f"<b>Organizations:</b> {ORGANIZATION_SLUG}" in summary
assert "<b>Authentication:</b> Personal Access Token" in summary
def test_summary_and_stdout_hooks_are_global(self):
provider = SupabaseProvider.__new__(SupabaseProvider)
provider._identity = mock.MagicMock(
organizations=[
SupabaseOrganization(
id=ORGANIZATION_ID,
name=ORGANIZATION_NAME,
slug=ORGANIZATION_SLUG,
)
]
)
assert provider.get_summary_entity() == (
"Organization",
f"{ORGANIZATION_NAME} ({ORGANIZATION_SLUG})",
)
assert provider.get_stdout_detail(mock.MagicMock()) == "global"