DocsGovernanceEnforcement

Enforcement

RBAC decides what a principal is allowed to do. Enforcement is what makes the decision impossible to skip. This page describes the data path: how a query from an untrusted app reaches PostgreSQL, and why the app can never see a row its caller is not entitled to.

The short version: apps never hold a database connection. They send SQL to the Core over IPC. The Core runs every statement under the caller's identity, as a restricted role, with Row-Level Security forcing the row filter, in the database, not in app code.


The data path

Every app query takes the same route:

  1. The app sends a SQL string and parameters to the Core over IPC.
  2. The Core opens a transaction on its own connection pool.
  3. Inside that transaction, the Core:
    • scopes search_path to the app's schema (never rootcx_system),
    • sets a statement timeout and an idle-in-transaction timeout,
    • poses the caller's identity as 3 PostgreSQL settings,
    • poses the audit attribution settings,
    • drops to the non-superuser rootcx_app_executor role.
  4. The statement runs. Row-Level Security, not the app, decides which rows are visible.
  5. Results are capped and returned; the transaction commits.

Every SET LOCAL runs while the connection is still the superuser, before the drop to the restricted role. The restricted role has set_config revoked, so once control passes to it, the app can no longer touch its own identity.


The restricted role

App SQL never runs as the Core's database user. It runs as rootcx_app_executor, a role created at boot with NOLOGIN NOBYPASSRLS: it cannot log in, and it cannot bypass Row-Level Security. It is the opposite of the Core's superuser pool.

The role is deliberately starved:

Capability Status
Read rootcx_system tables Denied (no table grants)
set_config (rewrite identity) Revoked
DDL, DO blocks None
pgmq / cron schemas Locked down
App tables SELECT, INSERT, UPDATE, DELETE only, gated by RLS

Into the system schema it gets exactly 1 grant: USAGE on rootcx_system, so it can call the check_access function that the RLS policies invoke. It cannot call the underlying permission functions directly (those are revoked from PUBLIC), so an app cannot enumerate the permission graph through them.


Row-Level Security

Every app table is created with both ENABLE and FORCE ROW LEVEL SECURITY. FORCE is the important word: it applies the policies even to the table owner. There is no role, short of a superuser, that sees an app table unfiltered.

Each table gets 4 policies, one per operation:

Policy Operation Gate
rootcx_rls_select SELECT check_access('app:{schema}:{table}.read')
rootcx_rls_insert INSERT check_access('app:{schema}:{table}.create')
rootcx_rls_update UPDATE check_access('app:{schema}:{table}.update') (USING + WITH CHECK)
rootcx_rls_delete DELETE check_access('app:{schema}:{table}.delete')

The check is wrapped in a (SELECT ...) so PostgreSQL evaluates it once per query, not once per row.


Posed identity

The Core poses the request identity as 3 transaction-local PostgreSQL settings before dropping to the restricted role:

Setting Meaning
rootcx.user_id The acting principal's UUID
rootcx.is_delegated 1 when the authority is a delegated intersection
rootcx.effective_perms The pre-computed permission list, used only when delegated

check_access reads them and decides:

  • No rootcx.user_id → deny every row.
  • Delegated (is_delegated = 1) → match against the pre-computed effective_perms. An empty list denies everything.
  • Otherwise → resolve the principal's permissions live from the database and match.

Because the app cannot set these (the executor role has set_config revoked), it cannot impersonate another user or widen its own grant from inside a query.


One permission implementation

The permission logic lives as PL/pgSQL functions inside rootcx_system, and the RLS policies call them. The same logic also runs in Rust for HTTP-layer checks, so both paths agree on every decision.

Function Role
expand_roles Walk the inheritance graph (max depth 64)
resolve_permissions Union of all keys from all assigned roles
match_permission Wildcard and exact matching
has_permission Resolve a principal, then match
check_access The entry point the RLS policies call

Each is SECURITY DEFINER with a frozen search_path, so a malicious object in a higher-priority schema cannot hijack the call.


Worker isolation

Identity is enforced at the process level too. A worker process is keyed by (app_id, principal). One process serves exactly 1 identity for its whole life. There is no shared worker that switches users between requests, and there is no token a worker could forge to act as someone else. The cross-user confused-deputy problem is structurally impossible.

3 classes of principal never share a process:

Principal Role
System The per-app lifecycle worker. Runs onStart with a bypass on its own schema. Spawned only by app startup, never by an incoming request.
Anonymous A request with no authenticated user (public share-token RPC, owner-less webhook or job). Denied every row by RLS, and kept off the System worker.
User A real identity: a direct user, or an agent's delegated authority. Poses its real identity to RLS.

Changing a principal's roles invalidates its running workers, so a permission change takes effect on the next action rather than waiting for a process to recycle.


Limits

The same transaction that enforces identity also bounds resource use:

Limit Value
Statement timeout (user-facing: CRUD, ctx.sql, collection ops) 8 seconds
Statement timeout (AI agent tool calls) 30 seconds
Idle-in-transaction timeout 30 seconds
Max rows returned per statement 1,000 (exceeding it rolls back)

A best-effort check rejects obvious DDL and privileged statements early (CREATE, DROP, ALTER, TRUNCATE, GRANT, REVOKE, SET, RESET, DO, and similar) so apps get a clear error instead of a raw permission failure. This is not the security boundary: the restricted role already lacks those privileges, and multi-statement injection is blocked structurally by the extended query protocol.


Deployment requirement

The Core's own database role must be a SUPERUSER or carry the BYPASSRLS attribute. FORCE ROW LEVEL SECURITY filters everyone, including the Core's internal operations (schema sync, app startup, retroactive migration), which must read and write across schemas. The Core asserts this at boot and refuses to start otherwise, so a misconfigured role fails loudly instead of silently dropping rows.