Skip to Content
SQL ReferenceRow-Level Security

Row-Level Security

KalamDB 0.6 applies FORCE row-level security to every SHARED table. You grant visible and writable rows with PostgreSQL-shaped CREATE POLICY statements. There is no ACCESS_LEVEL table option.

This page is the SQL reference. For compile, bind, MVCC ordering, and live keyed routing, see /docs/server/architecture/row-level-security.

Row-level security applies to SQL scans, DML, live subscriptions, and FILE downloads for user and service sessions. system and dba bypass RLS. Anonymous sessions cannot open shared tables.

Default Deny

A shared table with no matching policy returns zero rows on SELECT and rejects writes for user and service. Create at least one policy before those roles can see or mutate data.

USER and STREAM tables stay partitioned by the effective user_id. They do not use CREATE POLICY. See /docs/server/architecture/table-types.

CREATE POLICY

SQL
CREATE POLICY <name> ON [<namespace>.]<table>  [AS PERMISSIVE]  FOR { ALL | SELECT | INSERT | UPDATE | DELETE }  TO { PUBLIC | user | service | user, service }  [USING (<boolean_expr>)]  [WITH CHECK (<boolean_expr>)];

AS RESTRICTIVE is rejected. Policies are always permissive: if any applicable policy allows the row, the row is allowed.

TO selects which roles the policy applies to:

TargetWho it applies to
TO userEnd-user sessions
TO serviceService-account sessions
TO user, serviceBoth authenticated principals
TO PUBLIC (or omit TO)Every role subject to RLS (user and service)

Named principals such as TO alice are not supported. Bind identity in the expression with CURRENT_USER (or CURRENT_USER()).

Policy DDL (CREATE / ALTER / DROP POLICY) requires system, dba, or service. Regular user sessions cannot change policies.

USING vs WITH CHECK

CommandUSINGWITH CHECK
SELECTRequired. Filters visible rows.Not allowed.
INSERTNot allowed.Required. Must hold for the new row.
UPDATEExisting row must pass.New row must pass.
DELETEExisting row must pass.Not allowed.
ALLExisting-row check.New-row check for insert/update.

Client WHERE clauses, including OR true, cannot bypass RLS. Authorized MVCC winners are selected first, then the query filter runs.

Examples

SQL
-- End users see only their own documentsCREATE POLICY owner_read ON app.documents  FOR SELECT TO user  USING (owner_id = CURRENT_USER); -- Membership subquery (same as EXISTS)CREATE POLICY member_read ON app.messages  FOR SELECT TO user  USING (    conversation_id IN (      SELECT conversation_id FROM app.conversation_members      WHERE user_id = CURRENT_USER    )  ); -- Service accounts can read published rowsCREATE POLICY service_published_read ON app.documents  FOR SELECT TO service  USING (status = 'published'); -- Both user and service share one visibility ruleCREATE POLICY tenant_read ON app.events  FOR SELECT TO user, service  USING (tenant_id = CURRENT_USER); CREATE POLICY owner_insert ON app.documents  FOR INSERT TO user  WITH CHECK (owner_id = CURRENT_USER); CREATE POLICY owner_update ON app.documents  FOR UPDATE TO user  USING (owner_id = CURRENT_USER)  WITH CHECK (owner_id = CURRENT_USER); CREATE POLICY owner_delete ON app.documents  FOR DELETE TO user  USING (owner_id = CURRENT_USER); CREATE POLICY service_full ON app.documents  FOR ALL TO service  USING (true)  WITH CHECK (true);

The IN (SELECT …) form and a correlated EXISTS compile to the same membership relation:

SQL
CREATE POLICY member_read_exists ON app.messages  FOR SELECT TO user  USING (    EXISTS (      SELECT 1      FROM app.conversation_members m      WHERE m.conversation_id = messages.conversation_id        AND m.user_id = CURRENT_USER    )  );

ALTER POLICY and DROP POLICY

SQL
ALTER POLICY owner_read ON app.documents  USING (owner_id = CURRENT_USER); ALTER POLICY owner_read ON app.documents  RENAME TO document_owner_read; DROP POLICY owner_read ON app.documents;DROP POLICY IF EXISTS owner_read ON app.documents;

DROP POLICY CASCADE is not supported.

Supported USING expressions

KalamDB compiles policy SQL into bounded authorization semantics. These shapes are supported:

ShapeExampleLive routing
Column equals current userowner_id = CURRENT_USERKeyed on that column and principal
Column equals a literalvisibility = 'public'Keyed on that literal
Membership IN subqueryconversation_id IN (SELECT … WHERE user_id = CURRENT_USER)Keyed on each membership value
Correlated EXISTSEXISTS (SELECT 1 FROM members …)Same keys as the equivalent IN
Allow alltrueEvery change on the table is a candidate
Deny allfalseNo live candidates

Membership subqueries may add static predicates on the relation, for example AND role = 'member'. The live index is still the protected key (conversation_id in the examples above).

Give the members table a covering primary key on (principal, relation_key) so lookups can probe the index instead of scanning the relation:

SQL
CREATE SHARED TABLE app.conversation_members (  user_id TEXT,  conversation_id TEXT,  role TEXT NOT NULL,  PRIMARY KEY (user_id, conversation_id));

Rejected policy shapes

These fail at CREATE POLICY / ALTER POLICY because they cannot produce a bounded live route:

  • Row-local NOT, AND, or OR such as NOT (owner_id = CURRENT_USER) or owner_id = CURRENT_USER OR is_public = true
  • Negated IN / EXISTS
  • Aggregates in the membership subquery (max(conversation_id))
  • Membership subqueries that do not restrict CURRENT_USER
  • Per-user TO <user_id> lists

Keep extra predicates in the client WHERE clause, not in the policy, when they are not a bounded equality or membership key.

Live subscriptions

Shared-table live queries bind RLS once at subscribe time. Each change looks up subscribers by the policy keys (for example conversation_id) instead of evaluating every subscriber on the table.

The bound policy is still the final check. If a grant, revoke, or membership change races an in-flight event, delivery fail-closes: the row is not leaked. The client should resubscribe after a fail-closed gap.

USING (true) broadcasts every table change to those subscribers. Prefer keyed owner or membership policies when many users share one table.

Existing live subscriptions do not pick up a newly created policy until the client resubscribes.

See /docs/server/architecture/row-level-security, /docs/server/architecture/live-query, and /docs/server/sql-reference/subscriptions.

Writes and upserts

INSERT, UPDATE, and DELETE use the same compiled policies. user and service ON CONFLICT DO UPDATE on shared tables is rejected so upsert cannot skip USING / WITH CHECK. Use a plain INSERT or UPDATE instead.

EXECUTE AS does not change shared-table RLS. The acting role and CURRENT_USER still come from the session that opened the statement. See /docs/server/sql-reference/impersonation.

Last updated on