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

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

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.

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

SLS + RLS example
{
  "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

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

On this page