multi-tenant-saas-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.
Superpowers 插件: 所有使用此技能的工作都必须激活该插件。在整个构建流程中使用——包括设计决策、代码生成、调试、质量检查以及任何它能提供增强功能的任务。如果Superpowers插件提供了更优的实现方式,优先使用它而非默认方法。

Multi-Tenant SaaS Platform Architecture

多租户SaaS平台架构

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)
生产级多租户SaaS架构,具备严格租户隔离、零信任安全和三面板分离特性。
核心原则:
  • 零信任:每一个请求都需经过认证、授权和验证
  • 默认租户隔离:禁止跨租户数据访问
  • 最小权限:细粒度、明确且可审计的权限
  • 全量审计:特权操作的不可变审计追踪
安全基线(必填): 所有Web应用、API或数据访问工作都必须加载并应用Vibe Security Skill。其控制措施是多租户模式的强制性要求。
数据库标准(必填): 所有数据库工作( schema设计、迁移、存储过程、查询)都必须遵循mysql-best-practices技能模式。参考该技能的迁移清单获取必填的迁移前后步骤。
子目录说明:
  • references/
    - 数据库schema(database-schema.md)、权限模型(permission-model.md)
  • documentation/
    - 迁移模式(migration.md)

Deployment Environments

部署环境

Multi-tenant apps must work identically across all environments:
EnvironmentOSDatabaseWeb Root
DevelopmentWindows 11 (WAMP)MySQL 8.4.7
C:\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/
).
多租户应用必须在所有环境中保持一致运行:
环境操作系统数据库Web根目录
开发环境Windows 11 (WAMP)MySQL 8.4.7
C:\wamp64\www\{project}\
预发布环境Ubuntu VPSMySQL 8.x
/var/www/html/{project}/
生产环境Debian VPSMySQL 8.x
/var/www/html/{project}/
跨平台规则: 所有场景使用
utf8mb4_unicode_ci
排序规则。严格匹配文件/目录大小写(Linux区分大小写)。生产环境迁移必须是非破坏性且幂等的(
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
✅ 多租户SaaS平台 ✅ 需要严格租户数据隔离 ✅ 带管理员监督的基于角色的权限 ✅ 合规性和审计追踪要求 ✅ 多种用户类型(内部员工、外部客户)
❌ 单租户应用 ❌ 无需隔离的简单CRUD应用 ❌ 权限模型扁平化的内部工具

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!
这是核心架构概念:
┌──────────────────────────────────────────────────────────────┐
│              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/
根目录是加盟商管理员工作区,而非用户面板!

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
public/
├── index.php           # 带导航按钮的登录页(非路由)
├── sign-in.php         # 集成SweetAlert的登录页
├── dashboard.php       # 加盟商管理员仪表盘
├── skeleton.php        # 新页面模板
├── adminpanel/         # 超级管理员面板
│   ├── index.php
│   └── includes/       # 管理员专属依赖文件
├── memberpanel/        # 终端用户门户
│   ├── index.php
│   └── includes/       # 用户专属依赖文件
├── includes/           # `/public/`根目录的共享依赖文件
├── assets/             # 共享CSS/JS资源
└── uploads/            # 文件上传目录

1. Franchise Admin Panel (
/public/
root) - THE MAIN WORKSPACE

1. 加盟商管理面板(
/public/
根目录)—— 主工作区

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)
用途: 加盟商日常运营(非用户门户!) 位置:
/public/dashboard.php
,
/public/students.php
用户: 加盟商所有者、管理者、员工 用户类型:
owner
,
staff
认证方式: 基于Session(Web端)、JWT(移动端/API) 范围: 仅单个加盟商,无法访问其他加盟商数据
关键约束:
  • 所有查询必须包含
    WHERE franchise_id = ?
  • 无法修改平台设置
  • 无法创建/暂停其他加盟商
  • 所有操作都会记录到加盟商审计日志
示例页面:
  • /public/dashboard.php
    - 加盟商管理员仪表盘
  • /public/students.php
    - 管理学生(教育类SaaS)
  • /public/inventory.php
    - 管理库存(餐饮类SaaS)
  • /public/patients.php
    - 管理患者(医疗类SaaS)

2. Super Admin Panel (
/public/adminpanel/
)

