Skip to content

Setting Up Snowflake User Impersonation for SSO

Summary

Snowflake query impersonation lets PuppyGraph connect through one shared Snowflake service account while asking Snowflake to evaluate queries for the current SSO user.

In this tutorial, you will:

  • Create Snowflake demo tables, a proxy-user stored procedure, and Snowflake access policies.
  • Start PuppyGraph and a local Keycloak identity provider with Docker Compose.
  • Sign in as the local PuppyGraph administrator and upload a graph schema.
  • Sign in through SSO as alice and bob.
  • Run Cypher and PageRank queries and verify that Snowflake returns only the data authorized for the current SSO user.
  • Query as those users from an application through a service account, without a browser sign-in (headless impersonation).
  • Optionally switch the catalog to session claims, where the user's verified JWT claims become Snowflake session variables instead of a proxy-user name.

See Connecting to Snowflake for the catalog reference and OAuth / OIDC Single Sign-On for the SSO configuration reference.

Prerequisites

docker compose version
jq --version
  • jq, used to read tokens and secrets from JSON responses.
  • For the optional Bolt and Gremlin examples in the headless section, Python 3 with the Neo4j driver and gremlinpython (pip install neo4j gremlinpython).
  • A Snowflake account and a role that can create databases, schemas, tables, stored procedures, and row access policies.
  • A Snowflake service-account user configured for key-pair authentication.
  • The service-account role can use the configured warehouse, read the demo tables, and call the proxy-user stored procedure.
  • The private key for that service account in PKCS #8 format.
  • A browser for the PuppyGraph Web UI.

Setup

Configure Snowflake

Run the complete script in a Snowflake worksheet using a role with the required setup privileges.

Administrative role

The script starts with USE ROLE ACCOUNTADMIN. Replace that role if your organization uses a more limited administrative role for this setup.

Demo object replacement

The script creates the PUPPYGRAPH_SECURITY and PUPPYGRAPH_DEMO databases if they do not already exist. It also uses CREATE OR REPLACE for the proxy-user stored procedure, the PUPPYGRAPH_DEMO.MODERN tables, and the row access policies, so existing objects with those names will be replaced. Run it in a sandbox account, or confirm these database and object names are safe to use in your Snowflake account.

The script:

  • Creates the demo database, schema, and modern graph tables.
  • Creates a proxy-user stored procedure that stores the current SSO user in a Snowflake session variable.
  • Creates row access policies that read that session variable.
  • Attaches the policies to the demo tables.

It uses CREATE OR REPLACE for the demo objects, so you can rerun the script after fixing a worksheet mistake.

snowflake.sql
USE ROLE ACCOUNTADMIN;

-- ============================================================
-- 0. Create the security/utility database and schema.
--    PuppyGraph calls:
--    CALL "PUPPYGRAPH_SECURITY"."UTIL"."SET_PROXY_USER"(?)
-- ============================================================

CREATE DATABASE IF NOT EXISTS PUPPYGRAPH_SECURITY;
CREATE SCHEMA IF NOT EXISTS PUPPYGRAPH_SECURITY.UTIL;

-- ============================================================
-- 1. Create the test database and schema.
-- ============================================================

CREATE DATABASE IF NOT EXISTS PUPPYGRAPH_DEMO;
CREATE SCHEMA IF NOT EXISTS PUPPYGRAPH_DEMO.MODERN;

-- ============================================================
-- 2. Create the proxy-user stored procedure.
-- ============================================================

CREATE OR REPLACE PROCEDURE "PUPPYGRAPH_SECURITY"."UTIL"."SET_PROXY_USER"(USER_NAME STRING)
RETURNS STRING
LANGUAGE JAVASCRIPT
EXECUTE AS CALLER
AS
$$
  if (USER_NAME === null || USER_NAME.trim() === "") {
    throw "USER_NAME cannot be null or empty";
  }

  var normalizedUser = USER_NAME.trim().toLowerCase();

  snowflake.createStatement({
    sqlText: "SET PG_PROXY_USER = ?",
    binds: [normalizedUser]
  }).execute();

  return "PG_PROXY_USER set to: " + normalizedUser;
$$;

-- ============================================================
-- 3. Recreate the PUPPYGRAPH_DEMO.MODERN test tables.
-- ============================================================

