Row-Level Security Architecture
Shared tables in KalamDB 0.6 are always FORCE RLS. Developers write PostgreSQL-shaped
CREATE POLICY SQL. The server compiles that SQL once into authorization semantics, then
enforces those semantics on every scan, write, live event, and file download.
This page explains how that path works. For statement syntax and examples, see /docs/server/sql-reference/policies. For which table types use policies at all, see /docs/server/architecture/table-types.
USER and STREAM tables do not use CREATE POLICY. They are physically scoped by the effective
user_id. Shared tables store one copy of the rows and filter per principal.
Two moments: compile, then bind
KalamDB never re-parses policy SQL per row or per live event.
- Compile at DDL.
CREATE POLICYandALTER POLICYturn the expression into a program: either a row-local equality (owner_id = CURRENT_USER,status = 'published',true) or a membership relation (conversation_id IN (SELECT … WHERE user_id = CURRENT_USER)).EXISTSand thatINform compile to the same relation. Identity is not baked in: the program still says “column equals current user”, not “column equals alice”. - Bind at execution or subscribe. The session supplies
CURRENT_USERand role. Matching policies for that command and role become a bound evaluator. Alice and Bob can share the same cached query plan and still see different rows.
system and dba skip the bound evaluator. user and service with no matching policy are
default-deny. Anonymous sessions never open a shared table.
Policies are permissive. If any applicable policy allows the row, the row is allowed.
AS RESTRICTIVE is rejected at parse time so the runtime never has to AND independent policies.
Query and DML path
Visible rows are authorized MVCC winners, then the client WHERE clause. A filter such as
OR true cannot reveal a row the policy hid.
Writes use the same programs:
SELECT/DELETEevaluateUSINGon the existing row.INSERTevaluatesWITH CHECKon the new row.UPDATEevaluatesUSINGon the old row andWITH CHECKon the new row.
ON CONFLICT DO UPDATE is rejected for user and service on shared tables so upsert cannot
skip those checks.
Membership policies load the principal’s relation keys (conversation IDs, tenant IDs) once per
bind, then test the protected column against that set. A covering primary key on
(user_id, conversation_id) lets that lookup probe an index instead of scanning the members
table.
Live subscription path
Live queries bind RLS once at subscribe, then reuse that bound evaluator on every change.
The route is a candidate selector, not a grant:
| Route | When | Fan-out |
|---|---|---|
| Broadcast | USING (true) or RLS bypass | Every subscriber on that table |
| Keyed | owner_id = CURRENT_USER, status = 'public', or membership IN / EXISTS | Only subscribers whose bound keys match the new row (and the old row on UPDATE) |
| Deny | Default-deny or USING (false) | Not stored in the lookup index |
After candidate lookup, the bound evaluator still runs. That is the fail-closed guard: a lookup hit never delivers a row the policy would hide.
Unsubscribe removes the handle from the keyed and broadcast maps. Empty conversation buckets and empty table maps are dropped so a disconnected user does not keep occupying the index.
Fail-closed generations
Authorization is generation-aware. Two catalogs can move independently:
- Policy catalog.
CREATE/ALTER/DROP POLICYinvalidates bound live evaluators. An already-open subscription does not silently pick up a new grant. Delivery stops rather than leaking; the client resubscribes. - Membership relation. Inserting or deleting a members row bumps the relation generation. In-flight evaluation that races that change fails closed. A live subscriber whose membership set is stale does not receive the new conversation until they bind again.
The rule is the same in both cases: never deliver a maybe-unauthorized row.
Why this scales
RLS is designed to eliminate work, not add a per-row SQL interpreter.
- Compile once at DDL. Query planning does not recompile policy text.
- Bind identity after the plan cache, so Alice and Bob share plans.
- Membership keys are a set lookup, not a subquery per row at fan-out time.
- Live events probe typed keys such as
conversation_id = conv-123instead of walking every subscriber on the shared table. USING (true)is the expensive shape: it is broadcast. Prefer owner or membership keys when many users share one table.
Complex row-local AND / OR / NOT is rejected at CREATE POLICY because those shapes cannot
produce a bounded live key. Keep extra predicates in the client WHERE clause.