2. 超级管理员面板(
/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)
用途: 平台管理与监督 位置:
/public/adminpanel/
用户: 超级管理员、平台运营人员 用户类型:
super_admin
认证方式: 基于Session + 推荐MFA(多因素认证) 范围: 跨加盟商,带审计追踪
功能:
  • 创建/暂停加盟商
  • 管理平台用户
  • 查看跨加盟商分析数据
  • 配置平台设置
  • 访问所有加盟商数据(操作会被记录)
重要规则:
  • 每一个操作都会生成审计日志
  • 生产环境数据访问会被记录
  • 超级管理员的franchise_id可以为NULL
  • 可以模拟加盟商用户(操作会被记录)

3. End User Portal (
/public/memberpanel/
)

3. 终端用户门户(
/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
用途: 终端用户自助服务 位置:
/public/memberpanel/
用户: 终端客户/患者/学生(加盟商员工以外的用户) 用户类型:
member
,
student
,
customer
,
patient
(可自定义) 认证方式: 基于Session或JWT 范围: 仅能访问自身记录,以只读操作为主
示例:
  • 学生门户 - 查看成绩、作业
  • 客户门户 - 订单追踪、发票
  • 患者门户 - 查看病历、预约
  • 用户门户 - 自助服务访问

Franchise Isolation Model (Multi-Tenant)

加盟商隔离模型(多租户)

Terminology: We use
franchise
instead of
tenant
in SaaS Seeder Template.
术语说明: 在SaaS Seeder模板中,我们使用
franchise
代替
tenant

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)
重要说明: 理解不同用户类型对franchise_id的要求:
super_admin - 平台运营人员(franchise_id 可以为NULL)
owner       - 加盟商所有者(franchise_id 为必填项,不可为NULL)
staff       - 加盟商员工(franchise_id 为必填项,不可为NULL)
member      - 终端用户:学生、客户、患者(franchise_id 为必填项,不可为NULL)

Database-Level Isolation

数据库级隔离

Option 1: Shared Database (Row-Level franchise_id) ← SaaS Seeder Template Uses This
sql
-- 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
sql
-- 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
方案1:共享数据库(行级franchise_id隔离) ← SaaS Seeder模板采用此方案
sql
-- 所有加盟商范围的表都包含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),
    -- 其他字段
    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;

-- 所有查询必须包含franchise_id
SELECT * FROM students WHERE franchise_id = ? AND id = ?;
方案2:按加盟商分Schema
sql
-- PostgreSQL:每个加盟商对应独立Schema
CREATE SCHEMA franchise_123;
CREATE TABLE franchise_123.students (...);
方案3:按加盟商分数据库(隔离度高,但运维成本大)
推荐方案: 从方案1(行级隔离)开始,针对大型/受监管的加盟商迁移到方案2。
SaaS Seeder模板规范:
  • 表前缀:共享表(用户、加盟商、角色)使用
    tbl_
    前缀
  • 无前缀:加盟商范围的数据表(学生、订单、库存)
  • 排序规则:所有文本列使用
    utf8mb4_unicode_ci
  • 字符集:
    utf8mb4
    ,支持表情符号和国际字符

Application-Level Enforcement

应用层强制隔离

PHP Pattern (Session-based with prefix system):
php
// 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):
javascript
// 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
php
// 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; // ✅
PHP模式(基于Session的前缀系统):
php
// 从Session中提取加盟商上下文(带前缀)
$franchiseId = getSession('franchise_id'); // 使用SESSION_PREFIX
$userType = getSession('user_type');

// 始终按franchise_id过滤
$stmt = $db->prepare("
    SELECT * FROM students
    WHERE franchise_id = ? AND id = ?
");
$stmt->execute([$franchiseId, $studentId]);

// 超级管理员允许跨加盟商访问(操作会被记录)
if ($userType === 'super_admin') {
    // 可以访问任意加盟商,但操作会被记录
    auditLog('CROSS_FRANCHISE_ACCESS', [
        'admin_user_id' => getSession('user_id'),
        'target_franchise_id' => $requestedFranchiseId,
        'action' => 'VIEW_STUDENTS'
    ]);

    // 超级管理员专属:查询无需加盟商过滤
    $stmt = $db->prepare("SELECT * FROM students WHERE id = ?");
    $stmt->execute([$studentId]);
} else {
    // 普通用户:必须按自身franchise_id过滤
    $stmt = $db->prepare("
        SELECT * FROM students
        WHERE franchise_id = ? AND id = ?
    ");
    $stmt->execute([getSession('franchise_id'), $studentId]);
}
JavaScript模式(基于JWT):
javascript
// 从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
  };
}