CREATE OR REPLACE TABLE PUPPYGRAPH_DEMO.MODERN.PERSON (
    id STRING,
    name STRING,
    age INTEGER
);

INSERT INTO PUPPYGRAPH_DEMO.MODERN.PERSON (id, name, age) VALUES
    ('v1', 'marko', 29),
    ('v2', 'vadas', 27),
    ('v4', 'josh', 32),
    ('v6', 'peter', 35);

CREATE OR REPLACE TABLE PUPPYGRAPH_DEMO.MODERN.SOFTWARE (
    id STRING,
    name STRING,
    lang STRING
);

INSERT INTO PUPPYGRAPH_DEMO.MODERN.SOFTWARE (id, name, lang) VALUES
    ('v3', 'lop', 'java'),
    ('v5', 'ripple', 'java');

CREATE OR REPLACE TABLE PUPPYGRAPH_DEMO.MODERN.CREATED (
    id STRING,
    from_id STRING,
    to_id STRING,
    weight DOUBLE
);

INSERT INTO PUPPYGRAPH_DEMO.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);

CREATE OR REPLACE TABLE PUPPYGRAPH_DEMO.MODERN.KNOWS (
    id STRING,
    from_id STRING,
    to_id STRING,
    weight DOUBLE
);

INSERT INTO PUPPYGRAPH_DEMO.MODERN.KNOWS (id, from_id, to_id, weight) VALUES
    ('e7', 'v1', 'v2', 0.5),
    ('e8', 'v1', 'v4', 1.0);

-- ============================================================
-- 4. Create row access policies.
-- ============================================================

CREATE OR REPLACE ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_PERSON_READ
AS (id STRING)
RETURNS BOOLEAN ->
  CASE LOWER(GETVARIABLE('PG_PROXY_USER'))
    WHEN 'alice' THEN id IN ('v1', 'v2', 'v4')
    WHEN 'bob' THEN id IN ('v4', 'v6')
    ELSE FALSE
  END;

CREATE OR REPLACE ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_KNOWS_READ
AS (id STRING)
RETURNS BOOLEAN -> LOWER(GETVARIABLE('PG_PROXY_USER')) = 'alice';

CREATE OR REPLACE ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_SOFTWARE_READ
AS (id STRING)
RETURNS BOOLEAN ->
  CASE LOWER(GETVARIABLE('PG_PROXY_USER'))
    WHEN 'alice' THEN id = 'v5'
    WHEN 'bob' THEN id = 'v3'
    ELSE FALSE
  END;

CREATE OR REPLACE ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_CREATED_READ
AS (id STRING)
RETURNS BOOLEAN ->
  CASE LOWER(GETVARIABLE('PG_PROXY_USER'))
    WHEN 'alice' THEN id = 'e10'
    WHEN 'bob' THEN id = 'e12'
    ELSE FALSE
  END;

-- ============================================================
-- 5. Attach row access policies to the tables.
-- ============================================================

ALTER TABLE PUPPYGRAPH_DEMO.MODERN.PERSON
ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_PERSON_READ
ON (id);

ALTER TABLE PUPPYGRAPH_DEMO.MODERN.KNOWS
ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_KNOWS_READ
ON (id);

ALTER TABLE PUPPYGRAPH_DEMO.MODERN.SOFTWARE
ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_SOFTWARE_READ
ON (id);

ALTER TABLE PUPPYGRAPH_DEMO.MODERN.CREATED
ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_CREATED_READ
ON (id);

The row access policies return no rows when PG_PROXY_USER is absent or when the user is not allowed to read a table. This makes the Snowflake policy itself fail closed.

Create the local deployment

Create a working directory:

mkdir puppygraph-snowflake-impersonation
cd puppygraph-snowflake-impersonation

Copy the Snowflake service account's private key into this directory as snowflake_rsa_key.p8.

Create the 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": "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
        }
      ]
    }
  ]
}

Create the Docker Compose file

Create docker-compose.yaml:

