Skip to content

Fix audit_logs RLS timeout#1588

Merged
riderx merged 1 commit into
mainfrom
riderx/audit-logs-timeout
Feb 6, 2026
Merged

Fix audit_logs RLS timeout#1588
riderx merged 1 commit into
mainfrom
riderx/audit-logs-timeout

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Feb 6, 2026

Summary (AI generated)

  • Fix audit_logs unfiltered selects timing out by replacing per-row identity evaluation in RLS with statement-scoped audit_logs_allowed_orgs().

Motivation (AI generated)

  • /rest/v1/audit_logs?select=id&limit=1 timed out (57014) and parallel load caused other endpoints to time out.

Business Impact (AI generated)

  • Reduces DB load and prevents audit log queries from degrading unrelated API traffic.

Test plan (AI generated)

  • bun lint:backend
  • bun lint
  • bun test:backend

Screenshots (AI generated)

  • N/A

Checklist (AI generated)

  • 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

Generated with AI

Summary by CodeRabbit

  • Bug Fixes

    • Fixed audit log access control for API key authentication to properly restrict visibility based on organization permissions.
  • Tests

    • Expanded test coverage for audit log access and API key-based audit operations.

Copilot AI review requested due to automatic review settings February 6, 2026 00:20
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

This PR introduces a new audit_logs_allowed_orgs() PostgreSQL function that centralizes organization-level access control for audit log queries. The function authenticates requests via user or API key, enforces security policies (2FA, password checks), collects permitted org IDs from legacy rights and RBAC bindings, and returns an array of allowed organizations. The RLS policy is updated to use this function as an index-friendly predicate. Tests are expanded to verify API key-based audit log access scenarios.

Changes

Cohort / File(s) Summary
Migration - Audit Log Access Control
supabase/migrations/20260205120000_fix_audit_logs_select_rls.sql
Introduces audit_logs_allowed_orgs() function that determines permitted organizations for audit log access based on authentication method, RBAC bindings, legacy rights, and security policies (2FA, password requirements). Updates RLS SELECT policy to use the function for index-friendly filtering via org_id = ANY(audit_logs_allowed_orgs()).
Test Suite - API Key Audit Logging
supabase/tests/40_test_audit_log_apikey.sql
Expands test plan from 6 to 9 cases. Adds validation for audit_logs_allowed_orgs() behavior with and without authentication, verifies policy wiring, tests get_identity with API key context, and validates audit log fidelity (old_record, new_record, changed_fields) across INSERT, UPDATE, and DELETE operations with API key authentication.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

💰 Rewarded

Poem

🐰 Hop and bound through orgs we go,
With 2FA checks to guard below,
API keys now find their way,
To audit logs they can survey!
Security woven, policies tight,

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly addresses the main objective: fixing audit_logs RLS timeout through optimized access control logic, which is the core change in this PR.
Description check ✅ Passed The description covers summary, motivation, business impact, and test plan. However, it lacks detailed manual testing steps and E2E coverage is not checked, which are template requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

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.0)
supabase/migrations/20260205120000_fix_audit_logs_select_rls.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


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.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Feb 6, 2026

@riderx riderx merged commit ead104f into main Feb 6, 2026
15 checks passed
@riderx riderx deleted the riderx/audit-logs-timeout branch February 6, 2026 00:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a critical performance issue where audit_logs SELECT queries were timing out due to per-row identity resolution in the RLS policy. The solution replaces the per-row get_identity_org_allowed() call with a new statement-scoped audit_logs_allowed_orgs() function that computes the list of accessible org_ids once per query, enabling index-friendly filtering with org_id = ANY(...).

Changes:

  • Introduced audit_logs_allowed_orgs() function that performs authentication and authorization checks once per statement instead of per row
  • Updated the audit_logs RLS policy to use the new function with an index-friendly predicate
  • Added comprehensive tests to verify the new function behavior and policy implementation

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
supabase/migrations/20260205120000_fix_audit_logs_select_rls.sql Implements new audit_logs_allowed_orgs() SECURITY DEFINER function and replaces the RLS policy to use statement-scoped authorization
supabase/tests/40_test_audit_log_apikey.sql Adds 3 new tests for audit_logs_allowed_orgs() function behavior and verifies policy implementation; updates test count and renumbers existing tests

Comment on lines +10 to +143
CREATE OR REPLACE FUNCTION "public"."audit_logs_allowed_orgs"()
RETURNS "uuid"[]
LANGUAGE "plpgsql" STABLE SECURITY DEFINER
SET "search_path" TO ''
AS $$
DECLARE
v_user_id uuid;
v_api_key_text text;
v_api_key public.apikeys%ROWTYPE;
v_allowed uuid[] := '{}'::uuid[];
v_org_id uuid;
v_use_rbac boolean;
v_perm text := public.rbac_permission_for_legacy(
public.rbac_right_super_admin(),
public.rbac_scope_org()
);
v_enforcing_2fa boolean;
BEGIN
SELECT auth.uid() INTO v_user_id;

-- If no authenticated user, attempt Capgo API key auth (capgkey header).
IF v_user_id IS NULL THEN
SELECT public.get_apikey_header() INTO v_api_key_text;
IF v_api_key_text IS NULL THEN
RETURN v_allowed;
END IF;

SELECT * FROM public.find_apikey_by_value(v_api_key_text) INTO v_api_key;
IF v_api_key.id IS NULL THEN
RETURN v_allowed;
END IF;

IF NOT (v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[])) THEN
RETURN v_allowed;
END IF;

IF public.is_apikey_expired(v_api_key.expires_at) THEN
RETURN v_allowed;
END IF;

