Skip to content

Harden RPC access and redact PII logs#1562

Closed
riderx wants to merge 3 commits into
mainfrom
riderx/apikey-rpc-fix
Closed

Harden RPC access and redact PII logs#1562
riderx wants to merge 3 commits into
mainfrom
riderx/apikey-rpc-fix

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Feb 3, 2026

Summary (AI generated)

  • Restrict execution of sensitive SECURITY DEFINER functions and add auth checks.
  • Redact PII from invite logging and remove default EXECUTE grants for new functions.

Test plan (AI generated)

  • bun lint:backend

Screenshots (AI generated)

  • Not applicable (backend changes only).

Checklist (AI generated)

  • My code follows the code style of this project and passes
    .
  • My change requires a change to the documentation.
  • I have updated the documentation
    accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce
    my tests

Summary by CodeRabbit

  • Chores
    • Implemented comprehensive security hardening to strengthen authentication and authorization mechanisms across the platform
    • Enhanced privilege management by restricting default function access and implementing stricter access controls
    • Improved organization user invitation system with advanced security validation and strict authorization checks
    • Updated authentication requirements for sensitive operations to ensure only authorized access

Copilot AI review requested due to automatic review settings February 3, 2026 05:57
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 3, 2026

Warning

Rate limit exceeded

@riderx has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 21 minutes and 47 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Single SQL migration introduces security-hardening measures: adds three SECURITY DEFINER functions for account deletion checks, organization resolution, and user invitations, along with restrictive privilege revocation and explicit role-based access grants to service_role and authenticated users only.

Changes

Cohort / File(s) Summary
Security Hardening Migration
supabase/migrations/20260203140000_security_hardening.sql
Adds three new SECURITY DEFINER functions: get_account_removal_date() for owner/service_role only account deletion lookups, get_user_main_org_id_by_app_id() for organization resolution with access validation, and invite_user_to_org() for user invitations with admin rights enforcement. Revokes default EXECUTE privileges for anon/authenticated roles, explicitly grants to authenticated and service_role, and restricts find_apikey_by_value() to service_role only.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 With locks and keys so tidy,
We guard each secret gateway,
Three guardians now stand upright,
With roles and rights held tight—
Service_role leads the way! 🔐

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main changes: hardening RPC access through SECURITY DEFINER functions and authorization checks, plus PII redaction in logging.
Description check ✅ Passed The description includes most required sections (Summary, Test plan, Screenshots, Checklist) but lacks detailed explanation of what was actually tested and how to reproduce manual tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/apikey-rpc-fix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06db5108bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +90 to +92
SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id;
IF api_user_id IS NULL THEN
RETURN NULL;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid null org_id when no JWT/API key context