docker-compose.yaml
services:
  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:
      - "127.0.0.1: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
    pull_policy: always
    ports:
      - "127.0.0.1:8081:8081"
      - "127.0.0.1:8083:8083"
      - "127.0.0.1:8182:8182"
      - "127.0.0.1:7687:7687"
    environment:
      PUPPYGRAPH_USERNAME: puppygraph
      PUPPYGRAPH_PASSWORD: puppygraph123

      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_ENABLED: "true"
      RBAC_DEFAULT_ROLE: Analyst
      BOLTSERVER_AUTHENTICATION_ENABLED: "true"
      GREMLINSERVER_AUTHENTICATION_ENABLED: "true"
      AUTHENTICATION_JWT_SECRETKEY: snowflake-impersonation-demo-secret

      SNOWFLAKE_ACCOUNT_IDENTIFIER: <account_identifier>
      SNOWFLAKE_SERVICE_ACCOUNT_USER: <service_account_username>
      SNOWFLAKE_WAREHOUSE: <warehouse>
      SNOWFLAKE_PRIVATE_KEY_PASSWORD: <private_key_password>
    volumes:
      - ./snowflake_rsa_key.p8:/home/keys/snowflake_rsa_key.p8:ro
    depends_on:
      keycloak:
        condition: service_healthy

The RBAC_*, *_AUTHENTICATION_ENABLED and AUTHENTICATION_JWT_SECRETKEY settings are what make the identity reach Snowflake on every query path:

  • RBAC_ENABLED turns on users, roles and service accounts.
  • BOLTSERVER_AUTHENTICATION_ENABLED and GREMLINSERVER_AUTHENTICATION_ENABLED make the Bolt and Gremlin servers authenticate each connection. Without them those servers run queries with no user identity, which an impersonation-enabled catalog refuses. With Bolt authentication off, the Bolt server refuses impersonation requests. Never turn Bolt authentication off while Gremlin authentication is on.
  • AUTHENTICATION_JWT_SECRETKEY signs the tokens that carry the identity from the control plane to the query path. When unset, it is derived from CLUSTER_SECURITYKEY, whose default is public. Use a random value of at least 32 bytes in production, and the same value on every node.

Replace the Snowflake environment variable values before starting the containers. schema.json references these variables using the ${ENV:...} syntax, so secrets such as the private key password do not need to be written directly into the schema file.

Start Keycloak and PuppyGraph:

docker compose up -d

Wait until PuppyGraph is ready:

curl --user "puppygraph:puppygraph123" http://localhost:8081/status

Create the graph schema

Create schema.json:

schema.json
{
  "catalog": [
    {
      "name": "snowflake_puppygraph_demo",
      "type": "snowflake",
      "jdbc": {
        "username": "${ENV:SNOWFLAKE_SERVICE_ACCOUNT_USER}",
        "jdbcUri": "jdbc:snowflake://${ENV:SNOWFLAKE_ACCOUNT_IDENTIFIER}.snowflakecomputing.com/?db=PUPPYGRAPH_DEMO&warehouse=${ENV:SNOWFLAKE_WAREHOUSE}&private_key_file=/home/keys/snowflake_rsa_key.p8&private_key_file_pwd=${ENV:SNOWFLAKE_PRIVATE_KEY_PASSWORD}",
        "driverClass": "net.snowflake.client.jdbc.SnowflakeDriver"
      },
      "identityPropagation": {
        "mode": "proxyUser",
        "proxyUserProcedure": "\"PUPPYGRAPH_SECURITY\".\"UTIL\".\"SET_PROXY_USER\""
      }
    }
  ],
  "node": [
    {
      "label": "Person",
      "dataSourceGroup": {
        "externalDataSource": {
          "enabled": true,
          "catalog": "snowflake_puppygraph_demo",
          "schema": "MODERN",
          "table": "PERSON",
          "mappedField": [
            {"sourceFieldName": "ID", "targetFieldName": "id"},
            {"sourceFieldName": "NAME", "targetFieldName": "name"},
            {"sourceFieldName": "AGE", "targetFieldName": "age"}
          ]
        }
      },
      "id": [{"name": "id", "type": "STRING"}],
      "attribute": [
        {"name": "name", "type": "STRING"},
        {"name": "age", "type": "LONG"}
      ]
    },
    {
      "label": "Software",
      "dataSourceGroup": {
        "externalDataSource": {
          "enabled": true,
          "catalog": "snowflake_puppygraph_demo",
          "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": "snowflake_puppygraph_demo",
          "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": "weight", "type": "DOUBLE"}]
    },
    {
      "label": "Created",
      "fromNodeLabel": "Person",
      "toNodeLabel": "Software",
      "dataSourceGroup": {
        "externalDataSource": {
          "enabled": true,
          "catalog": "snowflake_puppygraph_demo",
          "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": "weight", "type": "DOUBLE"}]
    }
  ]
}