v_user_id := v_api_key.user_id;
END IF;

-- Collect candidate orgs from legacy + RBAC bindings.
FOR v_org_id IN
SELECT DISTINCT org_id
FROM (
SELECT ou.org_id
FROM public.org_users ou
WHERE ou.user_id = v_user_id
AND ou.org_id IS NOT NULL
AND ou.app_id IS NULL
AND ou.channel_id IS NULL
UNION
SELECT rb.org_id
FROM public.role_bindings rb
WHERE rb.principal_type = public.rbac_principal_user()
AND rb.principal_id = v_user_id
AND rb.scope_type = public.rbac_scope_org()
AND rb.org_id IS NOT NULL
UNION
SELECT rb.org_id
FROM public.role_bindings rb
WHERE v_api_key.rbac_id IS NOT NULL
AND rb.principal_type = public.rbac_principal_apikey()
AND rb.principal_id = v_api_key.rbac_id
AND rb.scope_type = public.rbac_scope_org()
AND rb.org_id IS NOT NULL
) candidates
LOOP
-- Enforce API key org restrictions (if present).
IF v_api_key.id IS NOT NULL
AND COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0
AND NOT (v_org_id = ANY(v_api_key.limited_to_orgs))
THEN
CONTINUE;
END IF;

v_use_rbac := public.rbac_is_enabled_for_org(v_org_id);

IF NOT v_use_rbac THEN
-- Legacy rights (also enforces org 2FA + password policy).
IF public.check_min_rights_legacy(
'super_admin'::public.user_min_right,
v_user_id,
v_org_id,
NULL::character varying,
NULL::bigint
) THEN
v_allowed := array_append(v_allowed, v_org_id);
END IF;
ELSE
-- Mirror check_min_rights() org gating for RBAC orgs (2FA + password policy).
SELECT o.enforcing_2fa INTO v_enforcing_2fa
FROM public.orgs o
WHERE o.id = v_org_id;

IF v_enforcing_2fa = true AND NOT public.has_2fa_enabled(v_user_id) THEN
CONTINUE;
END IF;

IF NOT public.user_meets_password_policy(v_user_id, v_org_id) THEN
CONTINUE;
END IF;

-- Allow if the user or the API key principal has the required RBAC permission.
IF public.rbac_has_permission(
public.rbac_principal_user(),
v_user_id,
v_perm,
v_org_id,
NULL::character varying,
NULL::bigint
) THEN
v_allowed := array_append(v_allowed, v_org_id);
ELSIF v_api_key.id IS NOT NULL
AND v_api_key.rbac_id IS NOT NULL
AND public.rbac_has_permission(
public.rbac_principal_apikey(),
v_api_key.rbac_id,
v_perm,
v_org_id,
NULL::character varying,
NULL::bigint
)
THEN
v_allowed := array_append(v_allowed, v_org_id);
END IF;
END IF;
END LOOP;

RETURN v_allowed;
END;
$$;
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

The audit_logs_allowed_orgs function is marked as SECURITY DEFINER and will be executed by the "anon" role (via RLS policy on line 148), but it calls several helper functions that explicitly deny execution to "anon":

  1. rbac_permission_for_legacy (line 22-25) - revoked from anon in migration 20260123140712_fix_rbac_perf_security.sql:500
  2. rbac_is_enabled_for_org (line 88) - revoked from anon in migration 20260123140712_fix_rbac_perf_security.sql:494
  3. check_min_rights_legacy (line 92) - needs verification but likely restricted
  4. rbac_has_permission (lines 116, 127) - revoked from anon in migration 20260123140712_fix_rbac_perf_security.sql:488
  5. user_meets_password_policy (line 111) - revoked from anon in migration 20251228100000_password_policy_enforcement.sql:133
  6. has_2fa_enabled with user_id param (line 107) - revoked from anon in migration 20251224103713_2fa_enforcement.sql:53

When an unauthenticated API key request (using anon role) triggers the RLS policy, the audit_logs_allowed_orgs function will execute with the anon role's permissions (not elevated by SECURITY DEFINER for nested calls), causing these helper function calls to fail with permission denied errors.

To fix this, either:

  • Grant EXECUTE on these functions to anon (if safe given SECURITY DEFINER protections)
  • Restructure audit_logs_allowed_orgs to avoid calling restricted functions
  • Call the functions with explicit permission elevation where needed

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +77
SELECT rb.org_id
FROM public.role_bindings rb
WHERE v_api_key.rbac_id IS NOT NULL
AND rb.principal_type = public.rbac_principal_apikey()
AND rb.principal_id = v_api_key.rbac_id
AND rb.scope_type = public.rbac_scope_org()
AND rb.org_id IS NOT NULL
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

In the third UNION query, the WHERE clause references v_api_key.rbac_id (line 73), but v_api_key is only populated when v_user_id is NULL and an API key is found (lines 37-50). When a user authenticates via JWT (v_user_id from auth.uid() is not NULL), v_api_key remains uninitialized as a ROWTYPE.

In PostgreSQL, when a ROWTYPE variable is declared but not assigned, its fields are NULL. This means:

  • For JWT-authenticated users: v_api_key.rbac_id will be NULL, and the WHERE clause "v_api_key.rbac_id IS NOT NULL" (line 73) will correctly exclude this UNION branch
  • For API key users: v_api_key is populated and the query works as intended

While this works correctly due to the IS NOT NULL check, it relies on implicit NULL behavior for uninitialized ROWTYPEs. For clarity and maintainability, consider explicitly initializing v_api_key or restructuring to make the intent clearer, such as only including this UNION branch within an IF block that checks if v_api_key.id IS NOT NULL.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants