Skip to main content
Permission Mongo provides built-in multi-tenancy support, automatically isolating data by tenant ID. All queries, creates, updates, and deletes are scoped to the authenticated user’s tenant.

Overview

Multi-tenancy features:
  • Automatic tenant scoping - All operations filtered by tenant_id
  • JWT-based tenant identification - Tenant extracted from token claims
  • Per-collection tenant fields - Flexible field naming
  • Cross-tenant queries - Controlled access with admin roles
  • Tenant isolation enforcement - Prevents accidental data leaks

Configuration

1
Step 1: Define Tenant Fields
2
Configure tenant field mapping in schema.yaml:
3
settings:
  default_tenant_field: tenant_id  # Global default

collections:
  products:
    fields:
      name:
        type: string
      price:
        type: number
    access:
      tenant_field: tenant_id  # Uses default
      owner_field: created_by

  invoices:
    fields:
      amount:
        type: number
      customer:
        type: string
    access:
      tenant_field: company_id  # Custom field name
      owner_field: created_by
4
Source: /home/daytona/workspace/source/pkg/config/schema.go:131-136
5
Step 2: Include Tenant in JWT
6
Your authentication service must include tenant_id in the JWT:
7
{
  "sub": "user-123",
  "tenant_id": "acme-corp",  // Required for tenant scoping
  "roles": ["manager"],
  "iat": 1640000000,
  "exp": 1640003600
}
8
Step 3: Automatic Tenant Injection
9
Permission Mongo automatically adds tenant_id to documents:
10
# User creates a product
curl -X POST https://api.example.com/products \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Widget",
    "price": 29.99
  }'
11
Stored document:
12
{
  "_id": "507f1f77bcf86cd799439011",
  "name": "Widget",
  "price": 29.99,
  "tenant_id": "acme-corp",      // Automatically added
  "created_by": "user-123",      // From JWT sub claim
  "created_at": "2024-01-15T10:30:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}
13
Source: /home/daytona/workspace/source/pkg/api/handlers_batch.go:176-187

Tenant Isolation Patterns

Pattern 1: Automatic Filtering

All queries are automatically scoped to the user’s tenant:
# User queries products
GET /products?status=active

# Permission Mongo adds tenant filter:
db.products.find({
  tenant_id: "acme-corp",  // From JWT
  status: "active"         // From query
})

Pattern 2: Tenant-Specific Owner Access

Combine tenant scoping with owner checks:
policies:
  invoices:
    accountant:
      actions: [read, update]
      when: |
        doc.created_by == user.id
Query filter:
{
  tenant_id: "acme-corp",    // Automatic
  created_by: "user-123"     // From policy
}

Pattern 3: Role-Based Tenant Access

Different roles see different scopes within tenant:
policies:
  sales_leads:
    sales_rep:
      actions: [read, update]
      when: |
        doc.assigned_to == user.id
    
    sales_manager:
      actions: [read, update, delete]
      when: |
        doc.assigned_to in user.$subordinates
    
    sales_director:
      actions: [read, update, delete]
      # No condition - sees all tenant data
Source: /home/daytona/workspace/source/pkg/rbac/engine.go:224-286

Cross-Tenant Access

In some cases, admin users need to access multiple tenants:

Super Admin Role

Define a role that bypasses tenant filtering:
roles:
  super_admin:
    description: Cross-tenant administrator

policies:
  products:
    super_admin:
      actions: [read, update, delete]
      # No tenant filter - sees all tenants
Use super admin roles sparingly. They bypass tenant isolation and can access all data.

Tenant-Switching

For support scenarios, issue tokens with different tenant IDs:
{
  "sub": "support-user-456",
  "tenant_id": "customer-tenant",  // Switch to customer's tenant
  "roles": ["support"],
  "original_tenant": "support-org",  // Track original tenant
  "support_ticket": "TKT-1234"
}

Hierarchical Tenants

For complex organizational structures:
policies:
  reports:
    regional_manager:
      actions: [read]
      when: |
        doc.tenant_id in user.claims.managed_tenants
JWT claims:
{
  "sub": "manager-789",
  "tenant_id": "hq-corp",
  "roles": ["regional_manager"],
  "managed_tenants": ["us-west", "us-east", "us-central"]
}

Tenant Data Isolation

Database-Level Isolation

For stronger isolation, use separate MongoDB databases:
server:
  multi_tenancy:
    mode: database  # Each tenant gets own database
    database_prefix: tenant_
Tenant “acme-corp” → Database “tenant_acme_corp”

Collection-Level Isolation (Default)

All tenants share collections with filtering:
server:
  multi_tenancy:
    mode: collection  # Default - filter by tenant_id
Better performance, simpler management, adequate for most use cases. Source: /home/daytona/workspace/source/pkg/config/schema.go:131-136

Tenant Scoping in Batch Operations

Batch operations respect tenant boundaries:
# Batch create
curl -X POST https://api.example.com/products/batch \
  -H "Authorization: Bearer <token>" \
  -d '{
    "documents": [
      {"name": "Product 1", "price": 10.00},
      {"name": "Product 2", "price": 20.00}
    ]
  }'
