feat(sdk): check Supabase organization member MFA

This commit is contained in:
Hugo P.Brito committed 2026-08-19 16:52:09 +01:00
1 parent 36f74584a7
commit 4c748e8e88
11 files changed
+301

No files matched your search

+7
View File
@@ -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": [
+1
View File
@@ -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
@@ -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.
<Warning>
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.
</Warning>
## 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
@@ -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.
<Warning>
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.
</Warning>
## 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.
@@ -0,0 +1 @@
Supabase provider with the `organizations_member_mfa_enabled` check
@@ -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())
@@ -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": ""
}
@@ -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
@@ -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"),
]
@@ -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"),
[