From 4c748e8e88d5d65b54ec21383b769a82e1f5ef4b Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Wed, 19 Aug 2026 16:52:09 +0100 Subject: [PATCH] feat(sdk): check Supabase organization member MFA --- docs/docs.json | 7 ++ docs/introduction.mdx | 1 + .../providers/supabase/authentication.mdx | 42 ++++++++ .../supabase/getting-started-supabase.mdx | 40 +++++++ .../changelog.d/supabase-provider.added.md | 1 + .../organizations/organizations_client.py | 6 ++ .../__init__.py | 0 ...nizations_member_mfa_enabled.metadata.json | 37 +++++++ .../organizations_member_mfa_enabled.py | 28 +++++ .../organizations_member_mfa_enabled_test.py | 101 ++++++++++++++++++ .../supabase/supabase_provider_test.py | 38 +++++++ 11 files changed, 301 insertions(+) create mode 100644 docs/user-guide/providers/supabase/authentication.mdx create mode 100644 docs/user-guide/providers/supabase/getting-started-supabase.mdx create mode 100644 prowler/changelog.d/supabase-provider.added.md create mode 100644 prowler/providers/supabase/services/organizations/organizations_client.py create mode 100644 prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/__init__.py create mode 100644 prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json create mode 100644 prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py create mode 100644 tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py diff --git a/docs/docs.json b/docs/docs.json index 363d6ac082..705dc8fbaa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -465,6 +465,13 @@ "user-guide/providers/stackit/authentication" ] }, + { + "group": "Supabase", + "pages": [ + "user-guide/providers/supabase/getting-started-supabase", + "user-guide/providers/supabase/authentication" + ] + }, { "group": "Vercel", "pages": [ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index ea0a9c48b1..af7f3e401e 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -72,6 +72,7 @@ Prowler supports a wide range of providers organized by category: | [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | | [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | | [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI | +| [Supabase](/user-guide/providers/supabase/getting-started-supabase) | PoC | Organizations / Members | CLI | | [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI | ### Kubernetes diff --git a/docs/user-guide/providers/supabase/authentication.mdx b/docs/user-guide/providers/supabase/authentication.mdx new file mode 100644 index 0000000000..b7769cffe4 --- /dev/null +++ b/docs/user-guide/providers/supabase/authentication.mdx @@ -0,0 +1,42 @@ +--- +title: "Supabase Authentication in Prowler" +--- + +Prowler for Supabase authenticates to the hosted Supabase Management API using a **Personal Access Token** (PAT). Prowler reads the token exclusively from `SUPABASE_ACCESS_TOKEN`; there is no credential CLI argument, and Prowler does not read Supabase CLI state files. + +## Personal Access Token Security + +Supabase PATs inherit the privileges of the user account that created them. Use a dedicated least-privilege or read-only organization account where available, set a suitable token expiry, store the token in a secret manager, and rotate it regularly. + + +A PAT is not independently scoped to read-only Management API access. Compromise of the token grants the same Management API privileges as its issuing user account. + + +## Required Access + +The issuing account must be able to call: + +- [`GET /v1/organizations`](https://supabase.com/docs/reference/api/v1-list-all-organizations) +- [`GET /v1/organizations/{slug}/members`](https://supabase.com/docs/reference/api/v1-list-organization-members) + +An invalid token returns `401`, insufficient organization permissions return `403`, and rate limiting returns `429`. Prowler treats all three as scan errors rather than compliant results. Supabase applies a standard limit of approximately 120 Management API requests per minute for each user and scope; Prowler uses the response rate-limit reset headers before retrying. + +## Configure Authentication + +Create a PAT from [Supabase Account Tokens](https://supabase.com/dashboard/account/tokens), then export it: + +```bash +export SUPABASE_ACCESS_TOKEN="your-personal-access-token" +prowler supabase +``` + +For CI/CD, configure `SUPABASE_ACCESS_TOKEN` as a masked secret environment variable. Do not place it in command arguments, repository files, or job logs. + +## Out of Scope + +This proof of concept does not support: + +- OAuth 2.0 Authorization Code with Proof Key for Code Exchange (PKCE) +- Direct PostgreSQL authentication +- Self-hosted Supabase deployments +- Supabase CLI credential or state files diff --git a/docs/user-guide/providers/supabase/getting-started-supabase.mdx b/docs/user-guide/providers/supabase/getting-started-supabase.mdx new file mode 100644 index 0000000000..7b2188751f --- /dev/null +++ b/docs/user-guide/providers/supabase/getting-started-supabase.mdx @@ -0,0 +1,40 @@ +--- +title: "Getting Started With Supabase on Prowler" +--- + +Prowler for Supabase scans hosted Supabase Cloud organizations through the Management API. This proof of concept checks whether each organization member has multi-factor authentication (MFA) enabled. + + +This proof of concept supports Prowler SDK and Prowler CLI only. It does not support onboarding through Prowler Cloud, Prowler Private Cloud, or Prowler Local Server; self-hosted Supabase; direct PostgreSQL access; or OAuth. + + +## Prerequisites + +1. A hosted Supabase Cloud account with access to at least one organization. +2. A Supabase Personal Access Token (PAT) for an account that can list organizations and organization members. +3. A dedicated least-privilege or read-only organization account where your Supabase plan and organization roles allow one. + +See [Supabase Authentication in Prowler](./authentication) for PAT setup and security guidance. + +## Run a Scan + +Set the PAT through the environment and run Prowler: + +```bash +export SUPABASE_ACCESS_TOKEN="your-personal-access-token" +prowler supabase +``` + +To run only the organization member MFA check: + +```bash +prowler supabase --check organizations_member_mfa_enabled +``` + +## Checks Shipped + +| Check ID | Severity | Description | +|---|---|---| +| `organizations_member_mfa_enabled` | High | Creates one finding per organization member and fails members whose Supabase account does not have MFA enabled. | + +The finding resource UID is the Management API `user_id`. Prowler does not include member email addresses in findings or normal logs. diff --git a/prowler/changelog.d/supabase-provider.added.md b/prowler/changelog.d/supabase-provider.added.md new file mode 100644 index 0000000000..36be668051 --- /dev/null +++ b/prowler/changelog.d/supabase-provider.added.md @@ -0,0 +1 @@ +Supabase provider with the `organizations_member_mfa_enabled` check diff --git a/prowler/providers/supabase/services/organizations/organizations_client.py b/prowler/providers/supabase/services/organizations/organizations_client.py new file mode 100644 index 0000000000..e49d4c4a0c --- /dev/null +++ b/prowler/providers/supabase/services/organizations/organizations_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.supabase.services.organizations.organizations_service import ( + Organizations, +) + +organizations_client = Organizations(Provider.get_global_provider()) diff --git a/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/__init__.py b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json new file mode 100644 index 0000000000..e5c10eaab9 --- /dev/null +++ b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "supabase", + "CheckID": "organizations_member_mfa_enabled", + "CheckTitle": "Supabase organization members have multi-factor authentication enabled", + "CheckType": [], + "ServiceName": "organizations", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "This check verifies whether each Supabase organization member has multi-factor authentication enabled for their Supabase account.", + "Risk": "Without multi-factor authentication, compromised credentials can allow unauthorized access to Supabase organizations, projects, and management operations available to the member.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://supabase.com/docs/reference/api/v1-list-organization-members", + "https://supabase.com/docs/guides/platform/multi-factor-authentication" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Ask the organization member to open Supabase Dashboard > Account Settings > Security and enroll a TOTP factor. Organization owners on eligible plans can also enforce MFA for all members.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require every organization member to enroll multi-factor authentication and periodically review membership for accounts that do not meet this requirement.", + "Url": "https://hub.prowler.com/check/organizations_member_mfa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py new file mode 100644 index 0000000000..d0121be889 --- /dev/null +++ b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, CheckReportSupabase +from prowler.providers.supabase.services.organizations.organizations_client import ( + organizations_client, +) + + +class organizations_member_mfa_enabled(Check): + """Check whether each Supabase organization member has MFA enabled.""" + + def execute(self) -> list[CheckReportSupabase]: + """Return one MFA finding per organization member.""" + findings = [] + for member in organizations_client.members.values(): + report = CheckReportSupabase(metadata=self.metadata(), resource=member) + if member.mfa_enabled: + report.status = "PASS" + report.status_extended = ( + f"Supabase organization {member.organization_slug} member " + f"{member.id} has MFA enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Supabase organization {member.organization_slug} member " + f"{member.id} does not have MFA enabled." + ) + findings.append(report) + return findings diff --git a/tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py b/tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py new file mode 100644 index 0000000000..2c51a4c45b --- /dev/null +++ b/tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py @@ -0,0 +1,101 @@ +from unittest import mock + +from prowler.providers.supabase.services.organizations.organizations_service import ( + SupabaseOrganizationMember, +) +from tests.providers.supabase.supabase_fixtures import ( + ORGANIZATION_NAME, + ORGANIZATION_SLUG, + USER_ID, + set_mocked_supabase_provider, +) + + +class Test_organizations_member_mfa_enabled: + def _execute(self, members): + organizations_client = mock.MagicMock() + organizations_client.members = members + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_supabase_provider(), + ), + mock.patch( + "prowler.providers.supabase.services.organizations.organizations_member_mfa_enabled.organizations_member_mfa_enabled.organizations_client", + new=organizations_client, + ), + ): + from prowler.providers.supabase.services.organizations.organizations_member_mfa_enabled.organizations_member_mfa_enabled import ( + organizations_member_mfa_enabled, + ) + + return organizations_member_mfa_enabled().execute() + + def test_no_members(self): + assert self._execute({}) == [] + + def test_member_with_mfa_passes_without_email(self): + member = SupabaseOrganizationMember( + id=USER_ID, + name=f"member {USER_ID}", + organization_slug=ORGANIZATION_SLUG, + organization_name=ORGANIZATION_NAME, + mfa_enabled=True, + ) + + result = self._execute({f"{ORGANIZATION_SLUG}:{USER_ID}": member}) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == USER_ID + assert result[0].organization_slug == ORGANIZATION_SLUG + assert result[0].status_extended == ( + f"Supabase organization {ORGANIZATION_SLUG} member {USER_ID} has MFA enabled." + ) + + def test_member_without_mfa_fails(self): + member = SupabaseOrganizationMember( + id=USER_ID, + name=f"member {USER_ID}", + organization_slug=ORGANIZATION_SLUG, + organization_name=ORGANIZATION_NAME, + mfa_enabled=False, + ) + + result = self._execute({f"{ORGANIZATION_SLUG}:{USER_ID}": member}) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == USER_ID + assert result[0].status_extended == ( + f"Supabase organization {ORGANIZATION_SLUG} member {USER_ID} does not have MFA enabled." + ) + + def test_multiple_members_return_independent_pass_and_fail_findings(self): + enabled_member = SupabaseOrganizationMember( + id="enabled-user", + name="member enabled-user", + organization_slug=ORGANIZATION_SLUG, + organization_name=ORGANIZATION_NAME, + mfa_enabled=True, + ) + disabled_member = SupabaseOrganizationMember( + id="disabled-user", + name="member disabled-user", + organization_slug=ORGANIZATION_SLUG, + organization_name=ORGANIZATION_NAME, + mfa_enabled=False, + ) + + result = self._execute( + { + f"{ORGANIZATION_SLUG}:enabled-user": enabled_member, + f"{ORGANIZATION_SLUG}:disabled-user": disabled_member, + } + ) + + assert [(finding.resource_id, finding.status) for finding in result] == [ + ("enabled-user", "PASS"), + ("disabled-user", "FAIL"), + ] diff --git a/tests/providers/supabase/supabase_provider_test.py b/tests/providers/supabase/supabase_provider_test.py index 30bcf7a3ea..36d1cad896 100644 --- a/tests/providers/supabase/supabase_provider_test.py +++ b/tests/providers/supabase/supabase_provider_test.py @@ -1,11 +1,14 @@ import os from argparse import Namespace +from pathlib import Path from unittest import mock import pytest from prowler.config.config import Provider as ProviderName +from prowler.lib.check.models import CheckReportSupabase from prowler.lib.cli.parser import ProwlerArgumentParser +from prowler.lib.outputs.finding import Finding from prowler.lib.outputs.html.html import HTML from prowler.providers.common.provider import Provider from prowler.providers.supabase.exceptions.exceptions import ( @@ -15,12 +18,16 @@ from prowler.providers.supabase.exceptions.exceptions import ( SupabaseRateLimitError, ) from prowler.providers.supabase.models import SupabaseOrganization, SupabaseSession +from prowler.providers.supabase.services.organizations.organizations_service import ( + SupabaseOrganizationMember, +) from prowler.providers.supabase.supabase_provider import SupabaseProvider from tests.providers.supabase.supabase_fixtures import ( ACCESS_TOKEN, ORGANIZATION_ID, ORGANIZATION_NAME, ORGANIZATION_SLUG, + USER_ID, ) @@ -149,6 +156,37 @@ class TestSupabaseProviderOutputHooks: "region": "global", } + def test_finding_output_pipeline_uses_supabase_fields(self): + provider = SupabaseProvider.__new__(SupabaseProvider) + provider._identity = mock.MagicMock(organizations=[]) + member = SupabaseOrganizationMember( + id=USER_ID, + name=f"member {USER_ID}", + organization_slug=ORGANIZATION_SLUG, + organization_name=ORGANIZATION_NAME, + mfa_enabled=False, + ) + metadata = Path( + "prowler/providers/supabase/services/organizations/" + "organizations_member_mfa_enabled/" + "organizations_member_mfa_enabled.metadata.json" + ).read_text() + check_output = CheckReportSupabase(metadata=metadata, resource=member) + check_output.status = "FAIL" + check_output.status_extended = "Member does not have MFA enabled." + + finding = Finding.generate_output( + provider, check_output, Namespace(unix_timestamp=False) + ) + + assert finding.provider == "supabase" + assert finding.account_uid == ORGANIZATION_SLUG + assert finding.account_name == ORGANIZATION_NAME + assert finding.resource_name == f"member {USER_ID}" + assert finding.resource_uid == USER_ID + assert finding.region == "global" + assert finding.auth_method == "personal_access_token" + @pytest.mark.parametrize( ("output_filename", "expected"), [