// 对所有查询强制加盟商范围限制
function scopeQuery(query, franchiseId) {
  if (!franchiseId) throw new Error("Missing franchise context");
  return query.where("franchise_id", franchiseId);
}
重要提示:永远不要信任客户端提供的franchise_id
php
// 错误示例 - 客户端控制franchise_id
$franchiseId = $_POST['franchise_id']; // ❌ 绝对不要这么做!

// 正确示例 - 从服务端Session(带前缀)提取
$franchiseId = getSession('franchise_id'); // ✅

// 正确示例 - 从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:
sql
-- 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';
SaaS Seeder模板用户类型:
  • super_admin
    - 平台管理,跨加盟商(franchise_id 可以为NULL)
  • owner
    - 加盟商内部完全控制(franchise_id 为必填项)
  • staff
    - 加盟商内部操作权限(franchise_id 为必填项)
  • 自定义终端用户类型(franchise_id 为必填项):
    • student
      - 教育类SaaS
    • customer
      - 电商/餐饮类SaaS
    • patient
      - 医疗类SaaS
    • member
      - 通用终端用户
自定义用户类型:
sql
-- 修改数据库枚举以匹配你的SaaS领域
ALTER TABLE tbl_users MODIFY user_type ENUM(
  'super_admin',
  'owner',
  'staff',
  'student',    -- 教育类SaaS
  'customer',   -- 餐饮类SaaS
  'patient'     -- 医疗类SaaS
) NOT NULL DEFAULT 'staff';

Permission Model

权限模型

See
references/permission-model.md
for complete schema
javascript
// 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
}
完整Schema请参考
references/permission-model.md
javascript
// 权限解析优先级:
// 1. 用户明确拒绝 → 拒绝
// 2. 用户明确允许 → 允许
// 3. 加盟商覆盖规则 → 允许/拒绝
// 4. 角色权限 → 允许
// 5. 默认规则 → 拒绝

function hasPermission(userId, tenantId, permission) {
  // 超级管理员绕过所有权限检查
  if (user.type === "super_admin") return true;

  // 检查明确拒绝的权限
  if (userPermissions.denied(userId, tenantId, permission)) return false;

  // 检查明确允许的权限
  if (userPermissions.granted(userId, tenantId, permission)) return true;

  // 检查基于角色的权限
  const roles = getUserRoles(userId, tenantId);
  for (const role of roles) {
    if (roleHasPermission(role, permission, tenantId)) return true;
  }

  return false; // 默认拒绝
}

Session Management

Session管理

Session Prefix System (Multi-Tenant Isolation):
CRITICAL: All session variables use a prefix to prevent collisions:
php
// 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:
php
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):
php
// 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
Session前缀系统(多租户隔离):
重要提示:所有Session变量使用前缀以避免冲突:
php
// 在src/config/session.php中定义
define('SESSION_PREFIX', 'saas_app_'); // 每个SaaS应用单独修改

// 始终使用辅助函数
setSession('user_id', 123);        // 设置$_SESSION['saas_app_user_id']
$userId = getSession('user_id');   // 获取$_SESSION['saas_app_user_id']
hasSession('user_id');             // 检查是否存在
destroySession();                  // 清除所有带前缀的变量

// 常见Session变量(带前缀):
setSession('user_id', $userId);
setSession('franchise_id', $franchiseId);
setSession('user_type', $userType);
setSession('username', $username);
setSession('full_name', $fullName);
setSession('last_activity', time());
为每个SaaS应用自定义前缀:
php
define('SESSION_PREFIX', 'school_');     // 教育类SaaS
define('SESSION_PREFIX', 'restaurant_'); // 餐饮类SaaS
define('SESSION_PREFIX', 'clinic_');     // 医疗类SaaS
define('SESSION_PREFIX', 'hotel_');      // 酒店类SaaS
Web端(基于Session):
HttpOnly: true
Secure: 自动检测HTTPS(允许本地HTTP)
SameSite: Strict
有效期:30分钟
登录时重新生成Session
HTTPS自动检测(开发环境关键配置):
php
// 仅在HTTPS环境下设置Secure Cookie
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
           || $_SERVER['SERVER_PORT'] == 443;
ini_set('session.cookie_secure', $isHttps ? '1' : '0');

// 没有此配置,本地HTTP环境下Session无法持久化
API端(基于JWT):
访问令牌:15分钟
刷新令牌:30天
刷新时轮换令牌
登出时使用吊销表

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)
认证:
  • 管理员访问启用MFA
  • 密码:Argon2ID + 盐 + 胡椒
  • 5次失败尝试后锁定账户
  • Session闲置超时(30分钟)
  • 刷新时轮换令牌
