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:
- Looks up the user's entitlements in the entitlement table of each source table the query reads. Results are cached with a configurable TTL.
- For each source table with security filters configured, checks which values the user is authorized to access.
- AND-combines all filter conditions for the table.
- 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:
- Enable/disable. The
enabledflag. - Entitlement sources. The entitlement tables, each with a
name. - 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_entitlementsis declared as the sourceuser_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
persontable uses that source. It is filtered by bothteam(resource typeTeam) andlevel(resource typeLevel). A user must have entitlements for both resource types to see any persons. The filters are AND-combined. software,knows, andcreatedare not listed intableSecurityFilter, so they are unrestricted.
For example, when user sso:alice (entitled to Team={graph} and Level={senior}) runs:
The engine internally filters to only return rows where:
teamis in{graph}- AND
levelis 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:aliceseesmarkoandjosh(teamgraph) and the softwarelop.sso:bobseesvadasandpeter(teamsinfraandui) and no software. Bob has no row insoftware_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.
entitlementCacheTtlSecondsapplies 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
tableSecurityFilterentry with noentitlementSourceName, or with a name that is not declared innamedEntitlementSource. The error names the table and the source. Each data table may appear only once intableSecurityFilter, so a table cannot use two entitlement sources. Upload also rejects a filter whosecatalog,schemaandtablematch 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).