From qa-multi-tenancy
Pure-reference catalog of row/tenant isolation mechanisms for non-Postgres engines: MySQL and MariaDB (no native RLS - views with SQL SECURITY INVOKER plus app-layer enforcement), CockroachDB (native RLS via ALTER TABLE ENABLE ROW LEVEL SECURITY and CREATE POLICY, matching Postgres semantics), Vitess (keyspace sharding + vindexes route tenant writes to dedicated shards without a policy layer), and SQL Server (CREATE SECURITY POLICY with inline table-valued function filter/block predicates). Covers the isolation mechanism, tenant-context pattern, bypass risks, and test patterns for each engine. Use when designing or auditing tenant isolation on any of these four engines, or when the Postgres RLS reference does not apply.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-multi-tenancy:non-postgres-rls-referenceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The `row-level-security-postgres-reference` skill covers Postgres RLS in
The row-level-security-postgres-reference skill covers Postgres RLS in
full. This skill covers the four non-Postgres engines that appear most
frequently in multi-tenant B2B SaaS stacks: MySQL/MariaDB (views + app
layer), CockroachDB (native RLS close to Postgres semantics), Vitess
(keyspace sharding via vindexes), and SQL Server (security policies with
predicate functions).
Each engine section covers:
For the broader model-selection context see
tenant-isolation-models-reference.
cross-tenant-data-leak-tests).MySQL 8.x has no native row-level security statement. Isolation is
achieved by combining views with SQL SECURITY INVOKER and application-
layer enforcement.
Per dev.mysql.com/doc/refman/8.4/en/create-view.html,
the full CREATE VIEW syntax is:
CREATE
[OR REPLACE]
[ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]
[DEFINER = user]
[SQL SECURITY { DEFINER | INVOKER }]
VIEW view_name [(column_list)]
AS select_statement
[WITH [CASCADED | LOCAL] CHECK OPTION]
The default is SQL SECURITY DEFINER: the view executes with the
creator's privileges, so any connected user who has SELECT on the
view can read rows the creator can read - even rows from other tenants.
With SQL SECURITY INVOKER, "the required privileges must be held by
the user who defined or invoked the view" (per MySQL 8.4 docs). The
view executes with the caller's privileges and the WHERE clause in
the view definition becomes the isolation boundary.
Canonical per-tenant view pattern:
-- One view per tenant, or a parameterised-equivalent pattern:
CREATE VIEW tenant_docs AS
SELECT *
FROM documents
WHERE tenant_id = /* app sets this via stored-function or column */ ...
SQL SECURITY INVOKER
WITH CASCADED CHECK OPTION;
WITH CHECK OPTION prevents inserts or updates that would produce rows
invisible to the view's WHERE clause, per MySQL 8.4 docs.
MySQL views do not block TRUNCATE, constraint checks, or direct table
access if the connecting role has table-level grants. The application
must:
SELECT/INSERT/UPDATE/
DELETE grants on the base table (only on the view).WHERE clause as the sole row gate; validate it
in every schema migration.| 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 |
-- Connect as a role with SELECT on the view only (not the base table)
SET @tenant_id = 'tenant-A';
SELECT COUNT(*) FROM tenant_docs; -- must equal tenant-A row count only
-- Attempt tenant-B row via direct table access
SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-B';
-- Must fail with: SELECT command denied to user '...' for table 'documents'
-- Attempt insert that violates the WHERE clause
INSERT INTO tenant_docs (tenant_id, body) VALUES ('tenant-B', 'leak');
-- Must fail with: CHECK OPTION failed
MariaDB shares MySQL's view-based isolation model. Per
mariadb.com/kb/en/create-view/,
the CREATE VIEW syntax is identical to MySQL's except that the
DEFINER clause also accepts role | CURRENT_ROLE (MariaDB-only).
SQL SECURITY INVOKER semantics are identical: "the required privileges
must be held by the user who defined or invoked the view."
The isolation pattern, bypass risks, and test pattern are the same as
MySQL. One practical difference: MariaDB's role system makes a schema-
per-tenant view owned by a per-tenant role viable at low tenant counts.
Atomic DDL for CREATE VIEW was added in MariaDB 10.6.1.
CockroachDB supports native row-level security that closely mirrors Postgres RLS. Per cockroachlabs.com/docs/stable/row-level-security.html:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- To force table owners to obey policies too:
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
CREATE POLICY policy_name ON table_name
AS [PERMISSIVE | RESTRICTIVE]
FOR [SELECT | INSERT | UPDATE | DELETE | ALL]
TO role_name
USING (condition)
[WITH CHECK (condition)];
Per CockroachDB docs:
USING filters rows during reads and updates.WITH CHECK validates write operations; defaults to USING if
omitted.OR; restrictive policies with
AND.CockroachDB does not have Postgres's current_setting() with
SET LOCAL. The canonical pattern extracts the tenant from
application_name (a session variable every CRDB client sets):
-- Set at connection open time (application code):
SET application_name = 'tenant:<uuid>';
-- Policy:
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
);
Per CockroachDB RLS docs, the following bypass RLS and require separate controls:
| Bypass | CockroachDB 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 | Operations ignore RLS policies |
| Logical and physical cluster replication | Ignore RLS policies |
-- Connect as app_role (no BYPASSRLS, not table owner)
SET application_name = 'tenant:11111111-1111-1111-1111-111111111111';
SELECT COUNT(*) FROM documents; -- must equal tenant-1's count only
-- Attempt cross-tenant insert
SET application_name = 'tenant:11111111-1111-1111-1111-111111111111';
INSERT INTO documents (tenant_id, body)
VALUES ('22222222-2222-2222-2222-222222222222', 'leak');
-- Must fail: new row violates WITH CHECK policy
-- Disable row_security to detect filter-induced row exclusion
SET row_security = off;
SELECT * FROM documents; -- errors if a policy would have filtered rows
RESET row_security;
-- Role switch test
SET ROLE app_role;
SELECT COUNT(*) FROM documents; -- must return only tenant-scoped rows
RESET ROLE;
Vitess does not have a row-level security policy layer. Tenant isolation is achieved at the routing layer: a tenant's rows are placed on a dedicated shard (or shard range) and queries are routed by vindexes so cross-tenant access never reaches the wrong MySQL shard.
Per vitess.io/docs/21.0/concepts/keyspace/, a keyspace is a logical database. When sharded, one keyspace maps to multiple MySQL databases across shards. "A Vindex provides a way to map a column value to a keyspace ID."
A primary vindex on tenant_id maps each tenant to a keyspace ID
range. Vitess routes every query that carries a tenant_id condition
to the shard(s) that own that range. Per the Vitess vindex docs:
| 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 | For secondary routing on tenant-level subtables |
Example VSchema fragment (per vitess.io vindex reference):
{
"sharded": true,
"vindexes": {
"tenant_hash": {
"type": "xxhash"
}
},
"tables": {
"documents": {
"columnVindexes": [
{ "column": "tenant_id", "name": "tenant_hash" }
]
}
}
}
With this VSchema, any query without a tenant_id condition scatters
to all shards. Tenant isolation is enforced by ensuring every query
carries a tenant_id predicate - a responsibility of the application
layer.
| Risk | Why |
|---|---|
Query without tenant_id predicate | Scatters to all shards; returns rows from every tenant |
| Cross-shard transactions | Vitess uses 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; the tenant_id column must be immutable |
| Direct MySQL access (bypassing VTGate) | Shard-level MySQL has no vindex awareness; direct access bypasses all routing |
-- Via VTGate: query with tenant_id - should return only that tenant's rows
SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-A';
-- Explain to verify no scatter:
EXPLAIN SELECT COUNT(*) FROM documents WHERE tenant_id = 'tenant-A';
-- plan_type must be "EqualUnique", not "Scatter"
-- Via VTGate: query without tenant_id - must be disallowed by app layer
-- or must return rows from all shards (document the expected behaviour)
SELECT COUNT(*) FROM documents;
-- If scatter queries are prohibited, this must return an error
-- Attempt to update tenant_id (must be rejected at app layer)
UPDATE documents SET tenant_id = 'tenant-B' WHERE id = 1;
-- Must be rejected; changing the sharding key is unsupported
SQL Server has native RLS introduced in SQL Server 2016 (13.x), also available on Azure SQL Database, Azure SQL Managed Instance, and Microsoft Fabric Warehouse. Per learn.microsoft.com/en-us/sql/relational-databases/security/row-level-security.
Per the SQL Server RLS docs, RLS supports two predicate types:
| Predicate | Effect |
|---|---|
FILTER | Silently filters rows for SELECT, UPDATE, DELETE - application sees empty result, not an error |
BLOCK | Explicitly blocks write operations (AFTER INSERT, AFTER UPDATE, BEFORE UPDATE, BEFORE DELETE) that violate the predicate |
Per learn.microsoft.com/en-us/sql/t-sql/statements/create-security-policy-transact-sql:
CREATE SECURITY POLICY [schema_name.] security_policy_name
{ ADD [ FILTER | BLOCK ] } PREDICATE tvf_schema_name.predicate_fn_name
( { column_name | expression } [, ...n] ) ON table_schema_name.table_name
[ <block_dml_operation> ] [, ...n]
[ WITH ( STATE = { ON | OFF } [,] [ SCHEMABINDING = { ON | OFF } ] ) ]
[ NOT FOR REPLICATION ]
<block_dml_operation>
[ { AFTER { INSERT | UPDATE } }
| { BEFORE { UPDATE | DELETE } } ]
The predicate must be an inline table-valued function created with
SCHEMABINDING. Per the SQL Server docs, SCHEMABINDING = ON (the
default) means users querying the target table do NOT need permissions
on the predicate function or its helper tables - required for
zero-friction application use.
For shared-connection-pool architectures, the canonical pattern uses
SESSION_CONTEXT() to carry the tenant ID per connection. Per SQL
Server RLS docs (Scenario C in the examples):
-- Predicate function:
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
-- Security policy (filter + block):
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
-- Application sets context at connection open time:
EXEC sp_set_session_context @key = N'TenantId', @value = 42, @read_only = 1;
@read_only = 1 prevents the SESSION_CONTEXT value from being
changed until the connection is returned to the pool, per SQL Server
docs - critical for connection pool reuse safety.
For low-tenant-count deployments where each tenant maps to a SQL login, per the SQL Server RLS example A:
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';
GO
Per SQL Server RLS docs:
| 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 style parameter |
-- Connect as AppUser (no direct table access; SESSION_CONTEXT set by app)
EXECUTE AS USER = 'AppUser';
EXEC sp_set_session_context @key = N'TenantId', @value = 1;
SELECT COUNT(*) FROM dbo.Documents; -- must equal tenant-1's row count only
-- Attempt cross-tenant insert (AFTER INSERT block predicate fires)
INSERT INTO dbo.Documents (TenantId, Body) VALUES (2, 'leak');
-- Must fail with a policy violation error
-- Attempt to change SESSION_CONTEXT after @read_only = 1
EXEC sp_set_session_context @key = N'TenantId', @value = 2;
-- Must fail: context key is read-only for this connection
REVERT;
-- Disable policy and verify row counts match full table
ALTER SECURITY POLICY Security.TenantFilter WITH (STATE = OFF);
SELECT COUNT(*) FROM dbo.Documents; -- must equal total row count
ALTER SECURITY POLICY Security.TenantFilter WITH (STATE = ON);
| 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 |
tenant_id in every query. Vitess provides no mechanism to reject a
scatter query at the database tier.db_owner
and the CDC gating role regardless of active security policies. Change
Tracking leaks primary keys. Both are documented limitations in the
SQL Server RLS docs.CREATE VIEW: dev.mysql.com/doc/refman/8.4/en/create-view.html.CREATE VIEW: mariadb.com/kb/en/create-view/.CREATE SECURITY POLICY: learn.microsoft.com/en-us/sql/t-sql/statements/create-security-policy-transact-sql.row-level-security-postgres-reference.tenant-isolation-models-reference.tenant-leak-test-author, cross-tenant-data-leak-tests.npx claudepluginhub testland/qa --plugin qa-multi-tenancyProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.