Skip to content

Row-Level Security (RLS)

Row-Level Security (RLS) restricts which rows each user can see when querying graph data. RLS works transparently: filters are applied automatically at query time based on the authenticated user's entitlements. No changes to Cypher or Gremlin queries are required.

For a worked end-to-end example against a local Keycloak and Postgres, see Setting Up Row-Level Security with Keycloak.

Overview

RLS uses entitlement tables in your data sources to define which users can access which rows. Each row in an entitlement table maps a username to an authorized value, optionally qualified by a resource type (e.g., team, level, region, tenant). Each source table with a security filter uses one entitlement table. Different source tables can use the same entitlement table or different ones. The simplest setup is one entitlement table for the whole graph.

When a user runs a query, PuppyGraph:

  1. Looks up the user's entitlements in the entitlement table of each source table the query reads. Results are cached with a configurable TTL.
  2. For each source table with security filters configured, checks which values the user is authorized to access.
  3. AND-combines all filter conditions for the table.
  4. Applies the filters to every data access, including intermediate traversal hops.

Users only see rows they are entitled to, at every step of the traversal.

Prerequisites

  • Authentication must be enabled. RLS requires a username from the authenticated session. If RLS is enabled but no username is available, the query is rejected. See OAuth / OIDC Single Sign-On for IdP-backed logins and Role-Based Access Control for local users and service accounts.
  • At least one entitlement table must exist in one of your connected data sources or as a local table.

Entitlement Table

An entitlement table maps usernames to authorized resource values. Two columns are required and two are optional:

Column Required Description Example
Username Yes The user's login identifier sso:alice
Resource Value Yes Specific allowed value graph, senior
Resource Type No Category of entitlement. Without this column, every row grants the same kind of value, and the filters for this table do not set resourceType. Team, Level
Is Authorized No Boolean authorization flag. Without this column, every row grants access. true / false

Example Entitlement Data

USERNAME RESOURCE_TYPE RESOURCE_VALUE IS_AUTHORIZED
sso:alice Team graph true
sso:alice Team infra true
sso:alice Level senior true
sso:alice Level junior true
sso:bob Team ui true
sso:bob Level senior true

In this example, sso:alice can see rows whose team is graph or infra and whose level is senior or junior. sso:bob can only see rows whose team is ui and whose level is senior.

Schema Configuration

RLS is configured in the rowLevelSecurity section of your graph schema JSON. For the surrounding schema document and how to upload or edit it, see Graph Modeling and Managing the Graph. The configuration has three parts:

  1. Enable/disable. The enabled flag.
  2. Entitlement sources. The entitlement tables, each with a name.
  3. Table security filters. Which columns on which tables to filter, and which entitlement table governs each.

Configuration Reference

{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "<source_name>",
        "catalog": "<catalog_name>",
        "schema": "<schema_name>",
        "table": "<entitlement_table_name>",
        "usernameColumn": "<column_name>",
        "resourceValueColumn": "<column_name>",
        "resourceTypeColumn": "<column_name>",
        "isAuthorizedColumn": "<column_name>"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "<catalog_name>",
        "schema": "<schema_name>",
        "table": "<source_table_name>",
        "entitlementSourceName": "<source_name>",
        "filter": [
          {
            "column": "<security_column>",
            "resourceType": "<resource_type>"
          }
        ]
      }
    ],
    "entitlementCacheTtlSeconds": 3600
  }
}

Fields

rowLevelSecurity

Field Required Default Description
enabled Yes Set to true to enable RLS
namedEntitlementSource Yes (if enabled) The entitlement tables, each with a unique name. Every tableSecurityFilter entry names one of them in entitlementSourceName. The one exception is a schema that still uses the deprecated entitlementSource; see Upgrading from a Single entitlementSource
tableSecurityFilter No List of tables and their security filter columns. With no entries, RLS is enabled but restricts nothing. See Tables Without Filters
entitlementCacheTtlSeconds No 3600 How long (in seconds) to cache a user's entitlements before re-querying an entitlement table. Applies to every entitlement table

namedEntitlementSource[]

Field Required Description
name Yes Unique name that tableSecurityFilter[].entitlementSourceName refers to
catalog Yes Catalog containing the entitlement table
schema Yes Schema containing the entitlement table
table Yes Name of the entitlement table
usernameColumn Yes Column that stores the username
resourceValueColumn Yes Column that stores the resource value
resourceTypeColumn No Column that stores the resource type. Omit it when every row grants the same kind of value. Filters that use this source then do not set resourceType
isAuthorizedColumn No Column that stores the boolean authorization flag. Omit it when every row grants access

