Skip to content

Fix RPC auth guards and owner_org fallback#1752

Open
riderx wants to merge 6 commits intomainfrom
riderx/fix-unauth-rpc
Open

Fix RPC auth guards and owner_org fallback#1752
riderx wants to merge 6 commits intomainfrom
riderx/fix-unauth-rpc

Conversation

@riderx
Copy link
Member

@riderx riderx commented Mar 8, 2026

Summary (AI generated)

Secured all sensitive plan/usage RPC paths with org-aware rights checks plus service-role compatible bypass logic, fixed cycle-end boundary handling for metrics windows, and removed the old malformed migration filename in favor of a CLI-style migration name.
Migrating fallback handling was updated so on_version_update and cron_clear_versions resolve owner_org from deleted apps when missing before upserting app_versions_meta.
Cleanup rights were tightened by revoking delete_old_deleted_versions from public roles and granting it only to service_role.
Tests now reuse normalized Supabase constants from test-utils.ts, cover anon/authenticated/service role/api-key access for RPCs, and isolate the mutating cleanup RPC with dedicated fixture data.

Test plan (AI generated)

  • Ran bun lint, bun lint:backend, and bunx eslint tests/plan-rpc-auth.test.ts and confirmed they passed after fixes.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added metrics retrieval functions for app and global performance tracking with date-range filtering.
    • Added plan usage calculation functions to monitor quota consumption.
    • Added plan and billing cycle information lookups per organization.
  • Bug Fixes

    • Improved organization owner resolution with fallback logic for data consistency.
  • Tests

    • Added comprehensive authentication tests for sensitive API operations.
    • Added integration tests for owner organization data recovery.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces comprehensive org-scoped authentication checks for metrics and plan-related RPC endpoints through a database migration, adds owner_org fallback resolution logic in backend triggers, and provides extensive test coverage validating authentication enforcement across anonymous, authenticated, API key, and service role callers.

Changes

Cohort / File(s) Summary
RPC Authentication & Authorization
supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql
Implements org-scoped auth checks and JWT-based role detection for metrics and plan RPCs. Adds 9 new public functions (get_app_metrics, get_global_metrics, get_current_plan_max_org, get_current_plan_name_org, get_cycle_info_org, get_plan_usage_percent_detailed with overloads) with SECURITY DEFINER and rights validation. Includes privilege adjustments for delete_old_deleted_versions(). Functions support service_role bypass, identity/org scope checks, and cache-based metrics retrieval.
Backend Trigger Functions
supabase/functions/_backend/triggers/cron_clear_versions.ts, supabase/functions/_backend/triggers/on_version_update.ts
Adds resolveOwnerOrg() helper to both triggers that queries apps and deleted_apps tables as fallback when owner_org is missing, enabling app version metadata to retain org context even when source records lack it.
RPC Authentication Tests
tests/plan-rpc-auth.test.ts
New comprehensive test suite validating authentication behavior for metrics, plan, and usage RPCs across five caller types (anon, authenticated non-member, authenticated member, API key, service role). Includes parallelized RPC case testing and end-to-end workflow for delete_old_deleted_versions().
Owner Org Fallback Test
tests/on-version-update-owner-org-fallback.test.ts
Integration test verifying trigger correctly resolves owner_org from deleted_apps when app version record has null owner_org.
Test Utilities
tests/test-utils.ts
Exports SUPABASE_BASE_URL and SUPABASE_ANON_KEY constants for use by test modules.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant RPC as RPC Handler
    participant Auth as JWT/Session
    participant DB as Database
    
    alt Service Role
        Client->>RPC: Call RPC with service_role JWT
        RPC->>Auth: Extract role from JWT claims
        Auth-->>RPC: role = service_role
        RPC->>DB: Execute query (bypass rights check)
        DB-->>RPC: Return data
    else Authenticated User
        Client->>RPC: Call RPC with user JWT
        RPC->>Auth: Extract role & session_user
        Auth-->>RPC: role = authenticated
        RPC->>DB: Check user rights via check_min_rights()
        alt User has required rights
            DB-->>RPC: Rights granted
            RPC->>DB: Execute query
            DB-->>RPC: Return data
        else User lacks rights
            DB-->>RPC: NO_RIGHTS error
            RPC-->>Client: Return error
        end
    else Anonymous
        Client->>RPC: Call RPC without auth
        RPC->>Auth: No JWT/session detected
        Auth-->>RPC: role = anon
        RPC-->>Client: Return NO_RIGHTS error
    end
    RPC-->>Client: Return result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 With org-scoped guards now standing tall,
The metrics dance through auth's great hall,
No anon sneaks past our rightful doors,
Yet service roles unlock all stores—
Each version knows its owner now, 🔐

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description provides a detailed AI-generated summary and test plan, but the author-provided content is missing the standard template sections (Summary, Test plan, Screenshots, Checklist). Fill out the description template with clear Summary, Test plan steps, and checklist items to ensure consistency and completeness for code review.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title directly matches the main changes: fixing RPC authentication guards and implementing owner_org fallback logic across the codebase.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/fix-unauth-rpc

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 SQLFluff (4.0.4)
supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql

