Required Plugins
Superpowers plugin: MUST be active for all work using this skill. Use throughout the entire build pipeline — design decisions, code generation, debugging, quality checks, and any task where it offers enhanced capabilities. If superpowers provides a better way to accomplish something, prefer it over the default approach.
Multi-Tenant SaaS Platform Architecture
Overview
Production-grade multi-tenant SaaS architecture with strict tenant isolation, zero-trust security, and three-panel separation.
Core Principles:
- Zero-trust: Every request authenticated, authorized, validated
- Tenant isolation by default: No cross-tenant data access
- Least privilege: Granular, explicit, auditable permissions
- Audit everything: Immutable audit trails for privileged operations
Security Baseline (Required): Always load and apply the Vibe Security Skill for any web app, API, or data access work. Its controls are mandatory alongside multi-tenant patterns.
Database Standards (Required): All database work (schema design, migrations, stored procedures, queries) MUST follow mysql-best-practices skill patterns. See that skill's migration checklist for required pre/post-migration steps.
See subdirectories for:
references/- Database schemas (database-schema.md), permission models (permission-model.md)documentation/- Migration patterns (migration.md)
Deployment Environments
Multi-tenant apps must work identically across all environments:
| Environment | OS | Database | Web Root |
|---|---|---|---|
| Development | Windows 11 (WAMP) | MySQL 8.4.7 | C:\wamp64\www\{project}\ |
| Staging | Ubuntu VPS | MySQL 8.x | /var/www/html/{project}/ |
| Production | Debian VPS | MySQL 8.x | /var/www/html/{project}/ |
Cross-platform rules: Use utf8mb4_unicode_ci collation everywhere. Match file/directory case exactly (Linux is case-sensitive). Production migrations must be non-destructive and idempotent (database/migrations-production/).
When to Use
✅ Multi-tenant SaaS platforms ✅ Strict tenant data isolation required ✅ Role-based permissions with admin oversight ✅ Compliance and audit trail requirements ✅ Multiple user types (internal staff, external customers)
❌ Single-tenant applications ❌ Simple CRUD apps without isolation needs ❌ Internal tools with flat permission models
Three-Tier Panel Architecture
THIS IS THE CORE ARCHITECTURAL CONCEPT:
┌──────────────────────────────────────────────────────────────┐
│ Shared Infrastructure Layer │
│ ┌───────────┬─────────────┬─────────────┬────────────┐ │
│ │ Data │ Business │ Integration │ Session │ │
│ │ (Tenant │ Logic │ Layer │ Prefix │ │
│ │ Isolated) │ (Scoped) │ (External) │ System │ │
│ └───────────┴─────────────┴─────────────┴────────────┘ │
└──────────────────────────────────────────────────────────────┘
│ │ │
┌────────▼────────┐ ┌───▼──────┐ ┌──────────▼──────┐
│ /public/ │ │/adminpanel│ │ /memberpanel/ │
│ (ROOT) │ │ │ │ │
│ Franchise Admin │ │Super Admin│ │ End User │
│ Workspace │ │ System │ │ Portal │
│ │ │ │ │ │
│ owner, staff │ │super_admin│ │member, student, │
│ │ │ │ │customer, patient│
└─────────────────┘ └───────────┘ └─────────────────┘
CRITICAL: /public/ root is the FRANCHISE ADMIN WORKSPACE, not a member panel!
File Structure Convention
public/
├── index.php # Landing page with nav buttons (NOT a router)
├── sign-in.php # Login with SweetAlert
├── dashboard.php # Franchise admin dashboard
├── skeleton.php # Page template for new pages
├── adminpanel/ # Super admin panel
│ ├── index.php
│ └── includes/ # Admin-specific includes
├── memberpanel/ # End user portal
│ ├── index.php
│ └── includes/ # Member-specific includes
├── includes/ # Shared includes for /public/ root
├── assets/ # Shared CSS/JS
└── uploads/ # File uploads
1. Franchise Admin Panel (/public/ root) - THE MAIN WORKSPACE
Purpose: Daily franchise operations (NOT member portal!)
Location: /public/dashboard.php, /public/students.php, etc.
Users: Franchise owners, managers, staff
User Types: owner, staff
Auth: Session-based (web), JWT (mobile/API)
Scope: Single franchise only, cannot access other franchises
Key Constraints:
- All queries include
WHERE franchise_id = ? - Cannot modify platform settings
- Cannot create/suspend other franchises
- All operations logged for franchise audit
Example Pages:
/public/dashboard.php- Franchise admin dashboard/public/students.php- Manage students (school SaaS)/public/inventory.php- Manage inventory (restaurant SaaS)/public/patients.php- Manage patients (medical SaaS)
2. Super Admin Panel (/public/adminpanel/)
Purpose: Platform management and oversight
Location: /public/adminpanel/
Users: Super admins, platform operators
User Type: super_admin
Auth: Session-based + MFA recommended
Scope: Cross-franchise with audit trails
Capabilities:
- Create/suspend franchises
- Manage platform users
- View cross-franchise analytics
- Configure platform settings
- Access all franchise data (logged)
Critical Rules:
- Every action creates audit log
- Production data access logged
- franchise_id can be NULL for super admins
- Can impersonate franchise users (logged)
3. End User Portal (/public/memberpanel/)
Purpose: Self-service for end users
Location: /public/memberpanel/
Users: End customers/patients/students (outside franchise staff)
User Types: member, student, customer, patient (customizable)
Auth: Session-based or JWT
Scope: Own records only, read-mostly
Examples:
- Student portal - View grades, assignments
- Customer portal - Order tracking, invoices
- Patient portal - View medical records, appointments
- Member portal - Self-service access
Franchise Isolation Model (Multi-Tenant)
Terminology: We use franchise instead of tenant in SaaS Seeder Template.
User Types & Franchise Requirements
CRITICAL: Understand franchise_id requirements per user type:
super_admin - Platform operators (franchise_id CAN be NULL)
owner - Franchise owners (franchise_id REQUIRED, NOT NULL)
staff - Franchise staff (franchise_id REQUIRED, NOT NULL)
member - End users: student, customer, patient (franchise_id REQUIRED, NOT NULL)
Database-Level Isolation
Option 1: Shared Database (Row-Level franchise_id) ← SaaS Seeder Template Uses This
-- Every franchise-scoped table has franchise_id
CREATE TABLE students (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
franchise_id BIGINT UNSIGNED NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(100),
-- other fields
FOREIGN KEY (franchise_id) REFERENCES tbl_franchises(id)
ON DELETE CASCADE,
INDEX idx_franchise (franchise_id),
INDEX idx_franchise_email (franchise_id, email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ALL queries MUST include franchise_id
SELECT * FROM students WHERE franchise_id = ? AND id = ?;
Option 2: Schema-Per-Franchise
-- PostgreSQL: Separate schema per franchise
CREATE SCHEMA franchise_123;
CREATE TABLE franchise_123.students (...);
Option 3: Database-Per-Franchise (High isolation, ops overhead)
Recommendation: Start with Option 1 (row-level), migrate to Option 2 for large/regulated franchises.
SaaS Seeder Template Convention:
- Table prefix:
tbl_for shared tables (users, franchises, roles) - No prefix: For franchise-scoped data (students, orders, inventory)
- Collation:
utf8mb4_unicode_cifor all text columns - Charset:
utf8mb4for emoji and international character support
Application-Level Enforcement
PHP Pattern (Session-based with prefix system):
// Extract franchise context from session (with prefix)
$franchiseId = getSession('franchise_id'); // Uses SESSION_PREFIX
$userType = getSession('user_type');
// ALWAYS filter by franchise_id
$stmt = $db->prepare("
SELECT * FROM students
WHERE franchise_id = ? AND id = ?
");
$stmt->execute([$franchiseId, $studentId]);
// For super_admin, allow cross-franchise access (logged)
if ($userType === 'super_admin') {
// Can access any franchise, but log the action
auditLog('CROSS_FRANCHISE_ACCESS', [
'admin_user_id' => getSession('user_id'),
'target_franchise_id' => $requestedFranchiseId,
'action' => 'VIEW_STUDENTS'
]);
// Query without franchise filter (super admin only)
$stmt = $db->prepare("SELECT * FROM students WHERE id = ?");
$stmt->execute([$studentId]);
} else {
// Regular users: MUST filter by their franchise_id
$stmt = $db->prepare("
SELECT * FROM students
WHERE franchise_id = ? AND id = ?
");
$stmt->execute([getSession('franchise_id'), $studentId]);
}
JavaScript Pattern (JWT-based):
// Extract franchise context from JWT
function extractFranchiseContext(req) {
const token = verifyJWT(req.headers.authorization);
return {
userId: token.sub,
franchiseId: token.fid, // franchise_id in token
userType: token.ut, // user_type in token
};
}
// Enforce franchise scope on all queries
function scopeQuery(query, franchiseId) {
if (!franchiseId) throw new Error("Missing franchise context");
return query.where("franchise_id", franchiseId);
}
Critical: Never trust client-provided franchise_id
// BAD - Client controls franchise_id
$franchiseId = $_POST['franchise_id']; // ❌ NEVER DO THIS!
// GOOD - Server extracts from session (with prefix)
$franchiseId = getSession('franchise_id'); // ✅
// GOOD - Server extracts from JWT
$franchiseId = $jwtPayload->fid; // ✅
Authentication & Authorization
User Types
SaaS Seeder Template User Types:
super_admin- Platform management, cross-franchise (franchise_id CAN be NULL)owner- Full control within franchise (franchise_id REQUIRED)staff- Operational permissions within franchise (franchise_id REQUIRED)- Custom end user types (franchise_id REQUIRED):
student- For school/education SaaScustomer- For e-commerce/restaurant SaaSpatient- For medical/clinic SaaSmember- Generic end user
Customizing User Types:
-- Edit database enum to match your SaaS domain
ALTER TABLE tbl_users MODIFY user_type ENUM(
'super_admin',
'owner',
'staff',
'student', -- School SaaS
'customer', -- Restaurant SaaS
'patient' -- Medical SaaS
) NOT NULL DEFAULT 'staff';
Permission Model
See references/permission-model.md for complete schema
// Permission resolution priority:
// 1. User denial (explicit deny) → DENY
// 2. User grant (explicit allow) → ALLOW
// 3. Franchise override → ALLOW/DENY
// 4. Role permission → ALLOW
// 5. Default → DENY
function hasPermission(userId, tenantId, permission) {
// Super admin bypass
if (user.type === "super_admin") return true;
// Check explicit denials
if (userPermissions.denied(userId, tenantId, permission)) return false;
// Check explicit grants
if (userPermissions.granted(userId, tenantId, permission)) return true;
// Check role-based permissions
const roles = getUserRoles(userId, tenantId);
for (const role of roles) {
if (roleHasPermission(role, permission, tenantId)) return true;
}
return false; // Default deny
}
Session Management
Session Prefix System (Multi-Tenant Isolation):
CRITICAL: All session variables use a prefix to prevent collisions:
// Define in src/config/session.php
define('SESSION_PREFIX', 'saas_app_'); // Change per SaaS
// ALWAYS use helper functions
setSession('user_id', 123); // Sets $_SESSION['saas_app_user_id']
$userId = getSession('user_id'); // Gets $_SESSION['saas_app_user_id']
hasSession('user_id'); // Checks if exists
destroySession(); // Clears all prefixed vars
// Common session variables (with prefix):
setSession('user_id', $userId);
setSession('franchise_id', $franchiseId);
setSession('user_type', $userType);
setSession('username', $username);
setSession('full_name', $fullName);
setSession('last_activity', time());
Customize prefix per SaaS:
define('SESSION_PREFIX', 'school_'); // School SaaS
define('SESSION_PREFIX', 'restaurant_'); // Restaurant SaaS
define('SESSION_PREFIX', 'clinic_'); // Medical SaaS
define('SESSION_PREFIX', 'hotel_'); // Hospitality SaaS
Web (Session-based):
HttpOnly: true
Secure: auto-detect HTTPS (allow localhost HTTP)
SameSite: Strict
Lifetime: 30 minutes
Regenerate on login
HTTPS Auto-Detection (Critical for Development):
// Only set secure cookie if using HTTPS
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| $_SERVER['SERVER_PORT'] == 443;
ini_set('session.cookie_secure', $isHttps ? '1' : '0');
// Without this, sessions won't persist on localhost HTTP
API (JWT-based):
Access token: 15 minutes
Refresh token: 30 days
Rotation on refresh
Revocation table for logout
Security Architecture
Zero-Trust Checklist
Authentication:
- MFA for admin access
- Password: Argon2ID + salt + pepper
- Account lockout after 5 failures
- Session timeout (30 min idle)
- Token rotation on refresh
Authorization:
- Tenant context in every request
- Permission check before every operation
- Super admin actions audited
- Impersonation logged with justification
Data Access:
-
tenant_idin WHERE clause (ALWAYS) - Prepared statements (no SQL injection)
- Input validation at API boundary
- Output encoding (XSS prevention)
API Security:
- Rate limiting (per tenant, per user)
- CORS whitelist (no wildcards)
- Request size limits
- HTTPS only (HSTS enabled)
Common Security Mistakes
❌ Trusting client-provided franchise_id
// BAD - Client controls!
$franchiseId = $_POST['franchise_id'];
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");
✅ Extract from server-side session (with prefix)
// GOOD - Server-side session with prefix
$franchiseId = getSession('franchise_id');
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");
❌ Missing franchise_id in queries
// BAD - Missing franchise check! Data leakage!
$stmt = $db->prepare("SELECT * FROM students WHERE id = ?");
$stmt->execute([$studentId]);
✅ Always include franchise scope
// GOOD - Always filter by franchise_id
$stmt = $db->prepare("
SELECT * FROM students
WHERE franchise_id = ? AND id = ?
");
$stmt->execute([getSession('franchise_id'), $studentId]);
❌ Super admin without audit
if (getSession('user_type') === 'super_admin') {
// Direct action without logging
deleteStudent($studentId);
}
✅ Super admin WITH audit
if (getSession('user_type') === 'super_admin') {
// Log cross-franchise access
auditLog('ADMIN_DELETE_STUDENT', [
'admin_user_id' => getSession('user_id'),
'target_franchise_id' => $franchiseId,
'student_id' => $studentId
]);
deleteStudent($studentId);
}
❌ Not using session prefix system
// BAD - Direct session access (collision risk)
$_SESSION['user_id'] = $userId;
$userId = $_SESSION['user_id'];
✅ Use session prefix helpers
// GOOD - Prefixed session (namespace isolation)
setSession('user_id', $userId);
$userId = getSession('user_id');
API Design Principles
Tenant Context in Requests
Option 1: Subdomain
https://tenant-slug.yourapp.com/api/v1/orders
Option 2: Path parameter
https://api.yourapp.com/v1/tenants/{tenant_id}/orders
Option 3: Header
X-Tenant-ID: 123
Authorization: Bearer <token>
Recommendation: Use JWT with tenant_id claim (no client input).
RESTful Conventions
GET /api/v1/orders → List (tenant-scoped)
POST /api/v1/orders → Create (tenant-scoped)
GET /api/v1/orders/{id} → Show (tenant-scoped)
PUT /api/v1/orders/{id} → Update (tenant-scoped)
DELETE /api/v1/orders/{id} → Delete (tenant-scoped)
// Admin endpoints (cross-tenant)
GET /api/v1/admin/tenants → List all tenants
POST /api/v1/admin/tenants → Create tenant
GET /api/v1/admin/analytics → Cross-tenant analytics
POST /api/v1/admin/impersonate → Start impersonation
Response Format
{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"per_page": 25,
"total": 100
}
}
Error format:
{
"success": false,
"error": {
"code": "PERMISSION_DENIED",
"message": "You do not have permission to access this resource",
"details": {}
}
}
Audit & Compliance
What to Audit
Always log:
- Super admin actions (ALL)
- Impersonation start/end
- Permission changes
- Tenant creation/suspension
- Data exports
- Failed auth attempts
- Cross-tenant access attempts (should be 0)
Audit record format:
{
"id": "uuid",
"timestamp": "2025-01-23T10:30:00Z",
"actor_user_id": 123,
"actor_type": "super_admin",
"action": "IMPERSONATE_USER",
"target_tenant_id": 456,
"target_user_id": 789,
"justification": "Customer support request #12345",
"ip_address": "203.0.113.1",
"user_agent": "...",
"changes": { "before": {...}, "after": {...} }
}
Retention:
- Security logs: 1 year minimum
- Audit trails: 7 years (compliance)
- Operational logs: 90 days
Operational Safeguards
Tenant Lifecycle
PENDING → ACTIVE → SUSPENDED → ARCHIVED
- PENDING: Created, not yet activated
- ACTIVE: Normal operations
- SUSPENDED: Payment failure, ToS violation (data retained)
- ARCHIVED: Deleted (data purged after retention period)
Data Protection
Backups:
- Daily automated backups
- Point-in-time recovery (30 days)
- Test restore quarterly
- Tenant-level restore capability
Encryption:
- At rest: AES-256
- In transit: TLS 1.3
- Sensitive fields: Application-level encryption (PII, payment)
Rate Limiting
Per tenant: 1000 req/min
Per user: 100 req/min
Per IP: 500 req/min
Admin endpoints: 50 req/min
Monitoring Alerts
Critical:
- Cross-tenant access attempt
- Super admin login from new IP
- Failed auth spike (>100/min)
- Database query without tenant_id
- API error rate >5%
Development Guidelines
Code Review Checklist
Every feature must:
- Include
tenant_idin all queries - Validate permissions before operations
- Create audit log for privileged actions
- Test cross-tenant isolation
- Handle tenant suspension state
- Document permission requirements
Testing Requirements
describe("Order API", () => {
it("prevents cross-tenant data access", async () => {
const tenant1Order = await createOrder(tenant1);
const tenant2User = await authenticateAs(tenant2.user);
const response = await tenant2User.get(`/orders/${tenant1Order.id}`);
expect(response.status).toBe(404); // Not 403 (info leak)
});
it("requires permission for operation", async () => {
const user = await authenticateAs(limitedUser);
const response = await user.delete("/orders/123");
expect(response.status).toBe(403);
expect(response.body.error.code).toBe("PERMISSION_DENIED");
});
});
Migration Patterns
See documentation/migration.md for adding tenant_id to existing tables
Summary
Critical Implementation Rules:
- Franchise Isolation:
franchise_idin EVERY query (except super_admin with audit) - Auth Context: Extract franchise from session/JWT (never client input)
- Session Prefix: Use
setSession()/getSession()helpers (namespace isolation) - User Types: Understand franchise_id requirements (NULL only for super_admin)
- Permissions: Check before EVERY operation
- Audit: Log ALL privileged/cross-franchise actions
- Super Admin: Audit + MFA + IP restrictions
- Testing: Cross-franchise isolation tests mandatory
- Monitoring: Alert on cross-franchise access attempts
Architecture Patterns:
- Three-tier panel structure (CORE concept):
/public/root = Franchise admin workspace (NOT member panel!)/adminpanel/= Super admin system/memberpanel/= End user portal
- Session prefix system for multi-tenant isolation
- Zero-trust security model
- Row-level franchise isolation (start here)
- Role-based permissions with overrides
- Immutable audit trails
SaaS Seeder Template Specifics:
- Session prefix:
saas_app_(customize per SaaS) - Password hashing: Argon2ID + salt(32 chars) + pepper(64+ chars)
- Use
super-user-dev.phpto create admin users (correct hashing) - HTTPS auto-detection for session.cookie_secure (localhost development)
- Collation:
utf8mb4_unicode_cifor all text columns
See Also:
../../docs/PANEL-STRUCTURE.md- Complete three-tier architecture guide../../CLAUDE.md- Development guidelines and common pitfallsreferences/database-schema.md- Complete database design, indexes, partitioningreferences/permission-model.md- RBAC implementation, caching, middlewaredocumentation/migration.md- Adding franchise_id, zero-downtime migrations, rollback
Remember: Security failures in multi-tenant systems affect ALL franchises. Test isolation exhaustively.