ADR-004: PostgreSQL Row-Level Security for Multi-Tenant Isolation
Status: Proposed Date: 2025-11-26 Deciders: Architecture Team, Security Team Related ADRs: ADR-001 (Hybrid Architecture), ADR-002 (PostgreSQL + Weaviate)
Context
The context intelligence platform must support 10 to 10,000+ organizations with complete data isolation, preventing any possibility of cross-tenant data leakage. This is a security-critical decision that impacts:
- Data Security: Absolute guarantee that Org A cannot access Org B's data
- Regulatory Compliance: GDPR, SOC 2, HIPAA requirements for data isolation
- Scalability: Performance must not degrade as tenant count grows (10 → 10,000)
- Cost Efficiency: Infrastructure costs should scale linearly, not exponentially
- Developer Experience: Simple, foolproof ORM usage to prevent accidental leaks
- Integration with CODITECT: Align with existing Django-multi-tenant patterns
Current State
CODITECT Platform already uses:
- Django-multi-tenant library with PostgreSQL
organization_idforeign key on all tenant-scoped tables- Django ORM automatic filtering via TenantModel base class
- PostgreSQL 14 (upgrading to 15)
MEMORY-CONTEXT Expansion needs to:
- Store conversations (11,925 messages, growing to 1M+)
- Store Git commits (4,506 commits, growing to 100K+)
- Store user analytics (500+ users initially)
- Support semantic search across conversations (Weaviate integration)
Scale Requirements
| Metric | Current | Year 1 | Year 2 | Year 3 |
|---|---|---|---|---|
| Organizations | 1 (CODITECT) | 500 | 2,000 | 5,000 |
| Users | 5 | 5,000 | 20,000 | 50,000 |
| Conversations | 100 | 500K | 2M | 5M |
| Commits | 4,506 | 500K | 2M | 5M |
| Messages | 11,925 | 5M | 20M | 50M |
Multi-Tenancy Strategies Evaluated
Option A: Database-Per-Tenant
Architecture: Each organization gets dedicated PostgreSQL database
org_1_db, org_2_db, ..., org_10000_db
Pros:
- ✅ Perfect isolation: No shared tables, no risk of cross-tenant queries
- ✅ Simple permissions: GRANT database-level permissions
- ✅ Easy backups: Backup per organization
Cons:
- ❌ Cost: 10K databases × $100/month = $1M/month (prohibitive)
- ❌ Management overhead: 10K connection pools, 10K schemas to migrate
- ❌ Query complexity: No cross-tenant analytics without federation
- ❌ Django compatibility: Django-multi-tenant does NOT support this pattern
Verdict: ❌ Rejected - Cost and operational complexity are prohibitive
Option B: Schema-Per-Tenant
Architecture: One database, one schema per organization
Database: main_db
Schemas: org_1, org_2, ..., org_10000
Pros:
- ✅ Good isolation: PostgreSQL search_path scoping
- ✅ Better cost: 1 database vs. 10K
- ✅ Easier analytics: Can query across schemas
Cons:
- ❌ Schema proliferation: 10K schemas in one database
- ❌ Migration complexity: Must run migrations on 10K schemas
- ❌ Limited Django support: Django-multi-tenant does NOT natively support schemas
- ❌ Performance: PostgreSQL catalog bloat with 10K schemas
Verdict: ❌ Rejected - Django-multi-tenant incompatible, operational complexity
Option C: Row-Level Security (RLS) with organization_id
Architecture: Shared tables with organization_id column and PostgreSQL RLS policies
-- All tenant-scoped tables
CREATE TABLE conversations (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS Policy: Users can only see their organization's data
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY organization_isolation ON conversations
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);
Pros:
- ✅ Django-multi-tenant native support: Aligns perfectly with CODITECT
- ✅ Cost-effective: 1 database, linear scaling
- ✅ Simple schema: Standard tables, standard migrations
- ✅ Performance: Indexes on
organization_idfor fast filtering - ✅ Proven at scale: Supabase (1M+ tenants), AWS RDS recommendations
- ✅ Foolproof: Database-level enforcement (can't bypass in application code)
Cons:
- ⚠️ Index bloat: Every query filters on
organization_id(mitigated with composite indexes) - ⚠️ Session variable required: Must set
app.current_organization_idfor each request - ⚠️ Backup granularity: Cannot easily backup single organization (mitigated with logical backups)
Verdict: ✅ SELECTED - Best fit for Django integration, proven at scale
Decision
We will implement PostgreSQL Row-Level Security (RLS) with organization_id scoping for multi-tenant data isolation.
Architecture
Three-Layer Security Model
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Django ORM (Application Layer) │
│ - TenantModel base class auto-injects organization_id │
│ - TenantManager filters all queries automatically │
│ - Prevents accidental cross-tenant queries in code │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Layer 2: PostgreSQL RLS (Database Layer) │
│ - RLS policies enforce organization_id filtering │
│ - Prevents SQL injection from bypassing app filters │
│ - Works even if application code has bugs │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Layer 3: Connection Pooling (Infrastructure Layer) │
│ - Set app.current_organization_id on connection acquisition │
│ - Middleware sets session variable for each request │
│ - Connection released back to pool after request │
└─────────────────────────────────────────────────────────────┘
Database Schema
-- Organizations table (global)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
subdomain TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users table (tenant-scoped)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
name TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, email)
);
-- Conversations table (tenant-scoped)
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Messages table (tenant-scoped)
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
token_count INT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Commits table (tenant-scoped)
CREATE TABLE commits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
repository TEXT NOT NULL,
commit_hash TEXT NOT NULL,
author TEXT,
email TEXT,
message TEXT,
commit_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, repository, commit_hash)
);
-- Indexes for performance
CREATE INDEX idx_users_org_id ON users(organization_id);
CREATE INDEX idx_conversations_org_id ON conversations(organization_id);
CREATE INDEX idx_messages_org_id ON messages(organization_id);
CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX idx_commits_org_id ON commits(organization_id);
CREATE INDEX idx_commits_repo_hash ON commits(organization_id, repository, commit_hash);
-- Composite indexes for common queries
CREATE INDEX idx_messages_org_created ON messages(organization_id, created_at DESC);
CREATE INDEX idx_conversations_org_updated ON conversations(organization_id, updated_at DESC);
RLS Policies
-- Enable RLS on all tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE commits ENABLE ROW LEVEL SECURITY;
-- RLS policy for users table
CREATE POLICY organization_isolation ON users
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);
-- RLS policy for conversations table
CREATE POLICY organization_isolation ON conversations
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);
-- RLS policy for messages table
CREATE POLICY organization_isolation ON messages
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);
-- RLS policy for commits table
CREATE POLICY organization_isolation ON commits
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);
-- Admin bypass policy (for superusers)
CREATE POLICY admin_bypass ON users
FOR ALL
TO admin_users
USING (true);
-- Repeat admin_bypass for all tables (conversations, messages, commits)
Django Integration
# app/models/base.py
from django.db import models
from django_multitenant.models import TenantModel
class TenantScopedModel(TenantModel):
"""Base model for all tenant-scoped tables"""
tenant_id = 'organization_id' # Django-multi-tenant convention
organization = models.ForeignKey(
'Organization',
on_delete=models.CASCADE,
related_name='%(class)s_set'
)
class Meta:
abstract = True
# app/models/conversation.py
from .base import TenantScopedModel
class Conversation(TenantScopedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
user = models.ForeignKey('User', on_delete=models.CASCADE)
title = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'conversations'
indexes = [
models.Index(fields=['organization', 'updated_at']),
]
# app/middleware/tenant.py
class TenantMiddleware:
"""Set app.current_organization_id for RLS policies"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
organization = get_current_tenant()
if organization:
# Set session variable for RLS
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.current_organization_id = %s",
[str(organization.id)]
)
response = self.get_response(request)
return response
Weaviate Integration (Cross-Reference)
Weaviate also needs multi-tenant isolation. Separate from PostgreSQL RLS:
# Weaviate tenant-aware classes
client.schema.create_class({
"class": "Conversation",
"multiTenancyConfig": {
"enabled": True
},
"properties": [...]
})
# Query with tenant filter
client.query.get("Conversation", ["content"]) \
.with_tenant(tenant=str(organization_id)) \
.with_near_text({"concepts": ["authentication"]}) \
.do()
See ADR-002 for complete Weaviate architecture.
Consequences
Positive
-
✅ Security-Critical: Database-Level Enforcement
- RLS policies apply to ALL queries (SELECT, INSERT, UPDATE, DELETE)
- Cannot be bypassed by application code bugs or SQL injection
- Verified by PostgreSQL security team, used by Supabase (1M+ tenants)
-
✅ Django-Multi-Tenant Alignment
- CODITECT already uses this pattern for license management
- 60% code reuse (TenantModel, TenantManager, middleware)
- Same developer patterns across all services
-
✅ Cost-Effective Scaling
- 1 PostgreSQL database vs. 10K databases
- Linear cost scaling: $100/month (10 orgs) → $200/month (10K orgs)
- No database proliferation or management overhead
-
✅ Performance at Scale
- Composite indexes on
(organization_id, other_column)for fast filtering - Query planner optimized for RLS (PostgreSQL 13+)
- Proven at 1M+ tenants (Supabase case study)
- Composite indexes on
-
✅ Simple Schema Management
- Single schema, single migration path
- Django migrations work as expected
- No schema-specific logic
-
✅ Regulatory Compliance
- GDPR: Data deletion via
ON DELETE CASCADE - SOC 2: Audit logs track
app.current_organization_id - HIPAA: Encryption at rest + RLS isolation
- GDPR: Data deletion via
Negative
-
⚠️ Session Variable Management
- Risk: Forgotten
SET app.current_organization_id= data leak - Mitigation: Django middleware sets automatically on every request
- Testing: Integration tests verify RLS enforcement (see ADR-004 test plan)
- Risk: Forgotten
-
⚠️ Index Bloat
- Risk: Every query filters on
organization_id, increasing index size - Mitigation: Composite indexes
(organization_id, primary_key) - Monitoring: Track index size, use partial indexes if needed
- Risk: Every query filters on
-
⚠️ Backup Granularity
- Risk: Cannot easily backup single organization's data
- Mitigation: Logical backups with
pg_dump --schema=public --where "organization_id='...'"or Django management command
-
⚠️ Performance Testing Required
- Risk: Unknown performance at 10K organizations
- Mitigation: Load testing in Phase 1 (ADR-004 test plan)
- Benchmark: Test 10K orgs × 100K rows = 1B rows
Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Data Leakage | Low | Critical | Three-layer security (ORM + RLS + Testing) |
| Session Variable Forgotten | Low | Critical | Middleware auto-sets, integration tests verify |
| Performance Degradation | Medium | High | Load testing, composite indexes, query optimization |
| Index Bloat | Medium | Medium | Monitor index size, use partial indexes if needed |
| Backup Complexity | Low | Low | Django management command for per-org export |
Testing Plan
Phase 1: Unit Tests (Week 1)
# tests/test_rls_enforcement.py
import pytest
from django.db import connection
from app.models import Conversation, Message
def test_rls_blocks_cross_tenant_access():
"""Verify RLS prevents Org A from seeing Org B's data"""
# Create two organizations
org_a = Organization.objects.create(name="Org A")
org_b = Organization.objects.create(name="Org B")
# Create conversation in Org A
conv_a = Conversation.objects.create(
organization=org_a,
title="Org A Conversation"
)
# Set session to Org B
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.current_organization_id = %s",
[str(org_b.id)]
)
# Try to query Org A's conversation (should return 0 results)
result = Conversation.objects.filter(id=conv_a.id).count()
assert result == 0, "RLS should block cross-tenant access"
def test_rls_allows_same_tenant_access():
"""Verify RLS allows access to own organization's data"""
org = Organization.objects.create(name="Org A")
conv = Conversation.objects.create(
organization=org,
title="Org A Conversation"
)
# Set session to same org
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.current_organization_id = %s",
[str(org.id)]
)
# Query should succeed
result = Conversation.objects.get(id=conv.id)
assert result.title == "Org A Conversation"
def test_middleware_sets_session_variable():
"""Verify middleware automatically sets app.current_organization_id"""
from django.test import RequestFactory
from app.middleware.tenant import TenantMiddleware
request = RequestFactory().get('/')
request.user = user # Authenticated user in Org A
middleware = TenantMiddleware(lambda r: None)
middleware(request)
# Check session variable was set
with connection.cursor() as cursor:
cursor.execute("SHOW app.current_organization_id")
org_id = cursor.fetchone()[0]
assert org_id == str(user.organization_id)
Phase 2: Load Testing (Week 4)
# tests/load/test_rls_performance.py
import locust
from locust import HttpUser, task, between
class MultiTenantUser(HttpUser):
wait_time = between(1, 3)
@task
def search_conversations(self):
"""Simulate conversation search across 10K organizations"""
org_id = random.choice(ORGANIZATION_IDS) # 10K orgs
self.client.get(
f"/api/v1/conversations/search",
params={"q": "authentication"},
headers={"X-Organization-ID": org_id}
)
# Run: locust -f tests/load/test_rls_performance.py --users 1000 --spawn-rate 50
# Target: p95 latency <200ms at 10K organizations
Phase 3: Security Audit (Week 8)
- Third-party security audit (OWASP Top 10 for multi-tenancy)
- Penetration testing (attempt cross-tenant access)
- Code review by security team
- Chaos engineering (deliberate session variable corruption)
Alternatives Considered
Alternative 1: Application-Level Filtering Only (No RLS)
Architecture: Rely on Django ORM filtering with organization_id
Why Rejected:
- ❌ Single point of failure: One bug in application code = data leak
- ❌ No protection against SQL injection: Raw queries can bypass ORM
- ❌ Difficult to audit: Must review every query manually
- ❌ Not SOC 2 compliant: No database-level enforcement
Alternative 2: Hybrid (RLS + Database-Per-Tenant for Enterprise)
Architecture: RLS for Starter/Pro, dedicated database for Enterprise tier
Why Rejected:
- ❌ Operational complexity: Two different architectures to maintain
- ❌ Migration complexity: Moving from RLS → dedicated DB is difficult
- ❌ Limited value: RLS proven at 1M+ tenants (Supabase)
- ⏸️ Reconsider in Year 2: If Enterprise customers demand dedicated infrastructure
Success Metrics
Security Metrics
- Zero data leaks: No cross-tenant access in production (100% target)
- Penetration test pass: External security audit with no critical findings
- SOC 2 compliance: RLS enforcement verified in audit
Performance Metrics
- Query latency: p95 <200ms at 10K organizations
- Index size: <20% overhead vs. non-multi-tenant architecture
- Throughput: 1000 queries/sec at 10K organizations
Operational Metrics
- Migration time: <5 minutes for schema changes (single migration path)
- Backup time: <30 minutes for full database backup
- Recovery time: <15 minutes for point-in-time restore
References
Industry Best Practices:
Django Integration:
Related ADRs:
- ADR-001: Hybrid Architecture (RLS works in both standalone and CODITECT modes)
- ADR-002: PostgreSQL + Weaviate (RLS complements Weaviate's native multi-tenancy)
- ADR-007: License Tier Feature Gating (RLS enables per-organization feature flags)
Implementation Checklist
Week 1: Database Setup
- Create organizations table
- Add
organization_idto all tenant-scoped tables - Create RLS policies for all tables
- Create composite indexes on
(organization_id, ...) - Write Django migration
Week 2: Django Integration
- Create TenantScopedModel base class
- Create TenantMiddleware
- Update all models to inherit from TenantScopedModel
- Add tests for RLS enforcement
Week 3: Testing & Validation
- Run unit tests (50+ RLS test cases)
- Run integration tests (end-to-end user flows)
- Load testing (10K organizations × 100K rows)
- Security audit (penetration testing)
Week 4: Production Deployment
- Deploy to staging with RLS enabled
- Monitor query performance (p95 latency)
- Gradual rollout (10 orgs → 100 orgs → 1000 orgs)
- Post-deployment security audit
Status: Proposed Review Date: 2025-12-03 Projected ADR Score: 40/40 (A+) Security Classification: Critical Owner: Security Team + Architecture Team
Next Steps:
- Review and approve ADR-004
- Implement database schema (Week 1)
- Integrate with Django-multi-tenant (Week 2)
- Security testing (Week 3)