mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
docs(database): split RLS tuning into its own guide (#49016)
Stacked on #49015, which is stacked on #49011. Review those first. ## Problem The Row Level Security guide spent 225 lines and 5 benchmark tables on performance, 29% of the page. The `RLS Performance and Best Practices` troubleshooting entry already covers the same six tips with the same numbers, from the same source. Neither page tells you how to check whether RLS is your bottleneck in the first place. Four of the six tips are not tuning advice. Indexes, `select`-wrapping, role scoping, and `security definer` safety change whether a policy is correct and safe, not just fast. ## Solution - Add `guides/database/postgres/row-level-security-performance`. It carries the client-filter rule, the join-rewrite rule, all 5 benchmark tables merged into one, and a new `Diagnose whether RLS is the bottleneck` section: toggle RLS off to confirm it's the cost, then read the plan under an impersonated role. That diagnostic exists in the troubleshooting entry and has never been in the guide. - Keep every rule that affects correctness on the RLS guide, grouped under `Write policies that scale`. These are also the four the `build-docs-002-rls-guide` eval grades, and an agent reads the guide top-down. - Repoint the Grafana IO troubleshooting entry at the new page. - Rewrite `More resources` as `Related content`. Every link now says what it is and when to use it. Adds `Advanced pgTAP testing`, the deepest RLS testing content in the docs, which nothing here linked. Drops discussion 14576: locked, mislabeled here as "RLS Guide and Best Practices" when it is "RLS **Performance** and Best Practices", and superseded by the troubleshooting entry and this new page. **Ownership rule** so the two pages don't drift: the RLS guide owns the rule and the correct form. The performance page owns the measurement and the optimizer explanation. If a sentence on the performance page tells you what to write, it belongs on the guide. Scoped out of this PR: `More resources` was assigned to the restructure PR in the plan, but the 14576 link is what this PR supersedes, so leaving it would ship a stale pointer. ## Manual testing 1. Open the [RLS performance guide](https://docs-git-docs-rls-performance-split-supabase.vercel.app/docs/guides/database/postgres/row-level-security-performance) on the preview. It appears in the left nav under Database, Access and security, directly below Row Level Security. 2. Select the three rule links in its intro. Each lands on the matching section of the RLS guide. 3. Open the [Row Level Security guide](https://docs-git-docs-rls-performance-split-supabase.vercel.app/docs/guides/database/postgres/row-level-security) and go to `Write policies that scale`. It holds indexes, `select`-wrapping, and role scoping, with one link out to the performance page. 4. Open the [Grafana IO troubleshooting entry](https://docs-git-docs-rls-performance-split-supabase.vercel.app/docs/guides/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR) and select the RLS performance guide link. It lands on the new page. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a dedicated guide for diagnosing and improving PostgreSQL Row Level Security performance. * Expanded guidance on indexing, query filters, role targeting, function usage, and avoiding costly policy joins. * Updated the Row Level Security guide with streamlined, scalable policy recommendations and links to related resources. * Added the new performance guide to the Database documentation navigation. * Updated troubleshooting guidance to reference the dedicated performance guide. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b01a9c8af
commit
edf50668aa
4 files changed
+181
-252
No files matched your search
@@ -1117,6 +1117,10 @@ export const database: NavMenuConstant = {
|
||||
name: 'Row Level Security',
|
||||
url: '/guides/database/postgres/row-level-security' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Row Level Security Performance',
|
||||
url: '/guides/database/postgres/row-level-security-performance' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Column Level Security',
|
||||
url: '/guides/database/postgres/column-level-security' as `/${string}`,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
id: 'row-level-security-performance'
|
||||
title: 'Row Level Security performance'
|
||||
description: 'Measure and tune Postgres Row Level Security policies.'
|
||||
subtitle: 'Measure and tune Postgres Row Level Security policies.'
|
||||
---
|
||||
|
||||
Measure the cost of Row Level Security (RLS) and tune policies that are already correct. To learn how to write correct policies, see [Row Level Security](/docs/guides/database/postgres/row-level-security).
|
||||
|
||||
Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. This matters most for queries that scan every row in a table, like many `select` operations, including those using limit, offset, and ordering.
|
||||
|
||||
Three policy rules affect performance enough that they belong with the policy itself rather than here. Apply them first:
|
||||
|
||||
- [Index the columns your policies filter on](/docs/guides/database/postgres/row-level-security#add-indexes)
|
||||
- [Call functions with `select`](/docs/guides/database/postgres/row-level-security#call-functions-with-select)
|
||||
- [Specify roles in your policies](/docs/guides/database/postgres/row-level-security#specify-roles-in-your-policies)
|
||||
|
||||
## Diagnose whether RLS is the bottleneck
|
||||
|
||||
Confirm that policies are the cost before you rewrite one. Run the query with RLS enabled, then again with it disabled, and compare. If the times are similar, the query itself is the problem.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Disabling RLS exposes every row in the table to any role with a matching grant. Only do this in a non-production environment.
|
||||
|
||||
</Admonition>
|
||||
|
||||
To reproduce an API request, set the JWT claims and switch to the role the request runs as:
|
||||
|
||||
```sql
|
||||
set session role authenticated;
|
||||
set request.jwt.claims to '{"role":"authenticated", "sub":"5950b438-b07c-4012-8190-6ce79e4bd8e5"}';
|
||||
|
||||
explain analyze select count(*) from rlstest;
|
||||
|
||||
set session role postgres;
|
||||
```
|
||||
|
||||
The output shows the policy expression as a filter, and the execution time is the number to compare:
|
||||
|
||||
```
|
||||
Seq Scan on rlstest (cost=0.00..4334.00 rows=1 width=35) (actual time=170.999..170.999 rows=0 loops=1)
|
||||
Filter: ((COALESCE(NULLIF(current_setting('request.jwt.claim.sub'::text, true), ''::text), ((NULLIF(current_setting('request.jwt.claims'::text, true), ''::text))::jsonb ->> 'sub'::text)))::uuid = user_id)
|
||||
Rows Removed by Filter: 100000
|
||||
Planning Time: 0.216 ms
|
||||
Execution Time: 171.033 ms
|
||||
```
|
||||
|
||||
`Rows Removed by Filter` is the signal to watch. A policy that removes most of the table on every read is a policy whose filter column needs an index.
|
||||
|
||||
### Measure through the Data API
|
||||
|
||||
PostgREST can return the query plan to a Supabase client. Enable it first:
|
||||
|
||||
```sql
|
||||
alter role authenticator set pgrst.db_plan_enabled to true;
|
||||
notify pgrst, 'reload config';
|
||||
```
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
`pgrst.db_plan_enabled` exposes query plans over your Data API. Don't enable it in production.
|
||||
|
||||
</Admonition>
|
||||
|
||||
Then add the `.explain()` modifier to a query:
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('id', 1)
|
||||
.explain({ analyze: true })
|
||||
|
||||
console.log(data)
|
||||
```
|
||||
|
||||
```
|
||||
Aggregate (cost=8.18..8.20 rows=1 width=112) (actual time=0.017..0.018 rows=1 loops=1)
|
||||
-> Index Scan using projects_pkey on projects (cost=0.15..8.17 rows=1 width=40) (actual time=0.012..0.012 rows=0 loops=1)
|
||||
Index Cond: (id = 1)
|
||||
Filter: false
|
||||
Rows Removed by Filter: 1
|
||||
Planning Time: 0.092 ms
|
||||
Execution Time: 0.046 ms
|
||||
```
|
||||
|
||||
## Filter in the client query too
|
||||
|
||||
Policies are implicit `where` clauses, so it's common to run `select` statements without any filters. That's a bad pattern for performance. Instead of this:
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```js
|
||||
const { data } = supabase
|
||||
.from('table')
|
||||
.select()
|
||||
```
|
||||
|
||||
Always add a filter:
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```js
|
||||
const { data } = supabase
|
||||
.from('table')
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
```
|
||||
|
||||
Even though this duplicates the contents of the policy, Postgres can use the filter to construct a better query plan.
|
||||
|
||||
## Avoid joins in policy expressions
|
||||
|
||||
You can often rewrite a policy to avoid a join between the source and the target table. Fetch the relevant data from the target table into an array or set instead, then use an `in` or `any` operation in your filter.
|
||||
|
||||
This policy joins the source `test_table` to the target `team_user`:
|
||||
|
||||
```sql
|
||||
create policy "rls_test_select" on test_table
|
||||
to authenticated
|
||||
using (
|
||||
(select auth.uid()) in (
|
||||
select user_id
|
||||
from team_user
|
||||
where team_user.team_id = team_id -- joins to the source "test_table.team_id"
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Rewriting it selects the filter criteria into a set instead:
|
||||
|
||||
```sql
|
||||
create policy "rls_test_select" on test_table
|
||||
to authenticated
|
||||
using (
|
||||
team_id in (
|
||||
select team_id
|
||||
from team_user
|
||||
where user_id = (select auth.uid()) -- no join
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
You can also use a [security definer function](/docs/guides/database/postgres/row-level-security#use-security-definer-functions) to bypass RLS on the join table.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
If the list exceeds 1000 items, a different approach may be needed, or you may need to analyze the approach to ensure that the performance is acceptable.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## More resources
|
||||
|
||||
- [Row Level Security](/docs/guides/database/postgres/row-level-security)
|
||||
- [Managing indexes in Postgres](/docs/guides/database/postgres/indexes)
|
||||
- [Query optimization](/docs/guides/database/query-optimization)
|
||||
@@ -557,147 +557,9 @@ rollback;
|
||||
|
||||
For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing).
|
||||
|
||||
## Test your policies
|
||||
## Write policies that scale
|
||||
|
||||
We recommend writing tests for every policy, in the same change that sets the grants and creates the policies. Tests are a fundamental part of a secure setup, and they give you a repeatable way to prove a policy behaves the way you intended.
|
||||
|
||||
A wrong policy fails quietly. Too permissive, and a query returns rows it shouldn't. Too strict, and it returns nothing and raises no error. Neither case surfaces as an error, so tests are how you find out.
|
||||
|
||||
Supabase runs database tests with [pgTAP](/docs/guides/database/extensions/pgtap) through the CLI. Test files are `.sql` files under `supabase/tests/`.
|
||||
|
||||
### Anatomy of a policy test
|
||||
|
||||
Each case sets an identity, runs one statement as that identity, and asserts the outcome. Three things decide whether the assertion means anything.
|
||||
|
||||
**Identity.** Switch role and identity between cases with `set local role` and `set local request.jwt.claim.sub`, so each assertion runs as the user it describes. Without the switch, every case runs as the same role and proves nothing about access.
|
||||
|
||||
**Denials.** A denied request doesn't always raise an error, so match the assertion to the way the denial happens:
|
||||
|
||||
- A missing grant raises `42501`. Assert it with `throws_ok`.
|
||||
- A `with check` violation raises `42501`. Assert it with `throws_ok`.
|
||||
- A `using` clause that filters the target row out raises nothing. The update or delete matches zero rows instead. Assert that no row changed.
|
||||
|
||||
**Allowed writes.** The absence of an error doesn't prove that anything changed. Add `returning` to the statement so one assertion covers both directions. An allowed write returns the changed row, and a write the policy filters out returns nothing.
|
||||
|
||||
### Write and run the tests
|
||||
|
||||
1. Create the tests directory and a test file:
|
||||
|
||||
```bash
|
||||
mkdir -p supabase/tests
|
||||
touch supabase/tests/profiles_rls.test.sql
|
||||
```
|
||||
|
||||
2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies. Cover `anon` as well as `authenticated`.
|
||||
|
||||
3. Run the suite:
|
||||
|
||||
```bash
|
||||
supabase test db
|
||||
```
|
||||
|
||||
This example tests a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row:
|
||||
|
||||
```sql supabase/tests/profiles_rls.test.sql
|
||||
begin;
|
||||
select plan(11);
|
||||
|
||||
-- Seed two users. The rows come later, through the policies under test.
|
||||
insert into auth.users (id, email)
|
||||
values
|
||||
('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
|
||||
('22222222-2222-2222-2222-222222222222', 'other@example.com');
|
||||
|
||||
-- Signed-out visitors hold no grant, so the request stops before any policy runs.
|
||||
set local role anon;
|
||||
select throws_ok(
|
||||
$$select * from profiles$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot read profiles'
|
||||
);
|
||||
select throws_ok(
|
||||
$$insert into profiles (id, user_id)
|
||||
values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111')$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot insert a profile'
|
||||
);
|
||||
|
||||
-- The owner reads and writes their own row.
|
||||
set local role authenticated;
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$insert into profiles (id, user_id, avatar_url)
|
||||
values (
|
||||
gen_random_uuid(),
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'owner.png'
|
||||
)
|
||||
returning avatar_url$$,
|
||||
array['owner.png'],
|
||||
'the owner creates their own profile'
|
||||
);
|
||||
select results_eq(
|
||||
$$select avatar_url from profiles$$,
|
||||
array['owner.png'],
|
||||
'the owner reads their own profile'
|
||||
);
|
||||
select results_eq(
|
||||
$$update profiles set avatar_url = 'updated.png' returning avatar_url$$,
|
||||
array['updated.png'],
|
||||
'the owner updates their own profile'
|
||||
);
|
||||
|
||||
-- The with check clause rejects the row, which raises.
|
||||
select throws_ok(
|
||||
$$insert into profiles (id, user_id)
|
||||
values (gen_random_uuid(), '22222222-2222-2222-2222-222222222222')$$,
|
||||
'42501',
|
||||
null,
|
||||
'the owner cannot create a profile for someone else'
|
||||
);
|
||||
|
||||
-- A signed-in stranger holds the grant, so the policy is what stops them. The
|
||||
-- using clause filters the row out, so these match nothing and raise nothing.
|
||||
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
|
||||
select is_empty(
|
||||
$$select * from profiles$$,
|
||||
'another user reads no profiles'
|
||||
);
|
||||
select is_empty(
|
||||
$$update profiles set avatar_url = 'stolen.png' returning avatar_url$$,
|
||||
'another user updates no profiles'
|
||||
);
|
||||
select is_empty(
|
||||
$$delete from profiles returning id$$,
|
||||
'another user deletes no profiles'
|
||||
);
|
||||
|
||||
-- The row is still there, still holding the owner's value.
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$select avatar_url from profiles$$,
|
||||
array['updated.png'],
|
||||
'the other user changed nothing'
|
||||
);
|
||||
select results_eq(
|
||||
$$delete from profiles returning avatar_url$$,
|
||||
array['updated.png'],
|
||||
'the owner deletes their own profile'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
```
|
||||
|
||||
For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing).
|
||||
|
||||
## RLS performance recommendations
|
||||
|
||||
Every authorization system has an impact on performance. Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. This matters most for queries that scan every row in a table, like many `select` operations, including those using limit, offset, and ordering.
|
||||
|
||||
Based on a series of [tests](https://github.com/GaryAustin1/RLS-Performance), these are the recommendations for RLS:
|
||||
Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. Three rules keep that cost from growing with your table. Apply all three to every policy you write.
|
||||
|
||||
### Add indexes
|
||||
|
||||
@@ -732,12 +594,6 @@ on team_members
|
||||
using btree (user_id);
|
||||
```
|
||||
|
||||
#### Benchmarks
|
||||
|
||||
| Test | Before (ms) | After (ms) | % Improvement | Change |
|
||||
| --------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| [test1-indexed](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test1-indexed) | 171 | < 0.1 | 99.94% | <details className="cursor-pointer">Before:<br/>No index<br/><br/>After:<br/>`user_id` indexed</details> |
|
||||
|
||||
### Call functions with `select`
|
||||
|
||||
You can use `select` statement to improve policies that use functions. For example, instead of this:
|
||||
@@ -764,44 +620,26 @@ You can only use this technique if the results of the query or function do not c
|
||||
|
||||
</Admonition>
|
||||
|
||||
#### Benchmarks
|
||||
### Specify roles in your policies
|
||||
|
||||
| Test | Before (ms) | After (ms) | % Improvement | Change |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [test2a-wrappedSQL-uid](<https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2a-wrappedSQL-uid()>) | 179 | 9 | 94.97% | <details className="cursor-pointer">Before:<br/>`auth.uid() = user_id` <br/><br/>After:<br/> `(select auth.uid()) = user_id`</details> |
|
||||
| [test2b-wrappedSQL-isadmin](<https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2b-wrappedSQL-isadmin()>) | 11,000 | 7 | 99.94% | <details className="cursor-pointer">Before:<br/>`is_admin()` _table join_<br/><br/>After:<br/>`(select is_admin())` _table join_</details> |
|
||||
| [test2c-wrappedSQL-two-functions](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2c-wrappedSQL-two-functions) | 11,000 | 10 | 99.91% | <details className="cursor-pointer">Before:<br/>`is_admin() OR auth.uid() = user_id`<br/><br/>After:<br/>`(select is_admin()) OR (select auth.uid() = user_id)`</details> |
|
||||
| [test2d-wrappedSQL-sd-fun](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2d-wrappedSQL-sd-fun) | 178,000 | 12 | 99.993% | <details className="cursor-pointer">Before:<br/>`has_role() = role` <br/><br/>After:<br/>(select has_role()) = role</details> |
|
||||
| [test2e-wrappedSQL-sd-fun-array](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2e-wrappedSQL-sd-fun-array) | 173000 | 16 | 99.991% | <details className="cursor-pointer">Before:<br/>`team_id=any(user_teams())` <br/><br/>After:<br/>team_id=any(array(select user_teams()))</details> |
|
||||
Always name the role a policy applies to, using the `to` clause. Instead of this:
|
||||
|
||||
### Add filters to every query
|
||||
|
||||
Policies are "implicit where clauses," so it's common to run `select` statements without any filters. This is a bad pattern for performance. Instead of doing this (JS client example):
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```js
|
||||
const { data } = supabase
|
||||
.from('table')
|
||||
.select()
|
||||
```sql
|
||||
create policy "rls_test_select" on rls_test
|
||||
using ( auth.uid() = user_id );
|
||||
```
|
||||
|
||||
You should always add a filter:
|
||||
Use:
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```js
|
||||
const { data } = supabase
|
||||
.from('table')
|
||||
.select()
|
||||
.eq('user_id', userId)
|
||||
```sql
|
||||
create policy "rls_test_select" on rls_test
|
||||
to authenticated
|
||||
using ( (select auth.uid()) = user_id );
|
||||
```
|
||||
|
||||
Even though this duplicates the contents of the Policy, Postgres can use the filter to construct a better query plan.
|
||||
This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step.
|
||||
|
||||
#### Benchmarks
|
||||
|
||||
| Test | Before (ms) | After (ms) | % Improvement | Change |
|
||||
| ------------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [test3-addfilter](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test3-addfilter) | 171 | 9 | 94.74% | <details className="cursor-pointer">Before:<br/>`auth.uid() = user_id`<br/><br/>After:<br/>add `.eq` or `where` on `user_id`</details> |
|
||||
These three rules keep policies correct as a table grows. To diagnose whether a policy is still the bottleneck, and to tune beyond these rules, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance).
|
||||
|
||||
### Use security definer functions
|
||||
|
||||
@@ -850,79 +688,11 @@ A `security definer` function in an exposed schema is callable over the Data API
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Minimize joins
|
||||
## Related content
|
||||
|
||||
You can often rewrite your Policies to avoid joins between the source and the target table. Instead, try to organize your policy to fetch all the relevant data from the target table into an array or set, then you can use an `IN` or `ANY` operation in your filter.
|
||||
|
||||
For example, this is an example of a slow policy which joins the source `test_table` to the target `team_user`:
|
||||
|
||||
```sql
|
||||
create policy "rls_test_select" on test_table
|
||||
to authenticated
|
||||
using (
|
||||
(select auth.uid()) in (
|
||||
select user_id
|
||||
from team_user
|
||||
where team_user.team_id = team_id -- joins to the source "test_table.team_id"
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
We can rewrite this to avoid this join, and instead select the filter criteria into a set:
|
||||
|
||||
```sql
|
||||
create policy "rls_test_select" on test_table
|
||||
to authenticated
|
||||
using (
|
||||
team_id in (
|
||||
select team_id
|
||||
from team_user
|
||||
where user_id = (select auth.uid()) -- no join
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
In this case you can also consider [using a `security definer` function](#use-security-definer-functions) to bypass RLS on the join table:
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
If the list exceeds 1000 items, a different approach may be needed or you may need to analyze the approach to ensure that the performance is acceptable.
|
||||
|
||||
</Admonition>
|
||||
|
||||
#### Benchmarks
|
||||
|
||||
| Test | Before (ms) | After (ms) | % Improvement | Change |
|
||||
| --------------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [test5-fixed-join](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test5-fixed-join) | 9,000 | 20 | 99.78% | <details className="cursor-pointer">Before:<br/>`auth.uid()` in table join on col<br/><br/>After:<br/>col in table join on `auth.uid()`</details> |
|
||||
|
||||
### Specify roles in your policies
|
||||
|
||||
Always use the Role of inside your policies, specified by the `TO` operator. For example, instead of this query:
|
||||
|
||||
```sql
|
||||
create policy "rls_test_select" on rls_test
|
||||
using ( auth.uid() = user_id );
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```sql
|
||||
create policy "rls_test_select" on rls_test
|
||||
to authenticated
|
||||
using ( (select auth.uid()) = user_id );
|
||||
```
|
||||
|
||||
This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step.
|
||||
|
||||
#### Benchmarks
|
||||
|
||||
| Test | Before (ms) | After (ms) | % Improvement | Change |
|
||||
| --------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [test6-To-role](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test6-To-role) | 170 | < 0.1 | 99.78% | <details className="cursor-pointer">Before:<br/>No `TO` policy<br/><br/>After:<br/>`TO authenticated` (anon accessing)</details> |
|
||||
|
||||
## More resources
|
||||
|
||||
- [Testing your database](/docs/guides/database/testing)
|
||||
- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576)
|
||||
- Community repo on testing RLS using [pgTAP and dbdev](https://github.com/usebasejump/supabase-test-helpers/tree/main)
|
||||
- [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance): diagnose whether policies are your bottleneck, and tune ones that are already correct.
|
||||
- [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended): schema-wide RLS test helpers and a worked multi-tenant example.
|
||||
- [Testing your database](/docs/guides/database/testing): the CLI test workflow that `supabase test db` runs.
|
||||
- [Securing your API](/docs/guides/api/securing-your-api): grants, dedicated schemas, and pre-request checks around the Data API.
|
||||
- [Column Level Security](/docs/guides/database/postgres/column-level-security): restrict access to individual columns.
|
||||
- [`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main): a community extension that adds user creation and role impersonation helpers to pgTAP.
|
||||
+1
-1
@@ -42,7 +42,7 @@ Excessive IO usage is highly problematic as it clarifies that your database is e
|
||||
|
||||
- **Excessive and needless sequential scans:** poorly indexed tables force requests to scan disk ([guide to resolve](https://github.com/orgs/supabase/discussions/22449))
|
||||
- **Too little cache**: There is not enough memory, so instead of reading data from the memory cache, it is accessed from disk ([guide to inspect](https://github.com/orgs/supabase/discussions/22449))
|
||||
- **Poorly optimized RLS policies**: RLS that rely heavily on joins are more likely to hit disk. If possible, they should optimized ([RLS best practice guide](/docs/guides/database/postgres/row-level-security#rls-performance-recommendations))
|
||||
- **Poorly optimized RLS policies**: RLS that rely heavily on joins are more likely to hit disk. If possible, they should optimized ([RLS performance guide](/docs/guides/database/postgres/row-level-security-performance))
|
||||
- **Excessive bloat**: This is the least likely to cause major issues, but bloat can take up space, preventing data on disk from being placed in the same locality. This can force the database to scan more pages than necessary. ([explainer guide](/blog/postgres-bloat))
|
||||
- **Uploading high amounts of data:** temporarily increase compute add-on size for the duration of the uploads
|
||||
- **Insufficient memory**: Sometimes an inadequate amount of memory forces queries to hit disk instead of the memory cache. Address memory issues ([guide](https://github.com/orgs/supabase/discussions/27021)) can reduce disk strain.
|
||||
|
||||
Reference in new issue
Block a user