Multi-Tenancy
How EnvCat isolates data between users and (future) organizations.
Overview
EnvCat uses a tenant-scoped architecture where every resource (bundles, keys) belongs to a specific tenant. Currently, each user is their own tenant (personal tenants). Future: organizations with shared access.
┌──────────────────────────────────────────────────────┐
│ Tenant: user_2abc123 (personal) │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Bundles │ │ Keys │ │
│ │ ├─ prod/api │ │ ├─ STRIPE_KEY │ │
│ │ ├─ prod/worker │ │ ├─ DATABASE_URL │ │
│ │ └─ dev/local │ │ └─ API_SECRET │ │
│ └──────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Tenant: user_3def456 (personal) │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Bundles │ │ Keys │ │
│ │ ├─ prod/api │ │ ├─ AWS_ACCESS_KEY │ │
│ │ └─ staging │ │ └─ REDIS_PASSWORD │ │
│ └──────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Key principle: Queries ALWAYS filter by tenant_id. No exceptions.
Current: Personal Tenants
Model
One tenant per user:
tenant_id= ClerkuserId(e.g.,user_2abc123)- User signs up → Clerk webhook → Flask creates
Tenantrecord - All bundles/keys created by this user belong to this tenant
- No sharing between users (isolated)
Database Schema
tenants table:
CREATE TABLE tenants (
id TEXT PRIMARY KEY, -- Clerk user ID (user_2abc123)
email TEXT UNIQUE, -- User email
name TEXT, -- Display name (from Clerk)
created_at DATETIME,
updated_at DATETIME
);
bundles table:
CREATE TABLE bundles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL, -- Foreign key to tenants
name TEXT NOT NULL, -- e.g., "production/api"
description TEXT,
tags TEXT,
created_at DATETIME,
updated_at DATETIME,
UNIQUE(tenant_id, name), -- Bundle names unique per tenant
FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE INDEX idx_bundles_tenant ON bundles(tenant_id);
keys table:
CREATE TABLE keys (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL, -- Foreign key to tenants
name TEXT NOT NULL, -- e.g., "STRIPE_KEY"
value TEXT NOT NULL, -- Encrypted value
is_secret BOOLEAN DEFAULT TRUE,
description TEXT,
created_at DATETIME,
updated_at DATETIME,
UNIQUE(tenant_id, name), -- Key names unique per tenant
FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE INDEX idx_keys_tenant ON keys(tenant_id);
Tenant Isolation
All queries filter by tenant_id:
# api/routes/bundles.py
from flask import g
from utils.bff_auth import verify_bff_signature
@app.get("/api/v1/bundles")
@verify_bff_signature
def get_bundles():
# g.tenant_id comes from HMAC-verified headers (BFF)
bundles = Bundle.query.filter_by(tenant_id=g.tenant_id).all()
return jsonify([b.to_dict() for b in bundles])
@app.post("/api/v1/bundles")
@verify_bff_signature
def create_bundle():
data = request.get_json()
bundle = Bundle(
id=str(uuid.uuid4()),
tenant_id=g.tenant_id, # Always set from verified headers
name=data['name'],
description=data.get('description'),
)
db.session.add(bundle)
db.session.commit()
return jsonify(bundle.to_dict()), 201
Key points:
- ✅ Every query filters by
tenant_id(no global queries) - ✅
tenant_idextracted from HMAC-signed headers (trusted source) - ✅ User A can't access User B's bundles (different
tenant_id) - ✅ Cascade deletes: Deleting tenant deletes all bundles/keys
JIT (Just-In-Time) Provisioning
Clerk webhook (api/routes/users.py):
@app.post("/api/webhooks/clerk")
def clerk_webhook():
# Verify webhook signature (Clerk secret)
# ...
event = request.json
if event['type'] == 'user.created':
user_data = event['data']
# Create tenant if doesn't exist
tenant = Tenant.query.get(user_data['id'])
if not tenant:
tenant = Tenant(
id=user_data['id'], # user_2abc123
email=user_data['email_addresses'][0]['email_address'],
name=user_data.get('first_name') or user_data.get('username'),
)
db.session.add(tenant)
db.session.commit()
return jsonify({"ok": True}), 200
Flow:
1. User signs up on env.cat
└─ Clerk creates user (user_2abc123)
2. Clerk → Webhook → Flask
└─ POST /api/webhooks/clerk
└─ Event: user.created
└─ Flask: Create Tenant(id="user_2abc123", email="...")
3. User creates first bundle
└─ POST /api/v1/bundles
└─ Flask: Bundle(tenant_id="user_2abc123", name="prod/api")
Result: User can immediately create resources (no manual setup).
Unique Constraints
Per-tenant uniqueness:
-- Two users can have bundles with same name
UNIQUE(tenant_id, name)
-- Example:
-- Tenant user_2abc: Bundle "production/api"
-- Tenant user_3def: Bundle "production/api" ✅ Allowed
Benefits:
- Users don't conflict with each other's naming
- Natural namespacing (no need for global unique names)
Future: Organization Tenants
Motivation
Team use case:
- 5 developers working on same project
- Need to share bundles (e.g.,
production/api) - Want role-based access (admin can edit, viewer can only read)
Current limitation:
- Each developer has their own tenant
- Can't share bundles across personal tenants
- No collaboration
Solution: Organization tenants.
Model (Future)
Organization as tenant:
- Create
Tenantwithtype="organization",org_id="org_abc123"(Clerk org) - Multiple users belong to this organization (via
tenant_members) - Bundles/keys belong to organization tenant (shared)
- Role-based access control (RBAC)
Database Changes (Future)
tenants table (updated):
CREATE TABLE tenants (
id TEXT PRIMARY KEY, -- org_abc123 (org) or user_2abc123 (personal)
type TEXT DEFAULT 'personal', -- personal | organization
org_id TEXT, -- Clerk organization ID (if type=org)
email TEXT, -- For personal tenants
name TEXT, -- Display name
created_at DATETIME,
updated_at DATETIME
);
tenant_members table (new):
CREATE TABLE tenant_members (
tenant_id TEXT NOT NULL, -- Foreign key to tenants
user_id TEXT NOT NULL, -- Clerk user ID (user_2abc123)
role TEXT DEFAULT 'viewer', -- admin | editor | viewer
created_at DATETIME,
PRIMARY KEY(tenant_id, user_id),
FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE INDEX idx_tenant_members_user ON tenant_members(user_id);
Example data:
-- Organization tenant
INSERT INTO tenants (id, type, org_id, name) VALUES
('org_abc123', 'organization', 'org_abc123', 'Acme Corp');
-- Members
INSERT INTO tenant_members (tenant_id, user_id, role) VALUES
('org_abc123', 'user_2alice', 'admin'),
('org_abc123', 'user_3bob', 'editor'),
('org_abc123', 'user_4carol', 'viewer');
-- Shared bundle
INSERT INTO bundles (id, tenant_id, name) VALUES
('bundle_xyz', 'org_abc123', 'production/api');
Authorization (Future)
Roles:
| Role | Read Bundles | Create/Edit Bundles | Delete Bundles | Approve Requests |
|---|---|---|---|---|
admin | ✅ | ✅ | ✅ | ✅ |
editor | ✅ | ✅ | ❌ | ✅ |
viewer | ✅ | ❌ | ❌ | ✅ |
Implementation (api/utils/authorization.py):
from flask import g, jsonify
def require_role(minimum_role):
"""
Decorator to check user has minimum role in tenant.
Roles: admin > editor > viewer
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# g.user_id and g.tenant_id set by @verify_bff_signature
membership = TenantMember.query.filter_by(
tenant_id=g.tenant_id,
user_id=g.user_id
).first()
if not membership:
return jsonify({"error": "Not a member of this tenant"}), 403
role_hierarchy = ['viewer', 'editor', 'admin']
user_level = role_hierarchy.index(membership.role)
required_level = role_hierarchy.index(minimum_role)
if user_level < required_level:
return jsonify({"error": f"Requires {minimum_role} role"}), 403
g.user_role = membership.role
return f(*args, **kwargs)
return decorated_function
return decorator
Usage:
@app.post("/api/v1/bundles")
@verify_bff_signature
@require_role('editor') # Must be editor or admin
def create_bundle():
# g.tenant_id - organization tenant
# g.user_id - specific user creating bundle
# g.user_role - user's role in organization
bundle = Bundle(
tenant_id=g.tenant_id,
name=request.json['name'],
)
db.session.add(bundle)
db.session.commit()
return jsonify(bundle.to_dict()), 201
Clerk Integration (Future)
Clerk Organizations:
- Clerk provides built-in organization support
- Users can create/join multiple organizations
- Organization metadata includes roles
BFF changes (web/lib/clerk-session.ts):
import { auth } from '@clerk/nextjs/server';
export async function requireSession() {
const { userId, orgId, orgRole } = await auth();
if (!userId) {
throw new Error('Unauthorized');
}
// If in organization context, use org as tenant
const tenantId = orgId || userId; // org_abc123 or user_2abc123
return { userId, tenantId, role: orgRole };
}
HMAC signing (includes role):
export function signRequest(
userId: string,
tenantId: string,
role?: string
) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const payload = `${userId}|${tenantId}|${role || ''}|${timestamp}`;
// ... sign with HMAC
}
Flow:
1. User selects organization in UI
└─ Clerk: Set active organization (orgId="org_abc123")
2. Browser → BFF
└─ BFF: Extract orgId from Clerk session
└─ tenantId = "org_abc123"
3. BFF → Flask
└─ Headers:
- X-User-Id: user_2alice
- X-Tenant-Id: org_abc123
- X-User-Role: admin
- X-Signature: ...
4. Flask: Query bundles
└─ WHERE tenant_id = "org_abc123"
└─ Returns shared organization bundles
Switching Tenants (Future)
UI (future):
┌──────────────────────────────────────┐
│ EnvCat [Alice ▼] │
│ │
│ Tenant: [Acme Corp (org) ▼] │
│ Personal (alice) │
│ Beta Inc (org) │
│ │
│ Bundles for "Acme Corp" │
│ ├─ production/api │
│ ├─ production/worker │
│ └─ staging/app │
└──────────────────────────────────────┘
How it works:
- User selects tenant from dropdown
- Clerk sets active organization (or clears for personal)
- BFF extracts
tenantIdfrom Clerk - All subsequent requests scoped to that tenant
Data isolation:
- Switching tenant → Different set of bundles/keys
- No cross-tenant access (enforced at database level)
Migration Path
Phase 1: Personal Tenants Only (Current)
- ✅ Each user is their own tenant
- ✅ Full isolation between users
- ✅ Simple mental model
Phase 2: Add Organization Support (Future)
- Add
type,org_idcolumns totenants - Add
tenant_memberstable - Update BFF to extract
orgIdfrom Clerk - Add authorization decorators (
@require_role) - Update queries to check membership
Phase 3: Role-Based Access (Future)
- Implement
@require_role('admin')decorators - Add role checks to UI (hide delete button if not admin)
- Add audit logs (track who did what)
Testing Multi-Tenancy
Current (Personal Tenants)
Test isolation:
# 1. Sign up as user A (alice@example.com)
# 2. Create bundle "production/api"
# 3. Sign out
# 4. Sign up as user B (bob@example.com)
# 5. List bundles → Should see 0 bundles (not Alice's)
# 6. Create bundle "production/api" → Should succeed (different tenant)
Test database:
-- Verify isolation
SELECT * FROM bundles WHERE tenant_id = 'user_2alice';
-- Returns: production/api
SELECT * FROM bundles WHERE tenant_id = 'user_3bob';
-- Returns: production/api (different bundle_id, same name)
Future (Organizations)
Test organization access:
# 1. Create organization "Acme Corp" in Clerk
# 2. Invite user B to organization (editor role)
# 3. User A creates bundle in org context
# 4. User B switches to org tenant → Should see bundle
# 5. User B tries to delete bundle → Should fail (not admin)
Security Considerations
Threats Mitigated
1. Unauthorized Access
- ✅ Every query filters by
tenant_idfrom HMAC headers - ✅ User can't specify arbitrary
tenant_id(comes from signed headers)
2. Data Leakage
- ✅ No global queries (always scoped to tenant)
- ✅ Cascade deletes prevent orphaned data
3. SQL Injection
- ✅ SQLAlchemy ORM uses parameterized queries
- ✅
tenant_idfrom trusted source (HMAC headers, not user input)
Threats NOT Mitigated
1. Compromised BFF
- ⚠️ If attacker controls BFF, they can sign requests with any
tenant_id - Mitigation: Secure BFF server, rotate
BFF_SHARED_SECRETregularly
2. Shared Database Access
- ⚠️ If attacker gains direct database access, they can query all tenants
- Mitigation: Encrypt database at rest, limit database credentials
3. Timing Attacks
- ⚠️ Query performance may leak tenant size (e.g., large bundle takes longer)
- Mitigation: Add random delay, use constant-time comparisons where possible
Best Practices
DO
✅ Always filter by tenant_id:
# Good
bundles = Bundle.query.filter_by(tenant_id=g.tenant_id).all()
✅ Extract tenant_id from verified headers:
@verify_bff_signature # Sets g.tenant_id from HMAC
def my_endpoint():
tenant_id = g.tenant_id # Trusted source
✅ Use unique constraints per tenant:
UNIQUE(tenant_id, name) -- Not just UNIQUE(name)
✅ Test isolation:
def test_tenant_isolation():
# Create bundle for tenant A
# Query as tenant B
# Assert bundle not visible
DON'T
❌ Never accept tenant_id from user input:
# BAD - user can specify arbitrary tenant_id
data = request.get_json()
tenant_id = data['tenant_id'] # ❌ NEVER
❌ Never query without tenant filter:
# BAD - returns all bundles (data leak)
bundles = Bundle.query.all() # ❌ NEVER
❌ Never hardcode tenant_id:
# BAD - hardcoded tenant
bundles = Bundle.query.filter_by(tenant_id='user_2abc').all() # ❌ NEVER
Next Steps
- Authentication - How tenant_id is extracted and verified
- Encryption - How data is encrypted within each tenant
- API Reference - All tenant-scoped endpoints