multi-tenant-saas-architecture

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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "multi-tenant-saas-architecture" with this command: npx skills add peterbamuhigire/skills-web-dev/peterbamuhigire-skills-web-dev-multi-tenant-saas-architecture

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:

EnvironmentOSDatabaseWeb Root
DevelopmentWindows 11 (WAMP)MySQL 8.4.7C:\wamp64\www\{project}\
StagingUbuntu VPSMySQL 8.x/var/www/html/{project}/
ProductionDebian VPSMySQL 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_ci for all text columns
  • Charset: utf8mb4 for 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 SaaS
    • customer - For e-commerce/restaurant SaaS
    • patient - For medical/clinic SaaS
    • member - 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_id in 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_id in 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:

  1. Franchise Isolation: franchise_id in EVERY query (except super_admin with audit)
  2. Auth Context: Extract franchise from session/JWT (never client input)
  3. Session Prefix: Use setSession()/getSession() helpers (namespace isolation)
  4. User Types: Understand franchise_id requirements (NULL only for super_admin)
  5. Permissions: Check before EVERY operation
  6. Audit: Log ALL privileged/cross-franchise actions
  7. Super Admin: Audit + MFA + IP restrictions
  8. Testing: Cross-franchise isolation tests mandatory
  9. 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.php to create admin users (correct hashing)
  • HTTPS auto-detection for session.cookie_secure (localhost development)
  • Collation: utf8mb4_unicode_ci for all text columns

See Also:

  • ../../docs/PANEL-STRUCTURE.md - Complete three-tier architecture guide
  • ../../CLAUDE.md - Development guidelines and common pitfalls
  • references/database-schema.md - Complete database design, indexes, partitioning
  • references/permission-model.md - RBAC implementation, caching, middleware
  • documentation/migration.md - Adding franchise_id, zero-downtime migrations, rollback

Remember: Security failures in multi-tenant systems affect ALL franchises. Test isolation exhaustively.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Security

vibe security skill

No summary provided by upstream source.

Repository SourceNeeds Review
Security

skill-safety-audit

No summary provided by upstream source.

Repository SourceNeeds Review
Security

implementation-status-auditor

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

google-play-store-review

No summary provided by upstream source.

Repository SourceNeeds Review