User Error: No dialect was specified. You must configure a dialect or specify one on the command line using --dialect after the command. Available dialects:
ansi, athena, bigquery, clickhouse, databricks, db2, doris, duckdb, exasol, flink, greenplum, hive, impala, mariadb, materialize, mysql, oracle, postgres, redshift, snowflake, soql, sparksql, sqlite, starrocks, teradata, trino, tsql, vertica


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

Copy link

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

ℹ️ 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".

cycle_start timestamptz;
cycle_end timestamptz;
BEGIN
IF session_user <> 'service_role' AND NOT public.check_min_rights(

Choose a reason for hiding this comment

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

P1 Badge Allow service-role callers in new RPC auth guard

This guard relies on session_user to detect service-role bypass, but these RPCs are invoked from backend code through supabaseAdmin(...) (for example in supabase/functions/_backend/triggers/cron_email.ts and cron_stat_app.ts) where PostgREST auth is conveyed by JWT role claims, not by changing session_user. In that context the bypass does not trigger, get_identity_org_allowed(...) resolves to NULL (no auth.uid()/capgkey), check_min_rights(...) returns false, and the RPC raises NO_RIGHTS, breaking internal cron/statistics flows that call get_cycle_info_org/related functions.

Useful? React with 👍 / 👎.

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

🧹 Nitpick comments (1)
supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql (1)

91-93: Qualify the ordinary pg_catalog calls in these empty-search_path functions.

search_path = '' is set correctly, but ordinary built-ins like NOW(), date_trunc(), and jsonb_to_recordset() are still unqualified in the new bodies. Please prefix those with pg_catalog. for consistency with the repo SQL convention. As per coding guidelines, "Always set an empty search_path = '' in every PostgreSQL function and use fully qualified names for all references".

Also applies to: 112-124, 292-297, 340-355

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

In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql around
lines 91 - 93, Several functions use an empty search_path but call unqualified
built-ins (e.g., NOW(), date_trunc(), jsonb_to_recordset()); update each
occurrence to use pg_catalog-qualified names so they work with search_path = ''.
Find usages in the shown block (the conditional using NOW() and the assignment
calling public.seed_get_app_metrics_caches) and the other mentioned blocks
(lines 112-124, 292-297, 340-355) and replace NOW(), date_trunc(),
jsonb_to_recordset() (and any other ordinary built-ins) with pg_catalog.NOW(),
pg_catalog.date_trunc(), pg_catalog.jsonb_to_recordset() respectively, leaving
calls to public.* functions (like public.seed_get_app_metrics_caches) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql:
- Around line 22-30: The guard that checks for service-role is using
session_user which won't reflect PostgREST JWT role switching; update the
conditional checks to use current_user instead of session_user in all RPC auth
guards (e.g., the IF block that calls public.check_min_rights with
get_app_metrics and related checks), and make the same replacement for the other
occurrences referenced (lines/groups around 62-70, 145-153, 181-189, 219-227,
244-252, 278-286, 330-338, 399-407) so the service_role bypass works correctly
under PostgREST.

---

Nitpick comments:
In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql:
- Around line 91-93: Several functions use an empty search_path but call
unqualified built-ins (e.g., NOW(), date_trunc(), jsonb_to_recordset()); update
each occurrence to use pg_catalog-qualified names so they work with search_path
= ''. Find usages in the shown block (the conditional using NOW() and the
assignment calling public.seed_get_app_metrics_caches) and the other mentioned
blocks (lines 112-124, 292-297, 340-355) and replace NOW(), date_trunc(),
jsonb_to_recordset() (and any other ordinary built-ins) with pg_catalog.NOW(),
pg_catalog.date_trunc(), pg_catalog.jsonb_to_recordset() respectively, leaving
calls to public.* functions (like public.seed_get_app_metrics_caches) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a040bf5-2518-4a25-8c1e-ec200838dec8

📥 Commits

Reviewing files that changed from the base of the PR and between 2af3a08 and 5f2f768.

📒 Files selected for processing (1)
  • supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql

Copy link
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 the current code and only fix it if needed.

Inline comments:
In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql:
- Line 1: The migration filename uses a nonstandard timestamp
("03082026,093259PM_fix-unauth-rpc-auth-checks.sql") which will break numeric
ordering; create a properly timestamped Supabase migration instead (use the CLI:
bunx supabase migration new <feature_slug> to generate a file named
YYYYMMDDHHMMSS_<slug>.sql), move the SQL from the existing file into that new
file, remove the old misnamed file, and continue editing the new CLI-generated
migration (keep a single migration file per feature as described).
- Around line 38-42: The cycle end is currently cast to date directly and
becomes inclusive, which pulls in the first day of the next cycle; update all
call sites that pass cycle_end as a date (e.g., where RETURN QUERY SELECT * FROM
public.get_app_metrics(org_id, cycle_start::date, cycle_end::date) and any other
calls using cycle_start/cycle_end) to subtract one day before casting—use either
(cycle_end::date - 1) or (cycle_end - INTERVAL '1 day')::date when passing the
end date so downstream BETWEEN p_start_date AND p_end_date remains correct;
locate uses by the variables cycle_start and cycle_end and the functions
get_cycle_info_org and get_app_metrics and apply the same change at each call
site mentioned in the review.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c8884db6-5ca0-470a-ba4b-5abf499d33bf

📥 Commits

Reviewing files that changed from the base of the PR and between 5f2f768 and a646b3b.

📒 Files selected for processing (1)
  • supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql

@@ -0,0 +1,492 @@
-- Fix: add org-scoped auth checks on sensitive plan/usage RPCs and lock down cleanup RPC
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use a normal Supabase migration timestamp here.

03082026,093259PM_... will not sort with the repo’s numeric migration sequence, so this change can be applied out of order on fresh environments. Please rename it to a standard CLI-generated YYYYMMDDHHMMSS_<slug>.sql migration before merge.

As per coding guidelines, "When a feature requires schema changes, create a single migration file with Supabase CLI (bunx supabase migration new <feature_slug>) and keep editing that file until the feature ships."

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

In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql at line
1, The migration filename uses a nonstandard timestamp
("03082026,093259PM_fix-unauth-rpc-auth-checks.sql") which will break numeric
ordering; create a properly timestamped Supabase migration instead (use the CLI:
bunx supabase migration new <feature_slug> to generate a file named
YYYYMMDDHHMMSS_<slug>.sql), move the SQL from the existing file into that new
file, remove the old misnamed file, and continue editing the new CLI-generated
migration (keep a single migration file per feature as described).

Comment on lines +38 to +42
SELECT subscription_anchor_start, subscription_anchor_end
INTO cycle_start, cycle_end
FROM public.get_cycle_info_org(org_id);

RETURN QUERY SELECT * FROM public.get_app_metrics(org_id, cycle_start::date, cycle_end::date);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how downstream metric functions interpret end_date / cycle_end.
rg -n -C3 --glob '*.sql' \
  "CREATE OR REPLACE FUNCTION public\.(get_total_metrics|seed_get_app_metrics_caches)|BETWEEN[[:space:]].*(end_date|cycle_end)|<=[[:space:]].*(end_date|cycle_end)|<[[:space:]].*(end_date|cycle_end)" \
  supabase/migrations

Repository: Cap-go/capgo

Length of output: 10290


🏁 Script executed:

cat -n supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql | head -50

Repository: Cap-go/capgo

Length of output: 1782


🏁 Script executed:

rg -n "get_cycle_info_org" supabase/migrations --glob "*.sql" -A 15 | head -100

Repository: Cap-go/capgo

Length of output: 10474


🏁 Script executed:

rg -n "get_app_metrics" supabase/migrations --glob "*.sql" -B 3 -A 10 | head -150

Repository: Cap-go/capgo

Length of output: 14222


🏁 Script executed:

rg -n "CREATE.*FUNCTION.*get_cycle_info_org" supabase/migrations --glob "*.sql" -A 30

Repository: Cap-go/capgo

Length of output: 6178


🏁 Script executed:

sed -n '177,178p; 403,411p' supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql

Repository: Cap-go/capgo

Length of output: 551


Fix end-date boundary overflow in cycle calculations by subtracting one day before casting to date.

The get_cycle_info_org() function computes end_date := start_date + INTERVAL '1 MONTH', creating a timestamp at the start of the next cycle (e.g., 2026-04-01 00:00:00). This is semantically an exclusive boundary. However, casting to date (cycle_end::date) and passing to downstream functions that use BETWEEN p_start_date AND p_end_date (inclusive on both ends) causes metrics from the first day of the next cycle to be included in the current cycle.

Change to (cycle_end::date - 1) or (cycle_end - INTERVAL '1 day')::date at lines 42, 177-178, and 403-411.

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

In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql around
lines 38 - 42, The cycle end is currently cast to date directly and becomes
inclusive, which pulls in the first day of the next cycle; update all call sites
that pass cycle_end as a date (e.g., where RETURN QUERY SELECT * FROM
public.get_app_metrics(org_id, cycle_start::date, cycle_end::date) and any other
calls using cycle_start/cycle_end) to subtract one day before casting—use either
(cycle_end::date - 1) or (cycle_end - INTERVAL '1 day')::date when passing the
end date so downstream BETWEEN p_start_date AND p_end_date remains correct;
locate uses by the variables cycle_start and cycle_end and the functions
get_cycle_info_org and get_app_metrics and apply the same change at each call
site mentioned in the review.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/plan-rpc-auth.test.ts`:
- Around line 102-115: The test matrix currently only checks anon vs
service_role; add two authenticated paths per rpc: call the RPC with an
authenticated non-member client (e.g., create or reuse a helper like
getAuthenticatedClient(nonMember) or a JWT-backed client) and assert the
returned error message contains rpc.anonError ?? 'NO_RIGHTS', and call the RPC
with an authenticated member/API-key client (e.g.,
getAuthenticatedClient(member) or an API-key-backed client) and assert error is
null; reuse rpc.label, rpc.name and rpc.params in both new it.concurrent tests
so each RPC is exercised for authenticated non-member (expect NO_RIGHTS) and
authenticated member/API-key (expect success).
- Around line 77-82: The test that calls delete_old_deleted_versions() mutates
shared DB state and must be isolated: remove that case from the it.concurrent()
matrix and create a new serial test (use it() not it.concurrent()) that seeds
its own test-owned rows, invokes delete_old_deleted_versions(), asserts
outcomes, and then cleans up those seeded rows; apply the same change for the
duplicate case referenced around lines 103-114 so both mutation tests use
dedicated seed data and run serially to avoid interfering with read-only
concurrent tests.
- Around line 6-7: Export the already-normalized SUPABASE_BASE_URL and
SUPABASE_ANON_KEY from test-utils.ts and stop hard-reading env vars in
tests/plan-rpc-auth.test.ts; specifically remove the duplicate
normalizeLocalhostUrl()/env.SUPABASE_URL logic and in getAnonClient() (and the
block around lines 85-99) import and use SUPABASE_BASE_URL and SUPABASE_ANON_KEY
from test-utils.ts so both getAnonClient() and getSupabaseClient() use the same
normalized base URL and anon key.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7c5c842-7442-4441-aa87-ce06843d8f45

📥 Commits

Reviewing files that changed from the base of the PR and between a646b3b and f015e83.

📒 Files selected for processing (1)
  • tests/plan-rpc-auth.test.ts

@riderx riderx force-pushed the riderx/fix-unauth-rpc branch from f015e83 to 725bf8c Compare March 8, 2026 23:59
@riderx riderx changed the title fix: restrict unauthenticated plan usage RPCs fix: secure rpc auth and owner_org fallback Mar 9, 2026
Copy link
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

♻️ Duplicate comments (4)
supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql (2)

42-42: ⚠️ Potential issue | 🟠 Major

Fix cycle end boundary overflow.

The cycle_end from get_cycle_info_org() represents an exclusive boundary (start of next month), but casting to ::date and using with BETWEEN (inclusive) may include metrics from the next cycle's first day. Use (cycle_end::date - 1) or adjust downstream queries.

This applies to all cycle_end usages at lines 42, 178, and 408.

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

In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql at line
42, The cycle_end value from get_cycle_info_org() is an exclusive boundary but
is being cast to ::date and used inclusively, which can include the next cycle's
first day; update all usages (e.g., the RETURN QUERY calling
public.get_app_metrics and other places referencing cycle_end) to use an
exclusive-to-inclusive conversion such as (cycle_end::date - 1) or change the
downstream predicate to use a strict less-than (date < cycle_end::date) so that
metrics for the next cycle’s first day are excluded.

1-1: ⚠️ Potential issue | 🟠 Major

Use standard Supabase migration timestamp format.

The filename 03082026,093259PM_fix-unauth-rpc-auth-checks.sql won't sort correctly with the repo's numeric migration sequence. Rename to YYYYMMDDHHMMSS_<slug>.sql format using the Supabase CLI.

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

In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql at line
1, The migration filename 03082026,093259PM_fix-unauth-rpc-auth-checks.sql uses
a non-standard timestamp and will mis-sort; rename the file to Supabase's
numeric migration format YYYYMMDDHHMMSS_<slug>.sql (e.g., generate a proper
timestamped name) and create the migration via the Supabase CLI so the filename
and internal migration metadata match; update any references to the old name if
present and ensure the new filename retains the descriptive slug like
fix-unauth-rpc-auth-checks.
tests/plan-rpc-auth.test.ts (2)

102-115: ⚠️ Potential issue | 🟠 Major

Add authenticated non-member/member test paths.

The current matrix only tests "anon is denied" and "service_role is allowed". It doesn't cover JWT-backed authenticated callers (API keys and signed-in users). Add at least one authenticated non-member case expecting NO_RIGHTS and one authorized member case expecting success.

Based on learnings: "API keys authenticate through the authenticated role via JWT, not the anon role."

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

In `@tests/plan-rpc-auth.test.ts` around lines 102 - 115, Extend the existing RPC
auth matrix to cover JWT-backed "authenticated" callers by adding two extra
concurrent tests for each rpc in the loop: one that uses an authenticated
non-member client (create a JWT-backed client instead of getAnonClient(), e.g.,
a helper that returns a client authenticated via API key or a signed-in user)
and asserts the error message contains rpc.anonError ?? 'NO_RIGHTS', and another
that uses an authenticated member client (create or sign-in a test user and
ensure they are a member for this RPC) and asserts the RPC error is null; use
the same rpc.name and rpc.params in the calls and reuse getSupabaseClient()
semantics for service-role comparisons when implementing the authenticated
helpers.

77-82: ⚠️ Potential issue | 🟠 Major

Isolate delete_old_deleted_versions from concurrent test matrix.

This case mutates shared DB state but runs under it.concurrent() alongside read-only RPCs. This makes the suite order-dependent. Move this RPC to a separate serial test with dedicated seed data.

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

In `@tests/plan-rpc-auth.test.ts` around lines 77 - 82, The test entry for the RPC
labeled 'delete_old_deleted_versions' mutates shared DB state and must be
removed from the concurrent test matrix and placed into its own serial test with
dedicated seed data; remove the object with name 'delete_old_deleted_versions'
from the test matrix (where the list of RPC cases is defined) and add a new
standalone test (use it(...) not it.concurrent(...)) that seeds the DB with the
specific state needed, invokes the RPC 'delete_old_deleted_versions', asserts
the expected anonError 'permission denied' (or other expectations), and ensures
DB cleanup/reset before/after the test so it cannot affect other concurrent
read-only RPC tests.
🧹 Nitpick comments (2)
supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql (1)

23-26: Consider using consistent service-role detection across migrations.

This migration uses a simpler detection pattern (auth.jwt() ->> 'role' + session_user) compared to the more comprehensive pattern in 20260309000518_fix-unauth-rpc-ci.sql which also checks current_setting('request.jwt.claim.role') and auth.role(). For consistency and robustness, consider aligning these patterns.

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

In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql around
lines 23 - 26, The v_is_service_role assignment uses a limited detection
pattern; update the expression that sets v_is_service_role to match the more
robust approach used elsewhere by checking auth.jwt() ->> 'role',
current_setting('request.jwt.claim.role', true), and auth.role() in addition to
session_user (and treating nulls safely), so replace the existing OR conditions
in the v_is_service_role calculation with a combined check that mirrors the
logic in 20260309000518_fix-unauth-rpc-ci.sql (use auth.jwt(),
current_setting('request.jwt.claim.role', true), and auth.role() plus the
session_user comparison) to ensure consistent service-role detection across
migrations.
tests/on-version-update-owner-org-fallback.test.ts (1)

21-50: Use it.concurrent() for parallel test execution.

Per coding guidelines, tests should use it.concurrent() instead of it() to maximize parallelism. Since this test creates unique seed data with randomUUID(), it's safe to run concurrently.

♻️ Proposed change
-  it('uses deleted_apps fallback when apps lookup is missing owner_org', async () => {
+  it.concurrent('uses deleted_apps fallback when apps lookup is missing owner_org', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/on-version-update-owner-org-fallback.test.ts` around lines 21 - 50, The
test currently uses it(...) which prevents parallel execution; update the test
declaration in tests/on-version-update-owner-org-fallback.test.ts to use
it.concurrent('uses deleted_apps fallback when apps lookup is missing
owner_org', async () => { ... }) so Jest will run it in parallel — ensure the
async callback and use of randomUUID()-seeded data (appId/versionId) remain
unchanged and that any setup/teardown in this test (executeSQL inserts/deletes)
is still isolated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/functions/_backend/triggers/cron_clear_versions.ts`:
- Line 9: Add a missing type import for Context used by the function
resolveOwnerOrg: import the Context type from the Hono package (use a type-only
import like in on_version_update.ts) near the top alongside other imports so the
signature async function resolveOwnerOrg(c: Context, appId: string | null):
Promise<string | null> compiles without TS2304.

In `@supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql`:
- Around line 43-47: The downstream call to public.get_app_metrics is passing
cycle_end as an inclusive date which pulls metrics from the next cycle; update
the RETURN QUERY call that currently uses cycle_start::date, cycle_end::date to
use cycle_start::date, (cycle_end::date - 1) so the exclusive boundary from
public.get_cycle_info_org(org_id) is preserved, and make the same adjustment at
the other occurrence later in the file (the second call that passes
p_start_date/p_end_date into generate_series or get_app_metrics around the later
block referenced in the comment).

---

Duplicate comments:
In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql:
- Line 42: The cycle_end value from get_cycle_info_org() is an exclusive
boundary but is being cast to ::date and used inclusively, which can include the
next cycle's first day; update all usages (e.g., the RETURN QUERY calling
public.get_app_metrics and other places referencing cycle_end) to use an
exclusive-to-inclusive conversion such as (cycle_end::date - 1) or change the
downstream predicate to use a strict less-than (date < cycle_end::date) so that
metrics for the next cycle’s first day are excluded.
- Line 1: The migration filename
03082026,093259PM_fix-unauth-rpc-auth-checks.sql uses a non-standard timestamp
and will mis-sort; rename the file to Supabase's numeric migration format
YYYYMMDDHHMMSS_<slug>.sql (e.g., generate a proper timestamped name) and create
the migration via the Supabase CLI so the filename and internal migration
metadata match; update any references to the old name if present and ensure the
new filename retains the descriptive slug like fix-unauth-rpc-auth-checks.

In `@tests/plan-rpc-auth.test.ts`:
- Around line 102-115: Extend the existing RPC auth matrix to cover JWT-backed
"authenticated" callers by adding two extra concurrent tests for each rpc in the
loop: one that uses an authenticated non-member client (create a JWT-backed
client instead of getAnonClient(), e.g., a helper that returns a client
authenticated via API key or a signed-in user) and asserts the error message
contains rpc.anonError ?? 'NO_RIGHTS', and another that uses an authenticated
member client (create or sign-in a test user and ensure they are a member for
this RPC) and asserts the RPC error is null; use the same rpc.name and
rpc.params in the calls and reuse getSupabaseClient() semantics for service-role
comparisons when implementing the authenticated helpers.
- Around line 77-82: The test entry for the RPC labeled
'delete_old_deleted_versions' mutates shared DB state and must be removed from
the concurrent test matrix and placed into its own serial test with dedicated
seed data; remove the object with name 'delete_old_deleted_versions' from the
test matrix (where the list of RPC cases is defined) and add a new standalone
test (use it(...) not it.concurrent(...)) that seeds the DB with the specific
state needed, invokes the RPC 'delete_old_deleted_versions', asserts the
expected anonError 'permission denied' (or other expectations), and ensures DB
cleanup/reset before/after the test so it cannot affect other concurrent
read-only RPC tests.

---

Nitpick comments:
In `@supabase/migrations/03082026`,093259PM_fix-unauth-rpc-auth-checks.sql:
- Around line 23-26: The v_is_service_role assignment uses a limited detection
pattern; update the expression that sets v_is_service_role to match the more
robust approach used elsewhere by checking auth.jwt() ->> 'role',
current_setting('request.jwt.claim.role', true), and auth.role() in addition to
session_user (and treating nulls safely), so replace the existing OR conditions
in the v_is_service_role calculation with a combined check that mirrors the
logic in 20260309000518_fix-unauth-rpc-ci.sql (use auth.jwt(),
current_setting('request.jwt.claim.role', true), and auth.role() plus the
session_user comparison) to ensure consistent service-role detection across
migrations.

In `@tests/on-version-update-owner-org-fallback.test.ts`:
- Around line 21-50: The test currently uses it(...) which prevents parallel
execution; update the test declaration in
tests/on-version-update-owner-org-fallback.test.ts to use it.concurrent('uses
deleted_apps fallback when apps lookup is missing owner_org', async () => { ...
}) so Jest will run it in parallel — ensure the async callback and use of
randomUUID()-seeded data (appId/versionId) remain unchanged and that any
setup/teardown in this test (executeSQL inserts/deletes) is still isolated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8adb8040-3b6f-4856-9db0-c27140a4930c

📥 Commits

Reviewing files that changed from the base of the PR and between f015e83 and 8cc5a4f.

📒 Files selected for processing (6)
  • supabase/functions/_backend/triggers/cron_clear_versions.ts
  • supabase/functions/_backend/triggers/on_version_update.ts
  • supabase/migrations/03082026,093259PM_fix-unauth-rpc-auth-checks.sql
  • supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql
  • tests/on-version-update-owner-org-fallback.test.ts
  • tests/plan-rpc-auth.test.ts

@riderx riderx changed the title fix: secure rpc auth and owner_org fallback Fix RPC auth guards and owner_org fallback Mar 9, 2026
Copy link
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 the current code and only fix it if needed.

Inline comments:
In `@supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql`:
- Around line 1-529: This new migration duplicates the same feature changes
already present in the existing migration
20260308000000_fix_metrics_plan_rpcs_auth; fold its SQL for functions like
get_app_metrics, get_global_metrics, get_current_plan_max_org,
get_current_plan_name_org, get_cycle_info_org, get_plan_usage_percent_detailed
and the REVOKE/GRANT for delete_old_deleted_versions into that single migration
instead of adding 20260309000518_fix-unauth-rpc-ci.sql; to fix, remove the extra
migration file and copy/merge the new PL/pgSQL blocks (and their SECURITY
DEFINER/permission changes and service-role JWT checks) into the existing
migration file, resolving any duplicates so each function is declared once and
preserving all auth checks and GRANT/REVOKE statements.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4d6ce880-ef64-4b27-a135-7b2049e1f43c

📥 Commits

Reviewing files that changed from the base of the PR and between 8cc5a4f and 84645b1.

📒 Files selected for processing (4)
  • supabase/functions/_backend/triggers/cron_clear_versions.ts
  • supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql
  • tests/plan-rpc-auth.test.ts
  • tests/test-utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/plan-rpc-auth.test.ts

Comment on lines +1 to +529
-- Fix: add org-scoped auth checks on sensitive plan/usage RPCs and lock down cleanup RPC
-- Add service-role detection by JWT role claim for backend caller contexts.

CREATE OR REPLACE FUNCTION public.get_app_metrics(org_id uuid)
RETURNS TABLE (
app_id character varying,
date date,
mau bigint,
storage bigint,
bandwidth bigint,
build_time_unit bigint,
get bigint,
fail bigint,
install bigint,
uninstall bigint
) LANGUAGE plpgsql STABLE
SET
search_path = '' AS $$
DECLARE
cycle_start timestamptz;
cycle_end timestamptz;
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_app_metrics.org_id),
get_app_metrics.org_id,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

SELECT subscription_anchor_start, subscription_anchor_end
INTO cycle_start, cycle_end
FROM public.get_cycle_info_org(org_id);

RETURN QUERY SELECT * FROM public.get_app_metrics(org_id, cycle_start::date, (cycle_end::date - 1));
END;
$$;

CREATE OR REPLACE FUNCTION public.get_app_metrics(
org_id uuid,
start_date date,
end_date date
) RETURNS TABLE (
app_id character varying,
date date,
mau bigint,
storage bigint,
bandwidth bigint,
build_time_unit bigint,
get bigint,
fail bigint,
install bigint,
uninstall bigint
) LANGUAGE plpgsql SECURITY DEFINER
SET
search_path = '' AS $$
DECLARE
cache_entry public.app_metrics_cache%ROWTYPE;
org_exists boolean;
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_app_metrics.org_id),
get_app_metrics.org_id,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

SELECT EXISTS (
SELECT 1
FROM public.orgs
WHERE id = get_app_metrics.org_id
) INTO org_exists;

IF NOT org_exists THEN
RETURN;
END IF;

SELECT *
INTO cache_entry
FROM public.app_metrics_cache
WHERE org_id = get_app_metrics.org_id;

IF cache_entry.id IS NULL
OR cache_entry.start_date IS DISTINCT FROM get_app_metrics.start_date
OR cache_entry.end_date IS DISTINCT FROM get_app_metrics.end_date
OR cache_entry.cached_at IS NULL
OR cache_entry.cached_at < (NOW() - interval '5 minutes')
THEN
cache_entry := public.seed_get_app_metrics_caches(get_app_metrics.org_id, get_app_metrics.start_date, get_app_metrics.end_date);
END IF;

IF cache_entry.response IS NULL THEN
RETURN;
END IF;

RETURN QUERY
SELECT
metrics.app_id,
metrics.date,
metrics.mau,
metrics.storage,
metrics.bandwidth,
metrics.build_time_unit,
metrics.get,
metrics.fail,
metrics.install,
metrics.uninstall
FROM jsonb_to_recordset(cache_entry.response) AS metrics(
app_id character varying,
date date,
mau bigint,
storage bigint,
bandwidth bigint,
build_time_unit bigint,
get bigint,
fail bigint,
install bigint,
uninstall bigint
)
ORDER BY metrics.app_id, metrics.date;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_global_metrics(org_id uuid)
RETURNS TABLE (
date date,
mau bigint,
storage bigint,
bandwidth bigint,
get bigint,
fail bigint,
install bigint,
uninstall bigint
) LANGUAGE plpgsql
SET
search_path = '' AS $$
DECLARE
cycle_start timestamptz;
cycle_end timestamptz;
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_global_metrics.org_id),
get_global_metrics.org_id,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

SELECT subscription_anchor_start, subscription_anchor_end
INTO cycle_start, cycle_end
FROM public.get_cycle_info_org(org_id);

RETURN QUERY
SELECT * FROM public.get_global_metrics(org_id, cycle_start::date, (cycle_end::date - 1));
END;
$$;

CREATE OR REPLACE FUNCTION public.get_global_metrics(
org_id uuid,
start_date date,
end_date date
) RETURNS TABLE (
date date,
mau bigint,
storage bigint,
bandwidth bigint,
get bigint,
fail bigint,
install bigint,
uninstall bigint
) LANGUAGE plpgsql
SET
search_path = '' AS $$
DECLARE
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_global_metrics.org_id),
get_global_metrics.org_id,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

RETURN QUERY
SELECT
metrics.date,
SUM(metrics.mau)::bigint AS mau,
SUM(metrics.storage)::bigint AS storage,
SUM(metrics.bandwidth)::bigint AS bandwidth,
SUM(metrics.get)::bigint AS get,
SUM(metrics.fail)::bigint AS fail,
SUM(metrics.install)::bigint AS install,
SUM(metrics.uninstall)::bigint AS uninstall
FROM
public.get_app_metrics(org_id, start_date, end_date) AS metrics
GROUP BY
metrics.date
ORDER BY
metrics.date;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_current_plan_max_org(orgid uuid)
RETURNS TABLE (
mau bigint,
bandwidth bigint,
storage bigint,
build_time_unit bigint
) LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = '' AS $$
DECLARE
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_current_plan_max_org.orgid),
get_current_plan_max_org.orgid,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

RETURN QUERY
SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit
FROM public.orgs o
JOIN public.stripe_info si ON o.customer_id = si.customer_id
JOIN public.plans p ON si.product_id = p.stripe_id
WHERE o.id = orgid;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_current_plan_name_org(orgid uuid)
RETURNS character varying
LANGUAGE plpgsql
SET
search_path = '' AS $$
DECLARE
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_current_plan_name_org.orgid),
get_current_plan_name_org.orgid,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

RETURN
(SELECT name
FROM public.plans
WHERE stripe_id=(SELECT product_id
FROM public.stripe_info
WHERE customer_id=(SELECT customer_id FROM public.orgs WHERE id=orgid)
));
END;
$$;

CREATE OR REPLACE FUNCTION public.get_cycle_info_org(orgid uuid)
RETURNS TABLE (
subscription_anchor_start timestamp with time zone,
subscription_anchor_end timestamp with time zone
) LANGUAGE plpgsql
SET
search_path = '' AS $$
DECLARE
customer_id_var text;
stripe_info_row public.stripe_info%ROWTYPE;
anchor_day INTERVAL;
start_date timestamp with time zone;
end_date timestamp with time zone;
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_cycle_info_org.orgid),
get_cycle_info_org.orgid,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

SELECT customer_id INTO customer_id_var FROM public.orgs WHERE id = orgid;

SELECT * INTO stripe_info_row FROM public.stripe_info WHERE customer_id = customer_id_var;

anchor_day := COALESCE(stripe_info_row.subscription_anchor_start - date_trunc('MONTH', stripe_info_row.subscription_anchor_start), '0 DAYS'::INTERVAL);

IF anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN
start_date := date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + anchor_day;
ELSE
start_date := date_trunc('MONTH', NOW()) + anchor_day;
END IF;

end_date := start_date + INTERVAL '1 MONTH';

RETURN QUERY
SELECT start_date, end_date;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_plan_usage_percent_detailed(orgid uuid)
RETURNS TABLE (
total_percent double precision,
mau_percent double precision,
bandwidth_percent double precision,
storage_percent double precision,
build_time_percent double precision
) LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = '' AS $$
DECLARE
v_start_date date;
v_end_date date;
v_plan_mau bigint;
v_plan_bandwidth bigint;
v_plan_storage bigint;
v_plan_build_time bigint;
v_anchor_day INTERVAL;
total_stats RECORD;
percent_mau double precision;
percent_bandwidth double precision;
percent_storage double precision;
percent_build_time double precision;
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_plan_usage_percent_detailed.orgid),
get_plan_usage_percent_detailed.orgid,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

SELECT
COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL),
p.mau,
p.bandwidth,
p.storage,
p.build_time_unit
INTO v_anchor_day, v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time
FROM public.orgs o
LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id
LEFT JOIN public.plans p ON si.product_id = p.stripe_id
WHERE o.id = orgid;

IF v_anchor_day > NOW() - date_trunc('MONTH', NOW()) THEN
v_start_date := (date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + v_anchor_day)::date;
ELSE
v_start_date := (date_trunc('MONTH', NOW()) + v_anchor_day)::date;
END IF;
v_end_date := (v_start_date + INTERVAL '1 MONTH')::date;

SELECT * INTO total_stats
FROM public.get_total_metrics(orgid, v_start_date, v_end_date);

percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau);
percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth);
percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage);
percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time);

RETURN QUERY SELECT
GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time),
percent_mau,
percent_bandwidth,
percent_storage,
percent_build_time;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_plan_usage_percent_detailed(
orgid uuid,
cycle_start date,
cycle_end date
) RETURNS TABLE (
total_percent double precision,
mau_percent double precision,
bandwidth_percent double precision,
storage_percent double precision,
build_time_percent double precision
) LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = '' AS $$
DECLARE
v_plan_mau bigint;
v_plan_bandwidth bigint;
v_plan_storage bigint;
v_plan_build_time bigint;
total_stats RECORD;
percent_mau double precision;
percent_bandwidth double precision;
percent_storage double precision;
percent_build_time double precision;
v_is_service_role boolean;
v_request_role text;
BEGIN
v_request_role := NULLIF(current_setting('request.jwt.claim.role', true), '');
v_is_service_role := (
(v_request_role = 'service_role')
OR ((SELECT auth.jwt() ->> 'role') = 'service_role')
OR ((SELECT auth.role()) = 'service_role')
OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres')
);

IF NOT v_is_service_role AND NOT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], get_plan_usage_percent_detailed.orgid),
get_plan_usage_percent_detailed.orgid,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'NO_RIGHTS';
END IF;

SELECT p.mau, p.bandwidth, p.storage, p.build_time_unit
INTO v_plan_mau, v_plan_bandwidth, v_plan_storage, v_plan_build_time
FROM public.orgs o
JOIN public.stripe_info si ON o.customer_id = si.customer_id
JOIN public.plans p ON si.product_id = p.stripe_id
WHERE o.id = orgid;

SELECT * INTO total_stats
FROM public.get_total_metrics(orgid, cycle_start, cycle_end);

percent_mau := public.convert_number_to_percent(total_stats.mau, v_plan_mau);
percent_bandwidth := public.convert_number_to_percent(total_stats.bandwidth, v_plan_bandwidth);
percent_storage := public.convert_number_to_percent(total_stats.storage, v_plan_storage);
percent_build_time := public.convert_number_to_percent(total_stats.build_time_unit, v_plan_build_time);

RETURN QUERY SELECT
GREATEST(percent_mau, percent_bandwidth, percent_storage, percent_build_time),
percent_mau,
percent_bandwidth,
percent_storage,
percent_build_time;
END;
$$;

REVOKE EXECUTE ON FUNCTION public.delete_old_deleted_versions() FROM public;
REVOKE EXECUTE ON FUNCTION public.delete_old_deleted_versions() FROM anon, authenticated;
GRANT EXECUTE ON FUNCTION public.delete_old_deleted_versions() TO service_role;
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Fold this into the existing PR migration.

The PR already has 20260308000000_fix_metrics_plan_rpcs_auth.sql, so adding 20260309000518_fix-unauth-rpc-ci.sql creates a second unshipped migration for the same feature. Please merge this delta back into the existing migration before release.

As per coding guidelines, "When a feature requires schema changes, create a single migration file with Supabase CLI (bunx supabase migration new <feature_slug>) and keep editing that file until the feature ships; never edit previously committed migrations."

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

In `@supabase/migrations/20260309000518_fix-unauth-rpc-ci.sql` around lines 1 -
529, This new migration duplicates the same feature changes already present in
the existing migration 20260308000000_fix_metrics_plan_rpcs_auth; fold its SQL
for functions like get_app_metrics, get_global_metrics,
get_current_plan_max_org, get_current_plan_name_org, get_cycle_info_org,
get_plan_usage_percent_detailed and the REVOKE/GRANT for
delete_old_deleted_versions into that single migration instead of adding
20260309000518_fix-unauth-rpc-ci.sql; to fix, remove the extra migration file
and copy/merge the new PL/pgSQL blocks (and their SECURITY DEFINER/permission
changes and service-role JWT checks) into the existing migration file, resolving
any duplicates so each function is declared once and preserving all auth checks
and GRANT/REVOKE statements.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 9, 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