How row & column security works
Cedar-based policies that filter rows and hide columns per user - enforced in the engine on every transport.
Row-level security (RLS) and column-level security (CLS) let you serve one table to
many audiences: a contractor sees only EU rows, everyone but admins loses the
salary column, each tenant sees rows matching their own tag. Policies are written
in Cedar, stored per organisation, and enforced
inside the query engine - the same rules apply over REST, Flight SQL, and MCP.
The model
A policy has a kind, a table binding, and Cedar logic:
| Kind | Binds to | Effect |
|---|---|---|
rls | A table and one categorical column | Filters which rows a caller sees, by that column's value |
cls | A table (optionally one column) | Controls which columns a caller can read |
grant | Nothing | Org-wide helper logic other policies build on |
Who a policy applies to is written inside the Cedar text, from three principal
inputs: the caller's role (admin, builder, editor, viewer), their
email, and free-form attributes you assign per member (for example
tenant = "ACME"). Attributes are per-organisation, so a person in two orgs has two
independent attribute bags.
// Each user sees only rows where customer_id equals their own tenant attribute
permit(principal, action == Eddytor::Action::"read",
resource == Eddytor::Column::"prod.shared.orders::customer_id")
when { context.rowValue == principal.getTag("tenant") };// Everyone can read the table, but only admins see salary
permit(principal, action == Eddytor::Action::"read", resource);
forbid(principal, action == Eddytor::Action::"read",
resource == Eddytor::Column::"prod.hr.employees::salary")
unless { principal.role == "admin" };Enforcement at query time
Enforcement is opt-in per table - tables without a bound policy are never filtered. When a query touches a governed table:
- CLS: the engine asks Cedar per column and serves a masked schema.
SELECT *silently narrows to the allowed columns,DESCRIBEandinformation_schemareflect the masked shape, and referencing a hidden column fails like any unknown field - hidden columns don't advertise their existence. - RLS: the engine enumerates the bound column's distinct values, asks Cedar which ones the caller may see, and injects the resulting filter into every scan of the table. This is why RLS requires a low-cardinality categorical column (10,000 distinct values max).
- Writes are governed too: you can only write columns you can read, only write row values inside your allow-set, and updates/deletes can't touch rows you can't see.
Policy changes propagate within about 60 seconds (the engine's policy cache).
Semantics: allowlist vs blocklist
Cedar is default-deny, with one ergonomic twist:
- If a table's applicable policies contain only
forbids, they act as a blocklist - everything not forbidden stays visible. - The moment one real
permitapplies to the table, it flips to allowlist semantics - only what's permitted is visible.
Everything fails closed: a row value with no decision is hidden, NULL values
in an RLS column are hidden from partially-restricted callers, a caller with no
readable columns gets nothing (not even row counts), and a policy that fails to
compile denies the entire organisation's tables until an operator fixes it.
Who can manage policies
Two scopes gate the feature, both held by Builder and Admin roles (Editor and Viewer hold neither):
| Scope | Grants |
|---|---|
rls_policies:read | List and inspect policies and member attributes |
rls_policies:write | Create, update, delete policies; set member attributes |
Policy changes are audited as rls_policy.created / updated / deleted and
user.attributes_updated.
Next: create your first policy, and read the limitations before governing production tables.