The identityPropagation block sits on the catalog, beside jdbc. Schemas written for earlier releases carried the same setting as jdbc.queryImpersonation.proxyUserProcedure; they still load and mean the same thing. Metadata caching can stay at its default: Snowflake policies filter rows, not table and column metadata, so there is no reason to set enableMetaCache to "false" on an impersonated catalog.

Upload the schema as the local administrator

  1. Open http://localhost:8081.
  2. Use the local username and password form to sign in as puppygraph / puppygraph123. Do not use Sign in with SSO for this step.
  3. Open Graph from the left navigation.
  4. Choose Upload Schema and select schema.json.

The local administrator is used only to create the graph. A local user has no SSO identity, so PuppyGraph intentionally rejects data queries against an impersonation-enabled Snowflake catalog.

Run regular Cypher as an SSO user

Sign out, choose Sign in with SSO, and log in to Keycloak as alice / alice.

Alice can read marko, vadas, and josh from PERSON. Run:

MATCH (p:Person)
RETURN p.name AS name
ORDER BY name

Expected result:

name
josh
marko
vadas

Alice can also read the josh to ripple Created edge:

MATCH (p:Person)-[:Created]->(software:Software)
RETURN p.name AS person,
       software.name AS software
ORDER BY software

Expected result:

person software
josh ripple

Sign out and sign in through SSO as bob / bob. Bob sees a different slice of the same PERSON table:

MATCH (p:Person)
RETURN p.name AS name
ORDER BY name

Expected result:

name
josh
peter

Bob can read Peter's Created edge to lop:

MATCH (p:Person)-[:Created]->(software:Software)
RETURN p.name AS person,
       software.name AS software
ORDER BY software

Expected result:

person software
peter lop

To verify that Bob cannot read Alice's authorized software row, run:

MATCH (p:Person)-[:Created]->(software:Software {name: 'ripple'})
RETURN p.name AS person,
       software.name AS software

Expected result: 0 rows.

Run a graph algorithm as an SSO user

Graph algorithms use the same query identity and Snowflake authorization as regular Cypher scans.

As Alice, run PageRank over the Knows edges Alice can read:

CALL algo.paral.pagerank({
  labels: ['Person'],
  relationshipTypes: ['Knows'],
  maxIterations: 5
})
YIELD id, score
RETURN id, score
ORDER BY id

The result contains these node IDs:

Person[v1]
Person[v2]
Person[v4]

Query as an SSO user from an application (headless)

An application such as a GraphRAG service or an API backend can query on behalf of an SSO user without that user opening the Web UI. The application authenticates as a service account with impersonation enabled and names the user it acts for. Snowflake then applies that user's policies exactly as it does for a browser session. See Impersonation for the reference.

This flow is for mode proxyUser. Impersonation changes the user name but carries no identity claims, so a sessionClaims catalog cannot be queried through it; the application must present the user's own token instead, as shown in Switch to session claims.

Accept users who have never signed in

Impersonation needs a PuppyGraph record for the target user, and an SSO user normally gets one on their first browser sign-in. A headless user may never sign in, so tell PuppyGraph what to do when the record is missing. Add these two lines to the puppygraph service's environment in docker-compose.yaml:

      RBAC_IMPERSONATION_MISSING_USER: passthrough
      SECURITY_REQUIRE_ENFORCEMENT: "true"
  • RBAC_IMPERSONATION_MISSING_USER: passthrough accepts a missing sso: user with the role in RBAC_DEFAULT_ROLE, without creating a record. Snowflake still decides which rows the user sees. Use jit instead to create the record on first use, or leave the default reject and pre-provision each user. See Impersonating SSO users who have never signed in.
  • SECURITY_REQUIRE_ENFORCEMENT: "true" refuses any graph with a data source whose catalog has no query impersonation and whose table has no row-level security filter, so a table from an unprotected catalog cannot be read by every passthrough identity. The gate checks PuppyGraph's configuration, not Snowflake's: every table in snowflake_puppygraph_demo counts as covered, so a table you add there still needs its own row access policy. See Mandatory Enforcement Gate.

Recreate the PuppyGraph container:

docker compose up -d

Wait until PuppyGraph is ready:

curl --user "puppygraph:puppygraph123" http://localhost:8081/status

