Loading...
Loading...
Production-grade multi-tenant SaaS platform architecture with three-panel separation, zero-trust security, strict tenant isolation, and comprehensive audit trails. Use for designing multi-tenant systems, implementing tenant-scoped permissions, ensuring data isolation, and building scalable SaaS platforms.
npx skill4agent add peterbamuhigire/skills-web-dev multi-tenant-saas-architecturereferences/documentation/| Environment | OS | Database | Web Root |
|---|---|---|---|
| Development | Windows 11 (WAMP) | MySQL 8.4.7 | |
| Staging | Ubuntu VPS | MySQL 8.x | |
| Production | Debian VPS | MySQL 8.x | |
utf8mb4_unicode_cidatabase/migrations-production/┌──────────────────────────────────────────────────────────────┐
│ 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│
└─────────────────┘ └───────────┘ └─────────────────┘/public/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/public//public/dashboard.php/public/students.phpownerstaffWHERE franchise_id = ?/public/dashboard.php/public/students.php/public/inventory.php/public/patients.php/public/adminpanel//public/adminpanel/super_admin/public/memberpanel//public/memberpanel/memberstudentcustomerpatientfranchisetenantsuper_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)-- 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 = ?;-- PostgreSQL: Separate schema per franchise
CREATE SCHEMA franchise_123;
CREATE TABLE franchise_123.students (...);tbl_utf8mb4_unicode_ciutf8mb4// 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]);
}// 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);
}// 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; // ✅super_adminownerstaffstudentcustomerpatientmember-- 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';references/permission-model.md// 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
}// 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());define('SESSION_PREFIX', 'school_'); // School SaaS
define('SESSION_PREFIX', 'restaurant_'); // Restaurant SaaS
define('SESSION_PREFIX', 'clinic_'); // Medical SaaS
define('SESSION_PREFIX', 'hotel_'); // Hospitality SaaSHttpOnly: true
Secure: auto-detect HTTPS (allow localhost HTTP)
SameSite: Strict
Lifetime: 30 minutes
Regenerate on login// 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 HTTPAccess token: 15 minutes
Refresh token: 30 days
Rotation on refresh
Revocation table for logouttenant_id// BAD - Client controls!
$franchiseId = $_POST['franchise_id'];
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");// GOOD - Server-side session with prefix
$franchiseId = getSession('franchise_id');
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");// BAD - Missing franchise check! Data leakage!
$stmt = $db->prepare("SELECT * FROM students WHERE id = ?");
$stmt->execute([$studentId]);// GOOD - Always filter by franchise_id
$stmt = $db->prepare("
SELECT * FROM students
WHERE franchise_id = ? AND id = ?
");
$stmt->execute([getSession('franchise_id'), $studentId]);if (getSession('user_type') === 'super_admin') {
// Direct action without logging
deleteStudent($studentId);
}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);
}// BAD - Direct session access (collision risk)
$_SESSION['user_id'] = $userId;
$userId = $_SESSION['user_id'];// GOOD - Prefixed session (namespace isolation)
setSession('user_id', $userId);
$userId = getSession('user_id');https://tenant-slug.yourapp.com/api/v1/ordershttps://api.yourapp.com/v1/tenants/{tenant_id}/ordersX-Tenant-ID: 123
Authorization: Bearer <token>tenant_idGET /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{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"per_page": 25,
"total": 100
}
}{
"success": false,
"error": {
"code": "PERMISSION_DENIED",
"message": "You do not have permission to access this resource",
"details": {}
}
}{
"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": {...} }
}PENDING → ACTIVE → SUSPENDED → ARCHIVEDPer tenant: 1000 req/min
Per user: 100 req/min
Per IP: 500 req/min
Admin endpoints: 50 req/mintenant_iddescribe("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");
});
});documentation/migration.mdfranchise_idsetSession()getSession()/public//adminpanel//memberpanel/saas_app_super-user-dev.phputf8mb4_unicode_ci../../docs/PANEL-STRUCTURE.md../../CLAUDE.mdreferences/database-schema.mdreferences/permission-model.mddocumentation/migration.md