# Multi-Tenancy Architecture **Last Verified:** January 20, 2026 **Version:** 1.8.4 **Backend Path:** `backend/igny8_core/auth/models.py` --- ## Data Hierarchy ``` Account (Tenant) ├── Users (team members) ├── Subscription (plan binding) ├── Sites │ ├── Sectors │ │ ├── Keywords │ │ ├── Clusters │ │ ├── ContentIdeas │ │ ├── Tasks │ │ ├── Content │ │ └── Images │ └── Integrations (WordPress) └── Billing (credits, transactions) ``` --- ## Core Models ### Account | Field | Type | Purpose | |-------|------|---------| | name | CharField | Account/organization name | | is_active | Boolean | Enable/disable account | | credits | Decimal | Plan credits (reset on renewal) | | bonus_credits | Decimal | Purchased credits (never expire) | | account_timezone | CharField | IANA timezone (e.g., America/New_York) | | stripe_customer_id | CharField | Stripe integration | | paypal_customer_id | CharField | PayPal integration | | created_at | DateTime | Registration date | > **Two-Pool Credit System (v1.8.3):** Plan credits consumed first, bonus credits only when plan = 0. Bonus credits never expire. ### Plan | Field | Type | Purpose | |-------|------|---------| | name | CharField | Plan name (Free, Starter, Growth, Scale) | | included_credits | Integer | Monthly credit allocation | | max_sites | Integer | Site limit (hard limit) | | max_users | Integer | User limit (hard limit) | | max_keywords | Integer | Keyword limit (hard limit) | | max_ahrefs_queries | Integer | Monthly Ahrefs queries (monthly limit) | | is_active | Boolean | Available for purchase | | is_internal | Boolean | Internal/test plan | > **Note (v1.5.0+):** Operation limits like `max_clusters`, `max_content_words`, `max_images_basic`, `max_images_premium` were removed. All AI operations are now credit-based. ### Site | Field | Type | Purpose | |-------|------|---------| | account | ForeignKey | Owner account | | name | CharField | Site name | | domain | CharField | Primary domain | | is_active | Boolean | Enable/disable | ### Sector | Field | Type | Purpose | |-------|------|---------| | site | ForeignKey | Parent site | | account | ForeignKey | Owner account | | industry | ForeignKey | Industry template | | name | CharField | Sector name | | is_active | Boolean | Enable/disable | --- ## Base Model Classes ### AccountBaseModel **File:** `auth/models.py` All account-scoped models inherit from this: ```python class AccountBaseModel(models.Model): account = models.ForeignKey(Account, on_delete=models.CASCADE) class Meta: abstract = True ``` **Used by:** Billing records, Settings, API keys ### SiteSectorBaseModel **File:** `auth/models.py` All content models inherit from this: ```python class SiteSectorBaseModel(AccountBaseModel): site = models.ForeignKey(Site, on_delete=models.CASCADE) sector = models.ForeignKey(Sector, on_delete=models.CASCADE) class Meta: abstract = True ``` **Used by:** Keywords, Clusters, Ideas, Tasks, Content, Images --- ## ViewSet Base Classes ### AccountModelViewSet **File:** `api/base.py` Automatically filters queryset by account: ```python class AccountModelViewSet(ModelViewSet): def get_queryset(self): qs = super().get_queryset() if not self.request.user.is_admin_or_developer: qs = qs.filter(account=self.request.account) return qs ``` ### SiteSectorModelViewSet **File:** `api/base.py` Filters by account + site + sector: ```python class SiteSectorModelViewSet(AccountModelViewSet): def get_queryset(self): qs = super().get_queryset() site_id = self.request.query_params.get('site_id') sector_id = self.request.query_params.get('sector_id') if site_id: qs = qs.filter(site_id=site_id) if sector_id: qs = qs.filter(sector_id=sector_id) return qs ``` --- ## Where Tenancy Applies ### Uses Site/Sector Filtering | Module | Filter | |--------|--------| | Planner (Keywords, Clusters, Ideas) | site + sector | | Writer (Tasks, Content, Images) | site + sector | | Linker | site + sector | | Optimizer | site + sector | | Setup/Add Keywords | site + sector | ### Account-Level Only (No Site/Sector) | Module | Filter | |--------|--------| | Billing/Plans | account only | | Account Settings | account only | | Team Management | account only | | User Profile | user only | | System Settings | account only | --- ## Frontend Implementation ### Site Selection **Store:** `store/siteStore.ts` ```typescript const useSiteStore = create({ activeSite: Site | null, sites: Site[], loadSites: () => Promise, setActiveSite: (site: Site) => void, }); ``` ### Sector Selection **Store:** `store/sectorStore.ts` ```typescript const useSectorStore = create({ activeSector: Sector | null, sectors: Sector[], loadSectorsForSite: (siteId: number) => Promise, setActiveSector: (sector: Sector) => void, }); ``` ### Sector Loading Pattern Sectors are loaded by `PageHeader` component, not `AppLayout`: ```typescript // PageHeader.tsx useEffect(() => { if (hideSiteSector) return; // Skip for account pages if (activeSite?.id && activeSite?.is_active) { loadSectorsForSite(activeSite.id); } }, [activeSite?.id, hideSiteSector]); ``` **Pages with `hideSiteSector={true}`:** - `/account/*` (settings, team, billing) - User profile pages --- ## Global Resources (No Tenancy) These are system-wide, not tenant-specific: | Model | Purpose | |-------|---------| | Industry | Global industry taxonomy | | IndustrySector | Sub-categories within industries | | SeedKeyword | Global keyword database | | GlobalIntegrationSettings | Platform API keys | | GlobalAIPrompt | Default prompt templates | | GlobalAuthorProfile | Author persona templates | --- ## Tenant Data vs System Data ### System Data (KEEP on reset) - Plans, CreditPackages - Industries, IndustrySectors - GlobalIntegrationSettings - GlobalAIPrompt, GlobalAuthorProfile - CreditCostConfig - PaymentMethodConfig ### Tenant Data (CLEAN on reset) - Accounts, Users, Sites, Sectors - Keywords, Clusters, Ideas, Tasks, Content, Images - Subscriptions, Invoices, Payments - CreditTransactions, CreditUsageLogs - SiteIntegrations, SyncEvents - AutomationConfigs, AutomationRuns --- ## Admin/Developer Bypass Admin and developer users can access all tenants: ```python # In auth/models.py class User: @property def is_admin_or_developer(self): return self.role in ['admin', 'developer'] ``` **Bypass Rules:** - Admin: Full access to own account - Developer: Full access to ALL accounts - System accounts protected from deletion --- ## Common Issues | Issue | Cause | Fix | |-------|-------|-----| | Data leak between accounts | Missing account filter | Use AccountModelViewSet | | Wrong site data | Site not validated for account | Validate site.account == request.account | | Sector not loading | Site changed but sector not reset | Clear activeSector when activeSite changes | | Admin can't see all data | Incorrect role check | Check is_admin_or_developer | --- ## Planned Changes | Feature | Status | Description | |---------|--------|-------------| | Account switching | 🔜 Planned | Allow users to switch between accounts | | Sub-accounts | 🔜 Planned | Hierarchical account structure |