The compose file keeps no PuppyGraph volume, so recreating the container discards the graph schema and the alice and bob records created by the browser sign-ins. The two users are now users who have never signed in. Upload the schema again as the local administrator:

curl --user "puppygraph:puppygraph123" -X POST http://localhost:8081/schema \
  -H 'Content-Type: application/json' --data @schema.json

The enforcement gate accepts it because every node and edge reads from snowflake_puppygraph_demo, which has query impersonation configured. A schema that also used a table from an unprotected catalog would be rejected with a message listing that table.

Create a service account that can impersonate

Create the service account as the local administrator. Its role must be at least RBAC_DEFAULT_ROLE (Analyst here), because that is the role a passthrough user receives:

command -v jq >/dev/null || { echo "install jq first"; false; } &&
SECRET=$(curl -sf --user "puppygraph:puppygraph123" \
  -X POST http://localhost:8081/api/service-accounts \
  -H 'Content-Type: application/json' \
  -d '{"name": "graph-app", "role": "Analyst", "allow_impersonation": true}' \
  | jq -er .secret) && [ -n "$SECRET" ] && export SECRET && echo "secret saved"

The secret is returned only once. If the command does not print secret saved, for example because graph-app already exists from an earlier run, issue a new secret instead of creating the account again:

SECRET=$(curl -sf --user "puppygraph:puppygraph123" \
  -X POST http://localhost:8081/api/service-accounts/graph-app/rotate-secret \
  | jq -er .secret) && [ -n "$SECRET" ] && export SECRET && echo "secret saved"

The same can be done under Settings → Service Accounts with Allow user impersonation turned on.

Run queries as Alice and Bob

Over REST, authenticate as the service account and name the user in the X-PuppyGraph-Assume-User header:

curl -s --user "graph-app:$SECRET" -X POST http://localhost:8081/submitCypher \
  -H 'X-PuppyGraph-Assume-User: sso:alice' -H 'Content-Type: application/json' \
  -d '{"query": "MATCH (p:Person) RETURN p.name AS name ORDER BY name"}'

The result names josh, marko, and vadas, the same rows Alice saw in the browser. With sso:bob it names josh and peter.

Over Bolt, use the sa: prefix for the service account and the driver's impersonated_user session option:

import os
from neo4j import GraphDatabase

driver = GraphDatabase.driver(
    "bolt://localhost:7687", auth=("sa:graph-app", os.environ["SECRET"])
)
for user in ["sso:alice", "sso:bob"]:
    with driver.session(impersonated_user=user) as session:
        names = [r["name"] for r in session.run(
            "MATCH (p:Person) RETURN p.name AS name ORDER BY name")]
        print(user, names)
driver.close()

Over Gremlin, the X-PuppyGraph-Assume-User header is honored only together with a bearer token, not with a username and password. Log in as the service account to get a token:

TOKEN=$(curl -sf -X POST http://localhost:8081/login \
  -d username=sa:graph-app --data-urlencode "password=$SECRET" \
  | jq -er .Token) && [ -n "$TOKEN" ] && export TOKEN && echo "token saved"

Then send both headers when opening the WebSocket connection. The identity is fixed for the lifetime of the connection, so open one connection per user:

import os
from gremlin_python.process.anonymous_traversal import traversal
from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection

for user in ["sso:alice", "sso:bob"]:
    connection = DriverRemoteConnection(
        "ws://localhost:8182/gremlin", "g",
        headers={"Authorization": f"Bearer {os.environ['TOKEN']}",
                 "X-PuppyGraph-Assume-User": user})
    g = traversal().with_remote(connection)
    print(user, sorted(g.V().hasLabel("Person").values("name").to_list()))
    connection.close()

PuppyGraph passes the name without its sso: prefix (alice) to the proxy-user procedure, which is why the rows match the browser session. Because of passthrough, no record was created:

curl -s -o /dev/null -w '%{http_code}\n' --user "puppygraph:puppygraph123" \
  http://localhost:8081/api/users/sso:alice

prints 404. Each impersonated request is still recorded under Settings → Audit Log with both the service account and the user.

Switch to session claims

Mode proxyUser hands Snowflake one string, the SSO account name, and leaves the policy to look the user's entitlements up. Mode sessionClaims instead hands Snowflake the claims of the user's verified JWT as session variables, so a policy can read GETVARIABLE('TENANT_ID') directly and no lookup table is needed. The two modes use procedures with different signatures, so moving a catalog from one to the other is a deliberate change rather than an upgrade.

