Skip to content

fix(rbac): enforce API key expiration policy in permission checks#2130

Open
teixr12 wants to merge 6 commits into
Cap-go:mainfrom
teixr12:bounty/rbac-apikey-expiration-policy
Open

fix(rbac): enforce API key expiration policy in permission checks#2130
teixr12 wants to merge 6 commits into
Cap-go:mainfrom
teixr12:bounty/rbac-apikey-expiration-policy

Conversation

@teixr12
Copy link
Copy Markdown

@teixr12 teixr12 commented May 11, 2026

Summary

Fixes an authorization gap where an API key created without expires_at could keep passing RBAC direct permission checks after its org later enables require_apikey_expiration.

Root Cause

The API-key create/update trigger enforces expiration policy for new writes, but rbac_check_permission_direct() and rbac_check_permission_direct_no_password_policy() only checked whether a key was expired. They did not reject existing non-expiring keys when the target org starts requiring expiration.

Fix

  • Adds a runtime org-policy check in both RBAC direct permission functions after API-key identity/org resolution and before scope/permission dispatch.
  • Denies non-expiring keys for orgs with require_apikey_expiration = true.
  • Logs RBAC_CHECK_PERM_APIKEY_EXPIRATION_REQUIRED using key id/org/app/channel metadata only, without logging secret key material.
  • Adds regression coverage for an existing scoped non-expiring key that is allowed before the policy flip and denied by both direct permission functions after the flip.

Test plan

  • git diff --check
  • bash scripts/check-supabase-migration-order.sh
  • Regression coverage added in tests/rbac-permissions.test.ts.
  • Attempted targeted local Vitest with bun run supabase:with-env -- bunx vitest run tests/rbac-permissions.test.ts, but this checkout does not have bun or installed project dependencies. GitHub CI is expected to run the backend suite for the pushed branch.

Screenshots

N/A. This is a backend SQL authorization change with regression test coverage and no frontend/CLI UI changes.

Checklist

  • My code follows the code style of this project and passes bun run lint:backend && bun run lint.
  • 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.

Refs #1667
/claim #1667

Summary by CodeRabbit

  • New Features

    • Orgs can enforce API-key expiration windows: expired keys, keys lacking required expirations, or keys whose expiration exceeds org limits are denied. Org scoping and 2FA requirements are enforced before granting permissions. Webhook endpoints now return 401 with an explicit "expiration_exceeds_max" error when a key’s expiry exceeds org limits.
  • Tests

    • Added tests covering API-key expiration enforcement and webhook rejection behavior when org expiry enforcement is toggled.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Warning

Rate limit exceeded

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

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ 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: 273779fb-94dd-430e-ba67-48a729203805

📥 Commits

Reviewing files that changed from the base of the PR and between 2c3dc12 and 7f7523f.

📒 Files selected for processing (2)
  • supabase/functions/_backend/utils/supabase.ts
  • supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql
📝 Walkthrough

Walkthrough

Adds two DB functions enforcing API-key expiration, max-expiration-window, org/app scoping, and 2FA before RBAC or legacy permission checks; updates backend util and webhook handling for max-expiration errors; tests validate the org toggle and overlong-key rejection.

Changes

API-Key Expiration and Scoping Enforcement

Layer / File(s) Summary
Main RBAC permission-check function
supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql
public.rbac_check_permission_direct enforces API-key lookup, expiration (including org max window), scoping (limited_to_orgs, limited_to_apps), user match, 2FA, and password-policy before RBAC evaluation or legacy fallback; sets ownership and grants.
No-password-policy variant
supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql
public.rbac_check_permission_direct_no_password_policy applies the same API-key/org checks but omits password-policy enforcement and uses legacy no-password helper when RBAC is disabled; sets ownership and grants.
Backend util change
supabase/functions/_backend/utils/supabase.ts
checkApikeyMeetsOrgPolicy adds max_apikey_expiration_days fetch and rejects keys that exceed the allowed window; apikeyHasOrgRightWithPolicy uses supabaseAdmin(c) for the org-policy check and renames the client parameter to _supabase.
Webhook handler change
supabase/functions/_backend/public/webhooks/index.ts
assertWebhookOrgPolicy maps expiration_exceeds_max to a 401 expiration_exceeds_max quickError.
RBAC test
tests/rbac-permissions.test.ts
New test toggles require_apikey_expiration and asserts app.read allowed before and denied after for a scoped API key (checks both function variants).
Webhook policy tests
tests/webhooks-apikey-policy.test.ts
Seeds an overlong API key, tests webhook listing returns 401 expiration_exceeds_max, and cleans up the seeded key.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Cap-go/capgo#2061: Both PRs modify/use the same DB authorization path—specifically touching public.rbac_check_permission_direct (and related RBAC/apikey checks used by check_min_rights/exist_app_versions)—so they are related.
  • Cap-go/capgo#1951: Both PRs modify the same RBAC permission-checking functions to enforce API-key scoping and authorization checks.
  • Cap-go/capgo#2060: Adds RPCs/triggers that depend on public.rbac_check_permission_direct and related apikey-expiration behavior introduced here.

