300 lines
7.0 KiB
Markdown
300 lines
7.0 KiB
Markdown
# Multi-Tenancy Architecture
|
|
|
|
**Last Verified:** December 25, 2025
|
|
**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 | Current credit balance |
|
|
| stripe_customer_id | CharField | Stripe integration |
|
|
| paypal_customer_id | CharField | PayPal integration |
|
|
| created_at | DateTime | Registration date |
|
|
|
|
### Plan
|
|
|
|
| Field | Type | Purpose |
|
|
|-------|------|---------|
|
|
| name | CharField | Plan name (Free, Starter, Growth, Scale) |
|
|
| included_credits | Integer | Monthly credit allocation |
|
|
| max_sites | Integer | Site limit |
|
|
| max_users | Integer | User limit |
|
|
| max_keywords | Integer | Keyword limit |
|
|
| max_clusters | Integer | Cluster limit |
|
|
| max_content_ideas | Integer | Monthly idea limit |
|
|
| max_content_words | Integer | Monthly word limit |
|
|
| max_images_basic | Integer | Monthly basic image limit |
|
|
| max_images_premium | Integer | Monthly premium image limit |
|
|
| is_active | Boolean | Available for purchase |
|
|
| is_internal | Boolean | Internal/test plan |
|
|
|
|
### 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<void>,
|
|
setActiveSite: (site: Site) => void,
|
|
});
|
|
```
|
|
|
|
### Sector Selection
|
|
|
|
**Store:** `store/sectorStore.ts`
|
|
|
|
```typescript
|
|
const useSectorStore = create({
|
|
activeSector: Sector | null,
|
|
sectors: Sector[],
|
|
loadSectorsForSite: (siteId: number) => Promise<void>,
|
|
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 |
|