Give the users claims and trust the issuer

In Keycloak, add a user attribute tenant_id to alice (t-42) and to bob (t-1), create a public client puppygraph-api with Direct access grants enabled, and give it two protocol mappers: an audience mapper that adds puppygraph to the token's aud, and a user-attribute mapper that puts the tenant_id attribute into the access token as the tenant_id claim.

A browser SSO session does not carry the token's claims yet, so this part of the tutorial presents Keycloak's token directly to the REST Cypher endpoint. Register Keycloak as a trusted issuer first, as the local administrator:

curl -u puppygraph:puppygraph123 -X POST http://localhost:8081/api/trusted-issuers \
  -H 'Content-Type: application/json' \
  -d '{
    "alias": "keycloak",
    "issuer": "http://localhost:18080/realms/puppygraph",
    "jwksUrl": "http://keycloak:18080/realms/puppygraph/protocol/openid-connect/certs",
    "allowedAudiences": ["puppygraph"],
    "allowedAlgorithms": ["RS256"],
    "principalClaim": "preferred_username",
    "defaultRole": "Analyst",
    "isDefault": true
  }'

issuer is the URL the token carries, jwksUrl the address at which the PuppyGraph container reaches Keycloak.

Create the claim-set procedure and tenant-aware policies

The procedure receives one JSON argument, {"set": {"TENANT_ID": "t-42"}, "unset": ["TENANT_ID"]}. It must UNSET every variable listed under unset before it SETs the ones under set, because PuppyGraph pools connections and a variable the previous user carried must not survive for the next one. It returns a JSON array naming the variables it set; PuppyGraph fails the query closed when that list differs from what it asked for.

USE ROLE ACCOUNTADMIN;

CREATE OR REPLACE PROCEDURE PUPPYGRAPH_SECURITY.UTIL.SET_SESSION_CLAIMS(CLAIMS VARCHAR)
RETURNS VARCHAR
LANGUAGE JAVASCRIPT
EXECUTE AS CALLER
AS
$$
  var spec = JSON.parse(CLAIMS);
  var validName = /^[A-Z_][A-Z0-9_]*$/;
  var unset = spec.unset || [];
  var set = spec.set || {};
  var names = Object.keys(set).sort();
  unset.concat(names).forEach(function (name) {
    if (!validName.test(name)) { throw "invalid session variable name: " + name; }
  });
  if (unset.length > 0) {
    // UNSET fails for a variable that was never set on this session, so give each one a value first.
    var nulls = unset.map(function () { return "NULL"; });
    snowflake.createStatement({sqlText: "SET (" + unset.join(", ") + ") = (" + nulls.join(", ") + ")"}).execute();
    snowflake.createStatement({sqlText: "UNSET (" + unset.join(", ") + ")"}).execute();
  }
  if (names.length > 0) {
    var marks = names.map(function () { return "?"; });
    var values = names.map(function (name) { return String(set[name]); });
    snowflake.createStatement({sqlText: "SET (" + names.join(", ") + ") = (" + marks.join(", ") + ")", binds: values}).execute();
  }
  return JSON.stringify(names);
$$;

-- The proxy-user policies read PG_PROXY_USER, which the claim-set procedure
-- neither sets nor clears: a pooled connection can still carry the variable a
-- previous proxy-user call left, so a table left on such a policy could answer
-- from that stale value. Replace every proxy-user policy. A row access policy
-- also restricts the rows DML can reach, so detach each one before the
-- backfill or the UPDATE touches nothing.
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.PERSON DROP ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_PERSON_READ;
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.KNOWS DROP ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_KNOWS_READ;
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.SOFTWARE DROP ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_SOFTWARE_READ;
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.CREATED DROP ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_CREATED_READ;

