Skip to content

Row level security

aizk does not filter rows in Python. Every scoped read and write is decided by PostgreSQL under forced row security, so a missing WHERE clause in application code cannot leak anything. This page assumes you know what a scope set is and can read a CREATE POLICY statement.

async with user ─▶ app-role transaction opens
│ SET LOCAL app.scopes = {read, write, public}
SELECT ... FROM document
│ scope_read policy runs
cardinality(scopes) > 0
scopes contained in the caller's readable array ?
│ yes │ no
▼ ▼
row visible one scope, and in public ?
│ yes │ no
▼ ▼
row visible row does not exist,
as far as this caller knows

The whole picture matters. The standing is written transaction-local, the policy reads it back inside the same transaction, and the commit discards it, so a pooled connection cannot carry one caller’s authority into the next request.

src/aizk/store/mixins/scoped.py holds one __rls__ classmethod, and every scoped table in the schema inherits it. It always emits two policies and conditionally two more.

policies = [
rls.Policy.select("scope_read", read, roles=(settings.app_role,)),
rls.Policy.insert("scope_insert", write, roles=(settings.app_role,)),
]
if cls.mutable:
policies.append(rls.Policy.update("scope_update", write, write, roles=(settings.app_role,)))
if cls.deletable:
policies.append(rls.Policy.delete("scope_delete", write, roles=(settings.app_role,)))

mutable and deletable are ClassVar booleans on the model, both defaulting to false. That is the point. Mutability is a property of the table declaration rather than something a policy file has to remember, so usage_event is an append-only ledger because it never sets either flag, and chunk is the only source table that can be deleted because it sets both.

The predicates themselves are the lattice. read requires a nonempty scopes array that is a subset of the caller’s readable set, with one extra branch that lets a single-scope row through when that scope is in the caller’s public set. write requires a nonempty array that is a subset of the caller’s writable set. Both are array containment with <@, which the GIN index on every scopes column serves.

Reading a public organization is not the same as owning what is in it. Standing.counted in the same module answers that second question, and it drops a row only when every scope on it is public and unwritable. A note filed into a member organization beside a public one is still the caller’s own work and still counts, while the public corpus it can only read does not. The private scope always counts, an ordinary member organization counts whether the caller writes there or not, and the public corpus counts only for the people who maintain it. Standing.owned_total wraps that into the scalar counts Knowledge.totals assembles, which is what the overview page and the browser dashboard show.

This narrows numbers, never rows. Recall, the catalogs and every lane still read the whole public corpus, because a brand-new account should be able to ask questions of shared knowledge on its first day. What it should not see is that corpus counted as forty thousand things it wrote.

A child table can set read_through to a parent table name.

class Chunk(Id, Scoped, Embedded, TableBase, table=True):
read_through: ClassVar[str | None] = "document"

The mixin then builds a different pair of predicates. Reading becomes document_id IN (SELECT id FROM document), which is already fenced by the parent’s own policy, so a chunk is visible exactly when its document is. Writing gains an extra conjunct, (document_id, scopes) IN (SELECT id, scopes FROM document), so the child’s scopes must equal a visible parent’s scopes. A chunk cannot be quietly widened away from the document it belongs to. artifact_content reads through artifact the same way.

Content tables use a third shape, described on The data model, and blob a fourth, described on Content and artifact tables.

The rls package owns the generic machinery

Section titled “The rls package owns the generic machinery”

Nothing above is aizk-specific except the predicates. The house package at packages/rls, published as rlsalchemy, owns policy compilation, the DDL statements, the Alembic autogenerate plugin, catalog reflection and the typed context. aizk supplies the lattice and nothing else.

Registration is one line in src/aizk/store/__init__.py.

_catalog = rls.Catalog(TableBase.mapper_registry)
def verify_rls(connection: Connection) -> list[str]:
"""Report drift from Aizk's complete row security declaration."""
return _catalog.verify(connection)

Catalog walks every mapper in the registry, compiles each __rls__ declaration onto its table’s info["rls"], and refuses a mapped table that declares nothing. A table that genuinely needs no policy must say so with rls.Open(), which is what entity_kind, relation_kind and every ViewBase do. An unprotected table is therefore always a decision and never an accident.

Two roles, and only one of them can bypass

Section titled “Two roles, and only one of them can bypass”

src/deploy/initdb/roles.sh provisions the runtime role.

CREATE ROLE aizk_app LOGIN PASSWORD %L NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE

NOBYPASSRLS is the guarantee. Even a bug that hands aizk_app a raw connection cannot see another tenant’s rows. The role gets default privileges for SELECT, INSERT, UPDATE, DELETE, so new tables inherit access without a follow-up grant, and 0001_init adds explicit grants for the tables it creates.

The separate owner role, aizk_admin, owns the schema, runs migrations, and is the only way to bypass row security. Database.owner() builds its engine and User.owner refuses to hand it out to anybody but the system identity.

if self.id != settings.system_user_id:
raise PermissionError("only the system caller may use the database owner role")

Policies name roles=(settings.app_role,) explicitly, and app_role is read from the database_url username rather than hardcoded, so a deployment that renames the role keeps its policies pointed at it.

User subclasses rls.Context with prefix="app", so its scopes field becomes the transaction-local setting app.scopes, serialized as JSON and written with set_config at after_begin. The policy reads it back with current_setting('app.scopes', true), casts to jsonb, picks the read, write or public key, and turns the JSON array into a native uuid[] with jsonb_array_elements_text. Both sides of that contract come from the same class, so a renamed field cannot drift away from the predicate that reads it.

One more listener in src/aizk/store/events.py catches the case where nobody opened a user transaction at all. Any ORM statement touching a protected table without a user in session.info raises NoTenantContext rather than quietly running with empty standing.

Declared policy and live policy are compared semantically, not textually, because PostgreSQL deparses what you gave it and the catalog text never matches the SQLAlchemy rendering byte for byte. CompiledPolicy._normalize in the rls package parses both sides with sqlglot, rewrites away deparser noise such as redundant casts and = ANY(ARRAY[...]) versus IN, and compares. RLSState.diff then reports missing, drifted and undeclared policies, and any table where enabled or forced is wrong.

Run it with chefe run aizk database check-rls, or read the test that fails the suite when it regresses, test_live_schema_forces_rls_with_no_violations in tests/store/test_catalog.py, which asserts the drift list is empty against the migrated database.