tableSecurityFilter

Each entry specifies a source table and the security filters to apply:

Field Required Description
catalog Yes Catalog of the source table
schema Yes Schema of the source table
table Yes Name of the source table
entitlementSourceName Yes The name of the namedEntitlementSource entry that governs this table. Schema upload rejects a name that is not declared. It also rejects a missing name, except in a schema that still uses the deprecated entitlementSource
filter Yes List of column-to-resource-type mappings

Each filter entry:

Field Required Description
column Yes The column to filter on. Use the name the node or edge declares for it (one of its id, attribute, fromKey or toKey names), not the source table's name, when a mapping renames the column
resourceType When the entitlement table has a resourceTypeColumn The resource type in the entitlement table that governs this column. Set it when the entitlement table has a resourceTypeColumn. Leave it out when it has none. Schema upload rejects either mismatch

Upgrading from a Single entitlementSource

Schemas written for PuppyGraph releases before 1.14.0 declare one entitlementSource object instead of namedEntitlementSource. Their filters have no entitlementSourceName. These schemas still work exactly as before: that table is the only entitlement source, and every filter uses it.

The entitlementSource field is deprecated. A schema that sets both entitlementSource and namedEntitlementSource is rejected at upload. To add a second entitlement table, or to move off the deprecated field, convert the schema: move the existing table under namedEntitlementSource with a name, set entitlementSourceName on every filter, and upload the schema again.

{
  "rowLevelSecurity": {
    "enabled": true,
    "entitlementSource": {
      "catalog": "pg_modern",
      "schema": "security",
      "table": "user_entitlements",
      "usernameColumn": "username",
      "resourceTypeColumn": "resource_type",
      "resourceValueColumn": "resource_value",
      "isAuthorizedColumn": "is_authorized"
    },
    "tableSecurityFilter": [
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "person",
        "filter": [
          {"column": "team",  "resourceType": "Team"},
          {"column": "level", "resourceType": "Level"}
        ]
      }
    ]
  }
}
{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "user_entitlements",
        "catalog": "pg_modern",
        "schema": "security",
        "table": "user_entitlements",
        "usernameColumn": "username",
        "resourceTypeColumn": "resource_type",
        "resourceValueColumn": "resource_value",
        "isAuthorizedColumn": "is_authorized"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "person",
        "entitlementSourceName": "user_entitlements",
        "filter": [
          {"column": "team",  "resourceType": "Team"},
          {"column": "level", "resourceType": "Level"}
        ]
      }
    ]
  }
}

Complete Example

This example uses the TinkerPop modern graph (two node types person and software, two edge types knows and created) backed by Postgres tables. The person table is extended with team and level columns; those columns drive row-level filtering. An entitlement table in a separate security schema controls per-user access.

Source Tables

modern.person
+----+-------+-----+-------+--------+
| id | name  | age | team  | level  |
+----+-------+-----+-------+--------+
| v1 | marko | 29  | graph | senior |
| v2 | vadas | 27  | infra | junior |
| v4 | josh  | 32  | graph | senior |
| v6 | peter | 35  | ui    | senior |
+----+-------+-----+-------+--------+

security.user_entitlements
+-------------+---------------+----------------+---------------+
| username    | resource_type | resource_value | is_authorized |
+-------------+---------------+----------------+---------------+
| sso:alice   | Team          | graph          | true          |
| sso:alice   | Level         | senior         | true          |
| sso:bob     | Team          | infra          | true          |
| sso:bob     | Team          | ui             | true          |
| sso:bob     | Level         | junior         | true          |
| sso:bob     | Level         | senior         | true          |
+-------------+---------------+----------------+---------------+

Schema JSON (RLS section)

Given a Postgres catalog named pg_modern with the source tables in schema modern and the entitlement table in schema security:

{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "user_entitlements",
        "catalog": "pg_modern",
        "schema": "security",
        "table": "user_entitlements",
        "usernameColumn": "username",
        "resourceTypeColumn": "resource_type",
        "resourceValueColumn": "resource_value",
        "isAuthorizedColumn": "is_authorized"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "person",
        "entitlementSourceName": "user_entitlements",
        "filter": [
          {"column": "team",  "resourceType": "Team"},
          {"column": "level", "resourceType": "Level"}
        ]
      }
    ],
    "entitlementCacheTtlSeconds": 3600
  }
}

How It Works

