Policy Types
CLS, SLS, and RLS security policies in Unified Security
Unified Security provides three policy types that control data access at different levels of your infrastructure. Each targets a specific isolation boundary.
| Policy | Full Name | Controls |
|---|---|---|
| CLS | Connection Level Security | Which database or file path an actor connects to |
| SLS | Schema Level Security | Which schema an actor queries within a database |
| RLS | Row Level Security | Which rows an actor sees within a table |
You define these policies inside a policy definition and assign them to actors (tenants, tenant users, or org users).
CLS -- Connection Level Security
CLS dynamically parameterizes database connection strings or S3 file paths per actor. Use it when tenants have separate databases or separate file storage locations.
Two Modes
CLS operates in one of two modes -- never both at once.
Database mode uses connectionTemplate to parameterize the connection string:
{
"connectionTemplate": "postgresql+psycopg2://app_user:{{ password@secret }}@db.company.com:5432/{{ tenantDatabase }}",
"params": {
"tenantDatabase": "acme_prod"
}
}File mode uses filePathTemplates to map table names to parameterized S3 paths:
{
"filePathTemplates": {
"orders": "s3://data-lake/{{ tenantId }}/orders/*.parquet",
"products": "s3://data-lake/{{ tenantId }}/products/*.parquet"
},
"params": {
"tenantId": "acme"
}
}Secret Parameters
Placeholders that end with @secret are treated as sensitive values. Semaphor stores them securely server-side and never exposes them to clients or in query logs.
{{ password@secret }}When to Use CLS
- Separate databases per tenant
- Separate S3 folders per tenant
- Connection credentials that vary per actor
SLS -- Schema Level Security
SLS routes actors to specific database schemas within a shared database. Use it when tenants share a database instance but have isolated schemas.
Config Options
Provide at least one of the following fields:
| Field | Type | Purpose |
|---|---|---|
schema | string | Fixed schema name |
schemaTemplate | string | Parameterized schema with {{ placeholder }} syntax |
allowedSchemas | string[] | Explicit allowlist of permitted schemas |
defaultSchema | string | Fallback when no narrower selection applies |
Fixed schema -- routes an actor to a specific schema:
{
"schema": "tenant_a"
}Parameterized schema -- resolves at runtime from assignment params:
{
"schemaTemplate": "{{ tenant_schema }}",
"defaultSchema": "public"
}Schema allowlist -- defines a boundary that lower-level assignments can select within:
{
"allowedSchemas": ["tenant_a", "tenant_b", "tenant_c"],
"defaultSchema": "tenant_a"
}Boundary Enforcement
Lower-level assignments and token overrides can only select schemas within the inherited boundary. An ALL_TENANTS assignment might set allowedSchemas to ["us_east", "us_west"]. A TENANT assignment can then narrow to "schema": "us_east", but cannot select "eu_central" because it falls outside the boundary.
When to Use SLS
- Shared database with per-tenant schemas
- Regional schema isolation within a single connection
- Schema-based access tiers (e.g.,
analyticsvs.raw_data)
RLS -- Row Level Security
RLS applies WHERE clause predicates to filter rows per actor. Each RLS config contains one or more rules, and each rule specifies which tables it applies to and what filter expression to inject.
Rule Structure
{
"parameters": {
"tenant_id": {
"source": "ASSIGNMENT",
"missing": "NO_ROWS"
},
"allowed_regions": {
"source": "ASSIGNMENT",
"missing": "NO_ROWS"
}
},
"rules": [
{
"name": "tenant_isolation",
"matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "tenant_id" },
"expression": "tenant_id = {{ tenant_id }}"
},
{
"name": "region_filter",
"matcher": {
"type": "TABLE_LIST",
"tables": [
{ "table": "orders" },
{ "schema": "sales", "table": "customers" }
]
},
"expression": "region IN {{ allowed_regions }}"
}
]
}Each parameter is declared once for the whole policy. Its source says who
supplies the value: the policy itself (FIXED), an assignment (ASSIGNMENT),
or the signed token (TOKEN). The two dynamic sources also say what should
happen when nobody supplies a value, either NO_ROWS or ERROR.
See How Row-Level Security Works for how to choose between them.
Matcher Types
Each rule uses a matcher to determine which tables the filter applies to.
ALL_TABLES_WITH_COLUMN -- applies the rule to every table the query references. This is the most common matcher for tenant isolation, where the key column is present on all secured tables.
Semaphor does not skip tables that lack the column. If a queried table does not have it, the query fails rather than running unfiltered, so use this matcher only for a column your secured tables genuinely share, and use TABLE_LIST when a column exists on only some of them.
{ "type": "ALL_TABLES_WITH_COLUMN", "column": "tenant_id" }TABLE_LIST -- applies the rule to an explicit set of tables. Use schema and database qualifiers when table names are ambiguous.
{
"type": "TABLE_LIST",
"tables": [
{ "table": "orders" },
{ "schema": "sales", "table": "customers" }
]
}SCHEMA -- applies the rule to all tables within a specific schema. Optionally filter to only tables that contain a given column.
{ "type": "SCHEMA", "schema": "sales", "column": "org_id" }Expression Syntax
Expressions are SQL predicates with {{ placeholder }} parameters. Semaphor injects them as WHERE clauses at query time.
| Expression | Param Value | Generated SQL |
|---|---|---|
tenant_id = {{ tenant_id }} | "acme" | tenant_id = 'acme' |
region IN {{ allowed_regions }} | ["us-east-1", "us-west-2"] | region IN ('us-east-1', 'us-west-2') |
department = {{ dept }} | "engineering" | department = 'engineering' |
Empty arrays fail closed
For a supported positive IN expression, an explicit empty array lowers to
an always-false restriction, so no rows are returned.
Semaphor does not inspect arbitrary SQL to guess whether another expression
is narrowing or widening.
Combination Semantics
When multiple RLS rules apply to the same query, their predicates combine with AND (intersection). Semaphor retains every enabled matching rule from every active definition.
SELECT * FROM orders
WHERE tenant_id = 'acme' -- from tenant_isolation rule
AND region IN ('us-east-1') -- from region_filter ruleCombining Policy Types
The three policy types work together. Use the combination that matches your data architecture.
CLS + RLS
Separate databases per tenant with additional row-level filtering within each database.
{
"clsConfig": {
"connectionTemplate": "postgresql+psycopg2://user:{{ pw@secret }}@db.host:5432/{{ tenant_db }}",
"params": { "tenant_db": "acme_prod" }
},
"rlsConfig": {
"parameters": {
"department": {
"source": "ASSIGNMENT",
"missing": "NO_ROWS"
}
},
"rules": [
{
"name": "department_filter",
"matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "department" },
"expression": "department = {{ department }}"
}
]
}
}SLS + RLS
Schema isolation combined with fine-grained row filtering.
{
"slsConfig": {
"schema": "tenant_a"
},
"rlsConfig": {
"parameters": {
"access_levels": {
"source": "ASSIGNMENT",
"missing": "NO_ROWS"
}
},
"rules": [
{
"name": "role_filter",
"matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "access_level" },
"expression": "access_level IN {{ access_levels }}"
}
]
}
}CLS + SLS + RLS
Maximum isolation with all three layers -- separate connections, schema routing, and row-level predicates.
Policy Resolution Order
When several assignments apply to the same viewer, each policy type resolves the overlap differently.
Resolution by Policy Type
| Policy | Resolution Strategy |
|---|---|
| CLS | Params merge (overlay). Lower layers supply param values but cannot change the connection template. |
| SLS | Lower layers select within the inherited boundary. Cannot reference schemas outside allowedSchemas. |
| RLS | The most specific value wins for each parameter, following TENANT_USER → TENANT → ALL_TENANTS. Every matching rule is kept and combined with AND. |
Row-level values come only from the source the policy names for each parameter, and Semaphor never reads your SQL to decide whether a value widens or narrows access. See How Row-Level Security Works.
Fail-closed design
If a required parameter is missing at resolution time, the query fails rather than executing with incomplete security context.
Next Steps
- Policy Definitions & Assignments -- create policy definitions and assign them to actors
- How Row-Level Security Works -- value sources, precedence, and fail-closed behavior
- Set Up Row-Level Security -- create a policy, choose sources, and assign it
- Token Integration -- pass runtime parameters at token generation time
- Security API -- manage policy definitions and assignments programmatically