授权:
  • 每个请求都包含租户上下文
  • 操作前检查权限
  • 超级管理员操作被审计
  • 模拟用户操作需记录理由
数据访问:
  • 查询中始终包含
    tenant_id
  • 使用预编译语句(防止SQL注入)
  • API边界处验证输入
  • 输出编码(防止XSS)
API安全:
  • 速率限制(按租户、按用户)
  • CORS白名单(禁止通配符)
  • 请求大小限制
  • 仅允许HTTPS(启用HSTS)

Common Security Mistakes

常见安全错误

Trusting client-provided franchise_id
php
// BAD - Client controls!
$franchiseId = $_POST['franchise_id'];
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");
Extract from server-side session (with prefix)
php
// GOOD - Server-side session with prefix
$franchiseId = getSession('franchise_id');
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");
Missing franchise_id in queries
php
// BAD - Missing franchise check! Data leakage!
$stmt = $db->prepare("SELECT * FROM students WHERE id = ?");
$stmt->execute([$studentId]);
Always include franchise scope
php
// 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
php
if (getSession('user_type') === 'super_admin') {
    // Direct action without logging
    deleteStudent($studentId);
}
Super admin WITH audit
php
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
php
// BAD - Direct session access (collision risk)
$_SESSION['user_id'] = $userId;
$userId = $_SESSION['user_id'];
Use session prefix helpers
php
// GOOD - Prefixed session (namespace isolation)
setSession('user_id', $userId);
$userId = getSession('user_id');
信任客户端提供的franchise_id
php
// 错误示例 - 客户端控制!
$franchiseId = $_POST['franchise_id'];
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");
从服务端Session(带前缀)提取
php
// 正确示例 - 带前缀的服务端Session
$franchiseId = getSession('franchise_id');
$stmt = $db->prepare("SELECT * FROM students WHERE franchise_id = ?");
查询中缺失franchise_id
php
// 错误示例 - 缺失加盟商检查!数据泄露风险!
$stmt = $db->prepare("SELECT * FROM students WHERE id = ?");
$stmt->execute([$studentId]);
始终包含加盟商范围限制
php
// 正确示例 - 始终按franchise_id过滤
$stmt = $db->prepare("
    SELECT * FROM students
    WHERE franchise_id = ? AND id = ?
");
$stmt->execute([getSession('franchise_id'), $studentId]);
超级管理员操作未审计
php
if (getSession('user_type') === 'super_admin') {
    // 直接操作未记录
    deleteStudent($studentId);
}
超级管理员操作需审计
php
if (getSession('user_type') === 'super_admin') {
    // 记录跨加盟商访问
    auditLog('ADMIN_DELETE_STUDENT', [
        'admin_user_id' => getSession('user_id'),
        'target_franchise_id' => $franchiseId,
        'student_id' => $studentId
    ]);
    deleteStudent($studentId);
}
未使用Session前缀系统
php
// 错误示例 - 直接访问Session(存在冲突风险)
$_SESSION['user_id'] = $userId;
$userId = $_SESSION['user_id'];
使用Session前缀辅助函数
php
// 正确示例 - 带前缀的Session(命名空间隔离)
setSession('user_id', $userId);
$userId = getSession('user_id');

API Design Principles

API设计原则

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).
方案1:子域名
https://tenant-slug.yourapp.com/api/v1/orders
方案2:路径参数
https://api.yourapp.com/v1/tenants/{tenant_id}/orders
方案3:请求头
X-Tenant-ID: 123
Authorization: Bearer <token>
推荐方案: 使用包含
tenant_id
声明的JWT(无需客户端输入)。

RESTful Conventions

RESTful规范

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
GET    /api/v1/orders          → 列表(租户范围)
POST   /api/v1/orders          → 创建(租户范围)
GET    /api/v1/orders/{id}     → 详情(租户范围)
PUT    /api/v1/orders/{id}     → 更新(租户范围)
DELETE /api/v1/orders/{id}     → 删除(租户范围)

// 管理员端点(跨租户)
GET    /api/v1/admin/tenants         → 查看所有租户
POST   /api/v1/admin/tenants         → 创建租户
GET    /api/v1/admin/analytics       → 跨租户分析
POST   /api/v1/admin/impersonate     → 开始模拟用户

Response Format

响应格式

