Setting Up Row-Level Security with Keycloak
Summary
In this tutorial, you will:
- Start a three-container stack: Keycloak (preseeded with three test users), Postgres (preseeded with the TinkerPop modern graph and an entitlement table), and PuppyGraph wired to both.
- Upload a graph schema that maps the Postgres tables onto
personandsoftwarenodes and turns on row-level security onperson. - Sign in as each Keycloak user and run the same Cypher query, observing that the three users see different rows.
The person table is the canonical modern-graph people (marko, vadas, josh, peter) extended with team and level columns. RLS filters person by team AND level. The three test users illustrate the three RLS outcomes you need to recognize when designing a security model:
| User | Entitlements | What they see |
|---|---|---|
testuser |
Team: graph, Level: senior |
marko, josh |
alice |
Team: {infra, ui}, Level: {junior, senior} |
vadas, peter |
bob |
No entitlement rows | All four persons. A user with no rows in the entitlement table bypasses RLS by design. |
Builds on the SSO tutorial
The Keycloak setup here mirrors the standalone SSO tutorial. If you have not done that one yet, the differences in this tutorial are the added Postgres service, the schema upload, and the entitlement table.
Prerequisites
A browser for the PuppyGraph Web UI.
Setup
Deployment
Create a working directory and
cd into it:
Create
docker-compose.yaml:
docker-compose.yaml
services:
postgres:
image: postgres:16
environment:
- POSTGRES_DB=demo
- POSTGRES_USER=puppy
- POSTGRES_PASSWORD=puppy-demo-password
volumes:
- ./postgres-init.sql:/docker-entrypoint-initdb.d/postgres-init.sql:ro
ports:
- "55432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U puppy -d demo"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
keycloak:
image: quay.io/keycloak/keycloak:26.0
command:
- start-dev
- --import-realm
- --http-port=18080
- --hostname=http://localhost:18080
- --health-enabled=true
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
ports:
- "18080:18080"
volumes:
- ./puppygraph-realm.json:/opt/keycloak/data/import/puppygraph-realm.json:ro
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/18080"]
interval: 5s
timeout: 3s
retries: 30
start_period: 20s
puppygraph:
image: puppygraph/puppygraph:latest
ports:
- "8081:8081"
- "8182:8182"
- "7687:7687"
environment:
- PUPPYGRAPH_USERNAME=puppygraph
- PUPPYGRAPH_PASSWORD=puppygraph123
# SSO / OIDC
- SSO_ENABLED=true
- SSO_CLIENT_ID=puppygraph-app
- SSO_CLIENT_SECRET=puppygraph-sso-secret
- SSO_ISSUER=http://localhost:18080/realms/puppygraph
- SSO_URL=http://localhost:18080/realms/puppygraph/protocol/openid-connect/auth
- SSO_ACCESS_TOKEN_URL=http://keycloak:18080/realms/puppygraph/protocol/openid-connect/token
- SSO_JWKS_URL=http://keycloak:18080/realms/puppygraph/protocol/openid-connect/certs
- SSO_CALLBACK_URL=http://localhost:8081/sso_callback
- SSO_CLAIM_AS_USER_ID=preferred_username
# RBAC: every SSO user gets the Analyst role on first login
- RBAC_ENABLED=true
- RBAC_DEFAULT_ROLE=Analyst
# Required for RLS: the engine reads the authenticated username from
# the Bolt/Gremlin auth context. Enabling auth on those servers and
# setting a JWT key lets the SSO identity flow through to the RLS
# predicate at query time.
- BOLTSERVER_AUTHENTICATION_ENABLED=true
- GREMLINSERVER_AUTHENTICATION_ENABLED=true
- AUTHENTICATION_JWT_SECRETKEY=puppy-sso-quickstart-jwt-secret-key
depends_on:
keycloak:
condition: service_healthy
postgres:
condition: service_healthy
Postgres Seed
Create
postgres-init.sql. The compose mounts this into Postgres's docker-entrypoint-initdb.d, so it runs on first start.
postgres-init.sql
-- modern.person, modern.software, modern.knows, modern.created
-- : the TinkerPop modern graph, with team / level columns added to person.
-- security.user_entitlements
-- : per-user allowed Team / Level values for RLS.
--
-- Entitlement keys use the "sso:<user>" form so they match the
-- authenticated identity PuppyGraph forwards from Keycloak.
CREATE SCHEMA IF NOT EXISTS modern;
CREATE SCHEMA IF NOT EXISTS security;
CREATE TABLE modern.person (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER NOT NULL,
team TEXT NOT NULL,
level TEXT NOT NULL
);
CREATE TABLE modern.software (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lang TEXT NOT NULL
);
CREATE TABLE modern.knows (
id TEXT PRIMARY KEY,
from_id TEXT NOT NULL,
to_id TEXT NOT NULL,
weight DOUBLE PRECISION
);
CREATE TABLE modern.created (
id TEXT PRIMARY KEY,
from_id TEXT NOT NULL,
to_id TEXT NOT NULL,
weight DOUBLE PRECISION
);
CREATE TABLE security.user_entitlements (
username VARCHAR(64) NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_value VARCHAR(32) NOT NULL,
is_authorized BOOLEAN NOT NULL,
PRIMARY KEY (username, resource_type, resource_value)
);
INSERT INTO modern.person (id, name, age, team, level) VALUES
('v1', 'marko', 29, 'graph', 'senior'),
('v2', 'vadas', 27, 'infra', 'junior'),
('v4', 'josh', 32, 'graph', 'senior'),
('v6', 'peter', 35, 'ui', 'senior');
INSERT INTO modern.software (id, name, lang) VALUES
('v3', 'lop', 'java'),
('v5', 'ripple', 'java');
INSERT INTO modern.knows (id, from_id, to_id, weight) VALUES
('e7', 'v1', 'v2', 0.5),
('e8', 'v1', 'v4', 1.0);
INSERT INTO modern.created (id, from_id, to_id, weight) VALUES
('e9', 'v1', 'v3', 0.4),
('e10', 'v4', 'v5', 1.0),
('e11', 'v4', 'v3', 0.4),
('e12', 'v6', 'v3', 0.2);
INSERT INTO security.user_entitlements (username, resource_type, resource_value, is_authorized) VALUES
('sso:testuser', 'Team', 'graph', TRUE),
('sso:testuser', 'Level', 'senior', TRUE),
('sso:alice', 'Team', 'infra', TRUE),
('sso:alice', 'Team', 'ui', TRUE),
('sso:alice', 'Level', 'junior', TRUE),
('sso:alice', 'Level', 'senior', TRUE);
-- sso:bob intentionally absent: not in entitlement table, RLS bypassed.
Keycloak Realm
Create
puppygraph-realm.json:
puppygraph-realm.json
{
"realm": "puppygraph",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"clients": [
{
"clientId": "puppygraph-app",
"name": "PuppyGraph SSO Client",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "puppygraph-sso-secret",
"redirectUris": [
"http://localhost:8081/sso_callback",
"http://localhost:8081/*"
],
"webOrigins": ["http://localhost:8081"],
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"publicClient": false,
"protocol": "openid-connect",
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8081/*"
},
"defaultClientScopes": ["openid", "profile", "email"]
}
],
"users": [
{
"username": "testuser",
"enabled": true,
"email": "testuser@puppygraph.local",
"emailVerified": true,
"firstName": "Test",
"lastName": "User",
"credentials": [{"type": "password", "value": "testpassword", "temporary": false}]
},
{
"username": "alice",
"enabled": true,
"email": "alice@puppygraph.local",
"emailVerified": true,
"firstName": "Alice",
"lastName": "Engineering",
"credentials": [{"type": "password", "value": "alice", "temporary": false}]
},
{
"username": "bob",
"enabled": true,
"email": "bob@puppygraph.local",
"emailVerified": true,
"firstName": "Bob",
"lastName": "Sales",
"credentials": [{"type": "password", "value": "bob", "temporary": false}]
}
]
}
Graph Schema
Create
demo-schema.json. This declares the Postgres catalog, two nodes (person, software) and two edges (knows, created) modeling the TinkerPop modern graph, and a rowLevelSecurity block that filters person by team and level.
demo-schema.json
{
"catalog": [
{
"name": "pg_modern",
"type": "postgresql",
"jdbc": {
"username": "puppy",
"password": "puppy-demo-password",
"jdbcUri": "jdbc:postgresql://postgres:5432/demo",
"driverClass": "org.postgresql.Driver"
}
}
],
"node": [
{
"label": "person",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "pg_modern",
"schema": "modern",
"table": "person",
"mappedField": [
{"sourceFieldName": "id", "targetFieldName": "id"},
{"sourceFieldName": "name", "targetFieldName": "name"},
{"sourceFieldName": "age", "targetFieldName": "age"},
{"sourceFieldName": "team", "targetFieldName": "team"},
{"sourceFieldName": "level", "targetFieldName": "level"}
]
}
},
"id": [{"name": "id", "type": "STRING"}],
"attribute": [
{"name": "name", "type": "STRING"},
{"name": "age", "type": "INT"},
{"name": "team", "type": "STRING"},
{"name": "level", "type": "STRING"}
]
},
{
"label": "software",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "pg_modern",
"schema": "modern",
"table": "software",
"mappedField": [
{"sourceFieldName": "id", "targetFieldName": "id"},
{"sourceFieldName": "name", "targetFieldName": "name"},
{"sourceFieldName": "lang", "targetFieldName": "lang"}
]
}
},
"id": [{"name": "id", "type": "STRING"}],
"attribute": [
{"name": "name", "type": "STRING"},
{"name": "lang", "type": "STRING"}
]
}
],
"edge": [
{
"label": "knows",
"fromNodeLabel": "person",
"toNodeLabel": "person",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "pg_modern",
"schema": "modern",
"table": "knows",
"mappedField": [
{"sourceFieldName": "id", "targetFieldName": "id"},
{"sourceFieldName": "from_id", "targetFieldName": "from_id"},
{"sourceFieldName": "to_id", "targetFieldName": "to_id"},
{"sourceFieldName": "weight", "targetFieldName": "weight"}
]
}
},
"id": [{"name": "id", "type": "STRING"}],
"fromKey": [{"name": "from_id", "type": "STRING"}],
"toKey": [{"name": "to_id", "type": "STRING"}],
"attribute": [
{"name": "from_id", "type": "STRING"},
{"name": "to_id", "type": "STRING"},
{"name": "weight", "type": "DOUBLE"}
]
},
{
"label": "created",
"fromNodeLabel": "person",
"toNodeLabel": "software",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "pg_modern",
"schema": "modern",
"table": "created",
"mappedField": [
{"sourceFieldName": "id", "targetFieldName": "id"},
{"sourceFieldName": "from_id", "targetFieldName": "from_id"},
{"sourceFieldName": "to_id", "targetFieldName": "to_id"},
{"sourceFieldName": "weight", "targetFieldName": "weight"}
]
}
},
"id": [{"name": "id", "type": "STRING"}],
"fromKey": [{"name": "from_id", "type": "STRING"}],
"toKey": [{"name": "to_id", "type": "STRING"}],
"attribute": [
{"name": "from_id", "type": "STRING"},
{"name": "to_id", "type": "STRING"},
{"name": "weight", "type": "DOUBLE"}
]
}
],
"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"}
]
}
],
"entitlementCacheTtlSeconds": 300
}
}
Start the Stack
Wait about 90 seconds for all three containers to become healthy.
Uploading the Graph Schema
The schema includes the rowLevelSecurity block, so uploading it activates RLS in one step. Upload requires the GraphAdmin or Admin role; sign in as the bootstrap admin for this step:
- Open http://localhost:8081.
- Sign in with the local username/password form:
puppygraph/puppygraph123(do not click Sign in with SSO for this step; the seeded SSO users only have theAnalystrole, which cannot upload schemas). - Click Graph in the sidebar, then Upload Schema, and select
demo-schema.json.
You can verify the schema is live by clicking Query and running MATCH (p:person) RETURN count(p) AS n while signed in as the bootstrap admin. The admin is not in the entitlement table, so RLS is bypassed and you should see 4.
Verifying Row-Level Security
Sign out of the bootstrap admin and sign back in via Sign in with SSO as each of the three users in turn. For each user, run the same Cypher query on the Query page:
The rows you see depend on who you logged in as:
| Logged-in user | Persons returned |
|---|---|
testuser (entitled to Team: graph, Level: senior) |
josh, marko |
alice (entitled to Team: {infra, ui}, Level: {junior, senior}) |
peter, vadas |
bob (no entitlement rows) |
All four: josh, marko, peter, vadas |
The filter is engine-injected, not query-rewritten, so the same Cypher works for everyone. The user identity flows from the OIDC ID token into the engine's auth context, and PuppyGraph builds a per-user column IN (allowed_values) predicate on every person scan before running the query.
The filter is also applied at every traversal hop, not just the starting vertex. Run the following as each user:
| Logged-in user | (person, software) pairs returned |
|---|---|
testuser |
(josh, lop), (josh, ripple), (marko, lop) |
alice |
(peter, lop) |
bob |
All four pairs in the seed data |
peter -> lop is filtered out for testuser because peter is a ui-team senior, and testuser is only entitled to Team: graph. Even though the created edge table has no security filter of its own, the join through the person table applies the per-user predicate, so the edge cannot surface a person the user is not entitled to see.
How a Filter Is Built
When alice runs the persons query, PuppyGraph:
- Looks up
sso:aliceinsecurity.user_entitlements, retrievingTeam: {infra, ui}andLevel: {junior, senior}. - For the
persontable, constructs the predicateteam IN ('infra', 'ui') AND level IN ('junior', 'senior'). - Injects the predicate into every scan of the
persontable inside the query plan, including the source side ofcreatedandknowstraversals.
The combination is AND because both team and level appear in tableSecurityFilter for the person table. To return any row, alice must be entitled to both that row's team and that row's level.
bob has no rows in the entitlement table, so PuppyGraph bypasses RLS for him and he sees everything. This is the documented behavior for users absent from the entitlement table (see Missing Entitlements in the reference) and lets you opt admins or system accounts out of RLS by leaving them out of the table. To put bob under RLS without authorizing any rows, add bob to the entitlement table with is_authorized = false for the relevant resource types; the lookup will then return zero authorized values, which blocks every row from RLS-managed tables.
Cleanup
-v removes the Postgres, Keycloak, and PuppyGraph volumes so the next up -d re-runs postgres-init.sql from scratch.
What's Next
- Row-Level Security reference. Schema fields, behavior notes, and additional patterns (multi-tenant, department-based, region+project).
- Role-Based Access Control. The other half of the access-control story: who can run which operations.
- SSO configuration reference. Swap Keycloak for a production identity provider.