Skip to content

[codex] fix GHSA-54mj-q77q-xwx5 RBAC demotion#2018

Open
riderx wants to merge 3 commits intomainfrom
codex/fix-ghsa-54mj-q77q-xwx5
Open

[codex] fix GHSA-54mj-q77q-xwx5 RBAC demotion#2018
riderx wants to merge 3 commits intomainfrom
codex/fix-ghsa-54mj-q77q-xwx5

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented May 2, 2026

Summary (AI generated)

  • Block PATCH role binding updates when the existing bound role outranks the caller.
  • Add a database trigger that prevents demoting the last org super_admin binding.
  • Add regression coverage for the endpoint guard, database guard, and function execute hardening.

Motivation (AI generated)

GHSA-54mj-q77q-xwx5 reported that an org_admin could demote an org_super_admin because PATCH only checked the new role rank, and the database only protected last-super-admin deletion.

Business Impact (AI generated)

This preserves org ownership integrity and prevents admins from locking every super admin out of billing, destructive app management, and other super-admin-only workflows.

Test Plan (AI generated)

  • bun lint
  • bun lint:backend
  • bunx eslint tests/private-role-bindings.test.ts tests/security-definer-execute-hardening.test.ts
  • bun run supabase:with-env -- bunx vitest run tests/private-role-bindings.test.ts tests/security-definer-execute-hardening.test.ts
  • bun typecheck

Generated with AI

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened access checks when updating role bindings to block unauthorized privilege changes.
    • Prevented removal/demotion of the last organization super_admin to avoid locking out org administrators.
  • Tests

    • Added tests covering role-binding update protections and the new safeguard against demoting the final org super_admin.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

Warning

Rate limit exceeded

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

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2a65b55-9386-4382-b018-0d069120a331

📥 Commits

Reviewing files that changed from the base of the PR and between 55546ac and 8563e6d.

📒 Files selected for processing (1)
  • supabase/functions/_backend/private/role_bindings.ts
📝 Walkthrough

Walkthrough

Adds a DB trigger to prevent updates that would demote the last organization org_super_admin binding, updates the PATCH /private/role_bindings/:binding_id handler to perform the privilege-check inside a parameterized UPDATE (returning 403 if no row is changed), extends error handling to map the DB error to HTTP 409, and updates tests/security-definer coverage.

Changes

Last Super Admin Demotion Prevention

Layer / File(s) Summary
Database Trigger / Guard
supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql
Adds public.prevent_last_super_admin_binding_update() (SECURITY DEFINER) and a BEFORE UPDATE OF role_id trigger on public.role_bindings that locks on org_id, counts remaining org super_admin bindings, and raises CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING if the update would remove the last one.
Core API Update
supabase/functions/_backend/private/role_bindings.ts
PATCH handler now performs a parameterized UPDATE ... FROM roles that joins to roles and constrains roles.priority_rank <= callerMaxRank; if no row is returned, responds 403 with an escalation error. Error handling maps CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING to HTTP 409.
Security Hardening Test
tests/security-definer-execute-hardening.test.ts
Adds public.prevent_last_super_admin_binding_update() to SERVICE_ONLY_PROCS to assert it cannot be directly executed by anon/auth callers.
Integration / DB Tests
tests/private-role-bindings.test.ts
Adds skipped integration tests for PATCH demotion rejection (403) and last-super-admin demotion (409), plus a DB-layer test asserting direct SQL UPDATE rejects with CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING.

Sequence Diagram

