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:
- The app sends a SQL string and parameters to the Core over IPC.
- The Core opens a transaction on its own connection pool.
- Inside that transaction, the Core:
- scopes
search_pathto the app's schema (neverrootcx_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_executorrole.
- scopes
- The statement runs. Row-Level Security, not the app, decides which rows are visible.
- 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-computedeffective_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.