Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ max_enrolled_factors = 10

# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
enroll_enabled = true
verify_enabled = true

# Configure MFA via Phone Messaging
[auth.mfa.phone]
Expand Down
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;
$$;
Comment on lines +1 to +25
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check indexes on auth.mfa_factors and get execution plan
cat > /tmp/verify_mfa_perf.sql << 'EOF'
-- Show indexes on auth.mfa_factors
SELECT
    schemaname,
    tablename,
    indexname,
    indexdef
FROM pg_indexes
WHERE schemaname = 'auth' AND tablename = 'mfa_factors';

-- EXPLAIN the subquery (using a sample UUID)
EXPLAIN (ANALYZE, BUFFERS)
SELECT
    CASE
      WHEN count(id) > 0 THEN array['aal2']
      ELSE array['aal1', 'aal2']
    END AS aal
  FROM auth.mfa_factors
  WHERE user_id = '00000000-0000-0000-0000-000000000000'::uuid
    AND status = 'verified';
EOF

# Execute via psql using POSTGRES_URL from test environment
psql "$POSTGRES_URL" -f /tmp/verify_mfa_perf.sql

Repository: Cap-go/capgo

Length of output: 102


🏁 Script executed:

# Find and read the migration file
find . -name "*require_aal2_for_platform_admin_mfa*" -type f

Repository: Cap-go/capgo

Length of output: 133


🏁 Script executed:

# Search for invocations of verify_mfa function
rg "verify_mfa" -t sql -t ts -t js

Repository: Cap-go/capgo

Length of output: 6237


🏁 Script executed:

# Check the auth schema in Supabase to understand mfa_factors table structure
find . -name "*.sql" -path "*/migrations/*" | head -20

Repository: Cap-go/capgo

Length of output: 1292


🏁 Script executed:

# Look for any existing documentation about this function
rg "verify_mfa" -A 5 -B 5

Repository: Cap-go/capgo

Length of output: 21851


🏁 Script executed:

cat -n ./supabase/migrations/20260510200000_require_aal2_for_platform_admin_mfa.sql

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:

  1. Execution model (called per row via RESTRICTIVE RLS policy by authenticated role)
  2. Cardinality expectations for auth.mfa_factors
  3. Confirmation that indexes exist on auth.mfa_factors(user_id, status)
  4. EXPLAIN (ANALYZE, BUFFERS) output for worst-case scenarios
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260510200000_require_aal2_for_platform_admin_mfa.sql`
around lines 1 - 20, Add PR notes documenting the runtime and performance proof
for the PostgreSQL function "public".verify_mfa(): state the execution model (it
runs as SECURITY DEFINER and is invoked per-row by RESTRICTIVE RLS policies on
the authenticated role for the seven tables apikeys, app_versions, apps,
channel_devices, channels, org_users, orgs), provide cardinality expectations
for auth.mfa_factors (typical row counts and worst-case), confirm presence of an
index on auth.mfa_factors(user_id, status) (or add one if missing), and include
EXPLAIN (ANALYZE, BUFFERS) outputs for worst-case queries that exercise
verify_mfa (showing timings and buffer usage) so reviewers can validate
scalability.

Copy link
Copy Markdown
Author

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:

  • Execution model: public.verify_mfa() is SECURITY DEFINER and is already used by existing restrictive RLS policies for authenticated-role access on apikeys, app_versions, apps, channel_devices, channels, org_users, and orgs.
  • Scope of this PR: it does not add new RLS call sites or a new table lookup. It removes the previous jsonb_array_elements(auth.jwt()->'amr') OTP fallback branch and keeps the existing auth.mfa_factors user/status check as the deciding path.
  • Cardinality expectation: MFA factors are per-user auth records, normally 0-1 verified TOTP factor for a user and bounded by the number of enrolled factors for the current authenticated user, not by tenant data rows.
  • Index evidence: this repo's supabase/schemas/prod.sql references auth.mfa_factors but does not dump Supabase-managed auth schema indexes, so I cannot truthfully confirm the exact (user_id, status) index from repository evidence alone.
  • EXPLAIN evidence: I cannot produce EXPLAIN (ANALYZE, BUFFERS) locally because this fresh environment has no project dependencies, bun, supabase, psql, or live POSTGRES_URL. I do not want to invent timings. The query to run in a maintainer DB is:
EXPLAIN (ANALYZE, BUFFERS)
SELECT CASE WHEN count(id) > 0 THEN array['aal2'] ELSE array['aal1', 'aal2'] END AS aal
FROM auth.mfa_factors
WHERE user_id = '<sample-user-id>'::uuid
  AND status = 'verified';

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 EXPLAIN result is available.

Copy link
Copy Markdown
Contributor

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 ✅

  • Execution model: Correctly identified — SECURITY DEFINER, called by restrictive RLS policies on the same 7 tables as before.
  • No new call sites: The diff is a pure logic replacement (AAL claim check instead of jsonb_array_elements AMR scan), not an expansion of where verify_mfa() is invoked.
  • Cardinality: The auth.mfa_factors subquery 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 with WHERE user_id = auth.uid() AND status = 'verified', this is inherently a tiny result set.
  • This PR is likely cheaper than the previous version: Replacing jsonb_array_elements(auth.jwt()->'amr') with a direct auth.jwt()->>'aal' string comparison eliminates JSONB set expansion entirely.

What remains unconfirmed ⚠️

  • auth.mfa_factors index: The auth schema 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's pg_indexes, not from the repo. A project maintainer should verify SELECT indexname, indexdef FROM pg_indexes WHERE schemaname='auth' AND tablename='mfa_factors'; before merge.
  • EXPLAIN output: The EXPLAIN (ANALYZE, BUFFERS) you've provided as a template query is the right one for maintainers to run. Given the low cardinality and the narrowing WHERE 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:

Supabase auth.mfa_factors table indexes user_id status

💡 Result:

In Supabase Auth (GoTrue), the auth.mfa_factors table ships with these indexes/constraints relevant to user_id (and no built-in index on status):

  • Primary key: mfa_factors_pkey on (id) (via PRIMARY KEY(id)). [1]
  • Unique (partial) index (friendly name per user, excluding blank):
    create unique index ... on auth.mfa_factors (friendly_name, user_id) where trim(friendly_name) <> ''. [1]
  • Btree index on (user_id, created_at):
    create index ... factor_id_created_at_idx on auth.mfa_factors (user_id, created_at). [2]
  • Btree index on (user_id):
    create index ... mfa_factors_user_id_idx on auth.mfa_factors(user_id). [3]
  • (If Phone MFA is enabled) Unique index on (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) (or status) 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_factors index inventory (Supabase-managed)

Index Columns
mfa_factors_pkey (id) — primary key
factor_id_created_at_idx (user_id, created_at) — btree
mfa_factors_user_id_idx (user_id) — btree
(partial unique) (friendly_name, user_id) where trim(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 in verify_mfa():

FROM auth.mfa_factors
WHERE user_id = auth.uid() AND status = 'verified'

The mfa_factors_user_id_idx btree index on (user_id) will be used to narrow the scan to the current user's rows. The status = '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:

  • ✅ An index on user_id exists and will be used — the performance concern is adequately addressed.
  • ✅ No (user_id, status) index is needed given the row counts involved.
  • ⚠️ An 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
Learnt from: WcaleNieWolny
Repo: Cap-go/capgo PR: 1300
File: supabase/migrations/20251224103713_2fa_enforcement.sql:85-96
Timestamp: 2025-12-25T11:22:13.039Z
Learning: In SQL migrations under the repository (e.g., supabase/migrations), enforce that when an org has enforcing_2fa=true, all users (including super_admins) must have 2FA enabled before accessing any org functions, including check_org_members_2fa_enabled. Do not grant admin exceptions to 2FA requirements. This ensures consistent security enforcement across all org-related operations; implement this rule within relevant migrations and associated stored procedures/tests.

Learnt from: Dalanir
Repo: Cap-go/capgo PR: 1724
File: supabase/migrations/20260228000000_role_bindings_rls_assignable.sql:6-6
Timestamp: 2026-02-28T16:27:25.073Z
Learning: In SQL migrations (e.g., supabase/migrations/...), enforce that API key identities resolve through the authenticated role. RLS policies used for admin operations like role_bindings must reference only TO "authenticated" (not anon). Use SECURITY DEFINER helpers like get_identity_org_appid() to resolve API key identities internally, and ensure policies restrict access accordingly. This guidance applies to all migration files under any migrations directory that deal with RBAC/RLS policy changes.

Learnt from: Dalanir
Repo: Cap-go/capgo PR: 1821
File: supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql:15-52
Timestamp: 2026-03-17T15:56:41.603Z
Learning: In supabase/migrations/**/*.sql for Cap-go/capgo, when auditing function permissions, do not require REVOKE ALL ON FUNCTION ... FROM PUBLIC for PostgreSQL trigger functions (RETURNS trigger). Triggered functions cannot be called directly by roles. Apply REVOKE only to regular (callable) functions, not to trigger functions. This guidance prevents unnecessary revokes in migration scripts related to trigger functions.

Learnt from: riderx
Repo: Cap-go/capgo PR: 2069
File: supabase/migrations/20260507130640_restore_accessible_apps_apikey_rpc.sql:1-4
Timestamp: 2026-05-07T13:58:13.674Z
Learning: In this repo’s Supabase migrations directory (supabase/migrations/*.sql), when you need to restore an RPC/function that was removed by an already-committed migration on main but is still referenced by the committed test suite, fix it via a new forward restoration migration (create a new migration file) instead of editing the already-committed migration. In code review, do not flag this new restoration migration as a duplicate or recommend folding it into an in-progress migration—follow the project rule “never edit previously committed migrations,” and treat the new forward migration as the canonical approach.

Learnt from: riderx
Repo: Cap-go/capgo PR: 2069
File: supabase/migrations/20260507130640_restore_accessible_apps_apikey_rpc.sql:56-64
Timestamp: 2026-05-07T13:58:21.937Z
Learning: In supabase/migrations/**/*.sql, do not flag an `SECURITY DEFINER` RPC function grant of `EXECUTE` to the `anon` role as a security issue when it is explicitly designed for CLI API-key access. This exemption applies when the RPC enforces the API key from request context (e.g., reads the key from the `capgkey`/request header via `public.get_apikey_header()`), rejects/does not return data unless the provided inputs match the header key, and performs an authorization check before returning rows (e.g., `public.rbac_check_permission_direct()`). In this scenario, the CLI calls the function using the `anon` Postgres role but authenticates via the API key header, so the `anon` grant is intentional. Note: the earlier rule about restricting grants to `authenticated` applies to RLS/admin/RBAC-table policy surfaces (e.g., `role_bindings`), not to these CLI-facing API-key–protected RPCs.


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";
29 changes: 28 additions & 1 deletion supabase/tests/07_auth_functions.sql
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
BEGIN;


SELECT plan(16);
SELECT plan(18);

-- Test is_platform_admin wrapper
SELECT tests.authenticate_as('test_admin');
SELECT set_config(
'request.jwt.claims',
(current_setting('request.jwt.claims', true)::jsonb || jsonb_build_object('aal', 'aal2'))::text,
true
);

SELECT
is(
Expand All @@ -26,6 +31,28 @@ SELECT

SELECT tests.clear_authentication();

SELECT
is(
has_function_privilege(
'service_role'::name,
'public.is_platform_admin()'::regprocedure,
'EXECUTE'
),
false,
'is_platform_admin wrapper test - service_role cannot execute user-context wrapper'
);

SELECT
is(
has_function_privilege(
'service_role'::name,
'public.is_platform_admin(uuid)'::regprocedure,
'EXECUTE'
),
true,
'is_platform_admin wrapper test - service_role can execute private uuid overload'
);

-- Test split behavior when an RBAC role exists (RBAC roles should not affect is_platform_admin)
SET LOCAL ROLE service_role;
INSERT INTO public.orgs (id, created_by, name, management_email)
Expand Down
33 changes: 2 additions & 31 deletions tests/admin-credits.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { randomUUID } from 'node:crypto'
import { env } from 'node:process'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { BASE_URL, getSupabaseClient, headers, ORG_ID, TEST_EMAIL, USER_ID } from './test-utils.ts'
import { BASE_URL, getAal2AuthHeadersForCredentials, getSupabaseClient, headers, ORG_ID, TEST_EMAIL, USER_ID } from './test-utils.ts'

// Test organization for admin credits tests
const TEST_ORG_ID = randomUUID()
Expand All @@ -17,34 +15,7 @@ async function getAdminHeaders() {
if (adminHeadersCache)
return adminHeadersCache

const supabaseUrl = env.SUPABASE_URL
const supabaseAnonKey = env.SUPABASE_ANON_KEY

if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('SUPABASE_URL or SUPABASE_ANON_KEY is missing for admin auth headers')
}

const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
})

const { data, error } = await supabase.auth.signInWithPassword({
email: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
})

if (error || !data.session?.access_token) {
throw error ?? new Error('Unable to obtain admin JWT for tests')
}

adminHeadersCache = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${data.session.access_token}`,
}
adminHeadersCache = await getAal2AuthHeadersForCredentials(ADMIN_EMAIL, ADMIN_PASSWORD)