json
{
  "success": true,
  "data": { ... },
  "meta": {
    "page": 1,
    "per_page": 25,
    "total": 100
  }
}
Error format:
json
{
  "success": false,
  "error": {
    "code": "PERMISSION_DENIED",
    "message": "You do not have permission to access this resource",
    "details": {}
  }
}
json
{
  "success": true,
  "data": { ... },
  "meta": {
    "page": 1,
    "per_page": 25,
    "total": 100
  }
}
错误格式:
json
{
  "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:
json
{
  "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
必须记录:
  • 超级管理员的所有操作
  • 模拟用户的开始/结束
  • 权限变更
  • 租户创建/暂停
  • 数据导出
  • 认证失败尝试
  • 跨租户访问尝试(应保持为0)
审计记录格式:
json
{
  "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": {...} }
}
保留期限:
  • 安全日志:至少1年
  • 审计追踪:7年(合规要求)
  • 运营日志:90天

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)
PENDING → ACTIVE → SUSPENDED → ARCHIVED
  • PENDING:已创建,尚未激活
  • ACTIVE:正常运营
  • SUSPENDED:支付失败、违反服务条款(数据保留)
  • ARCHIVED:已删除(保留期后清除数据)

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)
备份:
  • 每日自动备份
  • 时间点恢复(30天)
  • 每季度测试恢复
  • 支持按租户恢复
加密:
  • 静态数据:AES-256
  • 传输中数据:TLS 1.3
  • 敏感字段:应用层加密(个人身份信息、支付数据)

Rate Limiting

速率限制

Per tenant: 1000 req/min
Per user: 100 req/min
Per IP: 500 req/min
Admin endpoints: 50 req/min
按租户:1000 请求/分钟
按用户:100 请求/分钟
按IP:500 请求/分钟
管理员端点:50 请求/分钟

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%
关键告警:
  • 跨租户访问尝试
  • 超级管理员从新IP登录
  • 认证失败激增(>100次/分钟)
  • 未包含tenant_id的数据库查询
  • API错误率>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
每个功能必须:
  • 所有查询包含
    tenant_id
  • 操作前验证权限
  • 特权操作生成审计日志
  • 测试跨租户隔离
  • 处理租户暂停状态
  • 文档化权限要求

Testing Requirements

测试要求

javascript
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");
  });
});
javascript
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); // 不要返回403(避免信息泄露)
  });

  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
为现有表添加tenant_id请参考
documentation/migration.md

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.
关键实施规则:
  1. 加盟商隔离: 所有查询必须包含
    franchise_id
    (超级管理员操作需审计的情况除外)
  2. 认证上下文: 从Session/JWT中提取加盟商信息(绝不使用客户端输入)
  3. Session前缀: 使用
    setSession()
    /
    getSession()
    辅助函数(命名空间隔离)
  4. 用户类型: 理解不同用户类型对franchise_id的要求(仅超级管理员可为NULL)
  5. 权限: 每一个操作前都要检查权限
  6. 审计: 记录所有特权/跨加盟商操作
  7. 超级管理员: 审计 + MFA + IP限制
  8. 测试: 必须测试跨租户隔离
  9. 监控: 对跨租户访问尝试触发告警
架构模式:
  • 三面板结构(核心概念):
    • /public/
      根目录 = 加盟商管理员工作区(非用户面板!)
    • /adminpanel/
      = 超级管理员系统
    • /memberpanel/
      = 终端用户门户
  • 多租户隔离的Session前缀系统
  • 零信任安全模型
  • 行级加盟商隔离(初始方案)
  • 带覆盖规则的基于角色的权限
  • 不可变审计追踪
SaaS Seeder模板细节:
  • Session前缀:
    saas_app_
    (每个SaaS应用自定义)
  • 密码哈希:Argon2ID + 盐(32字符) + 胡椒(64+字符)
  • 使用
    super-user-dev.php
    创建管理员用户(正确哈希)
  • Session.cookie_secure的HTTPS自动检测(本地开发环境)
  • 所有文本列使用
    utf8mb4_unicode_ci
    排序规则
参考文档:
  • ../../docs/PANEL-STRUCTURE.md
    - 完整三面板架构指南
  • ../../CLAUDE.md
    - 开发指南与常见陷阱
  • references/database-schema.md
    - 完整数据库设计、索引、分区
  • references/permission-model.md
    - RBAC实现、缓存、中间件
  • documentation/migration.md
    - 添加franchise_id、零停机迁移、回滚
注意: 多租户系统中的安全故障会影响所有加盟商。请全面测试隔离机制。