Skip to content

fix(db): bind public permission oracle subjects#2150

Closed
hieu-lee wants to merge 2 commits into
Cap-go:mainfrom
hieu-lee:bounty-1667-bind-permission-oracles
Closed

fix(db): bind public permission oracle subjects#2150
hieu-lee wants to merge 2 commits into
Cap-go:mainfrom
hieu-lee:bounty-1667-bind-permission-oracles

Conversation

@hieu-lee
Copy link
Copy Markdown

@hieu-lee hieu-lee commented May 11, 2026

Summary

  • Bind public check_min_rights(min_right, user_id, ...) calls to the request JWT subject or to the user resolved from the request capgkey
  • Keep the temporary anonymous check_min_rights grant used by CLI/RLS app-list compatibility, including RBAC-managed API-key flows where user_id is intentionally NULL
  • Remove public execute from internal subject-taking helpers: check_min_rights_legacy, rbac_check_permission_direct, and rbac_check_permission_direct_no_password_policy
  • Keep new subject-binding denial logs metadata-only with presence/match booleans instead of raw user/API-key subject UUIDs
  • Extend the pgTAP oracle/RPC permission coverage for direct-helper grants, cross-subject denial, self checks, and capgkey-bound checks

/claim #1667

Motivation

The public SQL surface still exposed subject-taking permission helpers. An anonymous caller could ask whether an arbitrary known user UUID has a right on an org/app/channel through check_min_rights, and an authenticated caller had direct execute on RBAC helper functions that accept an arbitrary p_user_id. Those helpers should remain available to trusted internal code and self-bound wrappers, but public calls should not act as membership or permission oracles for arbitrary subjects.

Test Plan

  • git diff --check
  • Static pgTAP plan check: 49_test_apikey_oracle_rpc_permissions.sql has 42 planned assertions and 42 assertion calls
  • npx eslint supabase/functions/_backend/public/organization/audit.ts
  • Supabase pgTAP not run locally because this laptop cannot run Docker

Checklist

  • Public subject-taking permission checks are bound to JWT/API-key identity
  • Direct internal RBAC helpers are restricted away from public roles
  • Mixed app/channel/org scopes are denied before policy enforcement
  • New denial diagnostics avoid retaining raw subject UUIDs

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

This PR hardens the public.check_min_rights permission function by binding authorization checks to the request identity, validating API key subjects, enforcing organization/app consistency, and restricting helper-function access to service_role. Database execute permissions are tightened and comprehensive test coverage validates both privilege grants and behavioral security gates.

Changes

Permission Oracle Security Hardening

Layer / File(s) Summary
Function Signature and Setup
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql
New public.check_min_rights function defined with SECURITY DEFINER and empty search_path.
Effective Organization Resolution
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql, supabase/schemas/prod.sql
Function validates app owner org matches caller's effective org, derives effective org from channel_id when not provided, and logs app/org mismatch diagnostics.
Request Subject Binding and API Key Validation
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql, supabase/schemas/prod.sql
For anon/authenticated request roles, function denies when user_id is missing without API key, mismatches auth.uid(), or when resolved API key subject does not match provided user_id.
2FA and Password Policy Enforcement
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql, supabase/schemas/prod.sql
Function enforces org-level 2FA and user password-policy checks; denies with structured logging when requirements not met.
RBAC vs Legacy Evaluation Routing
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql
Function routes to RBAC-based evaluation or legacy fallback via rbac_is_enabled_for_org, resolves scope (channel/app/org), and delegates to public.rbac_check_permission_direct.
Function Ownership and Execute Grants
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql, supabase/schemas/prod.sql
Ownership of public.check_min_rights changed to postgres; execute permissions for check_min_rights_legacy, rbac_check_permission_direct, and rbac_check_permission_direct_no_password_policy revoked from PUBLIC/anon/authenticated and restricted to service_role only.
Test Authentication Context and Coverage
supabase/tests/19_test_identity_functions.sql, supabase/tests/49_test_apikey_oracle_rpc_permissions.sql
Test plan count increased to 38; new assertions verify function privilege grants across roles and behavioral security gates for API key/subject binding without capgkey header.