All documents get tenant_id:
[
  {
    "_id": "...",
    "name": "Product 1",
    "price": 10.00,
    "tenant_id": "acme-corp"  // Auto-added
  },
  {
    "_id": "...",
    "name": "Product 2",
    "price": 20.00,
    "tenant_id": "acme-corp"  // Auto-added
  }
]
Source: /home/daytona/workspace/source/pkg/api/handlers_batch.go:169-206

Testing Multi-Tenancy

Test Tenant Isolation

func TestTenantIsolation(t *testing.T) {
	// Create documents for different tenants
	tenant1Token := createToken("user-1", "tenant-1", []string{"user"})
	tenant2Token := createToken("user-2", "tenant-2", []string{"user"})

	// Tenant 1 creates product
	resp1 := createProduct(tenant1Token, map[string]interface{}{
		"name":  "Product A",
		"price": 100,
	})
	assert.Equal(t, 201, resp1.StatusCode)
	productID := resp1.JSON["_id"].(string)

	// Tenant 2 tries to read tenant 1's product
	resp2 := getProduct(tenant2Token, productID)
	assert.Equal(t, 404, resp2.StatusCode) // Not found (due to tenant filter)

	// Tenant 1 can read their own product
	resp3 := getProduct(tenant1Token, productID)
	assert.Equal(t, 200, resp3.StatusCode)
}

Test Cross-Tenant Admin

func TestSuperAdminAccess(t *testing.T) {
	adminToken := createToken("admin-1", "admin-tenant", []string{"super_admin"})

	// Query all products across tenants
	resp := listProducts(adminToken, nil)
	assert.Equal(t, 200, resp.StatusCode)
	
	// Verify products from multiple tenants
	products := resp.JSON["data"].([]interface{})
	tenants := make(map[string]bool)
	for _, p := range products {
		product := p.(map[string]interface{})
		tenants[product["tenant_id"].(string)] = true
	}
	assert.Greater(t, len(tenants), 1) // Multiple tenants
}

Audit Logging for Tenants

All tenant operations are automatically logged:
{
  "timestamp": "2024-01-15T10:30:00Z",
  "tenant_id": "acme-corp",
  "user_id": "user-123",
  "action": "create",
  "collection": "products",
  "doc_id": "507f1f77bcf86cd799439011",
  "success": true
}
Query audit logs per tenant:
GET /api/audit?tenant_id=acme-corp&start_date=2024-01-01
Source: /home/daytona/workspace/source/pkg/api/handlers_batch.go:800-828

Common Patterns

Pattern: Tenant Admin

Allow tenant admins to manage tenant users:
policies:
  users:
    tenant_admin:
      actions: [read, update, delete]
      when: |
        doc.tenant_id == user.tenant_id &&
        !doc.roles.includes("super_admin")

Pattern: Shared Reference Data

Some collections may be tenant-agnostic:
collections:
  countries:  # Shared reference data
    access:
      tenant_field: ""  # Empty = no tenant scoping
    fields:
      code:
        type: string
      name:
        type: string

Pattern: Tenant Quotas

Enforce per-tenant limits:
policies:
  products:
    user:
      actions: [create]
      when: |
        count("products", {tenant_id: user.tenant_id}) < 1000

Best Practices

Always include tenant_id in JWT tokens
Use consistent tenant field names across collections
Test tenant isolation thoroughly
Limit super admin roles to specific users
Monitor cross-tenant access attempts
Use separate databases for high-security tenants
Document tenant-specific features in your API

Troubleshooting

Tenant ID Missing

{
  "error": {
    "code": "forbidden",
    "message": "tenant context required"
  }
}
Solution: Ensure JWT includes tenant_id claim.

Cross-Tenant Access Denied

{
  "error": {
    "code": "not_found",
    "message": "document not found"
  }
}
Solution: Document exists but belongs to different tenant. This is expected behavior.

Tenant Field Not Configured

If tenant field is missing from schema, documents won’t be scoped:
collections:
  products:
    access:
      tenant_field: tenant_id  # Required!

Next Steps

Field-Level Permissions

Control access to specific fields

Hierarchical RBAC

Manage user hierarchies and subordinate access

Build docs developers (and LLMs) love