rls-reference
Pure-reference catalog of row-level security for tenant isolation, Postgres-first. Covers enabling RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY, default-deny semantics), CREATE POLICY syntax (USING vs WITH CHECK clauses, FOR SELECT/INSERT/UPDATE/DELETE/ALL, permissive vs restrictive, TO role_name), bypassing RLS (superuser / BYPASSRLS / table owner / FORCE ROW LEVEL SECURITY), tenant context patterns (current_user, current_setting, JWT claims via Supabase auth.uid() / auth.jwt()), and performance discipline (wrapping auth functions in SELECT, index on policy-referenced columns). Row/tenant isolation on the non-Postgres engines - MySQL / MariaDB invoker views, CockroachDB native RLS, Vitess vindex sharding, SQL Server security policies - lives in references/other-engines.md. Use as the RLS-pattern reference for tenant isolation on any of these engines. Consumed by cross-tenant-data-leak-tests.
Install with skills.sh (any agent)
npx skills add testland/qa --skill rls-referencerls-reference
Overview
Postgres Row-Level Security (RLS) lets the database itself enforce per-tenant row visibility, independent of the application code. It is the canonical defence-in-depth layer for pool / horizontally-partitioned tenancy models - even if the application forgets to add a WHERE tenant_id = ? filter, the database refuses to return another tenant's rows.
Per postgresql.org/docs/current/ddl-rowsecurity.html (opens in new window), RLS "restricts which rows users can access based on per-user policies." Unlike standard SQL GRANT / REVOKE privileges which act on whole tables, RLS controls row visibility and modification per role.
This skill is a pure reference consumed by the tenant-leak test authors and reviewers. Postgres is the primary engine; MySQL / MariaDB, CockroachDB, Vitess, and SQL Server are covered in references/other-engines.md. For the broader model context see the isolation-models reference in cross-tenant-data-leak-tests.
When to use
How to use
Enabling RLS
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;Per Postgres docs: "Once enabled, a default-deny policy applies - no rows are visible or modifiable unless explicitly allowed by a policy."
To disable:
ALTER TABLE accounts DISABLE ROW LEVEL SECURITY;Force RLS on table owner
By default, the table owner bypasses RLS. For tenant isolation this is dangerous - the application's connecting role is often the table owner. Force the owner to obey policies too:
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;Per Postgres docs, this is the production-safe default for multi-tenant tables.
CREATE POLICY syntax
The full grammar plus USING vs WITH CHECK, per-command policies, permissive vs restrictive combination, and TO role_name scoping: references/create-policy-syntax.md.
Tenant context patterns
The policy needs a source of truth for the current tenant. Four canonical patterns - current_setting() with SET LOCAL, current_user / session_user, Supabase auth.uid() / auth.jwt(), and in-policy JWT claim parsing: references/tenant-context-patterns.md.
Policy patterns and bypass
The three tenant-isolation policy shapes (strict discriminator, tenant + per-row ACL, admin override), the three RLS bypass categories (superuser / BYPASSRLS / table owner), and the operations that always bypass RLS (FK, unique, TRUNCATE, REFERENCES): references/policy-and-bypass-patterns.md.
Performance discipline
Per Supabase RLS performance docs:
| Practice | Effect |
|---|---|
Wrap auth function calls in SELECT | Postgres optimiser caches result per statement (initPlan); 94%+ improvement on large tables |
Specify TO role_name | Avoids policy evaluation for unrelated roles |
| Index on policy-referenced columns | 99%+ improvement for tenant_id-filtered queries |
| Include explicit filter in query | Helps query planner even when RLS adds implicit filter |
Bad:
USING ( auth.uid() = user_id )Good (initPlan-cached):
USING ( (SELECT auth.uid()) = user_id )Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| RLS enabled but no policy | Default-deny means no rows return - broken silently | Always create at least one policy after enabling |
Policy on table without FORCE ROW LEVEL SECURITY | Table owner bypasses; app role often is the owner | Add FORCE ROW LEVEL SECURITY |
current_user for tenant_id without per-tenant roles | Tenants share a role; no isolation | Use SET LOCAL or JWT claim |
tenant_id from request header -> SET LOCAL | Spoofable; never derive from request input | Derive from authenticated session/JWT only |
auth.uid() not wrapped in SELECT | Per-row evaluation - major perf hit at scale | (SELECT auth.uid()) for initPlan caching |
| No index on tenant_id | Sequential scan after policy filter | btree index on tenant_id always |
| Permissive policy + missing TO clause | Applies to all roles including superusers | Always specify TO app_user |
| Tests use superuser to verify policies | Bypasses RLS - test passes but prod leaks | Tests must connect as a non-superuser without BYPASSRLS |
| Trusting raw_user_meta_data in Supabase policies | User-modifiable per Supabase docs | Use raw_app_meta_data (server-set) |
| Single policy mixing tenant + per-row ACL with OR | Permissive OR widens access; one bad clause leaks tenant | Split into permissive (tenant) + restrictive (ACL) |
Testing RLS policies
Two levels:
Statement-level
-- Connect as app_user (no BYPASSRLS, not table owner)
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT count(*) FROM documents; -- should match tenant 1's count only
SET LOCAL app.tenant_id = '22222222-2222-2222-2222-222222222222';
SELECT count(*) FROM documents; -- should match tenant 2's count
-- Attempt cross-tenant
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
INSERT INTO documents (tenant_id, body)
VALUES ('22222222-2222-2222-2222-222222222222', 'leak');
-- Must fail with: new row violates row-level security policyApplication-level
Run the existing test suite under a non-superuser, non-BYPASSRLS, non-table-owner role. If passing tests rely on RLS bypass, the suite is silently invalid for production. Per cross-tenant-data-leak-tests the gate test must use a tenant-A session to attempt access to tenant-B-owned rows and assert 0 rows returned.
Worked example
Onboard a documents table to the strict tenant_id discriminator (Pattern 1). Enable and force RLS, then create the policy scoped to app_user:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
AS PERMISSIVE FOR ALL TO app_user
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);The application runs SET LOCAL app.tenant_id = '<uuid>' from the authenticated session at the start of every transaction. Connected as app_user (no BYPASSRLS, not the table owner), the tester sets tenant 1 and runs SELECT count(*) FROM documents - it returns tenant 1's rows only. Setting tenant 1 and attempting INSERT INTO documents (tenant_id, body) VALUES ('<tenant-2-uuid>', 'leak') fails with new row violates row-level security policy, because WITH CHECK rejects the foreign tenant_id. The table is isolated for both reads and writes.
Limitations
References
CockroachDB - native RLS
View source (opens in new window)CockroachDB - native RLS
CockroachDB supports native row-level security that closely mirrors Postgres RLS.
Enabling RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Force table owners to obey policies too:
ALTER TABLE documents FORCE ROW LEVEL SECURITY;CREATE POLICY
CREATE POLICY policy_name ON table AS [PERMISSIVE | RESTRICTIVE] FOR [SELECT | INSERT | UPDATE | DELETE | ALL] TO role USING (condition) [WITH CHECK (condition)]. Per the CockroachDB RLS docs: USING filters rows on reads and updates; WITH CHECK validates writes and defaults to USING when omitted; permissive policies combine with OR, restrictive with AND; access is denied by default once RLS is enabled and no policy applies.
Tenant context via application_name
CockroachDB has no Postgres-style SET LOCAL + current_setting transaction variable. The canonical pattern reads the tenant from application_name, a session variable every client sets at connection open:
SET application_name = 'tenant:<uuid>';
CREATE POLICY tenant_isolation ON documents
FOR ALL
TO app_role
USING (
tenant_id = split_part(current_setting('application_name'), ':', 2)::uuid
)
WITH CHECK (
tenant_id = split_part(current_setting('application_name'), ':', 2)::uuid
);Bypass risks
Per the CockroachDB RLS docs, these paths bypass RLS and need separate controls:
| Bypass | Behaviour |
|---|---|
| Foreign key constraints and cascades | Not subject to RLS |
| Primary / unique key constraints | Not subject to RLS |
TRUNCATE | Not subject to RLS |
| Change Data Capture (CDC) | Queries fail with error when RLS is enabled |
| Backup and restore | Ignore RLS policies |
| Logical and physical cluster replication | Ignore RLS policies |
Source: CockroachDB RLS cockroachlabs.com/docs/stable/row-level-security.html (opens in new window).
CREATE POLICY syntax reference
View source (opens in new window)CREATE POLICY syntax reference
Full CREATE POLICY grammar plus the clause semantics a tenant policy relies on.
Grammar
CREATE POLICY policy_name ON table_name
[ AS { PERMISSIVE | RESTRICTIVE } ]
[ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]
[ TO role_name [, ...] ]
[ USING ( using_expression ) ]
[ WITH CHECK ( check_expression ) ];USING vs WITH CHECK
| Clause | Controls |
|---|---|
USING | Which rows are visible (SELECT, UPDATE, DELETE) |
WITH CHECK | Which rows can be written (INSERT, UPDATE) |
If only USING is specified, it implicitly applies to both.
Per Postgres docs, the canonical own-data UPDATE policy uses both:
CREATE POLICY user_policy ON users
FOR UPDATE
USING (user_name = current_user)
WITH CHECK (
user_name = current_user AND
shell IN ('/bin/bash', '/bin/sh', '/bin/dash')
);USING ensures the user can only update their own row; WITH CHECK ensures they can't update the row into a state that violates other invariants.
Per-command policies
For SELECT/UPDATE divergence (a common tenant pattern: all users see all rows, but only modify their own):
CREATE POLICY user_sel_policy ON users
FOR SELECT
USING (true);
CREATE POLICY user_mod_policy ON users
FOR UPDATE
USING (user_name = current_user);Permissive vs restrictive policies
| Type | Combination |
|---|---|
| PERMISSIVE (default) | Multiple policies combine with OR - any match grants access |
| RESTRICTIVE | Multiple policies combine with AND - all must allow |
Restrictive policies layer additional constraints on top of permissive ones. Per Postgres docs:
-- Permissive: anyone in role 'admin' or whose row matches
-- Restrictive: only allow when from local network
CREATE POLICY admin_local_only ON passwd
AS RESTRICTIVE TO admin
USING (pg_catalog.inet_client_addr() IS NULL);TO role_name
If omitted, the policy applies to all users (TO PUBLIC). For tenant isolation, scope policies to the application role:
CREATE POLICY tenant_isolation ON documents
TO app_user
USING (tenant_id = current_setting('app.tenant_id')::uuid);References
MySQL and MariaDB - view-based tenant isolation
View source (opens in new window)MySQL and MariaDB - view-based tenant isolation
MySQL 8.x and MariaDB have no native row-level security statement. Isolation combines a view defined SQL SECURITY INVOKER with an application role that holds no direct grants on the base table.
Why INVOKER, not DEFINER
The CREATE VIEW default is SQL SECURITY DEFINER: the view runs with the creator's privileges, so any user with SELECT on the view reads every row the creator can read, including other tenants'. With SQL SECURITY INVOKER the view runs with the caller's privileges, so the view's WHERE clause becomes the tenant boundary. The only clauses material to isolation are SQL SECURITY { DEFINER | INVOKER } and WITH [CASCADED | LOCAL] CHECK OPTION; the MySQL 8.4 CREATE VIEW reference has the full grammar.
Canonical per-tenant view
CREATE VIEW tenant_docs AS
SELECT *
FROM documents
WHERE tenant_id = /* app sets via session var or stored function */ ...
SQL SECURITY INVOKER
WITH CASCADED CHECK OPTION;WITH CHECK OPTION rejects inserts or updates that would create rows outside the view's WHERE clause.
App-layer enforcement (required complement)
Views do not block TRUNCATE, constraint checks, or direct table access when the role holds table-level grants. The application must:
MariaDB shares this model. Its DEFINER clause also accepts role | CURRENT_ROLE, which makes a schema-per-tenant view owned by a per-tenant role viable at low tenant counts. Atomic DDL for CREATE VIEW landed in MariaDB 10.6.1.
Bypass risks
| Risk | Why |
|---|---|
SQL SECURITY DEFINER (default) | View runs as creator; tenant filter is advisory only |
| Direct base-table grants on the app role | App can bypass the view entirely |
TRUNCATE | Never filtered by views; requires separate role restriction |
| Schema changes to the view | Migration that widens WHERE clause removes isolation |
Sources: MySQL 8.4 CREATE VIEW dev.mysql.com/doc/refman/8.4/en/create-view.html (opens in new window); MariaDB CREATE VIEW mariadb.com/kb/en/create-view/ (opens in new window).
Row-level security on non-Postgres engines
View source (opens in new window)Row-level security on non-Postgres engines
Companion reference for rls-reference. The host skill covers Postgres RLS in full; this file covers the four non-Postgres engines that appear most in multi-tenant B2B SaaS stacks: MySQL / MariaDB, CockroachDB, Vitess, and SQL Server.
Overview
The host skill covers Postgres RLS in full. This reference covers the four non-Postgres engines that appear most in multi-tenant B2B SaaS stacks. For each engine the spine below gives the isolation primitive, the tenant-context essence, and a minimal isolation test; the full context SQL, vindex / VSchema detail, and the per-engine bypass table live in the linked per-engine reference file. For the broader model-selection context see the isolation-models reference in cross-tenant-data-leak-tests.
When to use
Engine cheat sheet
| Engine | Isolation primitive | Tenant context | Highest-signal gotcha |
|---|---|---|---|
| MySQL / MariaDB | SQL SECURITY INVOKER view + app-role grants | Session var / stored function | Default DEFINER view leaks; app role must lack base-table grants |
| CockroachDB | Native RLS (CREATE POLICY) | application_name session var | No Postgres SET LOCAL current_setting; CDC/backup/replication bypass RLS |
| Vitess | Keyspace sharding + tenant_id vindex | tenant_id predicate on every query | Query without tenant_id scatters to all shards; tenant_id must be immutable |
| SQL Server | CREATE SECURITY POLICY + inline TVF predicate | SESSION_CONTEXT with @read_only = 1 | Pooled connection reuse leaks context without @read_only = 1 |
MySQL / MariaDB - views + app layer
No native RLS. A view defined SQL SECURITY INVOKER runs with the caller's privileges, so its WHERE clause is the tenant boundary; the default SQL SECURITY DEFINER runs as the creator and leaks other tenants' rows. WITH CASCADED CHECK OPTION blocks writes that fall outside the view. Isolation holds only if the app role has no direct grants on the base table. MariaDB shares this model, with role-owned per-tenant views as an option at low tenant counts.
-- Role has SELECT on the view only (not the base table)
SET @tenant_id = 'tenant-A';
SELECT COUNT(*) FROM tenant_docs; -- tenant-A rows only
SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-B'; -- must be denied
INSERT INTO tenant_docs (tenant_id, body) VALUES ('tenant-B', 'leak'); -- CHECK OPTION failedFull view pattern, app-layer checklist, and bypass table: mysql-mariadb.md (opens in new window).
CockroachDB - native RLS
Native RLS mirroring Postgres. ALTER TABLE ... ENABLE ROW LEVEL SECURITY plus CREATE POLICY ... USING (...) WITH CHECK (...); access is denied by default once RLS is enabled and no policy applies. There is no Postgres SET LOCAL + current_setting transaction variable, so the tenant is read from the application_name session variable.
-- app_role: no BYPASSRLS, not table owner
SET application_name = 'tenant:11111111-1111-1111-1111-111111111111';
SELECT COUNT(*) FROM documents; -- tenant-1 rows only
INSERT INTO documents (tenant_id, body)
VALUES ('22222222-2222-2222-2222-222222222222', 'leak'); -- violates WITH CHECK
SET row_security = off;
SELECT * FROM documents; -- errors if a policy would filter rows
RESET row_security;Enable/force syntax, the application_name policy, and the bypass table (FK, TRUNCATE, CDC, backup, replication): cockroachdb.md (opens in new window).
Vitess - keyspace sharding + vindexes
No policy layer. A primary vindex on tenant_id routes each tenant's rows to a dedicated shard range; a query without a tenant_id predicate scatters to all shards and returns every tenant's rows, so the application must include tenant_id on every query. tenant_id is the sharding key and must be immutable - Vitess cannot move a row between shards on update.
-- Via VTGate: confirm the plan is a unique route, not a scatter
EXPLAIN SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-A';
-- plan_type must be "EqualUnique", not "Scatter"
UPDATE documents SET tenant_id = 'tenant-B' WHERE id = 1; -- must be rejected at app layerVindex types, the VSchema fragment, and the bypass table: vitess.md (opens in new window).
SQL Server - security policy + predicate function
Native RLS since SQL Server 2016. CREATE SECURITY POLICY adds a FILTER predicate (silently filters SELECT/UPDATE/DELETE) and/or a BLOCK predicate (blocks violating writes); the predicate is an inline table-valued function created WITH SCHEMABINDING. For shared pools the tenant ID is carried in SESSION_CONTEXT, set with @read_only = 1 so a reused connection cannot carry a prior tenant's context.
EXECUTE AS USER = 'AppUser';
EXEC sp_set_session_context @key = N'TenantId', @value = 1, @read_only = 1;
SELECT COUNT(*) FROM dbo.Documents; -- tenant-1 rows only
INSERT INTO dbo.Documents (TenantId, Body) VALUES (2, 'leak'); -- BLOCK predicate fires
EXEC sp_set_session_context @key = N'TenantId', @value = 2; -- fails: key is read-only
REVERT;Predicate function + policy, the USER_NAME() alternative, and the bypass table (CDC, Change Tracking, indexed views, FILESTREAM): sqlserver.md (opens in new window).
Anti-patterns (all engines)
| Anti-pattern | Engine(s) | Why it fails | Fix |
|---|---|---|---|
SQL SECURITY DEFINER view for isolation | MySQL, MariaDB | Runs as creator; any caller sees creator-scoped rows regardless of tenant | Use SQL SECURITY INVOKER + restrict base-table grants |
No WITH CHECK OPTION on isolation view | MySQL, MariaDB | Inserts into another tenant's row space silently succeed | Add WITH CASCADED CHECK OPTION |
| Direct base-table grants alongside view grants | MySQL, MariaDB | App can bypass the view | Grant only on the view, deny on the base table |
Querying Vitess without a tenant_id predicate | Vitess | Scatters to all shards; returns all tenants' rows | Enforce tenant_id predicate in ORM / query layer |
Mutable tenant_id (sharding key) in Vitess | Vitess | Vitess cannot move rows between shards; the update silently corrupts or errors | Declare tenant_id immutable at the application layer |
SESSION_CONTEXT without @read_only = 1 in SQL Server | SQL Server | Pooled connection reuse can carry a prior tenant's context | Always set @read_only = 1 when writing to SESSION_CONTEXT |
Tests run as db_owner / sysadmin in SQL Server | SQL Server | Policy applies to these roles, but they can drop/alter the policy - test does not prove the policy is correct for app-role | Tests must connect as the app role only |
| Policies applied to current temporal table but not history table | SQL Server | History table is unprotected | Add a matching CREATE SECURITY POLICY on the history table separately |
Limitations
References
Tenant policy patterns and RLS bypass
View source (opens in new window)Tenant policy patterns and RLS bypass
Canonical tenant-isolation policy shapes, plus the ways RLS can be bypassed that a tenant deployment must lock down.
Common policy patterns for tenant isolation
Pattern 1: Strict tenant_id discriminator
CREATE POLICY tenant_isolation ON documents
AS PERMISSIVE
FOR ALL
TO app_user
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;Both USING and WITH CHECK use the same expression - a tenant can't read OR insert rows for another tenant.
Pattern 2: Tenant + per-row ACL
CREATE POLICY tenant_isolation ON documents
AS PERMISSIVE
USING (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY owner_or_shared ON documents
AS RESTRICTIVE
USING (
owner_id = current_setting('app.user_id')::uuid
OR id IN (SELECT document_id FROM document_shares
WHERE shared_with = current_setting('app.user_id')::uuid)
);Permissive policy enforces tenant boundary; restrictive policy layers on per-row ACL.
Pattern 3: Admin override
CREATE POLICY tenant_isolation ON documents
USING (
tenant_id = current_setting('app.tenant_id')::uuid
OR current_setting('app.is_global_admin', true)::boolean = true
);Global admin claim bypasses the tenant filter. Audit usage - admin connections must be logged and short-lived.
Bypassing RLS
Three categories of bypass per Postgres docs:
| Bypass | When |
|---|---|
| Superusers | Always (cannot be disabled) |
Roles with BYPASSRLS attribute | Operational tasks; create explicit roles, audit usage |
| Table owners | Bypass by default unless FORCE ROW LEVEL SECURITY set |
For production tenant tables, the application connection role must NOT be a superuser, NOT have BYPASSRLS, and must NOT own the table (or, if it owns the table, FORCE ROW LEVEL SECURITY must be set).
Detecting RLS-bypassed sessions
To prevent accidentally-bypassed query patterns from masquerading as policy-respecting:
SET row_security = off;
-- Subsequent queries error if a policy would have filteredUseful in audit / backup contexts to catch unintended row exclusion.
Operations that always bypass RLS
Per Postgres docs, these operations are not subject to RLS:
This creates side-channel leaks: an attacker can probe for tenant-other rows via INSERT collisions on unique constraints, or via foreign-key reference patterns. Compensating controls:
References
SQL Server - CREATE SECURITY POLICY with predicate functions
View source (opens in new window)SQL Server - CREATE SECURITY POLICY with predicate functions
SQL Server has native RLS since SQL Server 2016 (13.x), also available on Azure SQL Database, Azure SQL Managed Instance, and Microsoft Fabric Warehouse.
Predicate types
| Predicate | Effect |
|---|---|
FILTER | Silently filters rows for SELECT, UPDATE, DELETE - the app sees an empty result, not an error |
BLOCK | Explicitly blocks writes (AFTER INSERT, AFTER UPDATE, BEFORE UPDATE, BEFORE DELETE) that violate the predicate |
The predicate is an inline table-valued function created WITH SCHEMABINDING. SCHEMABINDING = ON (the default) means users querying the target table need no permission on the predicate function or its helper tables. The clauses material to isolation are ADD [FILTER | BLOCK] PREDICATE tvf(cols) ON table and WITH (STATE = ON); the CREATE SECURITY POLICY reference has the full grammar.
Tenant context via SESSION_CONTEXT
For shared connection pools, carry the tenant ID per connection with SESSION_CONTEXT():
CREATE FUNCTION Security.fn_tenant_predicate(@TenantId int)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS result
WHERE CAST(SESSION_CONTEXT(N'TenantId') AS int) = @TenantId;
GO
CREATE SECURITY POLICY Security.TenantFilter
ADD FILTER PREDICATE Security.fn_tenant_predicate(TenantId) ON dbo.Documents,
ADD BLOCK PREDICATE Security.fn_tenant_predicate(TenantId) ON dbo.Documents AFTER INSERT
WITH (STATE = ON);
GO
EXEC sp_set_session_context @key = N'TenantId', @value = 42, @read_only = 1;@read_only = 1 locks the SESSION_CONTEXT value until the connection returns to the pool - critical for pool reuse safety.
Alternative tenant context - USER_NAME()
For low-tenant-count deployments where each tenant maps to a SQL login:
CREATE FUNCTION Security.tvf_securitypredicate(@TenantRep AS nvarchar(50))
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS result
WHERE @TenantRep = USER_NAME() OR USER_NAME() = 'GlobalAdmin';
GOBypass risks
| Risk | Why |
|---|---|
db_owner / sysadmin | Policy applies but these roles can alter/drop it; audit all policy changes |
DBCC SHOW_STATISTICS | Reports statistics on unfiltered data; restrict access to table owners |
| Change Data Capture (CDC) | Leaks full rows to db_owner and the CDC gating role regardless of policy |
| Change Tracking | Leaks primary keys of filtered rows to users with VIEW CHANGE TRACKING |
| Indexed views | Cannot be created on tables with a security policy |
FILESTREAM | Incompatible with RLS |
Predicate relying on SET options | SET DATEFORMAT/SET LANGUAGE can cause inconsistent filtering; use explicit CONVERT with a style parameter |
Sources: SQL Server RLS learn.microsoft.com/en-us/sql/relational-databases/security/row-level-security (opens in new window); CREATE SECURITY POLICY learn.microsoft.com/en-us/sql/t-sql/statements/create-security-policy-transact-sql (opens in new window).
Tenant context patterns
View source (opens in new window)Tenant context patterns
The policy needs a source of truth for the current tenant. Four canonical patterns.
1. current_setting() with SET LOCAL
Set the tenant at session / transaction start, read it in the policy:
-- Application code (every transaction):
SET LOCAL app.tenant_id = '<uuid>';
-- Policy:
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::uuid);SET LOCAL confines the value to the current transaction - critical when the connection is from a shared connection pool.
2. current_user / session_user
Map each tenant to a Postgres role. Suitable for low-tenant-count deployments (silo-leaning):
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = (SELECT id FROM tenants WHERE name = current_user));3. Supabase auth.uid() / auth.jwt()
For Supabase-backed apps, JWT claims are exposed through helper functions. Per supabase.com/docs/guides/database/postgres/row-level-security (opens in new window):
CREATE POLICY "User can see their own profile only."
ON profiles FOR SELECT
USING ( (SELECT auth.uid()) = user_id );Organisation / team membership via app_metadata:
CREATE POLICY "User is in team"
ON my_table TO authenticated
USING ( team_id IN (SELECT auth.jwt() -> 'app_metadata' -> 'teams') );Critical: Per Supabase docs, never trust raw_user_meta_data for authorisation - it's user-modifiable. Use raw_app_meta_data (server-set) or a separate server-side claim store.
4. JWT claim parsing in policy
For self-rolled auth, parse the JWT or token directly:
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = (current_setting('request.jwt.claims', true)::jsonb->>'tenant_id')::uuid);The true argument to current_setting makes it return NULL if the setting is absent (safer than erroring).
References
Vitess - keyspace sharding + vindexes
View source (opens in new window)Vitess - keyspace sharding + vindexes
Vitess has no row-level security policy layer. Tenant isolation happens at the routing tier: a tenant's rows sit on a dedicated shard (or shard range), and vindexes route queries so cross-tenant access never reaches the wrong MySQL shard. A keyspace is a logical database; when sharded it maps to multiple MySQL databases across shards, and a vindex maps a column value to a keyspace ID.
Vindexes for tenant isolation
A primary vindex on tenant_id maps each tenant to a keyspace-ID range; Vitess routes every query carrying a tenant_id condition to the owning shard(s).
| Vindex type | Use for tenant isolation |
|---|---|
Functional hash (xxhash, hash) | Maps tenant_id to a keyspace ID deterministically; no lookup table needed |
| Consistent lookup (unique) | Stores tenant_id -> keyspace_id in a MySQL lookup table; supports non-hash distributions |
| Non-unique lookup | Secondary routing on tenant-level subtables |
Example VSchema fragment:
{
"sharded": true,
"vindexes": { "tenant_hash": { "type": "xxhash" } },
"tables": {
"documents": {
"columnVindexes": [ { "column": "tenant_id", "name": "tenant_hash" } ]
}
}
}Any query without a tenant_id condition scatters to all shards. Isolation therefore depends on every query carrying a tenant_id predicate - an application-layer responsibility.
Bypass risks
| Risk | Why |
|---|---|
Query without tenant_id predicate | Scatters to all shards; returns rows from every tenant |
| Cross-shard transactions | 2PC for cross-shard writes; isolation bugs can occur in rollback paths |
Row updates that change tenant_id | Vitess cannot move rows between shards on update; tenant_id must be immutable |
| Direct MySQL access (bypassing VTGate) | Shard-level MySQL has no vindex awareness; direct access bypasses routing |
Sources: Vitess Vindexes vitess.io/docs/21.0/reference/features/vindexes/ (opens in new window); Vitess Keyspaces vitess.io/docs/21.0/concepts/keyspace/ (opens in new window).
Related skills
cross-tenant-data-leak-tests
Workflow-driven skill that plans and implements the cross-tenant leak-test suite - from surface inventory to the runtime CI gate a multi-tenant codebase must pass on every PR. The planning section inventories tenant-bearing surfaces (tables, APIs, object storage, search, queues, caches), classifies each by isolation model (silo / pool / bridge, per references/isolation-models.md), and derives the OWASP WSTG-ATHZ-02 coverage matrix. The battery defines the canonical test patterns (read-other-tenant-by-id, list-leak, spoofed-tenant-id-in-body, JWT-replay, FK-cross-tenant, unique-collision side channel, object-storage IDOR, search-index-direct-query, async-job-context-reload, cache-key-collision), the 404-vs-403 disclosure trade-off, the Postgres-RLS-direct patterns, and the CI integration (non-superuser non-BYPASSRLS role, fail the build on any leak). Use when designing or implementing a tenant-isolation test suite, adding the CI gate to an existing project, or investigating a leak finding.
tenant-onboarding-test-author
Workflow-driven skill that authors a test suite for tenant provisioning and offboarding: account creation, isolation at creation (no cross-tenant bleed from a new tenant's first API call), default resource quotas, billing record linkage, seed and default data correctness, idempotent re-provisioning, and teardown with full data deletion. Walks through mapping provisioning surfaces, generating test cases per surface, emitting the test suite skeleton (pytest / Jest / JUnit / Go test), and producing a coverage matrix. Use when a new tenant onboarding flow is introduced or changed, when the offboarding pipeline is modified, or when auditing provisioning coverage before a compliance review. Distinct from cross-tenant-data-leak-tests (leak-test planning + runtime CI gate): this skill covers the provisioning lifecycle, not steady-state access control.