Possibly Related PRs

  • Cap-go/capgo#1974: Modifies public.check_min_rights function-level permissions and tests anon/API-key behavior.
  • Cap-go/capgo#2061: Directly modifies the same public.check_min_rights function, RBAC/API-key identity handling, and pgTAP tests.
  • Cap-go/capgo#2074: Depends on public.check_min_rights() for org filtering; changes to function behavior and execute grants directly affect this PR.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A migration hops through with care,
Binding permissions to identity's prayer,
API keys checked, orgs aligned,
Security gates now tightly defined,
Legacy paths and RBAC combine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(db): bind public permission oracle subjects' clearly and concisely summarizes the main change: binding public permission oracle access to specific subjects.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description provides a clear summary of changes, motivations, test plan, and a completed checklist addressing all major aspects of the implementation.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented May 11, 2026

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing hieu-lee:bounty-1667-bind-permission-oracles (fdf48cf) with main (af3dedf)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copy link
Copy Markdown

@SpeedyArt SpeedyArt left a comment

Choose a reason for hiding this comment

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

One residual logging concern on this security fix: the new public subject-binding denials still persist raw stable identifiers in pg_log payloads (user_id, auth_uid, and apikey_user_id on the mismatch branches).

Because check_min_rights remains callable by public roles, a mismatched request can now cause retained logs to contain both the requested user subject and the caller/API-key subject. For the same #1667-style cleanup that is moving user/org IDs to booleans/counts elsewhere, I would keep these diagnostics metadata-only: for example hasUserId, hasAuthUid, hasApikeySubject, subjectMatchesAuth, subjectMatchesApikey, plus the deny reason, rather than storing the raw UUIDs.

That should preserve the useful debugging signal for the oracle-binding fix without reintroducing linkable user identifiers into the retained denial logs.

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: 3

🧹 Nitpick comments (2)
supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql (1)

196-220: ⚡ Quick win

Also revoke from anon as defensive hardening.

The search found no evidence of prior direct GRANT EXECUTE ... TO "anon" for either function in the migration chain. However, adding "anon" to the REVOKE statement remains a reasonable defensive practice: if an anon grant existed (from external sources or historical upgrades), REVOKE ... FROM PUBLIC would not remove it. Including "anon" in the revocation is low-effort and closes that gap.

