Semaphor
Security

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.

PolicyFull NameControls
CLSConnection Level SecurityWhich database or file path an actor connects to
SLSSchema Level SecurityWhich schema an actor queries within a database
RLSRow Level SecurityWhich 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:

CLS config -- database mode
{
  "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:

CLS config -- file mode
{
  "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:

FieldTypePurpose
schemastringFixed schema name
schemaTemplatestringParameterized schema with {{ placeholder }} syntax
allowedSchemasstring[]Explicit allowlist of permitted schemas
defaultSchemastringFallback when no narrower selection applies

Fixed schema -- routes an actor to a specific schema:

SLS config -- fixed schema
{
  "schema": "tenant_a"
}

Parameterized schema -- resolves at runtime from assignment params:

SLS config -- parameterized schema
{
  "schemaTemplate": "{{ tenant_schema }}",
  "defaultSchema": "public"
}

Schema allowlist -- defines a boundary that lower-level assignments can select within:

SLS config -- allowlist
{
  "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., analytics vs. 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

RLS config with two rules
{
  "rules": [
    {
      "name": "tenant_isolation",
      "matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "tenant_id" },
      "expression": "tenant_id = {{ tenant_id }}",
      "params": { "tenant_id": "acme" }
    },
    {
      "name": "region_filter",
      "matcher": {
        "type": "TABLE_LIST",
        "tables": [
          { "table": "orders" },
          { "schema": "sales", "table": "customers" }
        ]
      },
      "expression": "region IN {{ allowed_regions }}",
      "params": { "allowed_regions": ["us-east-1", "us-west-2"] }
    }
  ]
}

Matcher Types

Each rule uses a matcher to determine which tables the filter applies to.

ALL_TABLES_WITH_COLUMN -- applies the rule to any queried table that contains the specified column. This is the most common matcher for tenant isolation.

{ "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.

ExpressionParam ValueGenerated 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

If an array parameter resolves to an empty list [], the rule generates 1=0 -- no rows are returned. This prevents accidental data exposure.

Combination Semantics

When multiple RLS rules apply to the same query, their predicates combine with AND (intersection). Lower-level assignments can add rules but cannot remove rules inherited from higher levels.

Two rules applied to the same table
SELECT * FROM orders
WHERE tenant_id = 'acme'          -- from tenant_isolation rule
  AND region IN ('us-east-1')     -- from region_filter rule

Combining 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.

CLS + RLS example
{
  "clsConfig": {
    "connectionTemplate": "postgresql+psycopg2://user:{{ pw@secret }}@db.host:5432/{{ tenant_db }}",
    "params": { "tenant_db": "acme_prod" }
  },
  "rlsConfig": {
    "rules": [
      {
        "name": "department_filter",
        "matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "department" },
        "expression": "department = {{ department }}",
        "params": { "department": "sales" }
      }
    ]
  }
}

SLS + RLS

Schema isolation combined with fine-grained row filtering.

SLS + RLS example
{
  "slsConfig": {
    "schema": "tenant_a"
  },
  "rlsConfig": {
    "rules": [
      {
        "name": "role_filter",
        "matcher": { "type": "ALL_TABLES_WITH_COLUMN", "column": "access_level" },
        "expression": "access_level IN {{ access_levels }}",
        "params": { "access_levels": ["public", "internal"] }
      }
    ]
  }
}

CLS + SLS + RLS

Maximum isolation with all three layers -- separate connections, schema routing, and row-level predicates.


Policy Resolution Order

When multiple assignments exist for an actor, Semaphor resolves policies from broadest scope to narrowest. Each layer can only narrow access -- never widen it.

ALL_TENANTS  -->  TENANT  -->  TENANT_USER  -->  Token overlay
  (broadest)                                       (narrowest)

Resolution by Policy Type

PolicyResolution Strategy
CLSParams merge (overlay). Lower layers supply param values but cannot change the connection template.
SLSLower layers select within the inherited boundary. Cannot reference schemas outside allowedSchemas.
RLSRules accumulate with AND (intersection). Lower layers add rules but cannot remove inherited ones.

Persisted assignments (saved in the admin UI or via API) are the source of truth. Token-time parameters can supply missing param values or narrow further, but cannot override structural boundaries like connection templates, schema allowlists, or existing RLS rules.

Fail-closed design

If a required parameter is missing at resolution time, the query fails rather than executing with incomplete security context.


Next Steps