Skip to content

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 person and software nodes and turns on row-level security on person.
  • 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

docker compose version

A browser for the PuppyGraph Web UI.

Setup

Deployment

▶ Create a working directory and cd into it:

mkdir puppygraph-rls-demo
cd puppygraph-rls-demo

▶ 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

docker compose up -d

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:

  1. Open http://localhost:8081.
  2. 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 the Analyst role, which cannot upload schemas).
  3. 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:

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

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:

MATCH (p:person)-[:created]->(s:software)
RETURN p.name, s.name
ORDER BY p.name, s.name
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:

  1. Looks up sso:alice in security.user_entitlements, retrieving Team: {infra, ui} and Level: {junior, senior}.
  2. For the person table, constructs the predicate team IN ('infra', 'ui') AND level IN ('junior', 'senior').
  3. Injects the predicate into every scan of the person table inside the query plan, including the source side of created and knows traversals.

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

docker compose down -v

-v removes the Postgres, Keycloak, and PuppyGraph volumes so the next up -d re-runs postgres-init.sql from scratch.

What's Next