🤖 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/20260511024500_bind_permission_oracle_subjects.sql`
around lines 196 - 220, Update the REVOKE statements for the functions
rbac_check_permission_direct and rbac_check_permission_direct_no_password_policy
to also revoke from the anon role (i.e., change FROM PUBLIC, "authenticated" to
FROM PUBLIC, "authenticated", "anon") so any existing anon grants are explicitly
removed as defensive hardening.
supabase/tests/49_test_apikey_oracle_rpc_permissions.sql (1)

193-235: ⚡ Quick win

Consider adding anon role checks for RBAC direct helpers to match the test coverage pattern.

The check_min_rights_legacy helper (lines 160-191) comprehensively tests all three roles (anon, authenticated, service_role), but the RBAC direct permission helpers only test authenticated and service_role. For consistency and to explicitly verify the PR's objective of removing public EXECUTE from all direct helpers, consider adding anon role checks for:

  • public.rbac_check_permission_direct
  • public.rbac_check_permission_direct_no_password_policy

Both should assert false for the anon role.

📋 Proposed additions for complete coverage

Add these assertions after line 159 (before the existing authenticated checks):

SELECT
    is(
        has_function_privilege(
            'anon'::name,
            'public.rbac_check_permission_direct(text, uuid, uuid, character varying, bigint, text)'::regprocedure,
            'EXECUTE'
        ),
        false,
        'anon role has no execute privilege on direct RBAC helper'
    );

SELECT
    is(
        has_function_privilege(
            'anon'::name,
            'public.rbac_check_permission_direct_no_password_policy(text, uuid, uuid, character varying, bigint, text)'::regprocedure,
            'EXECUTE'
        ),
        false,
        'anon role has no execute privilege on direct RBAC no-policy helper'
    );

Then update the plan count from 38 to 40.

🤖 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/tests/49_test_apikey_oracle_rpc_permissions.sql` around lines 193 -
235, Tests for the RBAC direct helper functions are missing anon-role
assertions: add two assertions that verify has_function_privilege('anon',
'public.rbac_check_permission_direct(...)'::regprocedure, 'EXECUTE') returns
false and has_function_privilege('anon',
'public.rbac_check_permission_direct_no_password_policy(...)'::regprocedure,
'EXECUTE') returns false (use the same argument signature as the existing
authenticated checks) placed before the current authenticated-role checks, and
increment the TAP plan count from 38 to 40; target the functions
rbac_check_permission_direct and rbac_check_permission_direct_no_password_policy
in your edits.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql`:
- Around line 3-169: Add the required execution-model and worst-case plan
evidence for the check_min_rights function: describe where it runs, expected
invocation frequency, effective roles (who calls it), cardinality assumptions,
and which indexes support lookups on public.apps.owner_org, public.channels.id
and public.orgs.id; then attach EXPLAIN (ANALYZE, BUFFERS) output for
representative worst-case executions (org-level, app-level, channel-level)
including calls that exercise app lookup, channel lookup and org
2FA/password-policy checks and the rbac paths (rbac_is_enabled_for_org,
rbac_check_permission_direct), and include notes about which inputs (user_id
NULL vs non-NULL, apikey present) produce the worst cost and why.
- Around line 75-108: The branch only validates v_apikey when user_id IS
DISTINCT FROM auth.uid(), allowing a request where auth.uid() == user_id but an
attached API key belongs to a different user to slip through; always validate
the API key subject. Move the SELECT FROM public.find_apikey_by_value(v_apikey)
(and the assignment to v_apikey_user_id) out of the user_id IS DISTINCT FROM
auth.uid() branch so it runs whenever v_apikey IS NOT NULL, and if
v_apikey_user_id IS NULL or v_apikey_user_id IS DISTINCT FROM user_id then
PERFORM the appropriate pg_log (reuse the
CHECK_MIN_RIGHTS_APIKEY_SUBJECT_MISMATCH message) and RETURN false; keep the
existing user-id-vs-auth.uid() logging/deny logic for non-apikey cases and
ensure rbac_check_permission_direct(..., v_apikey) will only be reached when the
apikey subject equals user_id.
- Around line 52-58: Reject mixed channel/org scopes early: before deriving
v_effective_org_id from channels (the IF block referencing v_effective_org_id
and channel_id and the SELECT from public.channels), add a guard that if
channel_id IS NOT NULL and (org_id IS NOT NULL OR app_id IS NOT NULL) then look
up the channel's owner_org (from public.channels where id = channel_id) and
compare it against the provided org_id (or the org owning the provided app_id);
if they differ, RAISE EXCEPTION / RETURN an error to reject the mixed-scope
request. Ensure the check runs prior to any later org-gated 2FA/password/RBAC
logic so authorization cannot mix org A's policies with channel B's membership.

---

Nitpick comments:
In `@supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql`:
- Around line 196-220: Update the REVOKE statements for the functions
rbac_check_permission_direct and rbac_check_permission_direct_no_password_policy
to also revoke from the anon role (i.e., change FROM PUBLIC, "authenticated" to
FROM PUBLIC, "authenticated", "anon") so any existing anon grants are explicitly
removed as defensive hardening.

In `@supabase/tests/49_test_apikey_oracle_rpc_permissions.sql`:
- Around line 193-235: Tests for the RBAC direct helper functions are missing
anon-role assertions: add two assertions that verify
has_function_privilege('anon',
'public.rbac_check_permission_direct(...)'::regprocedure, 'EXECUTE') returns
false and has_function_privilege('anon',
'public.rbac_check_permission_direct_no_password_policy(...)'::regprocedure,
'EXECUTE') returns false (use the same argument signature as the existing
authenticated checks) placed before the current authenticated-role checks, and
increment the TAP plan count from 38 to 40; target the functions
rbac_check_permission_direct and rbac_check_permission_direct_no_password_policy
in your edits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 67417ab2-2085-4145-a908-e9cd40022a98

📥 Commits

Reviewing files that changed from the base of the PR and between 3d184f8 and 9834f81.

📒 Files selected for processing (4)
  • supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql
  • supabase/schemas/prod.sql
  • supabase/tests/19_test_identity_functions.sql
  • supabase/tests/49_test_apikey_oracle_rpc_permissions.sql

Comment on lines +3 to +169
CREATE OR REPLACE FUNCTION "public"."check_min_rights"(
"min_right" "public"."user_min_right",
"user_id" "uuid",
"org_id" "uuid",
"app_id" character varying,
"channel_id" bigint
) RETURNS boolean
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
DECLARE
v_perm text;
v_scope text;
v_apikey text;
v_apikey_user_id uuid;
v_request_role text;
v_use_rbac boolean;
v_effective_org_id uuid := org_id;
v_app_owner_org uuid;
v_org_enforcing_2fa boolean;
v_password_policy_ok boolean;
BEGIN
-- Existing apps are always authorized in the app owner's org scope.
-- Keep nonexistent apps on the caller org so API handlers can still return their
-- own not-found errors after a valid org-level check.
IF app_id IS NOT NULL THEN
SELECT owner_org INTO v_app_owner_org
FROM public.apps
WHERE public.apps.app_id = check_min_rights.app_id
LIMIT 1;

IF v_app_owner_org IS NOT NULL THEN
IF v_effective_org_id IS NOT NULL AND v_effective_org_id IS DISTINCT FROM v_app_owner_org THEN
PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APP_ORG_MISMATCH', jsonb_build_object(
'has_org_id', v_effective_org_id IS NOT NULL,
'has_app_owner_org', v_app_owner_org IS NOT NULL,
'has_app_id', app_id IS NOT NULL,
'has_channel_id', channel_id IS NOT NULL,
'min_right', min_right::text,
'has_user_id', user_id IS NOT NULL,
'org_matches_app_owner', v_effective_org_id IS NOT DISTINCT FROM v_app_owner_org
));
RETURN false;
END IF;

v_effective_org_id := v_app_owner_org;
END IF;
END IF;

-- Derive org from channel when not provided to honor org-level flag and scoping.
IF v_effective_org_id IS NULL AND channel_id IS NOT NULL THEN
SELECT owner_org INTO v_effective_org_id
FROM public.channels
WHERE public.channels.id = channel_id
LIMIT 1;
END IF;

SELECT public.get_apikey_header() INTO v_apikey;
v_request_role := public.current_request_role();

IF v_request_role IN ('anon', 'authenticated') THEN
IF user_id IS NULL THEN
IF v_apikey IS NULL THEN
PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_UNBOUND_PUBLIC_SUBJECT', jsonb_build_object(
'has_org_id', COALESCE(org_id, v_effective_org_id) IS NOT NULL,
'has_app_id', app_id IS NOT NULL,
'has_channel_id', channel_id IS NOT NULL,
'min_right', min_right::text,
'request_role', v_request_role
));
RETURN false;
END IF;
ELSIF user_id IS DISTINCT FROM auth.uid() THEN
IF v_apikey IS NULL THEN
PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PUBLIC_SUBJECT_MISMATCH', jsonb_build_object(
'has_org_id', COALESCE(org_id, v_effective_org_id) IS NOT NULL,
'has_app_id', app_id IS NOT NULL,
'has_channel_id', channel_id IS NOT NULL,
'min_right', min_right::text,
'has_user_id', user_id IS NOT NULL,
'has_auth_uid', auth.uid() IS NOT NULL,
'subject_matches_auth', user_id IS NOT DISTINCT FROM auth.uid(),
'request_role', v_request_role
));
RETURN false;
END IF;

SELECT found_key.user_id INTO v_apikey_user_id
FROM public.find_apikey_by_value(v_apikey) AS found_key
LIMIT 1;

IF v_apikey_user_id IS NULL OR v_apikey_user_id IS DISTINCT FROM user_id THEN
PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_APIKEY_SUBJECT_MISMATCH', jsonb_build_object(
'has_org_id', COALESCE(org_id, v_effective_org_id) IS NOT NULL,
'has_app_id', app_id IS NOT NULL,
'has_channel_id', channel_id IS NOT NULL,
'min_right', min_right::text,
'has_user_id', user_id IS NOT NULL,
'has_auth_uid', auth.uid() IS NOT NULL,
'request_role', v_request_role,
'has_apikey_subject', v_apikey_user_id IS NOT NULL,
'subject_matches_auth', user_id IS NOT DISTINCT FROM auth.uid(),
'subject_matches_apikey', user_id IS NOT DISTINCT FROM v_apikey_user_id
));
RETURN false;
END IF;
END IF;
END IF;

-- RBAC-managed API keys have apikeys.mode = NULL, so get_identity_org_appid()
-- returns NULL and rbac_check_permission_direct() must resolve the key before
-- org identity gates can be evaluated.
IF v_effective_org_id IS NOT NULL AND NOT (v_apikey IS NOT NULL AND user_id IS NULL) THEN
SELECT enforcing_2fa INTO v_org_enforcing_2fa
FROM public.orgs
WHERE id = v_effective_org_id;

IF v_org_enforcing_2fa = true AND (user_id IS NULL OR NOT public.has_2fa_enabled(user_id)) THEN
PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_2FA_ENFORCEMENT', jsonb_build_object(
'has_org_id', COALESCE(org_id, v_effective_org_id) IS NOT NULL,
'has_app_id', app_id IS NOT NULL,
'has_channel_id', channel_id IS NOT NULL,
'min_right', min_right::text,
'has_user_id', user_id IS NOT NULL
));
RETURN false;
END IF;

v_password_policy_ok := public.user_meets_password_policy(user_id, v_effective_org_id);
IF v_password_policy_ok = false THEN
PERFORM public.pg_log('deny: CHECK_MIN_RIGHTS_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object(
'has_org_id', COALESCE(org_id, v_effective_org_id) IS NOT NULL,
'has_app_id', app_id IS NOT NULL,
'has_channel_id', channel_id IS NOT NULL,
'min_right', min_right::text,
'has_user_id', user_id IS NOT NULL
));
RETURN false;
END IF;
END IF;

v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id);
IF NOT v_use_rbac THEN
RETURN public.check_min_rights_legacy(min_right, user_id, COALESCE(org_id, v_effective_org_id), app_id, channel_id);
END IF;

IF channel_id IS NOT NULL THEN
v_scope := public.rbac_scope_channel();
ELSIF app_id IS NOT NULL THEN
v_scope := public.rbac_scope_app();
ELSE
v_scope := public.rbac_scope_org();
END IF;

v_perm := public.rbac_permission_for_legacy(min_right, v_scope);

-- Keep RLS authorization semantics aligned with explicit RBAC checks. In
-- particular, an API key with direct role bindings must be evaluated as the
-- API-key principal and must not inherit broader owner-user permissions.
RETURN public.rbac_check_permission_direct(
v_perm,
user_id,
v_effective_org_id,
app_id,
channel_id,
v_apikey
);
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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Please attach the required execution-model and plan evidence for this function.

This helper now adds multiple lookups (apps, channels, orgs) plus several security checks on an authorization path, but the PR notes only include the pgTAP plan count. Before merge, please add the execution model and worst-case EXPLAIN (ANALYZE, BUFFERS) for org/app/channel invocations.

As 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.

🤖 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/20260511024500_bind_permission_oracle_subjects.sql`
around lines 3 - 169, Add the required execution-model and worst-case plan
evidence for the check_min_rights function: describe where it runs, expected
invocation frequency, effective roles (who calls it), cardinality assumptions,
and which indexes support lookups on public.apps.owner_org, public.channels.id
and public.orgs.id; then attach EXPLAIN (ANALYZE, BUFFERS) output for
representative worst-case executions (org-level, app-level, channel-level)
including calls that exercise app lookup, channel lookup and org
2FA/password-policy checks and the rbac paths (rbac_is_enabled_for_org,
rbac_check_permission_direct), and include notes about which inputs (user_id
NULL vs non-NULL, apikey present) produce the worst cost and why.

Comment thread supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql Outdated
Comment thread supabase/migrations/20260511024500_bind_permission_oracle_subjects.sql Outdated
@hieu-lee

This comment was marked as abuse.

@hieu-lee

This comment was marked as abuse.

@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants