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:
curl -X POST http://localhost:8010/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@acme.com", "password": "secret"}'{
"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:
| Permission | Access |
|---|---|
READ | MATCH, CALL db.vector.search, GET /v1/nodes, GET /v1/edges |
WRITE | READ + CREATE, MERGE, SET, POST /v1/nodes, PUT /v1/nodes/:id |
DELETE | WRITE + DELETE, DELETE /v1/nodes/:id, DELETE /v1/edges/:id |
SCHEMA | DELETE + schema management (create_schema, add_label, etc.) |
ADMIN | SCHEMA + user management, KMS key rotation, cluster ops |
Assign roles in 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.
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.
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.
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:
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:
# 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
| Variable | Description | Default |
|---|---|---|
JWT_SECRET | HMAC-SHA256 signing secret — must be changed in production | (required) |
JWT_EXPIRY_SECONDS | Access token TTL | 3600 |
JWT_REFRESH_EXPIRY_SECONDS | Refresh token TTL | 86400 |
ADMIN_EMAIL | Email for the initial admin user created on first start | (required) |
ADMIN_PASSWORD | Password for the initial admin user | (required) |
P8G_MULTI_TENANT | Enable per-tenant engine routing | false |
P8G_TENANT_DATA_ROOT | Root directory for per-tenant data dirs (multi-tenant mode) | ./data/tenants |