Skip to content

Latest commit

 

History

History
239 lines (193 loc) · 7.51 KB

File metadata and controls

239 lines (193 loc) · 7.51 KB

Multi-Tenant Implementation Summary

Overview

Successfully implemented comprehensive single-site multi-tenancy for saas_platform app with tenant_id isolation across ALL DocTypes.

Components Implemented

1. Plan DocType

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 Pro
  • price (Currency) - Monthly pricing
  • billing_cycle (Select) - Monthly/Yearly
  • max_users (Int) - User limit (-1 for unlimited)
  • max_storage_gb (Int) - Storage limit in GB
  • features (Text) - Feature description
  • tenant_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

2. Enhanced Tenant DocType

Location: saas_platform/saas_platform/doctype/tenant/

New Fields:

  • tenant_id (Data, unique, indexed, hidden) - Auto-generated unique identifier
  • company (Link to Company) - Auto-created ERPNext Company
  • current_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 uniqueness
  • after_insert(): Automatically creates:
    1. ERPNext Company (with unique abbreviation)
    2. ERPNext Subscription (linked to Free Plan with 14-day trial)

No Custom Subscription: Reuses ERPNext's existing Subscription DocType from accounts module

3. Universal tenant_id Patch

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 TABLE for 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.txt to run on bench migrate

Execution: Runs automatically during migration, adds tenant_id to ~200+ tables

4. Auto tenant_id Population

Location: saas_platform/utils.py

Functions:

  • set_tenant_id(doc, method): Hooks into before_insert for ALL DocTypes
  • sync_child_table_tenant_id(doc, tenant_id): Copies parent's tenant_id to all child rows
  • get_user_tenant_id(): Retrieves current user's tenant_id from User custom field
  • get_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

5. Tenant Data Isolation

Location: saas_platform/permissions.py

Functions:

  • get_tenant_query(user): Returns SQL condition for permission filtering
  • get_tenant_query_for_doctype(doctype): DocType-specific query builder
  • has_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

6. Hooks Configuration

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"]]
    }
]

How It Works

Signup Flow

  1. User signs up via signup-service API
  2. Service creates Tenant document
  3. Tenant.before_insert() generates unique tenant_id
  4. Tenant.validate() checks uniqueness
  5. Tenant saved to database
  6. 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

Data Isolation

  1. User logs in
  2. Every database query filtered by:
    • WHERE tenant_id IN ('user-tenant-id-123', 'SYSTEM')
  3. User sees:
    • Their own tenant's data
    • Shared SYSTEM data (Plans, UOMs, etc.)
  4. Administrator sees everything (no filter)

Document Creation

  1. User creates Sales Order
  2. before_insert hook fires
  3. set_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
  4. Document saved with tenant_id
  5. Only users from same tenant can see it

Migration Steps

To Apply This Implementation:

  1. Install saas_platform app:

    bench get-app /path/to/saas_platform
    bench --site dev.localhost install-app saas_platform
  2. 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
  3. 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)
  4. Update signup-service to set user's tenant_id when creating User

  5. Restart services:

    bench restart

Testing

Verify tenant_id columns:

SHOW COLUMNS FROM `tabSales Order` LIKE 'tenant_id';
SHOW COLUMNS FROM `tabUser` LIKE 'tenant_id';

Test isolation:

  1. Create Tenant: Should auto-create Company and Subscription
  2. Create User with tenant_id
  3. Login as that user
  4. Create Sales Order: Should auto-set tenant_id
  5. Query Sales Orders: Should only see own tenant's orders + SYSTEM

Check Plans:

frappe.get_all("Plan", fields=["*"])
# Should return 3 plans with tenant_id="SYSTEM"

Performance Considerations

  • 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

Security Notes

  • 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

Next Steps

  1. Update signup-service to create User with tenant_id
  2. Test complete signup → Company → Subscription flow
  3. Add upgrade/downgrade subscription logic
  4. Implement plan limit enforcement (max_users, max_storage_gb)
  5. Add tenant admin dashboard
  6. Consider adding tenant-level settings/configuration