Skip to content

Multi-tenancy

Purple8 Graph supports full multi-tenant isolation using JWT-based RBAC. Each tenant gets a scoped engine view with enforced permissions, field-level security, and row-level security.

Authentication

The REST API and CLI use JSON Web Tokens (JWT). To obtain a token:

bash
curl -X POST http://localhost:8010/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@acme.com", "password": "secret"}'
json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600
}

All subsequent requests require the Authorization: Bearer <token> header.

Permission model

Permissions are hierarchical and cumulative:

PermissionAccess
READMATCH, CALL db.vector.search, GET /v1/nodes, GET /v1/edges
WRITEREAD + CREATE, MERGE, SET, POST /v1/nodes, PUT /v1/nodes/:id
DELETEWRITE + DELETE, DELETE /v1/nodes/:id, DELETE /v1/edges/:id
SCHEMADELETE + schema management (create_schema, add_label, etc.)
ADMINSCHEMA + user management, KMS key rotation, cluster ops

Assign roles in Python:

python
from purple8_graph import GraphEngine
from purple8_graph.auth import UserStore, Permission

engine = GraphEngine("./data", jwt_secret="change-me-in-prod")
users = UserStore(engine)

# Create a read-only API consumer
users.create_user(
    email="api-consumer@acme.com",
    password="...",
    permissions=[Permission.READ],
    tenant_id="tenant-acme",
)

# Create a service account with write access
users.create_user(
    email="ingest-service@acme.com",
    password="...",
    permissions=[Permission.WRITE],
    tenant_id="tenant-acme",
)

Tenant isolation

For strict multi-tenancy, create one GraphEngine instance per tenant. Each engine has its own data directory, its own WAL, its own HNSW index, and its own KMS key.

python
engines = {
    "acme":    GraphEngine("./data/tenants/acme",    kms_key_id="key-acme"),
    "globex":  GraphEngine("./data/tenants/globex",  kms_key_id="key-globex"),
    "initech": GraphEngine("./data/tenants/initech", kms_key_id="key-initech"),
}

def get_engine(tenant_id: str, token: str) -> GraphEngine:
    """Resolve the engine for a tenant after token validation."""
    claims = verify_jwt(token, secret=JWT_SECRET)
    assert claims["tenant_id"] == tenant_id, "Cross-tenant access denied"
    return engines[tenant_id]

This is the architecture used in the Docker deployment when P8G_MULTI_TENANT=true. The REST API router extracts tenant_id from the JWT claims and routes to the correct engine automatically.

Row-level security (RLS)

RLS filters MATCH results so a user only sees nodes/edges their token is permitted to access. Filters are applied at the storage layer — the user cannot bypass them in Cypher.

python
from purple8_graph.auth import RowLevelPolicy

users.create_user(
    email="regional-reader@acme.com",
    password="...",
    permissions=[Permission.READ],
    tenant_id="tenant-acme",
    rls_policy=RowLevelPolicy(
        filter={"region": "us-east"},   # only nodes where region == "us-east"
    ),
)

The policy is embedded in the JWT. Every MATCH query by this user automatically adds WHERE node.region = 'us-east' at the storage layer.

Field-level security (FLS)

FLS redacts specific properties from query results. The properties exist in the graph but are returned as null for users who lack access.

python
from purple8_graph.auth import FieldLevelPolicy

users.create_user(
    email="analyst@acme.com",
    password="...",
    permissions=[Permission.READ],
    tenant_id="tenant-acme",
    fls_policy=FieldLevelPolicy(
        redact=["Person.ssn", "Person.salary", "Account.balance"],
    ),
)

FLS is applied after decryption — the ciphertext is decrypted, then the field is redacted before the value is returned. Downstream systems never see the plaintext.

Service-to-service tokens

For microservice-to-microservice calls (e.g., your Journey orchestrator calling the graph), use short-lived service tokens with narrow permissions:

python
from purple8_graph.auth import issue_service_token

token = issue_service_token(
    subject="journey-orchestrator",
    permissions=[Permission.WRITE],
    tenant_id="tenant-acme",
    ttl_seconds=300,         # 5-minute token
    jwt_secret=JWT_SECRET,
)

REST API: tenant-scoped endpoints

When P8G_MULTI_TENANT=true, all API endpoints require a tenant_id claim in the JWT. The URL structure is unchanged — routing is done via the token:

bash
# ACME tenant — token contains tenant_id=tenant-acme
curl -X POST http://localhost:8010/v1/nodes \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "Customer", "properties": {"name": "Alice"}}'

# Globex tenant — same endpoint, different engine
curl -X POST http://localhost:8010/v1/nodes \
  -H "Authorization: Bearer $GLOBEX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "Customer", "properties": {"name": "Bob"}}'

Alice and Bob are written to completely separate storage directories. Their data never mingles.

Environment variables

VariableDescriptionDefault
JWT_SECRETHMAC-SHA256 signing secret — must be changed in production(required)
JWT_EXPIRY_SECONDSAccess token TTL3600
JWT_REFRESH_EXPIRY_SECONDSRefresh token TTL86400
ADMIN_EMAILEmail for the initial admin user created on first start(required)
ADMIN_PASSWORDPassword for the initial admin user(required)
P8G_MULTI_TENANTEnable per-tenant engine routingfalse
P8G_TENANT_DATA_ROOTRoot directory for per-tenant data dirs (multi-tenant mode)./data/tenants

Purple8 Graph is proprietary software. All rights reserved.