Suggested labels

codex

Poem

🐰 I sniff the keys beneath the moonlit sky,
I count their days and watch expiry fly,
RBAC gates and org rules I mind,
Deny the long-lived keys I find,
A hopping guard to keep your auths in line.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: enforcing API key expiration policy in RBAC permission checks, which directly addresses the authorization gap fixed by this PR.
Description check ✅ Passed The description provides a comprehensive summary, root cause analysis, technical fix details, test plan with specific commands, and explains why screenshots are not applicable. However, the checklist items remain unchecked, which is expected for an open PR pending approval.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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

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 teixr12:bounty/rbac-apikey-expiration-policy (7f7523f) with main (a4f7c93)

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

🤖 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/20260511013000_enforce_rbac_apikey_expiration_policy.sql`:
- Around line 516-579: The _no_password_policy variant diverges from
rbac_check_permission_direct by not applying channel_permission_overrides,
allowing explicit channel denies to be bypassed; update the logic around
rbac_has_permission calls (both the apikey branch using
public.rbac_principal_apikey() and the user branch using
public.rbac_principal_user()) to run the same channel_permission_overrides
resolution as rbac_check_permission_direct does (i.e. after computing v_allowed
from rbac_has_permission, also consult/apply
channel_permission_overrides/group-based overrides for p_channel_id), and ensure
the logging paths (pg_log calls) reflect the override result; mirror the
override application that exists in rbac_check_permission_direct so channel.*
checks behave identically in the _no_password_policy path.

In `@tests/rbac-permissions.test.ts`:
- Around line 750-756: This test mutates the shared ORG_ID fixture via a direct
UPDATE; instead create a test-specific org/app pair and use that id instead of
ORG_ID: add a seeded org (name prefixed with the test file or feature, e.g.,
"rbac-permissions.test...") and associated app via the existing test helper or
DB insert helper, capture the returned org_id/app_id, then run the UPDATE query
against that new org_id (replace references to ORG_ID and the UPDATE block
around the query call), and ensure the rest of the test (including the similar
block at lines 797-801) uses the new ids so shared fixtures are never mutated
during parallel runs.
🪄 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: 8f922600-9ea1-4c0e-9658-8aa7845dce8d

📥 Commits

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

📒 Files selected for processing (2)
  • supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql
  • tests/rbac-permissions.test.ts

Comment thread tests/rbac-permissions.test.ts Outdated
@teixr12 teixr12 force-pushed the bounty/rbac-apikey-expiration-policy branch 2 times, most recently from 0e404ee to 5b984bb Compare May 11, 2026 01:35
@teixr12 teixr12 force-pushed the bounty/rbac-apikey-expiration-policy branch from 5b984bb to c0ac762 Compare May 11, 2026 01:42
@teixr12
Copy link
Copy Markdown
Author

teixr12 commented May 11, 2026

Follow-up after the latest push:

  • Mirrored channel override handling in rbac_check_permission_direct_no_password_policy(), including user/group deny precedence and API-key override checks.
  • Updated the regression coverage to use dedicated org/app data with app-scoped role bindings instead of mutating shared fixtures.
  • Preserved the existing webhook API-key policy contract by using a service-role policy lookup after local API-key org-scope validation, so non-expiring keys still return the explicit org_requires_expiring_key 401 response.

CI is green on the latest commit: backend tests, Playwright, benchmarks, dead-code, Socket, Sonar, and CodeRabbit all passed.

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.

I think the runtime policy check still only covers the require_apikey_expiration half of the org API-key expiration policy.

The migration reads only orgs.require_apikey_expiration inside both direct RBAC functions and rejects only expires_at IS NULL. But the existing create/update trigger also enforces orgs.max_apikey_expiration_days: if an org later sets or lowers that max, an already-issued key with expires_at far beyond the new max will keep passing rbac_check_permission_direct() until its old expiration date.

That leaves the same retroactive-policy gap this PR is closing for non-expiring keys, just for long-lived expiring keys. I would load max_apikey_expiration_days alongside require_apikey_expiration and deny when v_api_key.expires_at > now() + make_interval(days => max_apikey_expiration_days). A regression can mirror the new policy-flip test: create a scoped key expiring well beyond the future max, flip max_apikey_expiration_days to a shorter value, and assert both direct RBAC functions deny it.

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.

🧹 Nitpick comments (1)
supabase/functions/_backend/utils/supabase.ts (1)

314-314: 💤 Low value

Clarify why the unused _supabase parameter is retained.

The parameter is now ignored (indicated by the _ prefix) since the function always uses supabaseAdmin(c) for the policy lookup. If this is kept for backward compatibility with existing callers, consider adding a JSDoc comment explaining that the parameter is deprecated and ignored. If backward compatibility is not required, consider removing it in a follow-up to simplify the API.

🤖 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/functions/_backend/utils/supabase.ts` at line 314, The parameter
`_supabase: SupabaseClient<Database>` is unused because the function always uses
supabaseAdmin(c) for policy lookup; either remove the parameter in a follow-up
to simplify the API or add a concise JSDoc on the parameter (and function)
stating it is deprecated/ignored and kept only for backward compatibility so
callers understand it has no effect; update the function signature comment to
reference `_supabase` and supabaseAdmin(c) to make the intent explicit.
🤖 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.

