Skip to content

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 Graphs

FalkorDB supports multiple named graphs within a single database instance. Sibyl uses the organization UUID as the graph name:

python
# Each org gets its own graph
graph_name = str(org.id)  # e.g., "550e8400-e29b-41d4-a716-446655440000"

Organization Context

Every graph operation requires organization context. There are no defaults - callers must explicitly provide org scope:

python
# 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:

bash
# Via API
curl -X POST http://localhost:3334/api/orgs \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"name": "My Organization"}'

Listing Organizations

bash
sibyl org list

Switching Organizations

bash
# Set active organization
sibyl org switch org_abc123

# Check current organization
sibyl org current

Authentication and Authorization

JWT Tokens

JWT tokens include organization context in the org claim:

python
# Token payload
{
    "sub": "user_abc123",      # User ID
    "org": "org_xyz789",       # Organization ID
    "exp": 1234567890          # Expiration
}

API Keys

API keys are scoped to an organization:

bash
# Create org-scoped API key
sibyl auth api-key create --name "CI/CD" --scopes mcp,api:read

MCP Authentication

MCP requests extract organization from the authenticated token:

python
# 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_id

Code Patterns

EntityManager Pattern

Always create EntityManager with explicit org context:

python
from sibyl_core.graph import GraphClient, EntityManager

client = await GraphClient.create()

# CORRECT - explicit org context
manager = EntityManager(client, group_id=str(org_id))

# WRONG - no org context
manager = EntityManager(client, group_id="")  # Raises ValueError

Query Pattern

Queries must include group_id filter:

python
# 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:

python
# 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:

python
from sibyl.auth.context import get_current_org

@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:

FilePurpose
routes/orgs.pyOrganization CRUD
routes/org_members.pyMembership management
routes/org_invitations.pyInvitation handling
routes/entities.pyEntity operations (org-scoped)
routes/tasks.pyTask operations (org-scoped)

CLI Organization Support

Switching Context

bash
# Switch active organization
sibyl org switch org_abc123

# Commands now use this org context
sibyl task list  # Lists tasks in org_abc123

Per-Command Override

bash
# Override context for single command
sibyl --context org_xyz task list
# Or
SIBYL_CONTEXT=org_xyz sibyl task list

Database Schema

Organization Table

sql
-- In PostgreSQL
CREATE TABLE organizations (
    id UUID PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Users belong to organizations
CREATE TABLE organization_members (
    organization_id UUID REFERENCES organizations(id),
    user_id UUID REFERENCES users(id),
    role VARCHAR(50) DEFAULT 'member',
    PRIMARY KEY (organization_id, user_id)
);

Graph Storage

FalkorDB Instance
├── Graph: "550e8400-e29b-41d4-a716-446655440000" (Org A)
│   ├── Entity nodes
│   ├── Episodic nodes
│   └── Relationships
├── Graph: "6fa459ea-ee8a-3ca4-894e-db77e160355e" (Org B)
│   └── (completely isolated)
└── Graph: "default" (system/anonymous)

Security 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

python
# 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 org

Audit Logging

Track organization access:

python
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:

python
# 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:

bash
sibyl org list  # Shows orgs user belongs to

Best Practices

1. Always Validate Org Context

python
if not org_id:
    raise ValueError("Organization context required")

2. Use Type Hints

python
async def my_function(*, group_id: str) -> None:
    """Forces callers to use keyword argument."""
    ...

3. Log Organization Context

python
log.info("operation", org_id=org_id, action="create_entity")

4. Test Multi-Tenancy

python
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) == 0

Next Steps

Released under the MIT License.