sequenceDiagram
    actor Client
    participant API as API Handler\n(role_bindings.ts)
    participant DB as PostgreSQL\nDatabase

    Client->>API: PATCH /private/role_bindings/:id (demote role)
    API->>DB: compute callerMaxRank
    DB-->>API: callerMaxRank
    API->>DB: UPDATE role_bindings ... FROM roles WHERE roles.priority_rank <= callerMaxRank RETURNING *
    DB->>DB: Trigger: prevent_last_super_admin_binding_update BEFORE UPDATE OF role_id
    DB->>DB: Acquire advisory lock(org_id) and count remaining org_super_admin bindings
    alt Trigger raises CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING
        DB-->>API: Error
        API-->>Client: 409 Conflict (Cannot demote the last org_super_admin)
    else UPDATE prevented by JOIN/WHERE (no row returned)
        DB-->>API: no rows returned
        API-->>Client: 403 Forbidden (higher-privileges)
    else Update allowed
        DB-->>API: Success
        API-->>Client: 200 OK
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I hopped past roles with careful paws,
Guarding last admins with database laws.
A trigger, a lock, a careful refrain—
One super admin must always remain.
Hooray for safe bindings and peaceful applause!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the security fix addressing GHSA-54mj-q77q-xwx5 RBAC demotion vulnerability, directly reflecting the main purpose of the changeset.
Description check ✅ Passed The description includes a comprehensive summary, motivation, business impact, and detailed test plan with specific commands executed, covering all critical aspects. While it deviates from the exact template structure by using AI-generated sections instead of the template headers, the required information is present and well-organized.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-ghsa-54mj-q77q-xwx5

Review rate limit: 0/5 reviews remaining, refill in 48 seconds.

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 2, 2026

Merging this PR will not alter performance

✅ 28 untouched benchmarks


Comparing codex/fix-ghsa-54mj-q77q-xwx5 (8563e6d) with main (17d36c6)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (97ee8f0) during the generation of this report, so 17d36c6 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@riderx riderx marked this pull request as ready for review May 2, 2026 14:11
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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
supabase/functions/_backend/private/role_bindings.ts (2)

660-689: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Translate the last-super-admin trigger failure instead of returning 500.

After this migration lands, a super_admin demoting the final org_super_admin will hit CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING on Line 660. The generic catch on Lines 682-689 turns that expected guard into Internal server error, which makes an authorization/business-rule rejection look like backend failure.