When auth.uid() is NULL and there are no request headers (e.g., migrations/seed.sql or other SQL-run admin tasks), get_identity_org_appid(...) returns NULL and this function returns NULL as well. That breaks triggers like auto_owner_org_by_app_id (used by app_versions, channels, etc.), because owner_org is NOT NULL and the trigger overwrites it with this NULL value, causing inserts during bunx supabase db reset/seed or SQL maintenance to fail. Consider allowing the postgres/no-JWT context (or bypassing the trigger when owner_org is already set) so non-request inserts still work.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@supabase/migrations/20260203140000_security_hardening.sql`:
- Around line 106-186: The function public.invite_user_to_org lacks explicit
GRANT EXECUTE statements so after revoking default privileges no role can call
it; add GRANT EXECUTE ON FUNCTION public.invite_user_to_org(varchar, uuid,
public.user_min_right) TO the appropriate roles (e.g., authenticated, anon and
service_role or whichever roles your project uses to invoke invites) immediately
after the function definition so callers regain permission to execute
invite_user_to_org.
- Around line 58-101: Add explicit GRANT EXECUTE for the new function so
authenticated users can call it: after the function definition, add a statement
like GRANT EXECUTE ON FUNCTION public.get_user_main_org_id_by_app_id(text) TO
authenticated; (match the exact signature "get_user_main_org_id_by_app_id(text)"
and place this alongside the other hardened functions' GRANTs).

Comment on lines +58 to +101
CREATE OR REPLACE FUNCTION "public"."get_user_main_org_id_by_app_id"("app_id" "text") RETURNS "uuid"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
DECLARE
org_id uuid;
auth_uid uuid;
auth_role text;
api_user_id uuid;
BEGIN
SELECT apps.owner_org INTO org_id
FROM public.apps
WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text)
LIMIT 1;

IF org_id IS NULL THEN
RETURN NULL;
END IF;

SELECT auth.uid() INTO auth_uid;
IF auth_uid IS NOT NULL THEN
IF public.check_min_rights('read'::public.user_min_right, auth_uid, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;
RETURN NULL;
END IF;

SELECT auth.role() INTO auth_role;
IF auth_role = 'service_role' THEN
RETURN org_id;
END IF;

SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id;
IF api_user_id IS NULL THEN
RETURN NULL;
END IF;

IF public.check_min_rights('read'::public.user_min_right, api_user_id, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;

RETURN NULL;
END;
$$;
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

Missing GRANT statements for get_user_main_org_id_by_app_id.

The function is created but no explicit GRANT EXECUTE statements follow it. After the default privilege revocation at lines 191-192, this function will be inaccessible to authenticated users unless explicitly granted. This is inconsistent with the other hardened functions and likely breaks intended functionality.

🔧 Proposed fix: Add GRANT statements after line 101
   RETURN NULL;
 END;
 $$;
+
+REVOKE EXECUTE ON FUNCTION "public"."get_user_main_org_id_by_app_id"(text) FROM "anon";
+GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id_by_app_id"(text) TO "authenticated";
+GRANT EXECUTE ON FUNCTION "public"."get_user_main_org_id_by_app_id"(text) TO "service_role";
🤖 Prompt for AI Agents
In `@supabase/migrations/20260203140000_security_hardening.sql` around lines 58 -
101, Add explicit GRANT EXECUTE for the new function so authenticated users can
call it: after the function definition, add a statement like GRANT EXECUTE ON
FUNCTION public.get_user_main_org_id_by_app_id(text) TO authenticated; (match
the exact signature "get_user_main_org_id_by_app_id(text)" and place this
alongside the other hardened functions' GRANTs).

Comment on lines +106 to +186
CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" (
"email" varchar,
"org_id" uuid,
"invite_type" public.user_min_right
) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER
SET search_path = '' AS $$
DECLARE
org record;
invited_user record;
current_record record;
current_tmp_user record;
calling_user_id uuid;
BEGIN
-- Get the calling user's ID
SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id)
INTO calling_user_id;

-- Check if org exists
SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id;
IF org IS NULL THEN
RETURN 'NO_ORG';
END IF;

-- Check if user has at least public.rbac_right_admin() rights
IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;

-- If inviting as super_admin, caller must be super_admin
IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN
IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;
END IF;

-- Check if user already exists
SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email;

IF invited_user IS NOT NULL THEN
-- User exists, check if already in org
SELECT public.org_users.id INTO current_record
FROM public.org_users
WHERE public.org_users.user_id=invited_user.id
AND public.org_users.org_id=invite_user_to_org.org_id;

IF current_record IS NOT NULL THEN
RETURN 'ALREADY_INVITED';
ELSE
-- Add user to org
INSERT INTO public.org_users (user_id, org_id, user_right)
VALUES (invited_user.id, invite_user_to_org.org_id, invite_type);
RETURN 'OK';
END IF;
ELSE
-- User doesn't exist, check tmp_users for pending invitations
SELECT * INTO current_tmp_user
FROM public.tmp_users
WHERE public.tmp_users.email=invite_user_to_org.email
AND public.tmp_users.org_id=invite_user_to_org.org_id;

IF current_tmp_user IS NOT NULL THEN
-- Invitation already exists
IF current_tmp_user.cancelled_at IS NOT NULL THEN
-- Invitation was cancelled, check if recent
IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN
RETURN 'TOO_RECENT_INVITATION_CANCELATION';
ELSE
RETURN 'NO_EMAIL';
END IF;
ELSE
RETURN 'ALREADY_INVITED';
END IF;
ELSE
-- No invitation exists, need to create one (handled elsewhere)
RETURN 'NO_EMAIL';
END IF;
END IF;
END;
$$;
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

Missing GRANT statements for invite_user_to_org.

Similar to get_user_main_org_id_by_app_id, this function lacks explicit GRANT EXECUTE statements. After default privilege revocation, no role will be able to invoke this function.

🔧 Proposed fix: Add GRANT statements after line 186
 END;
 $$;
+
+REVOKE EXECUTE ON FUNCTION "public"."invite_user_to_org"(varchar, uuid, public.user_min_right) FROM "anon";
+GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"(varchar, uuid, public.user_min_right) TO "authenticated";
+GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"(varchar, uuid, public.user_min_right) TO "service_role";
📝 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
CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" (
"email" varchar,
"org_id" uuid,
"invite_type" public.user_min_right
) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER
SET search_path = '' AS $$
DECLARE
org record;
invited_user record;
current_record record;
current_tmp_user record;
calling_user_id uuid;
BEGIN
-- Get the calling user's ID
SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id)
INTO calling_user_id;
-- Check if org exists
SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id;
IF org IS NULL THEN
RETURN 'NO_ORG';
END IF;
-- Check if user has at least public.rbac_right_admin() rights
IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;
-- If inviting as super_admin, caller must be super_admin
IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN
IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;
END IF;
-- Check if user already exists
SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email;
IF invited_user IS NOT NULL THEN
-- User exists, check if already in org
SELECT public.org_users.id INTO current_record
FROM public.org_users
WHERE public.org_users.user_id=invited_user.id
AND public.org_users.org_id=invite_user_to_org.org_id;
IF current_record IS NOT NULL THEN
RETURN 'ALREADY_INVITED';
ELSE
-- Add user to org
INSERT INTO public.org_users (user_id, org_id, user_right)
VALUES (invited_user.id, invite_user_to_org.org_id, invite_type);
RETURN 'OK';
END IF;
ELSE
-- User doesn't exist, check tmp_users for pending invitations
SELECT * INTO current_tmp_user
FROM public.tmp_users
WHERE public.tmp_users.email=invite_user_to_org.email
AND public.tmp_users.org_id=invite_user_to_org.org_id;
IF current_tmp_user IS NOT NULL THEN
-- Invitation already exists
IF current_tmp_user.cancelled_at IS NOT NULL THEN
-- Invitation was cancelled, check if recent
IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN
RETURN 'TOO_RECENT_INVITATION_CANCELATION';
ELSE
RETURN 'NO_EMAIL';
END IF;
ELSE
RETURN 'ALREADY_INVITED';
END IF;
ELSE
-- No invitation exists, need to create one (handled elsewhere)
RETURN 'NO_EMAIL';
END IF;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION "public"."invite_user_to_org" (
"email" varchar,
"org_id" uuid,
"invite_type" public.user_min_right
) RETURNS varchar LANGUAGE plpgsql SECURITY DEFINER
SET search_path = '' AS $$
DECLARE
org record;
invited_user record;
current_record record;
current_tmp_user record;
calling_user_id uuid;
BEGIN
-- Get the calling user's ID
SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id)
INTO calling_user_id;
-- Check if org exists
SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id;
IF org IS NULL THEN
RETURN 'NO_ORG';
END IF;
-- Check if user has at least public.rbac_right_admin() rights
IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;
-- If inviting as super_admin, caller must be super_admin
IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN
IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;
END IF;
-- Check if user already exists
SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email;
IF invited_user IS NOT NULL THEN
-- User exists, check if already in org
SELECT public.org_users.id INTO current_record
FROM public.org_users
WHERE public.org_users.user_id=invited_user.id
AND public.org_users.org_id=invite_user_to_org.org_id;
IF current_record IS NOT NULL THEN
RETURN 'ALREADY_INVITED';
ELSE
-- Add user to org
INSERT INTO public.org_users (user_id, org_id, user_right)
VALUES (invited_user.id, invite_user_to_org.org_id, invite_type);
RETURN 'OK';
END IF;
ELSE
-- User doesn't exist, check tmp_users for pending invitations
SELECT * INTO current_tmp_user
FROM public.tmp_users
WHERE public.tmp_users.email=invite_user_to_org.email
AND public.tmp_users.org_id=invite_user_to_org.org_id;
IF current_tmp_user IS NOT NULL THEN
-- Invitation already exists
IF current_tmp_user.cancelled_at IS NOT NULL THEN
-- Invitation was cancelled, check if recent
IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN
RETURN 'TOO_RECENT_INVITATION_CANCELATION';
ELSE
RETURN 'NO_EMAIL';
END IF;
ELSE
RETURN 'ALREADY_INVITED';
END IF;
ELSE
-- No invitation exists, need to create one (handled elsewhere)
RETURN 'NO_EMAIL';
END IF;
END IF;
END;
$$;
REVOKE EXECUTE ON FUNCTION "public"."invite_user_to_org"(varchar, uuid, public.user_min_right) FROM "anon";
GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"(varchar, uuid, public.user_min_right) TO "authenticated";
GRANT EXECUTE ON FUNCTION "public"."invite_user_to_org"(varchar, uuid, public.user_min_right) TO "service_role";
🤖 Prompt for AI Agents
In `@supabase/migrations/20260203140000_security_hardening.sql` around lines 106 -
186, The function public.invite_user_to_org lacks explicit GRANT EXECUTE
statements so after revoking default privileges no role can call it; add GRANT
EXECUTE ON FUNCTION public.invite_user_to_org(varchar, uuid,
public.user_min_right) TO the appropriate roles (e.g., authenticated, anon and
service_role or whichever roles your project uses to invoke invites) immediately
after the function definition so callers regain permission to execute
invite_user_to_org.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This migration tightens database-level security around several RPC functions and logging behaviors, focusing on restricting sensitive function execution and reducing log exposure of PII.

Changes:

  • Revokes EXECUTE on public.find_apikey_by_value(text) from anon/authenticated, limiting it to service_role.
  • Hardens public.get_account_removal_date(uuid) to allow only the owning user or service_role to access the removal date, and adjusts grants to disallow anon.
  • Reimplements public.get_user_main_org_id_by_app_id(text) with auth/rights checks to mitigate org-id enumeration, and updates public.invite_user_to_org to remove PII (email, calling_user) from failure logs while leaving behavior otherwise unchanged.
  • Changes default privileges so new public functions owned by postgres no longer auto-grant EXECUTE to anon or authenticated.

Comment on lines +77 to +99
SELECT auth.uid() INTO auth_uid;
IF auth_uid IS NOT NULL THEN
IF public.check_min_rights('read'::public.user_min_right, auth_uid, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;
RETURN NULL;
END IF;

SELECT auth.role() INTO auth_role;
IF auth_role = 'service_role' THEN
RETURN org_id;
END IF;

SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id;
IF api_user_id IS NULL THEN
RETURN NULL;
END IF;

IF public.check_min_rights('read'::public.user_min_right, api_user_id, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;

RETURN NULL;
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

In the new implementation of get_user_main_org_id_by_app_id, the api_user_id path (the call to public.get_identity_org_appid and subsequent check_min_rights check) is effectively unreachable because all control-flow paths return before reaching it: authenticated users return inside the auth_uid IS NOT NULL block, and service_role returns inside the auth_role = 'service_role' block. As written, this dead code will never execute and API key–based authorization logic here cannot take effect. Consider either removing this unused block or restructuring the earlier authentication/authorization flow so that the API-key branch can actually run if it is intended to be supported.

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +99
auth_uid uuid;
auth_role text;
api_user_id uuid;
BEGIN
SELECT apps.owner_org INTO org_id
FROM public.apps
WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text)
LIMIT 1;

IF org_id IS NULL THEN
RETURN NULL;
END IF;

SELECT auth.uid() INTO auth_uid;
IF auth_uid IS NOT NULL THEN
IF public.check_min_rights('read'::public.user_min_right, auth_uid, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;
RETURN NULL;
END IF;

SELECT auth.role() INTO auth_role;
IF auth_role = 'service_role' THEN
RETURN org_id;
END IF;

SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id;
IF api_user_id IS NULL THEN
RETURN NULL;
END IF;

IF public.check_min_rights('read'::public.user_min_right, api_user_id, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;

RETURN NULL;
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

The updated get_user_main_org_id_by_app_id now returns NULL for unauthorized callers, which changes the behavior of existing RLS policies that pass its result into public.get_identity_org_appid and public.check_min_rights. When those policies call public.get_identity_org_appid with a NULL org_id, the limited_to_orgs restriction on API keys is no longer enforced (because the comparison org_id = ANY(limited_to_orgs) becomes NULL), while public.check_min_rights still re-derives the real owner_org from public.apps, allowing an API key that is scoped to one org but whose user has broader RBAC rights to access devices in other orgs. To fix this, keep org_id non-NULL in all code paths used by RLS (for example by separating the “org lookup” from the authorization decision), or update public.get_identity_org_appid to treat NULL org_id as a hard deny for keys with limited_to_orgs, so that per-org API key scoping cannot be bypassed.

Suggested change
auth_uid uuid;
auth_role text;
api_user_id uuid;
BEGIN
SELECT apps.owner_org INTO org_id
FROM public.apps
WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text)
LIMIT 1;
IF org_id IS NULL THEN
RETURN NULL;
END IF;
SELECT auth.uid() INTO auth_uid;
IF auth_uid IS NOT NULL THEN
IF public.check_min_rights('read'::public.user_min_right, auth_uid, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;
RETURN NULL;
END IF;
SELECT auth.role() INTO auth_role;
IF auth_role = 'service_role' THEN
RETURN org_id;
END IF;
SELECT public.get_identity_org_appid('{read,upload,write,all}'::public.key_mode[], org_id, get_user_main_org_id_by_app_id.app_id) INTO api_user_id;
IF api_user_id IS NULL THEN
RETURN NULL;
END IF;
IF public.check_min_rights('read'::public.user_min_right, api_user_id, org_id, get_user_main_org_id_by_app_id.app_id, NULL::bigint) THEN
RETURN org_id;
END IF;
RETURN NULL;
BEGIN
SELECT apps.owner_org INTO org_id
FROM public.apps
WHERE ((apps.app_id)::text = (get_user_main_org_id_by_app_id.app_id)::text)
LIMIT 1;
-- Pure lookup: return the main org for this app (may be NULL if app_id is unknown)
RETURN org_id;

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Feb 6, 2026

@riderx riderx closed this Feb 6, 2026
@riderx riderx deleted the riderx/apikey-rpc-fix branch March 17, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants