Multi-Tenant Setup Guide¶
This guide covers setting up OrmAI for multi-tenant applications where data must be strictly isolated between tenants.
Overview¶
Multi-tenancy in OrmAI is handled through:
- Principals - Carrying tenant context
- Scoping Rules - Automatic query filtering
- Policy Enforcement - Preventing cross-tenant access
Basic Setup¶
1. Define Tenant-Aware Models¶
Ensure your models have a tenant_id column:
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Tenant(Base):
__tablename__ = "tenants"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
tenant_id = Column(String, ForeignKey("tenants.id"), nullable=False)
email = Column(String, nullable=False)
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
tenant_id = Column(String, ForeignKey("tenants.id"), nullable=False)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
status = Column(String)
2. Configure Tenant Scoping¶
Add scoping rules to your policy:
from ormai.policy import Policy, ModelPolicy, FieldPolicy, FieldAction
policy = Policy(
models={
"User": ModelPolicy(
allowed=True,
fields={
"id": FieldPolicy(action=FieldAction.Allow),
"tenant_id": FieldPolicy(action=FieldAction.Allow),
"email": FieldPolicy(action=FieldAction.Allow),
},
scoping={"tenant_id": "principal.tenant_id"},
),
"Order": ModelPolicy(
allowed=True,
fields={
"id": FieldPolicy(action=FieldAction.Allow),
"tenant_id": FieldPolicy(action=FieldAction.Allow),
"user_id": FieldPolicy(action=FieldAction.Allow),
"status": FieldPolicy(action=FieldAction.Allow),
},
scoping={"tenant_id": "principal.tenant_id"},
),
},
)
3. Pass Tenant in Principal¶
Extract tenant from your authentication:
from ormai.core import Principal, RunContext
def get_context(request, db):
# Extract from JWT, header, or session
tenant_id = request.headers.get("X-Tenant-ID")
user_id = request.user.id
return RunContext(
principal=Principal(
tenant_id=tenant_id,
user_id=user_id,
),
db=db,
)
How Scoping Works¶
When a query is executed:
# User's query
result = await toolset.query(
ctx,
model="Order",
filters=[{"field": "status", "op": "eq", "value": "pending"}],
)
# OrmAI automatically adds tenant filter
# Resulting SQL:
# SELECT * FROM orders
# WHERE tenant_id = 'acme-corp' <-- Injected
# AND status = 'pending' <-- User filter
Users cannot override or bypass the tenant scope.
Multiple Scoping Fields¶
Scope by multiple fields:
ModelPolicy(
scoping={
"tenant_id": "principal.tenant_id",
"organization_id": "principal.metadata.org_id",
}
)
User-Level Scoping¶
For user-specific data access:
This restricts users to their own data within the tenant.
Hierarchical Tenancy¶
For parent-child tenant relationships:
# Principal with hierarchy
principal = Principal(
tenant_id="child-tenant",
metadata={
"parent_tenant_id": "parent-tenant",
"tenant_hierarchy": ["root", "parent-tenant", "child-tenant"],
},
)
# Policy with hierarchical access
ModelPolicy(
scoping={"tenant_id": "principal.tenant_id"},
row_policies=[
RowPolicy(
name="parent_access",
condition="tenant_id IN principal.metadata.tenant_hierarchy",
description="Access data from parent tenants",
),
],
)
Cross-Tenant Access (Admin)¶
For admin operations across tenants:
# Admin principal
admin_principal = Principal(
tenant_id="system",
user_id="admin-001",
roles=["super_admin"],
)
# Policy with admin bypass
ModelPolicy(
scoping={"tenant_id": "principal.tenant_id"},
row_policies=[
RowPolicy(
name="admin_bypass",
condition="'super_admin' IN principal.roles",
bypass=True, # Bypasses scoping for admins
),
],
)
Security Note
Use admin bypass carefully. Ensure proper authentication and audit logging for admin operations.
Preventing Tenant Leakage¶
Write Operations¶
Automatically set tenant on create:
from ormai.policy import WritePolicy, WriteAction
ModelPolicy(
write_policy=WritePolicy(
create=WriteAction.Allow,
auto_set={
"tenant_id": "principal.tenant_id",
},
immutable_fields=["tenant_id"], # Cannot be updated
),
)
Validation¶
Add row policies to catch edge cases:
RowPolicy(
name="tenant_required",
condition="tenant_id IS NOT NULL",
description="Ensure tenant_id is always set",
)
Testing Multi-Tenancy¶
Unit Tests¶
async def test_tenant_isolation():
# Create contexts for different tenants
ctx_tenant_a = RunContext(
principal=Principal(tenant_id="tenant-a", user_id="user-1"),
db=session,
)
ctx_tenant_b = RunContext(
principal=Principal(tenant_id="tenant-b", user_id="user-2"),
db=session,
)
# Create order in tenant A
await toolset.create(
ctx_tenant_a,
model="Order",
data={"status": "pending"},
)
# Query from tenant B - should not see tenant A's order
result = await toolset.query(ctx_tenant_b, model="Order")
assert len(result.rows) == 0
Invariant Testing¶
Use the eval harness:
from ormai.eval import EvalHarness, no_cross_tenant_data
harness = EvalHarness(toolset, policy)
# Run with invariant checks
result = await harness.run(
ctx,
tool="query",
kwargs={"model": "Order"},
invariants=[no_cross_tenant_data],
)
Audit Trail¶
Tenant information is included in audit logs:
{
"id": "aud-123",
"tenant_id": "acme-corp",
"user_id": "user-123",
"tool_name": "query",
"model": "Order",
"scopes_injected": ["tenant_id = 'acme-corp'"],
...
}
Query audit logs by tenant:
Best Practices¶
-
Always scope sensitive models - Any model with user data should have tenant scoping
-
Use immutable tenant_id - Prevent updates to tenant_id after creation
-
Auto-set tenant on create - Use
auto_setto ensure tenant is always set correctly -
Audit cross-tenant access - Log and monitor any admin/system access
-
Test isolation thoroughly - Include tenant isolation in your test suite
-
Use row policies for complex rules - When simple scoping isn't enough
Common Patterns¶
Shared Resources¶
For resources shared across tenants:
ModelPolicy(
scoping={}, # No tenant scoping
row_policies=[
RowPolicy(
name="public_or_owned",
condition="is_public = true OR tenant_id = principal.tenant_id",
),
],
)
Tenant Metadata Access¶
Allow read-only access to own tenant info:
"Tenant": ModelPolicy(
allowed=True,
fields={
"id": FieldPolicy(action=FieldAction.Allow),
"name": FieldPolicy(action=FieldAction.Allow),
},
scoping={"id": "principal.tenant_id"}, # Can only see own tenant
write_policy=WritePolicy(
create=WriteAction.Deny,
update=WriteAction.Deny,
delete=WriteAction.Deny,
),
)
Next Steps¶
- Write Operations - Secure write handling
- Evaluation & Testing - Testing tenant isolation
- Audit Logging - Monitoring access