With this configuration:

  • The entitlement table pg_modern.security.user_entitlements is declared as the source user_entitlements. PuppyGraph queries it to look up each user's authorized resource values. Results are cached for the configured TTL (1 hour in this example).
  • The person table uses that source. It is filtered by both team (resource type Team) and level (resource type Level). A user must have entitlements for both resource types to see any persons. The filters are AND-combined.
  • software, knows, and created are not listed in tableSecurityFilter, so they are unrestricted.

For example, when user sso:alice (entitled to Team={graph} and Level={senior}) runs:

MATCH (p:person) RETURN p.name, p.team, p.level

The engine internally filters to only return rows where:

  • team is in {graph}
  • AND level is in {senior}

So alice sees marko and josh. The filter is also applied to every traversal hop that scans person, so a query like MATCH (p:person)-[:created]->(s:software) RETURN p.name, s.name still only originates from persons alice is entitled to see.

More Entitlement Table Examples

The examples below show common access policies, each with a single entitlement table.

Department-Based Access

A simpler scenario: an HR analytics graph where each analyst can only see employees in their own department. Every row grants a department and every row is authorized, so the entitlement table needs only the two required columns.

Entitlement table (hr_catalog.security.department_access)
USERNAME DEPARTMENT
sso:alice@example.com Engineering
sso:alice@example.com Product
sso:bob@example.com Sales
sso:carol@example.com Engineering
Schema configuration
{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "department_access",
        "catalog": "hr_catalog",
        "schema": "security",
        "table": "department_access",
        "usernameColumn": "username",
        "resourceValueColumn": "department"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "hr_catalog",
        "schema": "people",
        "table": "employees",
        "entitlementSourceName": "department_access",
        "filter": [{"column": "department"}]
      },
      {
        "catalog": "hr_catalog",
        "schema": "people",
        "table": "performance_reviews",
        "entitlementSourceName": "department_access",
        "filter": [{"column": "employee_department"}]
      }
    ]
  }
}

Alice sees employees and performance reviews from Engineering and Product. Bob sees only Sales. Carol sees only Engineering. The filters set no resourceType, because the source has no resourceTypeColumn.

Multi-Tenant SaaS

For a multi-tenant SaaS where each customer (tenant) must only see their own data, the entitlement table holds a single resource type: Tenant.

Entitlement table (saas_catalog.security.tenant_access)
USERNAME RESOURCE_TYPE RESOURCE_VALUE IS_AUTHORIZED
sso:user1@acme.com Tenant acme-corp true
sso:user2@acme.com Tenant acme-corp true
sso:admin@globex.com Tenant globex-llc true
Schema configuration
{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "tenant_access",
        "catalog": "saas_catalog",
        "schema": "security",
        "table": "tenant_access",
        "usernameColumn": "username",
        "resourceTypeColumn": "resource_type",
        "resourceValueColumn": "resource_value",
        "isAuthorizedColumn": "is_authorized"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "saas_catalog",
        "schema": "app",
        "table": "orders",
        "entitlementSourceName": "tenant_access",
        "filter": [{"column": "tenant_id", "resourceType": "Tenant"}]
      },
      {
        "catalog": "saas_catalog",
        "schema": "app",
        "table": "customers",
        "entitlementSourceName": "tenant_access",
        "filter": [{"column": "tenant_id", "resourceType": "Tenant"}]
      },
      {
        "catalog": "saas_catalog",
        "schema": "app",
        "table": "events",
        "entitlementSourceName": "tenant_access",
        "filter": [{"column": "tenant_id", "resourceType": "Tenant"}]
      }
    ]
  }
}

Because every fact table includes a tenant_id column and the entitlement table only grants each user a single tenant, RLS provides hard tenant isolation across the graph, including across multi-hop traversals.

Region and Project (Multi-Type Combined)

When access is governed by more than one independent attribute (region AND project), list both resource types in the entitlement table and reference each in the corresponding filter columns. RLS will AND-combine the filters automatically.

Entitlement table
USERNAME RESOURCE_TYPE RESOURCE_VALUE IS_AUTHORIZED
sso:dana@example.com Region NA true
sso:dana@example.com Region EU true
sso:dana@example.com Project atlas true
sso:dana@example.com Project beacon true
Schema configuration

Excerpt, with the entitlement table declared as the source ops_entitlements:

