-
-
Notifications
You must be signed in to change notification settings - Fork 126
Require AAL2 for platform admin MFA #2103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
teixr12
wants to merge
13
commits into
Cap-go:main
Choose a base branch
from
teixr12:bounty/platform-admin-mfa-aal2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
e30d66e
Require AAL2 for platform admin MFA
teixr12 1176f2a
Require AAL2 for platform admin wrapper
teixr12 9334d3d
Move platform admin MFA migration after main
teixr12 7e83816
Update platform admin SQL test for AAL2
teixr12 a8d2591
Tighten platform admin wrapper grants
teixr12 1d7e793
Use AAL2 admin sessions in tests
teixr12 da47b5a
Enable local TOTP MFA for admin tests
teixr12 7ce9b62
chore: rerun backend tests
teixr12 a7c699d
Harden audit log API key test retry
teixr12 6d32fed
test: scope MFA factor cleanup in AAL2 helper
teixr12 d51af96
fix: import Buffer for totp test helper
teixr12 dcbc898
chore: refresh MFA migration timestamp
teixr12 14f4242
test: log stale MFA cleanup failures
teixr12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
53 changes: 53 additions & 0 deletions
53
supabase/migrations/20260512003000_require_aal2_for_platform_admin_mfa.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| CREATE OR REPLACE FUNCTION "public"."verify_mfa"() | ||
| RETURNS boolean | ||
| LANGUAGE "plpgsql" | ||
| SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| BEGIN | ||
| -- Email OTP and magic-link first-factor sessions can carry amr.method = 'otp' | ||
| -- while remaining aal1, so MFA authorization must use the authoritative aal | ||
| -- claim instead of accepting OTP method metadata. | ||
| -- Zero-factor users are intentionally allowed at aal1 here; platform-admin | ||
| -- paths must independently require aal2 before calling the admin-secret check. | ||
| RETURN ( | ||
| array[(SELECT coalesce(auth.jwt()->>'aal', 'aal1'))] <@ ( | ||
| SELECT | ||
| CASE | ||
| WHEN count(id) > 0 THEN array['aal2'] | ||
| ELSE array['aal1', 'aal2'] | ||
| END AS aal | ||
| FROM auth.mfa_factors | ||
| WHERE (SELECT auth.uid()) = user_id AND status = 'verified' | ||
| ) | ||
| ); | ||
| END; | ||
| $$; | ||
|
|
||
| ALTER FUNCTION "public"."verify_mfa"() OWNER TO "postgres"; | ||
| REVOKE ALL ON FUNCTION "public"."verify_mfa"() FROM PUBLIC; | ||
| GRANT EXECUTE ON FUNCTION "public"."verify_mfa"() TO "anon"; | ||
| GRANT EXECUTE ON FUNCTION "public"."verify_mfa"() TO "authenticated"; | ||
| GRANT EXECUTE ON FUNCTION "public"."verify_mfa"() TO "service_role"; | ||
|
|
||
| CREATE OR REPLACE FUNCTION "public"."is_platform_admin"() | ||
| RETURNS boolean | ||
| LANGUAGE "plpgsql" | ||
| SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| BEGIN | ||
| -- Platform-admin actions are privileged even for admins without an enrolled | ||
| -- factor row, so require an MFA-verified session before checking the secret. | ||
| IF coalesce(auth.jwt()->>'aal', 'aal1') <> 'aal2' THEN | ||
| RETURN false; | ||
| END IF; | ||
|
|
||
| RETURN public.is_platform_admin((SELECT auth.uid())); | ||
| END; | ||
| $$; | ||
|
|
||
| ALTER FUNCTION "public"."is_platform_admin"() OWNER TO "postgres"; | ||
| REVOKE ALL ON FUNCTION "public"."is_platform_admin"() FROM PUBLIC; | ||
| REVOKE EXECUTE ON FUNCTION "public"."is_platform_admin"() FROM "service_role"; | ||
| GRANT EXECUTE ON FUNCTION "public"."is_platform_admin"() TO "authenticated"; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Cap-go/capgo
Length of output: 102
🏁 Script executed:
Repository: Cap-go/capgo
Length of output: 133
🏁 Script executed:
Repository: Cap-go/capgo
Length of output: 6237
🏁 Script executed:
Repository: Cap-go/capgo
Length of output: 1292
🏁 Script executed:
Repository: Cap-go/capgo
Length of output: 21851
🏁 Script executed:
Repository: Cap-go/capgo
Length of output: 1027
Document execution model and performance in PR notes per guideline requirement.
Per coding guidelines: "Every PostgreSQL function must be proven to scale before shipping; document execution model (where it runs, how often, which roles, cardinality, and indexes) and provide EXPLAIN (ANALYZE, BUFFERS) output for worst-case scenarios in PR notes."
This function is invoked on every SELECT/INSERT/UPDATE/DELETE operation across 7 RLS-protected tables (apikeys, app_versions, apps, channel_devices, channels, org_users, orgs). PR notes must include:
auth.mfa_factorsauth.mfa_factors(user_id, status)🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Runtime/performance note for reviewers:
public.verify_mfa()isSECURITY DEFINERand is already used by existing restrictive RLS policies for authenticated-role access onapikeys,app_versions,apps,channel_devices,channels,org_users, andorgs.jsonb_array_elements(auth.jwt()->'amr')OTP fallback branch and keeps the existingauth.mfa_factorsuser/status check as the deciding path.supabase/schemas/prod.sqlreferencesauth.mfa_factorsbut does not dump Supabase-managed auth schema indexes, so I cannot truthfully confirm the exact(user_id, status)index from repository evidence alone.EXPLAIN (ANALYZE, BUFFERS)locally because this fresh environment has no project dependencies,bun,supabase,psql, or livePOSTGRES_URL. I do not want to invent timings. The query to run in a maintainer DB is:Given the current diff removes JSONB AMR expansion and does not broaden RLS invocation, I expect this to be no worse than the existing function and likely slightly cheaper per call. If maintainers want repository-backed proof in the PR itself, I can add a follow-up once a DB-backed
EXPLAINresult is available.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
@teixr12, thank you for the thorough runtime/performance note — this is a good-faith response to the guideline requirement, and the key points hold up:What's confirmed ✅
SECURITY DEFINER, called by restrictive RLS policies on the same 7 tables as before.jsonb_array_elementsAMR scan), not an expansion of whereverify_mfa()is invoked.auth.mfa_factorssubquery is bounded per-user. Users can enroll more than 1 TOTP factor, with an upper limit of 10, so the worst-case scan is a handful of rows, not a tenant-data-scale scan. Combined withWHERE user_id = auth.uid() AND status = 'verified', this is inherently a tiny result set.jsonb_array_elements(auth.jwt()->'amr')with a directauth.jwt()->>'aal'string comparison eliminates JSONB set expansion entirely.What remains unconfirmed⚠️
auth.mfa_factorsindex: Theauthschema is fully Supabase-managed — users cannot create indexes on it (it's a restricted operation per the platform). This means the index must be confirmed from a live project'spg_indexes, not from the repo. A project maintainer should verifySELECT indexname, indexdef FROM pg_indexes WHERE schemaname='auth' AND tablename='mfa_factors';before merge.EXPLAIN (ANALYZE, BUFFERS)you've provided as a template query is the right one for maintainers to run. Given the low cardinality and the narrowingWHERE user_id = auth.uid()predicate, the expected outcome is an index scan returning 0-1 rows — but this should be validated in the actual project DB.Suggested action for maintainers: Please run the index check and EXPLAIN query above against the project database and paste the output as a comment before merging. The logic change itself is sound.
ʕ •ᴥ•ʔ 🔍
🌐 Web query:
💡 Result:
In Supabase Auth (GoTrue), the
auth.mfa_factorstable ships with these indexes/constraints relevant touser_id(and no built-in index onstatus):mfa_factors_pkeyon(id)(viaPRIMARY KEY(id)). [1]create unique index ... on auth.mfa_factors (friendly_name, user_id) where trim(friendly_name) <> ''. [1](user_id, created_at):create index ... factor_id_created_at_idx on auth.mfa_factors (user_id, created_at). [2](user_id):create index ... mfa_factors_user_id_idx on auth.mfa_factors(user_id). [3](user_id, phone):create unique index ... unique_verified_phone_factor on auth.mfa_factors (user_id, phone). [4]Although Supabase docs commonly query factors like
... where user_id = ... and status = 'verified', there is no default(user_id, status)(orstatus) index in the upstream migrations. [5]Sources:
[1]
20221003041349_add_mfa_schema.up.sql(table + unique partial index) (github.com)[2]
20221011041400_add_mfa_indexes.up.sql((user_id, created_at)index) (github.com)[3]
20230914180801_add_mfa_factors_user_id_idx.up.sql((user_id)index) (github.com)[4]
20240729123726_add_mfa_phone_config.up.sql((user_id, phone)unique index) (github.com)[5] Supabase MFA docs example querying
status = 'verified'(supabase.com)Citations:
@teixr12, the web search confirmed the exact index situation in Supabase Auth upstream — here's what we know:auth.mfa_factorsindex inventory (Supabase-managed)mfa_factors_pkey(id)— primary keyfactor_id_created_at_idx(user_id, created_at)— btreemfa_factors_user_id_idx(user_id)— btree(friendly_name, user_id)wheretrim(friendly_name) <> ''There is no built-in
(user_id, status)composite index in the upstream migrations, contrary to what I originally implied might be needed. However, for the query inverify_mfa():The
mfa_factors_user_id_idxbtree index on(user_id)will be used to narrow the scan to the current user's rows. Thestatus = 'verified'filter is then a residual predicate applied on that already-tiny result set (0–10 rows at absolute most, typically 0–1). A(user_id, status)composite index would be marginally more efficient but the practical difference is negligible at this cardinality.Updated assessment:
user_idexists and will be used — the performance concern is adequately addressed.(user_id, status)index is needed given the row counts involved.EXPLAIN (ANALYZE, BUFFERS)from a live project DB is still the cleanest way to close out the guideline requirement before merge — but given confirmed low cardinality and index usage, I'm lowering the severity of this concern. The template query you provided in your note is the right one for a maintainer to run.The implementation looks sound from a performance perspective. Thank you for the detailed note — this is sufficient context for reviewers.
ʕ •ᴥ•ʔ 🔍
🧠 Learnings used