EddytorDocs & API
07 · Tables A→ZL · Row & column security

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:

KindBinds toEffect
rlsA table and one categorical columnFilters which rows a caller sees, by that column's value
clsA table (optionally one column)Controls which columns a caller can read
grantNothingOrg-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, DESCRIBE and information_schema reflect 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 permit applies 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):

ScopeGrants
rls_policies:readList and inspect policies and member attributes
rls_policies:writeCreate, 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.

On this page