Semaphor
SecurityRow-Level Security

Set Up Row-Level Security

Create a row-filter policy, choose where each value comes from, and assign it to your customers

This guide walks through a complete setup, from an empty Security page to a verified policy. It uses one running example.

What we are building

A retail analytics product serves several brands. Every brand should see sales for its own store locations and nothing else, and no brand should ever see archived records. One brand, Ridgeline Outfitters, has a regional manager who should see only the Chicago store.

You will need an organization Admin role and a connection set to Unified security mode.


1. Create the policy

Open the project's Security page and create a policy definition on the connection that holds the sales tables.

On the RLS step, add a rule. A rule has two halves: a matcher that picks the tables, and an expression that filters the rows.

Store access
{
  "name": "Store access",
  "matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "location_id" },
  "expression": "location_id IN {{ allowed_locations }}"
}

ALL_TABLES_WITH_COLUMN saves you from listing tables one by one. Use it when the column is present on every table these dashboards query, because Semaphor applies the filter to all of them and the query fails if one is missing the column. Here location_id is on every sales table, so it fits.

Now add a second rule for the archived-records requirement, rather than extending the first expression:

Active records only
{
  "name": "Active records only",
  "matcher": {
    "type": "TABLE_LIST",
    "tables": [{ "table": "sales" }, { "table": "returns" }]
  },
  "expression": "status = {{ record_status }}"
}

This one names its tables explicitly, because status exists on the fact tables but not on the lookup tables a dashboard might join to. TABLE_LIST applies only where you say, so a join to store_directory is unaffected.

Semaphor combines every rule that matches a query with AND, so the result is the same as one longer expression. Splitting them also means an empty list can resolve to a clean deny-all, which it cannot do inside a compound expression.


2. Choose where each value comes from

The two rules introduced two parameters. Semaphor lists them under Parameters on each rule and asks where each value comes from. This is the decision that matters most, so it is worth being deliberate.

record_status is the same for everybody, so choose Set in this policy and enter active. Nobody can override it later, which is exactly what you want for a rule that should never vary.

allowed_locations differs per brand, and you want to manage it in Semaphor. Choose Set by an assignment.

Semaphor then asks what should happen when nobody supplies a value, under If no value is provided:

  • Return no rows means a brand with no value configured sees an empty dashboard. This is the default, and it is the safer choice while you are onboarding customers one at a time.
  • Block the query means the dashboard shows a configuration error instead. Choose this when an empty dashboard could be mistaken for a genuine result.

For this example, keep Return no rows.

The finished configuration looks like this:

Policy parameters
{
  "parameters": {
    "allowed_locations": { "source": "ASSIGNMENT", "missing": "NO_ROWS" },
    "record_status": { "source": "FIXED", "value": "active" }
  }
}

Parameters used by several rules

When more than one rule uses the same parameter, the editor marks it as shared and keeps its settings in sync. You choose the source once for the whole policy, not once per rule.


3. Give each brand its locations

A policy does nothing until you assign it. Create one assignment per brand and enter that brand's locations.

For Ridgeline Outfitters, choose Single tenant and set the value:

Ridgeline Outfitters
{
  "scopeType": "TENANT",
  "tenantId": "tenant_ridgeline",
  "params": { "allowed_locations": ["chicago", "milwaukee"] }
}

Repeat for each brand. Because you are assigning per tenant, only the brands you assign receive the policy, which lets you roll out to customers gradually.

Give every brand a starting point

If most brands should share a baseline, use All tenants instead of repeating yourself. It turns the policy on for every tenant and supplies values that individual brands inherit unless they set their own.

Baseline for every brand
{
  "scopeType": "ALL_TENANTS",
  "params": { "allowed_locations": ["chicago", "milwaukee", "madison"] }
}

A brand that sets its own value overrides the baseline. A brand that does not inherits it.

Changing the baseline affects everyone

Before you save a change to All tenants, Semaphor shows how many tenants and tenant users have their own value for each parameter you changed. Those keep their own values. Everyone else moves to the new baseline.

Narrow it for one person

Ridgeline's regional manager should see only Chicago. Choose Tenant user and set just that parameter:

One user inside Ridgeline
{
  "scopeType": "TENANT_USER",
  "tenantUserId": "tuser_dana",
  "params": { "allowed_locations": ["chicago"] }
}

Everyone else at Ridgeline still sees both stores. Leave a parameter blank here and it inherits the tenant's value rather than clearing it, and the editor tells you which value is in effect under Effective value source.


4. Check what people will see

Open Security preview, pick a representative viewer, and confirm the result before anyone opens a dashboard.

Work through at least these:

  • a brand that inherits the baseline, if you created one
  • a brand with its own value, such as Ridgeline
  • a person with their own value, such as the regional manager
  • a brand you have not assigned yet
  • a brand you deliberately left without a value, to confirm it returns no rows

For each one, the preview shows the rules that apply, the value used for every parameter, and where that value came from. Preview and Troubleshoot explains how to read the result and what to do when something is wrong.

You can also preview an unsaved edit. The definition and assignment editors let you check one pending change against a real viewer before you commit it, and previewing never saves the draft.


Letting your application supply the value

Assignments suit entitlements that live in Semaphor. When your own application already knows the answer for the current session, let it send the value instead: choose Set by the signed token for that parameter.

Your backend then includes the value in securityParams when it mints the token:

Token request from your backend
{
  "securityParams": { "session_region": "us-east" }
}

Because the token is signed by your backend, Semaphor trusts the value. Nothing supplied by a browser can change it. Assignments cannot supply a token-owned parameter, and if the token omits it, the behavior you chose under If no value is provided applies.

Give the parameter a name that is unique across the policies active for a viewer. If a token arrives carrying a name that two of the viewer's active policies both expect, Semaphor cannot tell which one you meant and stops rather than guessing.

See Token Integration for the full token contract.


Changing a policy later

Renaming a parameter, or moving it from one source to another, is a deliberate sequence rather than a single edit:

  1. Remove the old value from the assignments that carry it.
  2. Update the policy's parameters and rules.
  3. Add the values the new configuration expects.
  4. Preview a few representative viewers.

While you are partway through, the behavior you chose for a missing value is what protects people, which is another reason to prefer Return no rows unless you have a specific reason not to.


Next steps

On this page