Semaphor
SecurityRow-Level Security

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:

What one viewer's query actually runs
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.

DecisionWhere you make it
What gets filteredThe policy's rules
Who supplies each valueThe policy's parameters
Who receives the policyThe 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.

One rule
{
  "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 editorWhat it meansUse it when
Set in this policyThe policy holds one value for everyoneThe value never varies, like status = 'active'
Set by an assignmentSemaphor stores the value per customer or userYou manage entitlements in Semaphor
Set by the signed tokenYour backend sends the value when it mints the tokenYour 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.

ScopeWho it covers
All tenantsEvery tenant and tenant user on the connection
Single tenantOne customer
Tenant userOne person inside one customer
Organization userOne 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 tenants

Leaving 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:

ViewerTheir own valueWhat they see
A tenant with no assignment of its ownnoneThe 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 editorWhat happens
Return no rowsThe affected rule denies every row from the tables it matches. The dashboard loads and is empty.
Block the queryThe 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:

Two rules on the same table
SELECT * FROM orders
WHERE tenant_id = 'acme'            -- from the tenant isolation rule
  AND region IN ('us-east-1')       -- from the regional access rule

Rules 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

On this page