Multi-Tenancy
Sibyl supports multiple organizations with complete data isolation. Each organization gets its own knowledge graph, ensuring security and separation.
How It Works
Isolated Namespaces
Each organization gets its own isolated SurrealDB namespace:
- SurrealDB: per-org namespace named
org_<uuid_hex>(hyphens stripped). All graph tables live inside that namespace.
# Sibyl derives the namespace/graph name from the organization UUID
# All operations require the group_id contextOrganization Context
Every graph operation requires organization context. There are no defaults - callers must explicitly provide org scope:
# EntityManager requires group_id
manager = EntityManager(client, group_id=str(org.id))
# This will raise an error
manager = EntityManager(client, group_id="") # ValueError!Organization Management
Creating Organizations
Organizations are typically created through the web UI or API:
# Via API
curl -X POST http://localhost:3334/api/orgs \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "My Organization"}'Listing Organizations
sibyl org listSwitching Organizations
# Set the active organization by slug
sibyl org switch my-org
# Create a new organization
sibyl org create
# Manage organization members
sibyl org memberssibyl org list shows the orgs you belong to and which one is active.
Authentication and Authorization
JWT Tokens
JWT tokens include organization context in the org claim:
# Token payload
{
"sub": "user_abc123", # User ID
"org": "org_xyz789", # Organization ID
"exp": 1234567890 # Expiration
}API Keys
API keys are scoped to an organization:
# Create org-scoped API key
sibyl auth api-key create --name "CI/CD" --scopes mcp,api:readMCP Authentication
MCP requests extract organization from the authenticated token:
# In server.py
async def _require_org_id() -> str:
org_id = await _get_org_id_from_context()
if not org_id:
raise ValueError("Organization context required")
return org_idCode Patterns
Graph Runtime Pattern
Always resolve graph helpers with explicit org context:
from sibyl_core.services.graph import get_surreal_graph_runtime
runtime = await get_surreal_graph_runtime(str(org_id))
manager = runtime.entity_managerQuery Pattern
Queries must include group_id filter:
# CORRECT - scoped to org
result = await driver.execute_query(
"""
MATCH (n)
WHERE n.group_id = $group_id AND n.entity_type = $type
RETURN n
""",
group_id=str(org_id),
type="task"
)
# WRONG - no org scope (queries wrong graph!)
result = await driver.execute_query(
"""
MATCH (n)
WHERE n.entity_type = $type
RETURN n
""",
type="task"
)Driver Cloning
For org-specific operations, clone the driver:
# Clone driver for org-specific graph
org_driver = client.client.driver.clone(str(org_id))
# Now queries go to the org's graph
result = await org_driver.execute_query(query)API Routes
Organization Context in Routes
API routes extract org context from the authenticated user:
from sibyl.auth.dependencies import get_current_organization
@router.get("/entities")
async def list_entities():
org = await get_current_org()
manager = EntityManager(client, group_id=str(org.id))
return await manager.list_all()Route Files
Key route files handling organization context:
| File | Purpose |
|---|---|
routes/orgs.py | Organization CRUD |
routes/org_members.py | Membership management |
routes/org_invitations.py | Invitation handling |
routes/entities.py | Entity operations (org-scoped) |
routes/tasks.py | Task operations (org-scoped) |
CLI Organization Support
Switching Organization
# Switch the active organization by slug
sibyl org switch my-org
# Commands now run in that org context
sibyl task list # Lists tasks in my-orgPer-Command Project Override
The --context flag and SIBYL_CONTEXT override the active project for a single command, not the organization. Switch organizations with sibyl org switch.
# Override project context for a single command
sibyl --context proj_xyz task list
# Or
SIBYL_CONTEXT=proj_xyz sibyl task listAuth Schema
Organization Table
DEFINE TABLE organizations SCHEMAFULL;
DEFINE TABLE organization_members SCHEMAFULL;Graph Storage
flowchart TD
DB[("SurrealDB Instance")]
subgraph OrgA["Namespace · org_550e8400… (Org A)"]
A1["Entity records"]
A2["Raw memories"]
A3["Relationships"]
end
subgraph OrgB["Namespace · org_6fa459ea… (Org B)"]
B1["Fully isolated graph"]
end
subgraph Auth["Namespace · sibyl_auth"]
AU["Auth and control plane"]
end
DB --> OrgA
DB --> OrgB
DB --> AuthSecurity Considerations
Data Isolation
Organizations are completely isolated:
- No cross-org queries - Queries only see their org's graph
- No shared data - Each org has its own nodes and relationships
- No data leakage - Embeddings are org-scoped
Access Control
# Verify user belongs to requested org
async def require_org_access(user: User, org_id: str) -> Organization:
org = await get_org(org_id)
if not await user.has_access_to(org):
raise PermissionDenied("No access to organization")
return orgAudit Logging
Track organization access:
log.info(
"org_access",
user_id=user.id,
org_id=org.id,
action="list_entities"
)Common Issues
Wrong Organization Data
Symptom: Seeing data from another organization or no data at all.
Cause: Missing or incorrect group_id in queries.
Fix: Verify org context is being passed correctly:
# Debug: log the org_id being used
log.debug("query_context", org_id=org_id)"Graph not found"
Symptom: Queries fail with graph not found errors.
Cause: New organization with no entities yet.
Fix: The graph is created automatically when first entity is added.
Permission Denied
Symptom: User can't access organization resources.
Fix: Verify user is a member of the organization:
sibyl org list # Shows orgs user belongs toBest Practices
1. Always Validate Org Context
if not org_id:
raise ValueError("Organization context required")2. Use Type Hints
async def my_function(*, group_id: str) -> None:
"""Forces callers to use keyword argument."""
...3. Log Organization Context
log.info("operation", org_id=org_id, action="create_entity")4. Test Multi-Tenancy
async def test_org_isolation():
# Create entities in org A
await create_entity(org_id=org_a)
# Query from org B should not see them
results = await search(org_id=org_b)
assert len(results) == 0Next Steps
- MCP Configuration - Configure organization-scoped MCP access
- Knowledge Graph - Understand graph structure
- Task Management - Org-scoped task operations
