Successfully implemented comprehensive single-site multi-tenancy for saas_platform app with tenant_id isolation across ALL DocTypes.
Location: saas_platform/saas_platform/doctype/plan/
Fields:
plan_name(Data, unique) - e.g., "Free Plan", "Business Basic", "Business Pro"plan_type(Select) - Free/Business Basic/Business Proprice(Currency) - Monthly pricingbilling_cycle(Select) - Monthly/Yearlymax_users(Int) - User limit (-1 for unlimited)max_storage_gb(Int) - Storage limit in GBfeatures(Text) - Feature descriptiontenant_id(Data, default="SYSTEM") - Shared plans accessible to all tenants
Fixtures: Three default plans pre-configured in saas_platform/fixtures/plan.json
- Free Plan: $0, 3 users, 5GB
- Business Basic: $29.99, 10 users, 50GB
- Business Pro: $99.99, unlimited users, 500GB
Location: saas_platform/saas_platform/doctype/tenant/
New Fields:
tenant_id(Data, unique, indexed, hidden) - Auto-generated unique identifiercompany(Link to Company) - Auto-created ERPNext Companycurrent_subscription(Link to Subscription) - Links to ERPNext's Subscription DocType
Lifecycle Hooks:
before_insert(): Generates unique tenant_id slug (e.g., "acme-corp-a3b8c9d2")validate(): Ensures subdomain and admin_email uniquenessafter_insert(): Automatically creates:- ERPNext Company (with unique abbreviation)
- ERPNext Subscription (linked to Free Plan with 14-day trial)
No Custom Subscription: Reuses ERPNext's existing Subscription DocType from accounts module
Location: saas_platform/patches/add_tenant_id_to_all_tables.py
What it does:
- Adds
tenant_id VARCHAR(140) DEFAULT 'SYSTEM'to ALL DocTypes - Uses
ALTER TABLEfor performance (direct SQL, not Frappe Custom Field) - Processes both parent tables (istable=0) and child tables (istable=1)
- Creates indexes on tenant_id for query performance
- Registered in
patches.txtto run onbench migrate
Execution: Runs automatically during migration, adds tenant_id to ~200+ tables
Location: saas_platform/utils.py
Functions:
set_tenant_id(doc, method): Hooks intobefore_insertfor ALL DocTypessync_child_table_tenant_id(doc, tenant_id): Copies parent's tenant_id to all child rowsget_user_tenant_id(): Retrieves current user's tenant_id from User custom fieldget_tenant_from_email(email): Utility to lookup tenant by email
Behavior:
- Auto-sets tenant_id based on authenticated user
- Defaults to "SYSTEM" for Administrator or users without tenant
- Syncs tenant_id to all child table rows automatically
Location: saas_platform/permissions.py
Functions:
get_tenant_query(user): Returns SQL condition for permission filteringget_tenant_query_for_doctype(doctype): DocType-specific query builderhas_permission(doc, ptype, user): Additional document-level permission check
Isolation Rules:
- Users can only see records where
tenant_id IN (user_tenant, 'SYSTEM') - "SYSTEM" tenant_id = shared data (Plans, UOMs, Item Groups, etc.)
- Administrator bypasses all tenant filters
- Fail-secure: If error, shows nothing
Location: saas_platform/hooks.py
Registered Hooks:
# Document events - auto-populate tenant_id
doc_events = {
"*": {
"before_insert": "saas_platform.utils.set_tenant_id",
}
}
# Permission query conditions - data isolation
permission_query_conditions = {
"*": "saas_platform.permissions.get_tenant_query",
}
# Has permission - additional checks
has_permission = {
"*": "saas_platform.permissions.has_permission",
}
# Fixtures - load default plans
fixtures = [
{
"doctype": "Plan",
"filters": [["tenant_id", "=", "SYSTEM"]]
}
]- User signs up via signup-service API
- Service creates Tenant document
Tenant.before_insert()generates unique tenant_idTenant.validate()checks uniqueness- Tenant saved to database
Tenant.after_insert()triggered:- Creates ERPNext Company (e.g., "Acme Corp - AC")
- Creates Subscription (Free Plan, 14-day trial)
- Links Company and Subscription back to Tenant
- User logs in
- Every database query filtered by:
WHERE tenant_id IN ('user-tenant-id-123', 'SYSTEM')
- User sees:
- Their own tenant's data
- Shared SYSTEM data (Plans, UOMs, etc.)
- Administrator sees everything (no filter)
- User creates Sales Order
before_inserthook firesset_tenant_id()called:- Gets user's tenant_id from User.tenant_id
- Sets
doc.tenant_id = 'user-tenant-id-123' - Syncs tenant_id to all child table items
- Document saved with tenant_id
- Only users from same tenant can see it
-
Install saas_platform app:
bench get-app /path/to/saas_platform bench --site dev.localhost install-app saas_platform
-
Run migrations (adds tenant_id to all tables):
bench --site dev.localhost migrate
This will:
- Create Plan DocType
- Update Tenant DocType
- Run ALTER TABLE patch on ~200+ tables
- Load default Plan fixtures
-
Add tenant_id custom field to User DocType (for storing user-tenant relationship):
bench --site dev.localhost console
# In bench console from frappe.custom.doctype.custom_field.custom_field import create_custom_fields custom_fields = { "User": [ { "fieldname": "tenant_id", "fieldtype": "Data", "label": "Tenant ID", "insert_after": "email", "read_only": 1, "hidden": 1 } ] } create_custom_fields(custom_fields)
-
Update signup-service to set user's tenant_id when creating User
-
Restart services:
bench restart
SHOW COLUMNS FROM `tabSales Order` LIKE 'tenant_id';
SHOW COLUMNS FROM `tabUser` LIKE 'tenant_id';- Create Tenant: Should auto-create Company and Subscription
- Create User with tenant_id
- Login as that user
- Create Sales Order: Should auto-set tenant_id
- Query Sales Orders: Should only see own tenant's orders + SYSTEM
frappe.get_all("Plan", fields=["*"])
# Should return 3 plans with tenant_id="SYSTEM"- Indexes: All tenant_id columns have indexes for fast filtering
- ALTER TABLE: Used for one-time migration speed
- Query overhead: Every query now has
WHERE tenant_id IN (...)clause - Future optimization: Consider partitioning tables by tenant_id for large installations
- Fail-secure: Permission errors default to showing nothing
- Administrator bypass: Admin can manage all tenants
- SYSTEM tenant: Shared reference data accessible to all
- No data leakage: Strict tenant_id enforcement on all queries
- Update signup-service to create User with tenant_id
- Test complete signup → Company → Subscription flow
- Add upgrade/downgrade subscription logic
- Implement plan limit enforcement (max_users, max_storage_gb)
- Add tenant admin dashboard
- Consider adding tenant-level settings/configuration