Suggested handling
-    catch (error) {
+    catch (error: any) {
+      if (error?.message?.includes('CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING')) {
+        return c.json({ error: 'At least one super_admin binding must remain in the org' }, 409)
+      }
+
       cloudlogErr({
         requestId: c.get('requestId'),
         message: 'role_binding_update_failed',
         bindingId,
         error,
       })
       return c.json({ error: 'Internal server error' }, 500)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/private/role_bindings.ts` around lines 660 - 689,
The catch block after the update in the role binding handler currently logs and
returns a generic 500, which hides the specific business-rule error
CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING; update the catch in the function that
calls drizzle.update(schema.role_bindings) (identify by bindingId, updated,
cloudlog and cloudlogErr usage) to detect when error.code ===
'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING' (or error.message contains that
constant) and respond with a translated client error (e.g., c.json({ error:
'Cannot demote the last org_super_admin' }, 400 or 403) while still logging via
cloudlogErr; for all other errors preserve the existing 500 logging/response
behavior.

611-664: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the current-role check atomic with the update.

loadManagedBinding() snapshots binding.role_id, then Lines 650-657 authorize against that stale value before Lines 660-664 update by bindingId only. A concurrent transaction can promote the binding after the read and before the write, and this request will still overwrite it. That reopens the demotion path as a TOCTOU authorization bug.

Please fold the read + authorization + write into a single transaction with a row lock, or encode the current-role-rank predicate directly into the UPDATE/CTE and require exactly one row to change.

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

In `@supabase/functions/_backend/private/role_bindings.ts` around lines 611 - 664,
The current check uses a stale snapshot from loadManagedBinding then updates by
bindingId, allowing a TOCTOU race; make the check and update atomic by either
wrapping the read+authorize+write in a DB transaction with a row lock (SELECT
... FOR UPDATE on role_bindings joined to roles to read current
roles.priority_rank, call getCallerMaxPriorityRank, enforce priority checks,
then update and commit) or by encoding the authorization predicate into the
UPDATE so it only succeeds when the existing role's priority_rank is <=
callerMaxRank (e.g. UPDATE role_bindings rb SET role_id = :newRoleId FROM roles
r WHERE rb.id = :bindingId AND rb.role_id = r.id AND r.priority_rank <=
:callerMaxRank RETURNING ... and verify one row was returned); adjust the code
path around loadManagedBinding, getCallerMaxPriorityRank, and the drizzle
.update(...) so you verify the atomic operation affected exactly one row and
return a 403 if it did not.
🧹 Nitpick comments (1)
tests/private-role-bindings.test.ts (1)

12-12: ⚡ Quick win

Drop the extra seeded super_admin from this endpoint regression.

This test fails on the existing-role rank check before any UPDATE happens, so the ADMIN_USER_ID row never contributes to the behavior being asserted. Keeping it adds an unnecessary dependency on a hardcoded seeded user outside test-utils, which makes the test more brittle across DB resets.

Possible simplification
-const ADMIN_USER_ID = 'c591b04e-cf29-4945-b9a0-776d0672061a'
@@
       const { error: membersError } = await supabase.from('org_users').insert([
         { org_id: orgId, user_id: USER_ID, user_right: 'admin' },
-        { org_id: orgId, user_id: ADMIN_USER_ID, user_right: 'super_admin' },
       ])

As per coding guidelines, "create dedicated seed data when tests modify resources or when resource state matters for assertions."

Also applies to: 241-244

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

In `@tests/private-role-bindings.test.ts` at line 12, Remove the hardcoded
ADMIN_USER_ID constant and any uses of that seeded super_admin row in the
failing test (ADMIN_USER_ID) because the existing-role rank check runs before
the UPDATE so that row is irrelevant; instead create and use a dedicated test
user seeded via the test utilities (e.g., call the project’s test-utils helper
like createTestUser/seedTestUser or the existing test fixture) within the test
setup so the test only depends on locally-seeded data, and update assertions
that referenced ADMIN_USER_ID accordingly (also remove the duplicate seeded
super_admin references around the other occurrences noted).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@supabase/functions/_backend/private/role_bindings.ts`:
- Around line 660-689: The catch block after the update in the role binding
handler currently logs and returns a generic 500, which hides the specific
business-rule error CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING; update the catch in
the function that calls drizzle.update(schema.role_bindings) (identify by
bindingId, updated, cloudlog and cloudlogErr usage) to detect when error.code
=== 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING' (or error.message contains that
constant) and respond with a translated client error (e.g., c.json({ error:
'Cannot demote the last org_super_admin' }, 400 or 403) while still logging via
cloudlogErr; for all other errors preserve the existing 500 logging/response
behavior.
- Around line 611-664: The current check uses a stale snapshot from
loadManagedBinding then updates by bindingId, allowing a TOCTOU race; make the
check and update atomic by either wrapping the read+authorize+write in a DB
transaction with a row lock (SELECT ... FOR UPDATE on role_bindings joined to
roles to read current roles.priority_rank, call getCallerMaxPriorityRank,
enforce priority checks, then update and commit) or by encoding the
authorization predicate into the UPDATE so it only succeeds when the existing
role's priority_rank is <= callerMaxRank (e.g. UPDATE role_bindings rb SET
role_id = :newRoleId FROM roles r WHERE rb.id = :bindingId AND rb.role_id = r.id
AND r.priority_rank <= :callerMaxRank RETURNING ... and verify one row was
returned); adjust the code path around loadManagedBinding,
getCallerMaxPriorityRank, and the drizzle .update(...) so you verify the atomic
operation affected exactly one row and return a 403 if it did not.

---

Nitpick comments:
In `@tests/private-role-bindings.test.ts`:
- Line 12: Remove the hardcoded ADMIN_USER_ID constant and any uses of that
seeded super_admin row in the failing test (ADMIN_USER_ID) because the
existing-role rank check runs before the UPDATE so that row is irrelevant;
instead create and use a dedicated test user seeded via the test utilities
(e.g., call the project’s test-utils helper like createTestUser/seedTestUser or
the existing test fixture) within the test setup so the test only depends on
locally-seeded data, and update assertions that referenced ADMIN_USER_ID
accordingly (also remove the duplicate seeded super_admin references around the
other occurrences noted).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c864e291-6a83-411a-8f77-abb71a78c766

📥 Commits

Reviewing files that changed from the base of the PR and between 97ee8f0 and 71f376d.

📒 Files selected for processing (4)
  • supabase/functions/_backend/private/role_bindings.ts
  • supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql
  • tests/private-role-bindings.test.ts
  • tests/security-definer-execute-hardening.test.ts

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@riderx
Copy link
Copy Markdown
Member Author

riderx commented May 2, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 2, 2026

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.

1 participant