Nitpick comments:
In `@supabase/functions/_backend/utils/supabase.ts`:
- Line 314: The parameter `_supabase: SupabaseClient<Database>` is unused
because the function always uses supabaseAdmin(c) for policy lookup; either
remove the parameter in a follow-up to simplify the API or add a concise JSDoc
on the parameter (and function) stating it is deprecated/ignored and kept only
for backward compatibility so callers understand it has no effect; update the
function signature comment to reference `_supabase` and supabaseAdmin(c) to make
the intent explicit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d3c1e04-54e1-45da-b100-bcfbb696aff7

📥 Commits

Reviewing files that changed from the base of the PR and between fa2c72e and 9af6165.

📒 Files selected for processing (3)
  • supabase/functions/_backend/utils/supabase.ts
  • supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql
  • tests/rbac-permissions.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/rbac-permissions.test.ts
  • supabase/migrations/20260511013000_enforce_rbac_apikey_expiration_policy.sql

Copy link
Copy Markdown

@mingisrookie mingisrookie left a comment

Choose a reason for hiding this comment

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

One remaining policy surface looks missed: the TS org-policy helper still only enforces require_apikey_expiration, not the max-expiration half that this PR now adds to the SQL RBAC checks.

Evidence: apikeyHasOrgRightWithPolicy() now deliberately calls checkApikeyMeetsOrgPolicy() via supabaseAdmin(c), but that helper still selects only require_apikey_expiration and returns valid once the key has any expires_at. Callers such as the webhook permission path (assertWebhookOrgPolicy() before hasOrgRightApikey()/check_min_rights) rely on this helper as the API-key org-policy gate, so an org-scoped key whose expires_at is beyond a newly lowered max_apikey_expiration_days can still pass those non-rbac_check_permission_direct() surfaces.

Could we load max_apikey_expiration_days in checkApikeyMeetsOrgPolicy() and mirror the same expires_at > now + max denial used in the migration? A small regression through one helper caller (webhooks or org audit) would cover the gap that the new rbac-permissions tests do not exercise.

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