return adminHeadersCache
}
Expand Down
4 changes: 2 additions & 2 deletions tests/admin-stats.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { BASE_URL, fetchWithRetry, getAuthHeadersForCredentials, getSupabaseClient, PRODUCT_ID, TEST_EMAIL, USER_ADMIN_EMAIL, USER_ID } from './test-utils.ts'
import { BASE_URL, fetchWithRetry, getAal2AuthHeadersForCredentials, getSupabaseClient, PRODUCT_ID, TEST_EMAIL, USER_ADMIN_EMAIL, USER_ID } from './test-utils.ts'

const DAY_IN_MS = 24 * 60 * 60 * 1000
const NOW = Date.now()
Expand Down Expand Up @@ -55,7 +55,7 @@ let creatorUserCreatedAt = ''
beforeAll(async () => {
const supabase = getSupabaseClient()

adminHeaders = await getAuthHeadersForCredentials(USER_ADMIN_EMAIL, 'adminadmin')
adminHeaders = await getAal2AuthHeadersForCredentials(USER_ADMIN_EMAIL, 'adminadmin')

const [{ data: planRow, error: planError }, { data: userRow, error: userError }] = await Promise.all([
supabase.from('plans').select('name, price_m_id, price_y_id, stripe_id').eq('stripe_id', PRODUCT_ID).single(),
Expand Down
49 changes: 19 additions & 30 deletions tests/audit-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,8 +441,8 @@ describe('audit log triggers', () => {
// These tests verify that audit logs are created when using API key authentication
// This was a bug where CLI/API users were not logged because get_identity() didn't check API keys
describe('audit logs for app_versions via API key', () => {
const testVersionName = `99.0.0-audit-test-${randomUUID()}`
let createdVersionId: number | null = null
let createdVersionName: string | null = null

afterAll(async () => {
// Clean up: delete the test version if it was created
Expand All @@ -454,6 +454,9 @@ describe('audit logs for app_versions via API key', () => {
})

it('app_version INSERT via API creates audit log with user_id from API key', async () => {
const testVersionName = `99.0.0-audit-test-${randomUUID()}`
createdVersionName = testVersionName

// Create a bundle via the API (uses API key authentication)
const response = await fetchWithRetry(`${BASE_URL}/bundle`, {
method: 'POST',
Expand All @@ -475,34 +478,20 @@ describe('audit logs for app_versions via API key', () => {
// Wait for the trigger to execute
await new Promise(resolve => setTimeout(resolve, 200))

// Fetch audit logs for the dedicated test org
const auditResponse = await fetchWithRetry(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=app_versions&operation=INSERT`, {
headers: authHeaders,
})
expect(auditResponse.status).toBe(200)
const auditData = await auditResponse.json()
const safe = parseAuditLogsResponse(auditData)
expect(safe.success).toBe(true)

if (safe.success) {
// Find the audit log for our created version
const versionAuditLog = safe.data.data.find(
log => log.record_id === createdVersionId?.toString(),
)
const versionAuditLog = await waitForAuditLog(
`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=app_versions&operation=INSERT`,
log => log.record_id === createdVersionId?.toString(),
)

expect(versionAuditLog).toBeTruthy()
if (versionAuditLog) {
expect(versionAuditLog.operation).toBe('INSERT')
expect(versionAuditLog.table_name).toBe('app_versions')
expect(versionAuditLog.org_id).toBe(ORG_ID)
// This is the key assertion: user_id should be set from the API key
expect(versionAuditLog.user_id).toBe(USER_ID)
expect(versionAuditLog.old_record).toBeNull()
expect(versionAuditLog.new_record).toBeTruthy()
if (versionAuditLog.new_record && typeof versionAuditLog.new_record === 'object') {
expect((versionAuditLog.new_record as Record<string, unknown>).name).toBe(testVersionName)
}
}
expect(versionAuditLog.operation).toBe('INSERT')
expect(versionAuditLog.table_name).toBe('app_versions')
expect(versionAuditLog.org_id).toBe(ORG_ID)
// This is the key assertion: user_id should be set from the API key
expect(versionAuditLog.user_id).toBe(USER_ID)
expect(versionAuditLog.old_record).toBeNull()
expect(versionAuditLog.new_record).toBeTruthy()
if (versionAuditLog.new_record && typeof versionAuditLog.new_record === 'object') {
expect((versionAuditLog.new_record as Record<string, unknown>).name).toBe(testVersionName)
}
})

Expand Down Expand Up @@ -545,7 +534,7 @@ describe('audit logs for app_versions via API key', () => {

it('app_version soft-DELETE via API creates UPDATE audit log with user_id from API key', async () => {
// Skip if we don't have a version to delete
if (!createdVersionId) {
if (!createdVersionId || !createdVersionName) {
console.warn('Skipping DELETE test: no version was created')
return
}
Expand All @@ -558,7 +547,7 @@ describe('audit logs for app_versions via API key', () => {
headers: apiKeyAuthHeaders,
body: JSON.stringify({
app_id: APIKEY_AUDIT_APP_ID,
version: testVersionName,
version: createdVersionName,
}),
})

Expand Down
82 changes: 82 additions & 0 deletions tests/is-admin-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ describe('is_platform_admin SQL function', () => {
}
}

async function setAuthClaims(query: QueryFn, claims: Record<string, unknown>) {
const sub = typeof claims.sub === 'string' ? claims.sub : ''
const email = typeof claims.email === 'string' ? claims.email : ''

await query('SELECT set_config(\'role\', \'authenticated\', true)')
await query('SELECT set_config(\'request.jwt.claim.sub\', $1, true)', [sub])
await query('SELECT set_config(\'request.jwt.claim.email\', $1, true)', [email])
await query('SELECT set_config(\'request.jwt.claim.role\', \'authenticated\', true)')
await query('SELECT set_config(\'request.jwt.claims\', $1, true)', [JSON.stringify({
role: 'authenticated',
...claims,
})])
}

afterAll(async () => {
await pool.end()
})
Expand Down Expand Up @@ -145,4 +159,72 @@ describe('is_platform_admin SQL function', () => {
expect(regularUserResults.rows[0].is_platform_admin).toBe(false)
})
})

it.concurrent('does not treat email OTP sessions as platform-admin MFA', async () => {
await withTransaction(async (query) => {
const legacyAdmin = await getAdminUserId(query)

expect(legacyAdmin).toBeTruthy()

await query(`
INSERT INTO public.user_security (user_id, email_otp_verified_at, created_at, updated_at)
VALUES ($1::uuid, NOW(), NOW(), NOW())
ON CONFLICT (user_id) DO UPDATE
SET
email_otp_verified_at = EXCLUDED.email_otp_verified_at,
updated_at = NOW();
`, [legacyAdmin])

await query(`
INSERT INTO auth.mfa_factors (id, user_id, friendly_name, factor_type, status, created_at, updated_at)
VALUES (gen_random_uuid(), $1::uuid, 'Admin TOTP', 'totp'::auth.factor_type, 'verified'::auth.factor_status, NOW(), NOW());
`, [legacyAdmin])

await setAuthClaims(query, {
sub: legacyAdmin,
email: 'admin@example.test',
aal: 'aal1',
amr: [{ method: 'otp' }],
})

const otpOnly = await query('SELECT public.is_platform_admin() as is_platform_admin')

expect(otpOnly.rows[0].is_platform_admin).toBe(false)

await setAuthClaims(query, {
sub: legacyAdmin,
email: 'admin@example.test',
aal: 'aal2',
amr: [{ method: 'password' }, { method: 'totp' }],
})

const totpVerified = await query('SELECT public.is_platform_admin() as is_platform_admin')

expect(totpVerified.rows[0].is_platform_admin).toBe(true)
})
})

it.concurrent('requires aal2 for platform admins without enrolled factors', async () => {
await withTransaction(async (query) => {
const legacyAdmin = await getAdminUserId(query)

expect(legacyAdmin).toBeTruthy()

await query(`
DELETE FROM auth.mfa_factors
WHERE user_id = $1::uuid;
`, [legacyAdmin])

await setAuthClaims(query, {
sub: legacyAdmin,
email: 'admin@example.test',
aal: 'aal1',
amr: [{ method: 'password' }],
})

const aal1Admin = await query('SELECT public.is_platform_admin() as is_platform_admin')

expect(aal1Admin.rows[0].is_platform_admin).toBe(false)
})
})
})
Loading
Loading