Multi-tenancy
Purple8 Hyper 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.
Engine contracts and admission failures
TenantManager.get_engine() and TenantContext.engine return a TenantEngine: a native GraphEngine, a tenant-scoped shared view, or a ShardedGraphEngine, according to the tenant's isolation and shard configuration. They are not all instances of the concrete native engine. Applications should use the supported logical operations rather than reaching into a physical engine's private storage.
Tenant creation verifies capacity before persisting a new tenant. Purple8 Hyper Graph is licensed in two self-hosted tiers:
- Developer (Self-Hosted Free) — up to
50Knodes and1MCP agent - Enterprise / Custom (Self-Hosted) — unlimited nodes, unlimited MCP agents, and full multi-tenant compliance/HA
If license resolution fails, the logged Developer (Self-Hosted Free) fallback still enforces its capacity. If the capacity check itself cannot complete, creation raises TenantLimitError and logs the failure; it does not silently admit the tenant. Sandbox-specific capacity remains separate from the operator's licensed tenant count.
Read-only restore snapshots require dedicated isolation. Snapshot sealing rejects an inconsistent shared-engine binding before closing or changing any shared storage instance.
Authentication
The REST API and CLI use JSON Web Tokens (JWT). To obtain a token:
curl -X POST http://localhost:8100/auth/login \
-H "Content-Type: application/json" \
-d '{"email_or_username": "admin@acme.com", "password": "secret"}'{
"access_token": "<ACCESS_TOKEN>",
"refresh_token": "<REFRESH_TOKEN>",
"token_type": "bearer",
"expires_in": 3600
}All subsequent requests require the Authorization: Bearer <token> header.
Permission model
Permissions are fine-grained, not a simple hierarchy — assign only what a role needs:
| Category | Permissions |
|---|---|
| Nodes | node:create, node:read, node:update, node:delete |
| Edges | edge:create, edge:read, edge:update, edge:delete |
| Query | query:traverse, query:vector, query:hybrid |
| Schema | schema:read, schema:modify |
| Data | data:export, data:import, graph:clear (tenant-wide wipe) |
| Journey | journey:read, journey:write, journey:admin |
| Security | security:policy (field/row-level policies), kms:manage, gdpr:admin |
| Admin | admin:users, admin:tenants, admin:settings, admin:backup, mcp:admin, rag:configure, soc:configure |
GET /nodes and GET /edges require node:read / edge:read respectively; POST /nodes requires node:create; and so on — every REST endpoint declares its required Permission via Depends(require_permission(...)).
Create users in Python via AuthManager:
from purple8_graph.auth import AuthManager
auth = AuthManager("./auth_data", jwt_secret="change-me-in-prod")
# Create a read-only API consumer
auth.create_user(
email="api-consumer@acme.com",
username="api-consumer",
password="Str0ng!Passw0rd123",
roles=["viewer"],
tenant_id="tenant-acme",
)
# Create a service account with write access
auth.create_user(
email="ingest-service@acme.com",
username="ingest-service",
password="Str0ng!Passw0rd456",
roles=["editor"],
tenant_id="tenant-acme",
)Roles map to sets of Permission values server-side; assign a role, not a raw permission list, when creating a user.
Tenant isolation
Purple8 supports two isolation levels per tenant, set via IsolationLevel on the Tenant record managed by TenantManager:
SHARED— tenants share oneGraphEngine/ BrickCoreStorage (Purple8 Cortex) instance; every key is prefixed with<tenant_id>::at the storage layer.DEDICATED— each tenant gets its own BrickCoreStorage (Purple8 Cortex) directory (and therefore its ownBrickWAL,CortexAutoConfigmemory envelope, HNSW index, and optional per-tenant KMS DEK viakms_tenant_id).
In both modes, per-tenant write fair-queuing (write_admission.py) and the disk high-watermark admission controller (P8G_WRITE_ADMISSION_DISK_MAX_PCT=95.0) prevent noisy-neighbour write starvation and disk exhaustion. Furthermore, GET /admin/health probes only active, opened tenant engines in memory for zero-spike observability across large tenant fleets.
Both modes have independent tenant schemas within one Purple8 instance. Tenant A can require Customer.account_number while tenant B requires Customer.membership_id; neither definition changes the other's validation. Schemas managed through REST/MCP and the tenant engine SDK share one native authority, not a global schema plus a second JSON configuration. See Schema & Data Model for persistence, migration and enforcement.
Manage tenants through the admin REST API (requires admin:tenants):
curl -X POST http://localhost:8100/admin/tenants \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tenant_id": "acme", "isolation_level": "dedicated"}'The auth layer extracts tenant_id from JWT claims and scopes every subsequent request automatically — the URL structure is unchanged regardless of isolation level.
Row-level and field-level security
Purple8's security.py module provides a declarative policy layer independent of the isolation level above — it filters which nodes/edges/properties a role can see, on top of tenant boundaries.
from purple8_graph.security import (
SecurityPolicyStore, FieldPolicy, RowPolicy, SecureGraphEngine,
)
store = SecurityPolicyStore(Path("./data/security"))
# Field-level: strip sensitive properties from results for the "viewer" role
store.upsert_field_policy(FieldPolicy(
policy_id="fp1", tenant_id="acme", roles=["viewer"],
action="deny", fields=["ssn", "salary"],
))
# Row-level: hide entire labels from the "viewer" role
store.upsert_row_policy(RowPolicy(
policy_id="rp1", tenant_id="acme", roles=["viewer"],
label_deny=["InternalDocument"],
))
# Wrap the engine — policies are enforced transparently on every read
secure = SecureGraphEngine(engine, policy_store=store, tenant_id="acme", user_roles=["viewer"])
node = secure.get_node("n1") # ssn/salary stripped; InternalDocument nodes hiddenPolicies can also be managed via the REST API (requires security:policy permission):
curl -X POST http://localhost:8100/security/field-policies \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"policy_id": "fp1", "roles": ["viewer"], "action": "deny", "fields": ["ssn", "salary"]}'SecureGraphEngine is wired into the read endpoints (GET /nodes, GET /nodes/{id}, GET /edges/{id}, POST /search/vector, POST /search/hybrid, POST /traverse) automatically when policies exist for the caller's tenant — no extra client code required.
Tenant-scoped REST calls
Every REST endpoint is unversioned (no /v1/ prefix) and tenant-scoped purely via the Authorization: Bearer token's tenant_id claim:
# ACME tenant — token contains tenant_id=acme
curl -X POST http://localhost:8100/nodes \
-H "Authorization: Bearer $ACME_TOKEN" \
-H "Content-Type: application/json" \
-d '{"labels": ["Customer"], "properties": {"name": "Alice"}}'
# Globex tenant — same endpoint, different tenant scope
curl -X POST http://localhost:8100/nodes \
-H "Authorization: Bearer $GLOBEX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"labels": ["Customer"], "properties": {"name": "Bob"}}'With DEDICATED isolation, Alice and Bob are written to completely separate storage directories. With SHARED isolation, they live in the same storage but under different key prefixes and are never visible cross-tenant.
Environment variables
| Variable | Description | Default |
|---|---|---|
P8G_JWT_SECRET | HMAC-SHA256 signing secret — must be changed in production | (insecure placeholder; server refuses to boot with it outside P8G_ENV=development) |
P8G_ADMIN_EMAIL | Bootstrap admin email (used on first start to create the initial admin user) | (required) |
P8G_ADMIN_PASSWORD | Bootstrap admin password | (required) |
See Environment Variables for the full reference.