Skip to content
Merged
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/functions/_backend/private/admin_credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ async function verifyAdmin(c: AppContext): Promise<{ isAdmin: boolean, userId: s
const userId = auth.userId
const userSupabase = supabaseClient(c, auth.jwt)

// is_admin() is MFA-aware and must run with the caller JWT context.
const { data: isAdmin, error: adminError } = await userSupabase.rpc('is_admin')
// is_platform_admin() is MFA-aware and must run with the caller JWT context.
const { data: isAdmin, error: adminError } = await userSupabase.rpc('is_platform_admin')
Comment on lines +40 to +41
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.

⚠️ Potential issue | 🔴 Critical

TypeScript build fails: is_platform_admin not in generated types.

The pipeline failure indicates the Supabase TypeScript types haven't been regenerated after adding the new SQL function. The RPC name "is_platform_admin" is not recognized in the type union.

Run bun types after the migration to regenerate the TypeScript types, as per the coding guidelines for schema changes.

🧰 Tools
🪛 GitHub Actions: Run tests

[error] 41-41: vue-tsc: TS2345: Argument of type '"is_platform_admin"' is not assignable to parameter of type union of permissions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/admin_credits.ts` around lines 40 - 41,
The TypeScript build fails because the new Supabase RPC "is_platform_admin"
isn't present in the generated types; run the type generation command (e.g.,
`bun types`) after the migration so the RPC name is added to the generated
Supabase types, then rebuild; ensure you regenerate types before committing so
usages like userSupabase.rpc('is_platform_admin') in admin_credits.ts compile
successfully.


if (adminError) {
cloudlog({ requestId: c.get('requestId'), message: 'is_admin_error', error: adminError })
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/_backend/private/admin_stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ app.post('/', middlewareAuth, async (c) => {

// Verify user is admin
const supabaseClient = useSupabaseClient(c, authToken)
const { data: isAdmin, error: adminError } = await supabaseClient.rpc('is_admin')
const { data: isAdmin, error: adminError } = await supabaseClient.rpc('is_platform_admin')
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.

⚠️ Potential issue | 🔴 Critical

TypeScript build fails: is_platform_admin not in generated types.

Same issue as in admin_credits.ts — the Supabase TypeScript types need to be regenerated with bun types after the migration to include the new is_platform_admin RPC function.

🧰 Tools
🪛 GitHub Actions: Run tests

[error] 83-83: vue-tsc: TS2345: Argument of type '"is_platform_admin"' is not assignable to parameter of type union of permissions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/admin_stats.ts` at line 83, The
TypeScript build fails because the new RPC `is_platform_admin` used in the call
supabaseClient.rpc('is_platform_admin') isn't present in the generated Supabase
types; regenerate the client types (the same fix applied in admin_credits.ts) by
running the project type generation command (e.g., `bun types`) after the DB
migration so the RPC is added to the generated types, then recompile to confirm
the error is resolved.


if (adminError) {
cloudlog({ requestId: c.get('requestId'), message: 'is_admin_error', error: adminError })
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/_backend/private/log_as.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ app.post('/', middlewareAuth, async (c) => {
const supabaseAdmin = await useSupabaseAdmin(c)
const supabaseClient = useSupabaseClient(c, authToken)

const { data: isAdmin, error: adminError } = await supabaseClient.rpc('is_admin')
const { data: isAdmin, error: adminError } = await supabaseClient.rpc('is_platform_admin')
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.

⚠️ Potential issue | 🔴 Critical

TypeScript build fails: is_platform_admin not in generated types.

Same issue as the other files — run bun types after the migration to regenerate the Supabase TypeScript types.

🧰 Tools
🪛 GitHub Actions: Run tests

[error] 30-30: vue-tsc: TS2345: Argument of type '"is_platform_admin"' is not assignable to parameter of type union of permissions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/log_as.ts` at line 30, Build fails
because the RPC "is_platform_admin" is missing from the generated Supabase
types; regenerate the Supabase TypeScript types and update usages accordingly by
running the type generation command (e.g., run "bun types" after the migration)
so that supabaseClient.rpc('is_platform_admin') is included in the generated
types and the TypeScript compile error is resolved.

if (adminError) {
throw simpleError('is_admin_error', 'Is admin error', { adminError })
}
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/_backend/public/replication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ async function validateReplicationAccess(c: ReplicationContext) {
})

const userClient = supabaseClient(c, authorization)
const { data: isAdmin, error: adminError } = await userClient.rpc('is_admin')
const { data: isAdmin, error: adminError } = await userClient.rpc('is_platform_admin')
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.

⚠️ Potential issue | 🔴 Critical

TypeScript build fails: is_platform_admin not in generated types.

Same issue as the other files — run bun types after the migration to regenerate the Supabase TypeScript types.

🧰 Tools
🪛 GitHub Actions: Run tests

[error] 178-178: vue-tsc: TS2345: Argument of type '"is_platform_admin"' is not assignable to parameter of type union of permissions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/public/replication.ts` at line 178, TypeScript
fails because the RPC "is_platform_admin" is not present in the generated
Supabase types; regenerate the client types and recompile by running the
project's type generation command (run bun types) after the migration so
userClient.rpc('is_platform_admin') is included in the generated types; then
commit the updated type artifacts and re-run the build to confirm the error is
resolved.

if (adminError) {
cloudlogErr({ requestId: c.get('requestId'), message: 'replication_is_admin_error', error: adminError })
throw quickError(500, 'is_admin_error', 'Unable to verify admin rights')
Expand Down
109 changes: 109 additions & 0 deletions supabase/migrations/20260311000000_split_is_admin_platform_admin.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
-- Split platform admin detection from is_admin
CREATE OR REPLACE FUNCTION public.is_platform_admin(userid uuid)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
admin_ids_jsonb jsonb;
is_admin_legacy boolean := false;
mfa_verified boolean;
rbac_enabled boolean;
has_platform_admin boolean := false;
BEGIN
SELECT public.verify_mfa() INTO mfa_verified;
IF NOT mfa_verified THEN
RETURN false;
END IF;

SELECT decrypted_secret::jsonb INTO admin_ids_jsonb
FROM vault.decrypted_secrets
WHERE name = 'admin_users';

is_admin_legacy := COALESCE(admin_ids_jsonb ? userid::text, false);

SELECT use_new_rbac INTO rbac_enabled
FROM public.rbac_settings
WHERE id = 1;

IF COALESCE(rbac_enabled, false) THEN
SELECT EXISTS (
SELECT 1
FROM public.role_bindings rb
JOIN public.roles r ON r.id = rb.role_id
WHERE rb.principal_type = public.rbac_principal_user()
AND rb.principal_id = userid
AND rb.scope_type = public.rbac_scope_platform()
AND r.name = public.rbac_role_platform_super_admin()
) INTO has_platform_admin;
END IF;

RETURN is_admin_legacy OR has_platform_admin;
END;
$$;

ALTER FUNCTION public.is_platform_admin(userid uuid) OWNER TO "postgres";

CREATE OR REPLACE FUNCTION public.is_platform_admin()
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
RETURN public.is_platform_admin((SELECT auth.uid()));
END;
$$;

ALTER FUNCTION public.is_platform_admin() OWNER TO "postgres";

GRANT ALL ON FUNCTION public.is_platform_admin(userid uuid) TO "anon";
GRANT ALL ON FUNCTION public.is_platform_admin(userid uuid) TO "authenticated";
GRANT ALL ON FUNCTION public.is_platform_admin(userid uuid) TO "service_role";
GRANT ALL ON FUNCTION public.is_platform_admin() TO "anon";
GRANT ALL ON FUNCTION public.is_platform_admin() TO "authenticated";
GRANT ALL ON FUNCTION public.is_platform_admin() TO "service_role";

COMMENT ON FUNCTION public.is_platform_admin(uuid) IS 'Checks if a user is a platform admin. In RBAC mode, accepts legacy vault list or platform_super_admin role. In legacy mode, only accepts legacy vault list. Always requires MFA.';

CREATE OR REPLACE FUNCTION public.is_admin(userid uuid)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
admin_ids_jsonb jsonb;
is_admin_legacy boolean;
mfa_verified boolean;
BEGIN
SELECT public.verify_mfa() INTO mfa_verified;
IF NOT mfa_verified THEN
RETURN false;
END IF;

SELECT decrypted_secret::jsonb INTO admin_ids_jsonb
FROM vault.decrypted_secrets
WHERE name = 'admin_users';

is_admin_legacy := COALESCE(admin_ids_jsonb ? userid::text, false);

RETURN is_admin_legacy;
Comment on lines +90 to +92
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep RBAC admin checks in is_admin or update dependents

This change makes public.is_admin(userid) return only the legacy vault result, so users who are platform admins only via RBAC (platform_super_admin) now evaluate as non-admin. Existing callers were not migrated: frontend admin gating still calls rpc('is_admin', { userid }) (src/services/supabase.ts:339-344, used by src/modules/auth.ts:161,215), and multiple RLS policies in supabase/migrations/20251222140030_rbac_system.sql still depend on public.is_admin(auth.uid()); in RBAC-enabled orgs, those users will lose admin UI access and policy-based admin capabilities unless they are also in admin_users vault.

Useful? React with 👍 / 👎.

END;
$$;

COMMENT ON FUNCTION public.is_admin(uuid) IS 'Checks if a user is listed in legacy platform admin secret. Always requires MFA.';

CREATE OR REPLACE FUNCTION public.is_admin()
RETURNS boolean
LANGUAGE plpgsql
SET
search_path = ''
AS $$
BEGIN
RETURN public.is_admin((SELECT auth.uid()));
END;
$$;

COMMENT ON FUNCTION public.is_admin() IS 'Legacy platform admin helper. Checks if the current user is listed in admin_users.';
116 changes: 116 additions & 0 deletions tests/is-admin-functions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { PoolClient } from 'pg'
import { randomUUID } from 'node:crypto'
import { Pool } from 'pg'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { POSTGRES_URL } from './test-utils.ts'

describe('is_admin / is_platform_admin SQL functions', () => {
let pool: Pool
let client: PoolClient

const query = (text: string, values: Array<string | boolean> = []) => {
return client.query(text, values)
}

beforeAll(() => {
pool = new Pool({ connectionString: POSTGRES_URL })
})

beforeEach(async () => {
client = await pool.connect()
await query('BEGIN')
})

afterEach(async () => {
if (!client)
return

try {
await query('ROLLBACK')
}
finally {
client.release()
}
})

afterAll(async () => {
await pool.end()
})

it('keeps is_admin legacy-only even when RBAC is enabled', async () => {
const legacyAdmin = randomUUID()
const nonAdmin = randomUUID()

await query(`
UPDATE public.rbac_settings
SET use_new_rbac = false
WHERE id = 1;
`)
Comment on lines +44 to +48
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.

⚠️ Potential issue | 🟠 Major

Line 46 disables the mode this test claims to cover.

The title says "even when RBAC is enabled", but Line 46 sets use_new_rbac = false, so this never exercises the compatibility path the PR is meant to protect. Either flip the flag to true or rename the case.

Suggested fix
-      SET use_new_rbac = false
+      SET use_new_rbac = true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await query(`
UPDATE public.rbac_settings
SET use_new_rbac = false
WHERE id = 1;
`)
await query(`
UPDATE public.rbac_settings
SET use_new_rbac = true
WHERE id = 1;
`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/is-admin-functions.test.ts` around lines 44 - 48, The test titled "even
when RBAC is enabled" flips the RBAC flag to the wrong state by executing an
UPDATE that sets use_new_rbac = false, so it never exercises the enabled
compatibility path; change the SQL in the test (the UPDATE against
public.rbac_settings that sets use_new_rbac) to set it to true so the test
actually runs with RBAC enabled, or alternatively rename the test case to
reflect that it tests the disabled path if you intend to keep use_new_rbac =
false.


await query(`
UPDATE vault.decrypted_secrets
SET decrypted_secret = (
COALESCE(decrypted_secret::jsonb, '{}'::jsonb)
|| $1::jsonb
)::text
WHERE name = 'admin_users';
`, [JSON.stringify({ [legacyAdmin]: true })])

const legacy = await query(
'SELECT public.is_admin($1::uuid) as is_admin, public.is_platform_admin($1::uuid) as is_platform_admin',
[legacyAdmin],
)

const regular = await query(
'SELECT public.is_admin($1::uuid) as is_admin, public.is_platform_admin($1::uuid) as is_platform_admin',
[nonAdmin],
)
Comment on lines +59 to +67
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.

⚠️ Potential issue | 🟠 Major

Add coverage for the current-user/MFA path, not just the uuid overloads.

Both cases only call public.is_admin($1::uuid) / public.is_platform_admin($1::uuid). The PR also changes the zero-arg RPC path and preserves MFA in the legacy check, but this suite never executes is_admin() / is_platform_admin() under a real session or verifies MFA-enabled vs MFA-disabled users. A regression there would still pass these assertions.

Based on learnings, "Always cover database changes with Postgres-level tests and complement them with end-to-end tests for affected user flows".

Also applies to: 101-109

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/is-admin-functions.test.ts` around lines 59 - 67, The test currently
only exercises the UUID overloads (public.is_admin($1::uuid) and
public.is_platform_admin($1::uuid)); add assertions that exercise the zero-arg
RPCs public.is_admin() and public.is_platform_admin() executed in a real DB
session representing the current_user and include cases for MFA-enabled and
MFA-disabled users so MFA-preservation is verified; implement this by
creating/using sessions that SET ROLE or authenticate as the test users (or run
the functions in a transaction with current_user set), call public.is_admin() /
public.is_platform_admin() for both an MFA user and a non-MFA user, and assert
the expected boolean results alongside the existing UUID-based queries (refer to
the test helpers that create legacyAdmin/nonAdmin users and any session/auth
utilities in the suite).


expect(legacy.rows[0].is_admin).toBe(true)
expect(legacy.rows[0].is_platform_admin).toBe(true)
expect(regular.rows[0].is_admin).toBe(false)
expect(regular.rows[0].is_platform_admin).toBe(false)
})

it('moves RBAC platform super-admin checks into is_platform_admin only', async () => {
const rbacUserId = randomUUID()
const normalUserId = randomUUID()

await query(`
UPDATE public.rbac_settings
SET use_new_rbac = true
WHERE id = 1;
`)

await query(`
INSERT INTO public.role_bindings (
principal_type,
principal_id,
role_id,
scope_type,
granted_by
) VALUES (
public.rbac_principal_user(),
$1::uuid,
(SELECT id FROM public.roles WHERE name = public.rbac_role_platform_super_admin()),
public.rbac_scope_platform(),
$1::uuid
);
`, [rbacUserId])

const rbacUserResults = await query(
'SELECT public.is_admin($1::uuid) as is_admin, public.is_platform_admin($1::uuid) as is_platform_admin',
[rbacUserId],
)

const regularUserResults = await query(
'SELECT public.is_admin($1::uuid) as is_admin, public.is_platform_admin($1::uuid) as is_platform_admin',
[normalUserId],
)

expect(rbacUserResults.rows[0].is_admin).toBe(false)
expect(rbacUserResults.rows[0].is_platform_admin).toBe(true)
expect(regularUserResults.rows[0].is_admin).toBe(false)
expect(regularUserResults.rows[0].is_platform_admin).toBe(false)
})
})
Loading