Atlas
Security

Row-Level Security

Isolate data per tenant using automatic WHERE clause injection based on user claims.

This page covers content for workspace admins (setting up RLS policies), operators (auth mode compatibility and claim resolution), and end users (understanding data access boundaries). Jump to the section relevant to your role, or read end-to-end for the full picture.

Row-Level Security (RLS) injects WHERE conditions into every SQL query based on the authenticated user's claims. This ensures tenants only see their own data -- without relying on the agent to add the right filters.

RLS is fail-closed. If the user's claims are missing or cannot be resolved, the query is blocked entirely. This is by design -- silent data leaks are worse than a blocked query.

How It Works

  1. You define policies that map JWT claims to table columns
  2. When the agent generates a SQL query, Atlas resolves the claim value from the authenticated user
  3. Atlas injects WHERE table.column = 'claim_value' (or IN (...) for array claims) into the query AST (not string concatenation)
  4. The injection runs after plugin beforeQuery hooks -- plugins cannot strip RLS conditions
  5. Claim values are SQL-escaped (single quotes doubled) before injection

Policies support single or multi-column conditions, array-valued JWT claims (generates IN (...) instead of =), and configurable AND/OR logic between policies. The AST manipulation handles CTEs, subqueries, UNIONs, derived tables, and table aliases correctly. Custom validators (SOQL, GraphQL) bypass RLS and must enforce their own filtering.


Quick Start (Environment Variables)

The simplest setup uses three environment variables for a single-policy configuration:

ATLAS_RLS_ENABLED=true
ATLAS_RLS_COLUMN=tenant_id        # Column name to add to WHERE clauses
ATLAS_RLS_CLAIM=org_id            # JWT claim path — resolved from the authenticated user's token

This creates a wildcard policy that applies to all tables -- equivalent to:

rls: {
  enabled: true,
  policies: [{ tables: ["*"], column: "tenant_id", claim: "org_id" }],
}

Both ATLAS_RLS_COLUMN and ATLAS_RLS_CLAIM are required when ATLAS_RLS_ENABLED=true. Atlas will fail to start if either is missing.


Advanced Configuration (atlas.config.ts)

For multi-policy setups, use the config file:

import { defineConfig } from "@atlas/api/lib/config";

export default defineConfig({
  rls: {
    enabled: true,
    policies: [
      // Wildcard — applies tenant_id filter to every table in every query
      { tables: ["*"], column: "tenant_id", claim: "org_id" },

      // Target specific tables — uses a nested JWT claim path (dot-delimited)
      { tables: ["orders", "shipments"], column: "region", claim: "app_metadata.region" },

      // Schema-qualified table name — the full "schema.table" must match
      { tables: ["analytics.events"], column: "workspace_id", claim: "workspace" },
    ],
  },
});

Policy Schema

Each policy uses either the single-condition shorthand (column + claim) or the multi-condition form (conditions):

FieldTypeDescription
tablesstring[]Table names this policy applies to. Use ["*"] for all tables
columnstringColumn name to filter on (single-condition shorthand)
claimstringClaim path to resolve the filter value (single-condition shorthand)
conditions{ column, claim }[]Multiple column/claim pairs — ANDed together within this policy

Use column+claim for single-condition policies, or conditions for multi-column policies. You cannot use both on the same policy.

RLS Config Options

FieldTypeDefaultDescription
enabledbooleanfalseWhether RLS is active
policiesRLSPolicy[][]Array of policies
combineWith"and" | "or""and"How to combine conditions from different policies

Validation rules:

  • At least one policy is required when enabled: true
  • Column names must match /^[a-zA-Z_][a-zA-Z0-9_]*$/
  • Tables array must have at least one entry

Claim Path Resolution

Claim paths support dot-delimited access into nested JWT structures:

Claim PathJWT ClaimsResolved Value
org_id{ "org_id": "acme" }acme
app_metadata.tenant{ "app_metadata": { "tenant": "acme" } }acme
custom.nested.id{ "custom": { "nested": { "id": 42 } } }42

Non-string values are coerced to strings. Array values generate IN (...) conditions (see Array Claims). If the path resolves to undefined or null, the query is blocked. Empty arrays are also blocked (fail-closed).


Array Claims

When a JWT claim resolves to an array, Atlas generates an IN (...) condition instead of =:

JWT claims:

{
  "sub": "user_123",
  "departments": ["engineering", "sales"]
}

Config:

rls: {
  enabled: true,
  policies: [
    { tables: ["*"], column: "department", claim: "departments" },
  ],
}

Injection result:

-- Agent generates:
SELECT * FROM tickets

-- After RLS injection:
SELECT * FROM tickets WHERE tickets.department IN ('engineering', 'sales')

Empty arrays are blocked (fail-closed). If a user's claim resolves to [], the query is rejected to prevent accidental full-table access.


Multi-Column Policies

When a policy needs to filter on multiple columns simultaneously, use the conditions array:

rls: {
  enabled: true,
  policies: [
    {
      tables: ["orders", "shipments"],
      conditions: [
        { column: "tenant_id", claim: "org_id" },
        { column: "region", claim: "app_metadata.region" },
      ],
    },
  ],
}

All conditions within a policy are ANDed together:

-- After injection:
SELECT * FROM orders
WHERE orders.tenant_id = 'org_acme' AND orders.region = 'us-east'

OR-Logic Between Policies

By default, conditions from different policies are ANDed (all must match). Set combineWith: "or" to allow access when any policy matches:

rls: {
  enabled: true,
  combineWith: "or",
  policies: [
    // Users can access data matching their org...
    { tables: ["*"], column: "org_id", claim: "org_id" },
    // ...OR data in their assigned region
    { tables: ["*"], column: "region", claim: "region" },
  ],
}

Injection result:

-- Agent generates:
SELECT * FROM orders WHERE status = 'active'

-- After RLS injection (OR between policies):
SELECT * FROM orders
WHERE status = 'active' AND (orders.org_id = 'org_acme' OR orders.region = 'us-east')

When using combineWith: "or", the OR-combined conditions are parenthesized to prevent precedence issues with existing WHERE clauses. Within each policy, multiple conditions are still ANDed.


Auth Mode Compatibility

RLS requires an authentication mode that provides user claims:

Auth ModeClaims AvailableRLS Compatible
noneNoNo -- queries blocked
api-keyOnly via ATLAS_RLS_CLAIMS (static JSON)Yes, with static claims
managedYes (Better Auth session)Yes
byotYes (JWT claims)Yes -- primary use case

If ATLAS_AUTH_MODE=none and RLS is enabled, all queries will be blocked. This is intentional -- RLS without authentication has no user context to resolve claims from.


Example: Multi-Tenant SaaS

A typical setup where each user belongs to an organization:

JWT claims (from your identity provider):

{
  "sub": "user_123",
  "email": "alice@acme.com",
  "org_id": "org_acme",
  "role": "analyst"
}

Atlas config:

export default defineConfig({
  auth: "byot",
  rls: {
    enabled: true,
    policies: [
      { tables: ["*"], column: "org_id", claim: "org_id" },
    ],
  },
});

What happens when Alice runs a query:

-- Agent generates:
SELECT department, COUNT(*) FROM employees GROUP BY department

-- After RLS injection:
SELECT department, COUNT(*) FROM employees WHERE employees.org_id = 'org_acme' GROUP BY department

The injection is transparent to the agent and the user. The agent sees the filtered results and builds its answer from there.


How Injection Works

RLS conditions are injected via AST manipulation (not string concatenation), which handles edge cases correctly:

Table Aliases

-- Before:
SELECT e.name FROM employees e JOIN departments d ON e.dept_id = d.id

-- After (policies: employees.org_id, departments.org_id):
SELECT e.name FROM employees e JOIN departments d ON e.dept_id = d.id
WHERE e.org_id = 'org_acme' AND d.org_id = 'org_acme'

CTEs and Subqueries

RLS conditions are injected into each SELECT that references a filtered table, including CTE definitions, derived tables in FROM clauses, and WHERE-clause subqueries.

UNIONs

Each branch of a UNION gets its own RLS conditions independently.


Security Model

  • Fail-closed -- missing user, missing claims, unresolvable claim paths, or empty array claims block the query
  • Post-plugin -- RLS injection runs after plugin beforeQuery hooks, so plugins cannot strip conditions
  • SQL-escaped -- claim values have single quotes doubled before injection
  • AST-based -- conditions are injected into the parsed AST and regenerated, preventing injection attacks
  • Scoped to SELECT -- only applies to queries that pass SQL validation (SELECT-only)

Troubleshooting

"RLS is enabled but no authenticated user is available"

Authentication is required. Check that your auth mode provides user context. See Authentication.

"RLS policy requires claim X but it is missing"

The JWT doesn't contain the expected claim. Verify:

  • The claim path is correct (check spelling, case sensitivity)
  • Your identity provider includes the claim in the JWT
  • For nested paths like app_metadata.tenant, ensure the full structure exists

"ATLAS_RLS_ENABLED=true requires both ATLAS_RLS_COLUMN and ATLAS_RLS_CLAIM"

Both environment variables must be set when using env-var configuration. For multi-policy setups, use atlas.config.ts instead.

Queries return no data

If RLS is working but queries return empty results, the claim value may not match any rows. Check:

ATLAS_LOG_LEVEL=debug

Look for "RLS conditions injected into SQL" log messages to see the exact WHERE clauses applied to each query.

See Troubleshooting for more diagnostic steps.


For workspace admins

If you manage an Atlas workspace through the admin console, here is what you need to know about RLS:

Setting up RLS policies requires access to atlas.config.ts or the environment variables listed in Quick Start. Coordinate with your platform operator to configure policies that match your tenant column structure.

What to verify:

  1. Every table your users query has a tenant column (e.g. org_id, tenant_id, workspace_id)
  2. The JWT claims from your identity provider include the corresponding claim path
  3. Test with ATLAS_LOG_LEVEL=debug to confirm injection — look for "RLS conditions injected into SQL" log messages

Admin console visibility: RLS rejections appear in Admin > Audit Log as failed queries. Filter by error messages containing "RLS" to monitor policy enforcement.


For operators

Auth mode compatibility

RLS depends on user claims being available at query time. See Auth Mode Compatibility for the full matrix. For api-key mode specifically, static claims are provided via ATLAS_RLS_CLAIMS (a JSON string parsed at startup) — suitable for service-to-service scenarios with a fixed tenant context.

Claim resolution

Claims are resolved using dot-delimited paths. See Claim Path Resolution for the full resolution rules and examples. Claim values are SQL-escaped (single quotes doubled) before injection.

Custom validator bypass

If a connection uses a custom validator (via ConnectionPluginMeta.validate), RLS injection is skipped entirely. Custom validators for SOQL, GraphQL, or other non-SQL languages must implement their own row-level filtering.


For end users

When RLS is enabled, you will only see data that belongs to your organization or tenant. This happens automatically — you do not need to add any filters to your questions.

What this means in practice:

  • If you ask "Show all orders," you see only your organization's orders
  • Aggregate queries (counts, sums, averages) only include your data
  • The agent does not know about other tenants' data — the filtering happens at the database level

If queries return no data:

  • Your account may not have the expected claim value — contact your workspace admin
  • The column name in the RLS policy may not match the actual database column — this is a configuration issue, not a data issue

See Also

Edit on GitHub

Last updated on

On this page