How Row-Level Security Works
Filter rows per customer so every viewer sees only the data they should
Row-level security (RLS) filters the rows a viewer can see. You write the filter once, decide who supplies each value in it, and Semaphor applies it to every query that touches the protected tables.
A retail analytics product might give each brand access to its own stores:
SELECT * FROM sales
WHERE location_id IN ('chicago', 'milwaukee') -- filter added by Semaphor
AND status = 'active'Nobody wrote that WHERE clause into the dashboard. The policy did, using
values that belong to the person asking.
Applies to Unified security mode
This page describes row-level security on connections set to Unified security mode. Connections still on the legacy mode keep their existing behavior. See Migration to move a connection over.
The three decisions
Setting up row-level security means making three decisions. The rest of this page explains each one.
| Decision | Where you make it |
|---|---|
| What gets filtered | The policy's rules |
| Who supplies each value | The policy's parameters |
| Who receives the policy | The assignments |
1. What gets filtered
A policy contains one or more rules. Each rule pairs a filter expression with a matcher that decides which tables the filter applies to.
{
"name": "Location access",
"matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "location_id" },
"expression": "location_id IN {{ allowed_locations }}"
}This matcher applies the filter to every table the query touches, so use it for a column your tables share, such as a tenant or location key. If a queried table does not have the column, the query fails rather than quietly going unfiltered. When a column exists on only some tables, name those tables explicitly instead. See Policy Types for the full matcher and expression reference.
2. Who supplies each value
Every {{ placeholder }} in a rule is a parameter, and every parameter names
exactly one source. You choose it in the policy editor under Parameters.
| In the editor | What it means | Use it when |
|---|---|---|
| Set in this policy | The policy holds one value for everyone | The value never varies, like status = 'active' |
| Set by an assignment | Semaphor stores the value per customer or user | You manage entitlements in Semaphor |
| Set by the signed token | Your backend sends the value when it mints the token | Your own app already knows the answer per session |
One source per parameter, always. An assignment cannot overwrite a value the policy fixed, and a token cannot supply a value that assignments own. Semaphor rejects a value from the wrong source instead of quietly ignoring it, so a misconfiguration surfaces as an error rather than as wrong data.
Parameters belong to their policy
Two policies can both use a parameter named region without interfering. A
value set on one policy's assignment never satisfies the other.
Token values are the exception, because tokens carry a single flat set of names. If a token arrives with a name that two of the viewer's active policies both expect from the token, Semaphor cannot tell which one you meant and stops rather than guessing. Give token-owned parameters distinct names.
3. Who receives the policy
A policy does nothing until you assign it. Each assignment names the actor it applies to, and can carry values for the parameters set by an assignment.
| Scope | Who it covers |
|---|---|
| All tenants | Every tenant and tenant user on the connection |
| Single tenant | One customer |
| Tenant user | One person inside one customer |
| Organization user | One person on your own team |
Any of these turns the policy on for the actor it names. All tenants is optional: use it when the policy should reach every customer, and skip it when only some customers should receive the policy. An actor with no applicable assignment is not covered by the policy.
When several assignments apply
A viewer can be covered by more than one assignment at once. A tenant user inside a tenant might be reached by an all-tenants assignment, their tenant's assignment, and their own. For each parameter, the most specific value wins:
Tenant user → Single tenant → All tenantsLeaving a parameter blank at a narrower scope is not the same as clearing it. The next broader value is inherited instead, and the editor shows you which one under Effective value source.
Consider a policy where the all-tenants assignment allows three locations:
| Viewer | Their own value | What they see |
|---|---|---|
| A tenant with no assignment of its own | none | The three all-tenants locations |
| A tenant that sets two locations | ["chicago", "milwaukee"] | Those two |
| A user in that tenant who sets one | ["chicago"] | That one |
An empty list is a real value, not a blank. It does not inherit anything broader,
and for a straightforward IN filter it means this viewer sees no rows from the
matched tables. The editor asks you to confirm when you save one, because it is
easy to confuse with leaving the field alone.
Keep list filters in their own rule
Semaphor can turn an empty list into a deny-all only when the rule is a plain
column IN {{ parameter }} filter. If you combine that with another condition
in the same expression using AND or OR, an empty list blocks the query as a
configuration error instead. Put each list filter in its own rule, and let
Semaphor combine the rules for you.
When a value is missing
Sometimes nobody supplies a value: a customer has not been onboarded yet, or a token arrives without the claim your backend was meant to send. For every parameter set by an assignment or a token, the policy editor asks what should happen under If no value is provided.
| In the editor | What happens |
|---|---|
| Return no rows | The affected rule denies every row from the tables it matches. The dashboard loads and is empty. |
| Block the query | The query stops with a configuration error for an administrator to fix. |
New parameters default to Return no rows, which keeps a half-configured policy safe. Choose Block the query when silence would be worse than an error, for example when an empty dashboard could be mistaken for a real result.
Neither option ever widens access. A missing value never removes the filter.
When several rules apply
Semaphor keeps every rule that matches a query, from every policy the viewer
receives, and joins them with AND:
SELECT * FROM orders
WHERE tenant_id = 'acme' -- from the tenant isolation rule
AND region IN ('us-east-1') -- from the regional access ruleRules narrow the result together. A later rule never cancels an earlier one, and adding a second policy can only restrict what a viewer sees, never expand it.
Fails closed
If Semaphor cannot work out exactly what a viewer should see, it does not guess and it does not fall back to a weaker rule. The query stops and an administrator gets a diagnostic explaining what to fix. That covers a required value nobody supplied, a value offered by the wrong source, two assignments at the same scope disagreeing, and a policy saved in a format Semaphor can no longer read.
Preview and Troubleshoot shows you what a viewer receives before they ever open the dashboard.
Alongside connection and schema security
A single policy can carry connection-level and schema-level security as well as row filters. Those layers keep their own behavior; the sources and precedence on this page apply to row filters only.
If a row filter and a connection template use the same placeholder name inside one policy, they share the value that the assignment or token supplies. Give them different names when they need different values. A row-filter parameter cannot reuse a connection secret placeholder, and Semaphor rejects that combination when you save.
Next steps
- Set Up Row-Level Security -- create a policy, choose sources, and assign it
- Preview and Troubleshoot -- check what a viewer receives
- Automate with the API -- manage policies programmatically
- Policy Types -- matchers, expressions, and the other security layers