-- A tenant partition of the demo data, deliberately different from the
-- overlapping proxy-user slices above: every row belongs to exactly one tenant.
-- t-42 (alice): marko, vadas, ripple and the edges leaving marko;
-- t-1 (bob): josh, peter, lop and the edges leaving josh and peter.
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.PERSON ADD COLUMN tenant_id STRING;
UPDATE PUPPYGRAPH_DEMO.MODERN.PERSON SET tenant_id = CASE WHEN id IN ('v1', 'v2') THEN 't-42' ELSE 't-1' END;
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.SOFTWARE ADD COLUMN tenant_id STRING;
UPDATE PUPPYGRAPH_DEMO.MODERN.SOFTWARE SET tenant_id = CASE WHEN id = 'v5' THEN 't-42' ELSE 't-1' END;
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.KNOWS ADD COLUMN tenant_id STRING;
UPDATE PUPPYGRAPH_DEMO.MODERN.KNOWS SET tenant_id = 't-42';
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.CREATED ADD COLUMN tenant_id STRING;
UPDATE PUPPYGRAPH_DEMO.MODERN.CREATED SET tenant_id = CASE WHEN id = 'e9' THEN 't-42' ELSE 't-1' END;

-- One policy shape serves every table: the row belongs to the session's tenant.
CREATE OR REPLACE ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_TENANT
AS (tenant_id STRING)
RETURNS BOOLEAN -> tenant_id = GETVARIABLE('TENANT_ID');

ALTER TABLE PUPPYGRAPH_DEMO.MODERN.PERSON ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_TENANT ON (tenant_id);
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.SOFTWARE ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_TENANT ON (tenant_id);
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.KNOWS ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_TENANT ON (tenant_id);
ALTER TABLE PUPPYGRAPH_DEMO.MODERN.CREATED ADD ROW ACCESS POLICY PUPPYGRAPH_DEMO.MODERN.RAP_TENANT ON (tenant_id);

GETVARIABLE returns NULL when the variable is not set, so the policy returns no rows to a connection on which the claim-set call did not run. Relationship queries and the graph algorithm above keep working because every mapped table now carries the same tenant policy. Do not leave a table on a PG_PROXY_USER policy: the claim-set procedure clears only the claim variables, so a pooled connection may still hold the proxy variable from an earlier session and such a table could answer from it. The procedure rethrows every error: one that swallowed a failed UNSET could report success while a previous user's variable is still set on the pooled connection. Grant the service account USAGE on the new procedure (Snowflake's procedure privilege; EXECUTE is not one), plus USAGE on PUPPYGRAPH_SECURITY and its UTIL schema if the role does not have them yet:

GRANT USAGE ON DATABASE PUPPYGRAPH_SECURITY TO ROLE <service_account_role>;
GRANT USAGE ON SCHEMA PUPPYGRAPH_SECURITY.UTIL TO ROLE <service_account_role>;
GRANT USAGE ON PROCEDURE PUPPYGRAPH_SECURITY.UTIL.SET_SESSION_CLAIMS(VARCHAR) TO ROLE <service_account_role>;

Point the catalog at the claims

Sign in again as the local administrator (puppygraph), as in Upload the schema as the local administrator: the schema's catalog section needs CATALOG:write, which the SSO users' Analyst role lacks. Then replace the catalog's identityPropagation block and upload the schema again:

"identityPropagation": {
  "mode": "sessionClaims",
  "claimSetProcedure": "\"PUPPYGRAPH_SECURITY\".\"UTIL\".\"SET_SESSION_CLAIMS\"",
  "claims": [
    { "claim": "tenant_id", "required": true }
  ]
}

Each claim becomes the session variable UPPER(claim). A user whose token lacks a claim marked required gets error IDP-02, naming the claim, before any Snowflake query runs; there is no setting that lets such a query proceed.

Obtain a token for alice and run the query with it:

TOKEN=$(curl -s -X POST http://localhost:18080/realms/puppygraph/protocol/openid-connect/token \
  -d grant_type=password -d client_id=puppygraph-api -d scope=openid \
  -d username=alice -d password=alice | jq -r .access_token)

curl -s -X POST http://localhost:8081/submitCypher \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query": "MATCH (p:Person) RETURN p.name AS name ORDER BY name"}'

The result names marko and vadas; the same call with a token for bob names josh and peter. A token without tenant_id gets error IDP-02 naming the claim instead of rows. Both modes need a PuppyGraph release whose DataAccess understands the request context; PuppyGraph rejects a sessionClaims catalog with a message naming the missing support wherever it does not.

Cleanup

Stop the local containers:

docker compose down -v

Remove the Snowflake demo database and security database when they are no longer needed:

USE ROLE ACCOUNTADMIN;
DROP DATABASE IF EXISTS PUPPYGRAPH_DEMO;
DROP DATABASE IF EXISTS PUPPYGRAPH_SECURITY;