Skip to main content

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 = Clerk userId (e.g., user_2abc123)
  • User signs up → Clerk webhook → Flask creates Tenant record
  • 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_id extracted 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 Tenant with type="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:

RoleRead BundlesCreate/Edit BundlesDelete BundlesApprove 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:

  1. User selects tenant from dropdown
  2. Clerk sets active organization (or clears for personal)
  3. BFF extracts tenantId from Clerk
  4. 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)

  1. Add type, org_id columns to tenants
  2. Add tenant_members table
  3. Update BFF to extract orgId from Clerk
  4. Add authorization decorators (@require_role)
  5. Update queries to check membership

Phase 3: Role-Based Access (Future)

  1. Implement @require_role('admin') decorators
  2. Add role checks to UI (hide delete button if not admin)
  3. 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_id from 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_id from 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_SECRET regularly

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