🤖 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/functions/_backend/utils/supabase.ts`:
- Around line 1715-1720: The max-expiration check currently compares
key.expires_at against a max date anchored to new Date(), which allows overly
long keys to become valid later; change the anchor to the key issuance time by
using key.created_at (e.g., parse key.created_at into a Date, add
org.max_apikey_expiration_days to that Date to compute maxDate) and then compare
new Date(key.expires_at) > maxDate, returning { valid: false, error:
'expiration_exceeds_max' } when it exceeds; ensure you handle missing/invalid
key.created_at the same way you handle other date fields.
🪄 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: ec830fe5-b332-4b7c-90ea-78ca23e43a47

📥 Commits

Reviewing files that changed from the base of the PR and between 9af6165 and 2c3dc12.

📒 Files selected for processing (3)
  • supabase/functions/_backend/public/webhooks/index.ts
  • supabase/functions/_backend/utils/supabase.ts
  • tests/webhooks-apikey-policy.test.ts

Comment thread supabase/functions/_backend/utils/supabase.ts
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@KCDaemon KCDaemon left a comment

Choose a reason for hiding this comment

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

Rechecked the latest head (7f7523f) against the policy gaps raised earlier. The current patch now enforces both halves of the org API-key expiration policy at runtime: non-expiring keys are denied when required, and overlong keys are compared against the key's created_at + max_apikey_expiration_days window in both the SQL RBAC functions and the TS org-policy helper.

I also checked the no-password direct path and the new regressions: the channel deny override case is covered, the overlong-key RBAC flip test covers both direct functions, and webhook listing now returns expiration_exceeds_max through the TS policy helper. GitHub checks are green, merge state is clean, and git diff --check origin/main...origin/pr-2130 passes locally. No remaining blocker from the issues I reviewed.

@austin33133-maker
Copy link
Copy Markdown

The runtime denial for max_apikey_expiration_days looks great overall, but I want to flag two consistency points between the SQL and TS implementations that I think could lead to the same key being allowed by one layer and denied by the other.

1. max_apikey_expiration_days = 0 is treated differently by SQL vs TS

In supabase/migrations/20260511203500_enforce_rbac_apikey_expiration_policy.sql the SQL guard fires whenever v_org_max_apikey_expiration_days IS NOT NULL:

sql IF v_org_max_apikey_expiration_days IS NOT NULL AND v_api_key.expires_at IS NOT NULL AND ( v_api_key.created_at IS NULL OR v_api_key.expires_at > v_api_key.created_at + make_interval(days => v_org_max_apikey_expiration_days) ) THEN ... deny

So if an admin sets max_apikey_expiration_days = 0, SQL ends up denying every key whose expires_at > created_at -- i.e. all expiring keys.

The TS counterpart in supabase/functions/_backend/utils/supabase.ts does the opposite:

ts if (org.max_apikey_expiration_days && key.expires_at) { // ... compute and compare }

0 && anything is 0 (falsy), so the route-level check silently skips when max is 0. Net effect: the same key is accepted at the HTTP route boundary and then rejected at the RLS layer (or vice-versa, depending on the call path), producing inconsistent 200/401 responses for the same caller.

Two ways to reconcile:

  • If 0 is meant to be "no max" / disabled, change the SQL to v_org_max_apikey_expiration_days IS NOT NULL AND v_org_max_apikey_expiration_days > 0 and add a CHECK constraint on the column.
  • If 0 is meant to be "deny everything with an expiration", change the TS to org.max_apikey_expiration_days != null && key.expires_at and document the semantic.

Either way worth a regression test that sets max_apikey_expiration_days = 0 and asserts both layers agree on the verdict.

2. Date arithmetic uses local-timezone JS on the TS side, UTC interval on the SQL side

ts const maxDate = new Date(createdAt) maxDate.setDate(maxDate.getDate() + org.max_apikey_expiration_days)

Date#getDate() / setDate() operate in the runtime's local timezone. PostgreSQL's make_interval(days => N) + timestamptz is timezone-stable. For most cases these agree, but two pathological cases:

  • Server in a non-UTC TZ across a DST transition can shift maxDate by 1 hour, which matters for keys whose expires_at sits within an hour of the boundary.
  • created_at strings parsed at the very end of a UTC day can flip to the next/previous local day before the +N days math, off-by-one-ing the verdict.

Supabase Edge Functions normally run in UTC so this is largely theoretical today, but the safer formulation is timezone-agnostic:

ts const maxDate = new Date(createdAt.getTime() + org.max_apikey_expiration_days * 86_400_000)

...and add a unit test that pins the system timezone to something non-UTC (e.g. TZ=America/New_York) for checkApikeyMeetsOrgPolicy.

3. Minor: created_at IS NULL mapped to expiration_exceeds_max

If key.created_at is null or unparseable, the TS returns error: 'expiration_exceeds_max'. That code's message ("API key expiration exceeds this organization's maximum allowed validity window") will mislead the consumer. A dedicated invalid_key_timestamps code (or reusing org_requires_expiring_key since these are inherently unverifiable keys) would be friendlier. SQL emits the same code in pg_log so the log message has the same drift.

Happy to send a tiny follow-up PR with the test cases if (1) reaches consensus.

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.

5 participants