{
  "tableSecurityFilter": [
    {
      "catalog": "ops_catalog",
      "schema": "delivery",
      "table": "deployments",
      "entitlementSourceName": "ops_entitlements",
      "filter": [
        {"column": "region", "resourceType": "Region"},
        {"column": "project_code", "resourceType": "Project"}
      ]
    }
  ]
}

Dana sees deployments only when the row's region is in {NA, EU} and project_code is in {atlas, beacon}. A row that matches one filter but not the other is filtered out.

Multiple Entitlement Tables

Authorization data does not have to live in one table. Declare each entitlement table as a named entitlement source, and have each source table name the one it uses in entitlementSourceName. Several source tables can use the same entitlement table.

One Entitlement Table per Source Table

This example extends the Complete Example. The software table gains a team column that records which team owns it, and person and software each have their own (username, team) entitlement table.

Source tables

modern.person is the same as in the Complete Example.

modern.software
+----+--------+------+-------+
| id | name   | lang | team  |
+----+--------+------+-------+
| v3 | lop    | java | graph |
| v5 | ripple | java | infra |
+----+--------+------+-------+

security.person_access
+-----------+-------+
| username  | team  |
+-----------+-------+
| sso:alice | graph |
| sso:bob   | infra |
| sso:bob   | ui    |
+-----------+-------+

security.software_access
+-----------+-------+
| username  | team  |
+-----------+-------+
| sso:alice | graph |
+-----------+-------+
Schema configuration
{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "person_access",
        "catalog": "pg_modern",
        "schema": "security",
        "table": "person_access",
        "usernameColumn": "username",
        "resourceValueColumn": "team"
      },
      {
        "name": "software_access",
        "catalog": "pg_modern",
        "schema": "security",
        "table": "software_access",
        "usernameColumn": "username",
        "resourceValueColumn": "team"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "person",
        "entitlementSourceName": "person_access",
        "filter": [{"column": "team"}]
      },
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "software",
        "entitlementSourceName": "software_access",
        "filter": [{"column": "team"}]
      }
    ]
  }
}

With this configuration:

  • sso:alice sees marko and josh (team graph) and the software lop.
  • sso:bob sees vadas and peter (teams infra and ui) and no software. Bob has no row in software_access, so every table that uses it returns nothing for him.
  • A user in neither table sees no persons and no software.

This is the same filter a per-user database view would express: SELECT p.* FROM person p JOIN person_access a ON a.team = p.team WHERE a.username = <signed-in user>. PuppyGraph fills in the authenticated PuppyGraph username, not the database session user. It applies the filter at every traversal hop, and no view has to be maintained.

Sharing One Table While Others Have Their Own

A graph-wide entitlement table and a table for a single source table can be declared side by side. Each source table names the one it uses. Here person is filtered by team and level through the four-column user_entitlements table from the Complete Example, and software through software_access:

{
  "rowLevelSecurity": {
    "enabled": true,
    "namedEntitlementSource": [
      {
        "name": "user_entitlements",
        "catalog": "pg_modern",
        "schema": "security",
        "table": "user_entitlements",
        "usernameColumn": "username",
        "resourceTypeColumn": "resource_type",
        "resourceValueColumn": "resource_value",
        "isAuthorizedColumn": "is_authorized"
      },
      {
        "name": "software_access",
        "catalog": "pg_modern",
        "schema": "security",
        "table": "software_access",
        "usernameColumn": "username",
        "resourceValueColumn": "team"
      }
    ],
    "tableSecurityFilter": [
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "person",
        "entitlementSourceName": "user_entitlements",
        "filter": [
          {"column": "team",  "resourceType": "Team"},
          {"column": "level", "resourceType": "Level"}
        ]
      },
      {
        "catalog": "pg_modern",
        "schema": "modern",
        "table": "software",
        "entitlementSourceName": "software_access",
        "filter": [{"column": "team"}]
      }
    ]
  }
}

How Multiple Sources Behave

  • Binding is per table. All column filters on a table use the same entitlement table, the one named in entitlementSourceName.
  • Fail-closed per source. A user with no rows in an entitlement table sees no rows from any table that uses it. Rows in other entitlement tables do not change this. If an entitlement table cannot be queried, a statement that reads a table using it fails. A statement that reads only tables using other sources is not affected.
  • Cached per source and user. A user looked up in two entitlement tables gets two separate cache entries. Each entry expires on its own. entitlementCacheTtlSeconds applies to every source.
  • Resolved lazily. A statement queries an entitlement table only when it reads a table that uses it, and at most once per statement.
  • Validated at upload. Schema upload rejects a tableSecurityFilter entry with no entitlementSourceName, or with a name that is not declared in namedEntitlementSource. The error names the table and the source. Each data table may appear only once in tableSecurityFilter, so a table cannot use two entitlement sources. Upload also rejects a filter whose catalog, schema and table match no node or edge data source in the schema, because no scan would ever apply it. A filter or entitlement source that names an undeclared catalog is rejected as well.

Behavior Notes

Filter Combination

When a table has multiple security filter columns, the conditions are AND-combined. A row must satisfy all filter conditions to be visible. For example, if a table filters on both team and level, the user must have entitlements for the row's team AND the row's level.

Missing Entitlements

RLS behavior when entitlements are missing depends on what is missing. Entitlements are always checked in the entitlement table that the source table uses. The four cases are:

Condition Behavior
RLS enabled, no authenticated username available Query is rejected with an error.
User row missing for one or more required resource types but present for others Tables filtered on the missing resource types return zero rows. Tables filtered only on resource types the user is entitled to are filtered normally.
User has no rows at all in an entitlement table Every table that uses that entitlement table returns zero rows for this user. Tables that use other entitlement tables are not affected.
Entitlement-table lookup fails and no cached entry exists A query that reads a table using that entitlement table is rejected with an error.

Users absent from an entitlement table see no rows from the tables bound to it

RLS fails closed. A principal with no rows in an entitlement table gets zero rows from every table that uses that entitlement table. This includes an Admin, a service account querying as itself (sa:<name>), and a headless SSO user accepted by RBAC_IMPERSONATION_MISSING_USER. Tables without a filter are not affected. To read on behalf of a user, a service account should impersonate that user; it then sees exactly that user's authorized rows. A custom heartbeat query should not scan an RLS-managed table, because it runs as an internal identity with no entitlements. Use something like RETURN 1 instead.

Local Tables

RLS table security filters also work with local tables. Set catalog and schema to "" (empty string) in tableSecurityFilter. If the entitlement table is itself a local table, do the same on its namedEntitlementSource entry, and declare the table under localTable. Entitlement tables are usually kept in an external catalog so they can be managed and updated through your existing data pipeline.

Tables Without Filters

Source tables that are not listed in tableSecurityFilter are unfiltered: all rows are visible to all users. Only tables explicitly configured with security filters are restricted.

To guarantee that no table is left out, set SECURITY_REQUIRE_ENFORCEMENT=true. PuppyGraph then rejects a schema, and refuses queries against an already loaded one, when any data source has neither a security filter nor identity propagation on its catalog. Local data sources are refused even with a security filter, because a local copy is read without the source's own access control. See Mandatory Enforcement Gate.

Entitlement Caching

User entitlements are cached per entitlement table and user to avoid querying the entitlement tables on every graph query. The cache TTL is controlled by entitlementCacheTtlSeconds (default: 3600 seconds / 1 hour) and applies to every entitlement table. After the TTL expires, the next query that needs the entry looks it up again in that entitlement table.

The cache is invalidated when the graph schema is reloaded.

Traversal Security

RLS filtering is applied at every step of a graph traversal, not just the starting vertices. A user cannot reach unauthorized data by traversing edges from authorized starting points. For example, if user sso:alice is not entitled to team ui, they cannot see any ui-team person vertices even if those vertices are connected (through knows or created) to persons alice can see.

Admin Users

The Admin role is not exempt from RLS. Admins follow the same rules as everyone else. An admin who appears in an entitlement table is filtered by their rows. An admin who is absent from it sees no rows from the tables that use it (see Missing Entitlements). If an admin needs full access, add entitlement rows for that admin covering all resource values in every entitlement table.

If entitlements are misconfigured and users are locked out of data, recovery is always possible through the control plane, which RLS does not gate: fix the entitlement table, or upload a schema with "enabled": false to turn RLS off. With SECURITY_REQUIRE_ENFORCEMENT=true, turning RLS off is rejected for any table that RLS alone covers, so fix the entitlement table instead.

Relationship to RBAC

RLS and RBAC are complementary:

Concern RBAC RLS
Controls Whether you can query What rows you see
Identity source PuppyGraph user store Entitlement tables
Granularity API/resource level Row level per table

A user must pass RBAC checks (e.g., query execution permission) before RLS is applied. RLS further restricts the result set.

Disabling RLS

To disable RLS, set "enabled": false in the rowLevelSecurity configuration, or remove the rowLevelSecurity section entirely. When disabled, all authenticated users see all rows (subject to RBAC permissions).