一位同事请求我帮忙排查他们使用的SaaS项目管理工具中出现的权限问题。用户们能够看到一些他们根本没有创建的资源。

我查看了查询日志,本以为会发现一些细微的问题,但结果并非如此。列表接口根本没有使用tenant_id进行过滤——数据库中的每个租户都能查看其他所有租户的项目信息。该应用程序从未抛出任何错误,只是直接返回了所有可用的数据。

如果缺少这种过滤机制,系统不会报错,而是会无声无息地返回错误的数据,而且日志中也不会有任何提示。我见过这种情况在生产环境中持续存在数周,直到有人通过支持工单才注意到这个问题,并开始查看查询日志。

当这类问题最终被发现时,谁首先发现它其实非常重要。如果是客户自己发现的,那当然很糟糕;但如果是在SOC 2审核过程中被合规审计人员发现的,那就另当别论了。

从一开始就建立完善的隔离机制需要花费大量的时间和精力。在我帮助一个团队在合规审核后进行相应的改造时,所花的时间和产生的客户沟通工作量远远超出了大家的预期。

我们使用的开发技术栈是Node.js和PostgreSQL。CRUD操作相对简单,但实现租户隔离、基于角色的访问控制以及审计日志功能则需要更加细致的设计。这些功能的执行位置也非常关键。我在所有路由处理函数之前添加了相应的中间件逻辑,这样那些不会直接调用隔离机制的处理函数就绝对不可能意外地跳过这些检查步骤。

先决条件

  • Node.js 18及以上版本

  • PostgreSQL 14及以上版本

  • 对Express.js和JWT有基本的了解

我们要构建什么

我们将构建一个多租户版的Express REST API,该API会实现以下功能:

  1. 租户隔离:所有数据库查询都会根据验证过的JWT中的tenant_id来限定查询范围。客户端无法改变查询针对的是哪个租户。

  2. 基于角色的访问控制:系统定义了四种角色,每种角色都有相应的权限等级(SuperAdmin的权限最高,Viewer的权限最低)。中间件会在处理函数执行之前检查用户的角色等级。

  3. 审计日志功能:任何写入操作或涉及敏感数据的读取操作都会在审计表中添加相应记录,而且应用程序之后无法修改这些记录。数据库会直接确保这一点的遵守。如果应用程序中的错误代码试图更新审计记录,数据库会拒绝执行该操作。仅依靠应用程序层面的控制是无法保证这一点的。

  4. 针对每个租户的速率限制:通过Redis来记录每个租户的请求次数,并根据租户身份进行区分。我曾经见过,由于基于IP地址的速率限制机制存在缺陷,导致50名用户通过同一个企业代理访问系统时,整个企业的部署工作受到了严重影响。

  5. 租户隔离测试:我们编写了专门的测试用例,用于验证跨租户数据是否会出现泄露现象。将这类测试集成到持续集成流程中,可以在产品发布之前及时发现并修复相关的隔离机制问题。

目录

  1. 多租户架构的工作原理

  2. 架构概述

  3. 数据库模式设计

  4. 项目配置流程

  5. 多租户环境下的JWT设计

  6. 认证与基于角色的访问控制中间件

  7. 保障租户数据安全的存储层设计

  8. 审计日志服务

  9. 针对每个租户的速率限制机制

  10. 路由功能的实现过程

  11. 租户隔离功能的测试方法

  12. 故障排除指南

  13. 总结

多租户模式的运作原理

本教程采用具有行级隔离功能的共享数据库:在每个表中都添加一个tenant_id列,并在每次查询时使用该字段进行过滤。所有用户的数据都存储在同一数据库中,而应用程序则负责决定每位租户能够查看哪些数据。

还存在另外两种实现方式:分别为“为每个租户单独创建数据库模式”和“为每个租户单独创建数据库”。我曾与一些采用第二种方式的团队交流过,他们发现,在开发迁移工具上花费的时间反而比开发核心产品还要多。而采用第一种方式虽然能提供更强的数据隔离保障,但每当有新客户注册时,连接池的规模也会随之扩大。

这两种方案都不具备良好的扩展性。实际上,行级隔离功能的扩展能力超出了大多数团队的预期。我认识的一些团队在使用了这种技术多年后,最终还是因为特定的监管要求才改用其他方案,而不是因为这种技术本身不再适用。

在这种设计中,有一项规定是绝对不能被违反的:tenant_id字段必须始终来源于经过验证的JWT令牌。它不能来自请求体,也不能来自URL地址。用户可以自行控制向这些位置输入什么信息,但他们无法控制服务器会在JWT令牌中嵌入哪些数据。

架构概述

HTTP请求
     │
     ▼
┌─────────────────────────────────────────┐
│           Express中间件栈                 │
│                                         │
│  1. 流量限制器(针对每个tenant_id)      │
│  2. 身份验证中间件(验证JWT令牌)    │
│     └─► 提取信息:userId、tenantId、role、permissions │
│  3. 角色基访问控制中间件            │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           路由处理程序                    │
│                                         │
│  1. 调用数据存储层(执行租户隔离查询)   │
│  2. 调用审计服务                │
│  3. 返回响应                        │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│  项目              │        │ 审计日志      │
│  表格            │        │    表格        │
│(+属于特定租户)     │        │(仅允许追加数据)|
└─────────┘        └────────────┘

流量限制、身份验证以及角色基访问控制这些功能都会在请求被任何处理程序处理之前执行。所有写入操作都会先通过审计服务进行审核。数据存储层会从req.user中获取tenant_id信息,而处理程序本身从未直接访问与租户相关的数据,因此不存在绕过这一机制的途径。

数据库架构设计

-- 租户表
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- '免费'、'专业版'、'企业版'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 用户表
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- '超级管理员'、'租户管理员'、'普通成员'、'查看者'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- 项目表(示例结构,可根据实际需求修改)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- 审计日志表(仅允许追加数据,禁止修改或删除记录)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- 操作发生时的用户角色
  action      TEXT NOT NULL,        -- 操作类型,如'创建'、'更新'、'删除'、'查看'
  resource    TEXT NOT NULL,        -- 相关资源名称
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs creado_at DESC;

-- 在数据库层面保护审计日志数据
-- 使用DO块确保该代码在Docker环境中能够安全执行,因为在那里app_user是超级用户
DO $$
BEGIN
  IF current_user <> 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;

REVOKE这个命令非常重要。应用程序中难免会出现错误,如果你的代码库中的某些部分不小心尝试更新审计记录,你肯定希望数据库能够直接拒绝这样的操作,而不是默默地执行这些指令。

项目配置

mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest

使用Docker启动PostgreSQL和Redis

可以跳过在本地进行安装的步骤。只需在项目根目录下创建一个docker-compose.yml文件,就可以同时启动PostgreSQL和Redis:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

当容器首次启动时,schema.sql文件会自动被执行,因此无需使用psql命令。

docker compose up -d

在项目根目录下创建一个.env文件,并设置以下内容:

DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development

请不要手动输入JWT_SECRET,可以通过以下命令生成一个随机密钥:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

文件结构如下:

nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT验证与租户信息提取
│   │   ├── rbac.js          # 角色权限控制
│   │   └── rateLimiter.js   # 租户级别的速率限制功能
│   ├── services/
│   │   └── auditService.js  # 只能写入的审计日志记录器
│   ├── repositories/
│   │   └── projectRepo.js   # 适用于不同租户的数据库查询逻辑
│   ├── routes/
│   │   └── projects.js      # 路由处理函数
│   └── utils/
│       └── token.js         # JWT令牌生成模块
├── db/
│   ├── index.js             # PostgreSQL连接池管理
│   └── redis.js             # Redis客户端库
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js

样板文件

本教程没有详细介绍四份文件,但测试脚本的运行需要这些文件的所有内容。

// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) => {
  console.error('Redis error:', err.message);
});

module.exports = { redisClient };
// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

const app = express();
app.use(express.json());

app.use('/api/projects', projectsRouter);

// 全局错误处理函数——必须包含4个参数才能被Express识别
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: '内部服务器错误' });
});

module.exports = app;
// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`服务器正在端口 ${PORT} 上运行`);
});

之所以引入bcryptjs,是为了实现带有正确密码加密功能的登录功能。这部分内容在这里没有详细说明,但GitHub仓库中有一个可运行的示例,路径为/api/auth/login

多租户环境下的JWT设计

tenantIdrole这两个字段都会被包含在JWT的有效载荷中。后续的所有处理流程都会读取这些字段的值。如果这些值有误,整个系统就会出现故障。

// 示例JWT有效载荷
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}

各角色的权限等级如下:

  • SuperAdmin:仅限于内部团队,具有跨租户访问权限

  • TenantAdmin:在其所属租户范围内拥有全部权限

  • Member:只能在其所属租户范围内进行读写操作

  • Viewer:仅具有在其所属租户范围内的读取权限

如何生成令牌(用于测试以及身份验证功能):

// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.envJWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };

身份验证与RBAC中间件

身份验证中间件主要完成两项工作:验证JWT签名,并将租户相关信息提取到req.user对象中。

后半部分功能是整个系统正常运行的关键。所有后续请求都会读取req.user.tenantId的值。客户端无法自行设置这个值;它们只需发送服务器签发的令牌,而服务器会根据该令牌中的信息来填充req.user对象。

// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headersauthorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: '缺少或格式错误的Authorization头信息' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.envJWT_SECRET);

    // tenantId始终来自经过验证的令牌,而不会从req.body或req.params中获取
    req.user = {
      userId: decoded.userId,
      tenantId: decoded.tenantId,
      email: decoded.email,
      role: decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: '无效或过期的令牌' });
  }
}

module.exports = { authMiddleware };

RBAC中间件在设计上是与认证机制分开的。认证机制会应用于所有路由;而角色验证则仅会在需要满足最低权限要求的情况下才会被执行。你可以通过将允许的角色传递给`requireRole()`函数,该函数会将用户的权限等级与预定义的角色层级结构进行对比。例如,如果某个“查看者”试图删除某些数据,那么在处理程序被调用之前,系统就会立即返回403错误码。

// src/middleware/rbac.js
const ROLE_HIERARCHY = {
SuperAdmin: 4,
TenantAdmin: 3,
Member: 2,
Viewer: 1,
};

// requireRole('TenantAdmin') — 用户必须具有TenantAdmin或更高的权限等级
function requireRole(...roles) {
return (req, res, next) => {
const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
const requiredLevel = Math.min(...roles.map(r => ROLE_HIERARCHY[r] ?? 999));

if (userLevel < requiredLevel) { return res.status(403).json({ error: '权限不足', required: roles, current: req.user?.role, }); } next(); }; } module.exports = { requireRole };

租户安全存储层

这里的每个功能都会将`tenantId`作为必填参数,该参数是由处理程序从`req.user`中提取出来的。如果不提供租户范围信息,就根本无法调用这些功能。我见过一些团队试图通过URL参数来实现这种隔离机制(例如`GET /api/projects?tenantId=xyz`),但他们其实并没有真正达到隔离的效果——任何客户端都可以随意在查询字符串中添加所需的参数。

// src/repositories/projectRepo.js
const { pool } = require('../../db');

// 列出某个租户的所有项目 —— tenantId始终来自JWT令牌
async function listProjects(tenantId) {
const result = await pool.query(
`SELECT id, name, description, created_by, created_at
FROM projects
WHERE tenant_id = $1
ORDER BY created_at DESC`,
[tenantId]
);
return result.rows;
}

// 获取单个项目 —— 如果该项目属于其他租户,则返回null
// 注意:故意返回404错误码,而不是403 —— 这样就可以避免泄露该资源确实存在的信息
async function getProject(id, tenantId) {
const result = await pool.query(
`SELECT id, name, description, created_by, created_at
FROM projects
WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
return result.rows[0] || null;
}

// 创建一个新的项目
async function createProject({ tenantId, name, description, createdBy }) {
const result = await pool.query(
`INSERT INTO projects (tenant_id, name, description, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[tenantId, name, description, creadoBy]
);
return result.rows[0];
}

// 更新项目信息
async function updateProject(id, tenantId, updates) {
const result = await pool.query(
`UPDATE projects
SET name = COALESCE($3, name),
description = COALESCE($4, description),
updated_at = NOW()
WHERE id = $1 AND tenant_id = $2
RETURNING *`,
[id, tenantId, updates.name, updates.description]
);
return result.rows[0] || null;
}

// 删除项目
async function deleteProject(id, tenantId) {
const result = await pool.query(
`DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
[id, tenantId]
);
return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };

请注意,当租户A尝试获取租户B的资源时,getProject函数会如何运作。该查询是使用租户A的tenantId来执行的。条件id = $1 AND tenant_id = $2并不会匹配到任何结果,因此返回值会是null,而处理程序也会发送一个404响应码。注意这里使用的是404,而不是403。因为403表示资源确实存在,但调用者无法访问它,而这种信息本就不应被调用者知晓。

审计日志服务


// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // 执行操作时的用户角色——由于角色可能会发生变化,因此日志中应记录操作时的角色
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // 资源对应的表名
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // 审计日志功能必须不会阻塞或导致用户请求失败
  pool.query(query, values).catch((err) => {
    console.error('[AuditService] 记录日志失败:', err.message);
  });
}

module.exports = { log };

在记录操作信息时,捕获userRole这一参数其实比看起来要重要得多。因为用户的角色是在操作发生后才会发生变化的——比如有人被降职,或者某项权限被撤销。如果日志中只记录了用户ID,那么就无法了解他们在执行该操作时拥有哪些权限。因此,应该在操作发生时记录用户的角色,这样才能确保信息的准确性。

按租户划分的速率限制机制

在SaaS环境中,基于IP地址的速率限制机制会遇到问题。例如,一家企业客户可能会让数百名用户共享同一个NAT网关,从而使用同一个IP地址。而如果其中某个占用大量资源的租户开始频繁请求资源,那么就会影响到其他所有使用该IP地址的租户。

我曾经看到一些团队在遇到这种情况时吃了苦头:当某个企业客户突然大量发送API请求时,其他租户就会莫名其妙地收到429错误响应。因此,我们应该将速率限制的范围限定在tenant_id上。


// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// 根据不同的套餐设置速率限制规则
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 每15分钟允许100次请求
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 每15分钟允许500次请求
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 每15分钟允许2000次请求
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // 键值对的键应该是经过验证的JWT中的tenant_id,而不是IP地址
    keyGenerator: (req) => `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) => redisClient.call(...args),
    }),
    handler: (req, res) => {
      res.status(429).json({
        error: '请求次数过多',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// 所有API路由的默认速率限制器
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };

构建路由系统

在这里,所有组件都会相互连接。认证机制和速率限制会应用于整个路由器;而角色验证则会在具体的路由处理过程中进行。每次写入操作完成后,审计日志都会被记录下来。tenantId这个值永远不会来自请求体或URL中,它的唯一来源是req.user.tenantId——这一值是由认证中间件根据经过验证的令牌来确定的,因此根本不存在绕过这一验证机制的方法。

对于Express 4来说,有一个实际需要注意的地方:它不会自动捕获异步错误。每个路由处理函数都必须将自己的逻辑包裹在try/catch语句中,并在遇到错误时使用next(err)将错误传递给后续的处理函数。如果不这样做,未处理的Promise拒绝异常将会导致服务器返回一个没有任何日志记录或审计痕迹的500错误页面。路由器顶部添加的注释就是为了提醒大家注意这一设计原则。


// 文件路径:src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// 所有路由都需要进行认证
router.use(authMiddleware);
router.use(defaultLimer);

// Express 4不会自动捕获异步错误。因此,每个处理函数都必须使用try/catch语句来处理异步操作,并在遇到错误时调用next(err)。
// 如果不这样做,未处理的Promise拒绝异常将会导致服务器返回一个500错误页面,而这个页面既没有有用的错误信息,也不会被记录到审计日志中。

// GET /api/projects — 显示所有项目(仅限具有“查看者”及以上权限的用户)
router.get('/', async (req, res, next) => {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — 显示指定项目(仅限具有“查看者”及以上权限的用户)
router.get('/:id', async (req, res, next) => {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: '项目未找到' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — 创建项目(仅限“成员”及以上权限的用户)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) => {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: '名称是必填项' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — 更新项目(仅限“成员”及以上权限的用户)
router.put(':id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) => {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: '项目未找到' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — 删除项目(仅限“TenantAdmin”及以上权限的用户)
router.delete(':id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) => {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: '项目未找到' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;

测试租户隔离功能

如果跳过这些隔离测试,那么你就会在完全不知情的情况下继续运行应用程序。虽然程序能够正常运行,也不会出现任何错误,但两个客户实际上会读取彼此的数据。

我曾经见过这种情况在生产环境中存在数月而未被发现,因为实际上并没有什么问题发生,只是错误数据悄然出现了而已。只有在每次提交代码请求时都进行自动化测试,才能及早发现问题。

// tests/tenantIsolation.test.js
require('dotenv').config();  // 这行代码必须放在最前面——用于加载DATABASE_URL和REDIS_URL
const request = require('supertest');
const app = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool } = require('../db');
const { redisClient } = require('../db/redis');

// 测试环境配置:两个相互隔离的租户,其中项目位于租户B中
async function seedTestData() {
  // 清除之前的测试数据,以避免因唯一性约束规则导致的错误
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`;

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // 用户B拥有租户B中的项目——这满足了“created_by”外键约束条件
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () => {
  let data;

  beforeAll(async () => {
    data = await seedTestData();
  });

  afterAll(async () => {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // 关闭Redis连接,以便Jest能够正常退出
  });

  test('租户A的用户无法访问租户B的项目', async () => {
    const token = generateToken({
      userId: data.userA,
      tenantId: data.tenantA,   // ← 租户A的token
      email: 'usera@a.com',
      role: 'Member',
    });

    const res = await request(app)
      .get `/api/projects/${data.projectB}`)  // ← 租户B的项目ID
      .set('Authorization', `Bearer ${token}`);

    // 回应状态码应该是404,而不是200或403
    expect(res.status).toBe(404);
  });

  test('租户A的用户无法查看租户B的项目列表', async () => {
    const token = generateToken({
      userId: data.userA,
      tenantId: data.tenantA,
      email: 'usera@a.com',
      role: 'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // 响应结果中不应该包含租户B的项目
    const names = res.body.map(p => p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('具有“查看者”权限的用户无法删除项目', async () => {
    const token = generateToken({
      userId: data.userA,
      tenantId: data.tenantA,
      email: 'usera@a.com',
      role: 'Viewer',         // ← “查看者”角色
    });

    const res = await request(app)
      .delete `/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});

运行测试:

npm test

共有三项测试,通过这些测试可以确认三个边界条件是否正确。需要将这些测试集成到持续集成系统中,以便在每次提交拉取请求时都能自动运行这些测试。如果将来对代码进行重构,导致tenant_id过滤条件被删除,这种问题也能在代码发布之前被发现。

故障排除

租户A能够看到租户B的数据

有些查询语句中缺少了AND tenant_id = $N这个条件。需要检查所有仓库文件中的SELECT语句,几乎所有的问题都是出在这里。

在本应可访问的路由上出现“403 Forbidden”错误

JWT令牌中指定的角色字符串与requireRole()函数所检查的内容不匹配。请仔细核对令牌有效载荷中的角色字符串。'member''Member'是不同的。可以将自己的令牌粘贴到jwt.io网站上,直接查看其中指定的角色信息。

速率限制机制无法正常工作

很可能是因为Redis没有连接成功。在服务器启动之前,请记录redisClient.status的值。如果该值不是ready,说明速率限制机制已经切换到了内存存储模式,此时重新启动服务器会导致所有计数器被重置,租户级别的限流功能也会失效。

审计日志表的大小正在迅速增加

这是正常现象。审计日志表的大小本来就会逐渐增大。当日志表变得过大时,可以将超过一年前的数据转移到S3或Azure Blob存储服务中,继续使用较小的热备份表进行查询。大多数合规性要求也规定至少需要保留12个月的日志记录。不过千万不要直接从审计日志表中删除数据。

调用jwt.verify时会出现JsonWebTokenError: invalid signature错误

用于签名令牌的密钥与你在验证代码中使用的JWT_SECRET值不匹配。这种情况通常发生在在不同环境之间切换时,或者当其他服务中的.env文件包含不同的密钥值时。所有调用jwt.verify的功能模块都必须使用完全相同的密钥,请确保不要重新输入这个密钥。

总结

你构建的这个系统具备以下特点:仓库中实现了行级数据隔离,在处理请求之前会进行角色验证,审计日志表被设置为应用程序无法直接访问的状态,并且为每个租户设置了独立的速率限制机制。这就是整个系统的核心功能。

我发现,测试环节最容易被忽视。开发团队通常会完成数据隔离功能的实现并将其部署到生产环境中,但却很少编写能够真正证明跨租户数据不会泄露的测试代码。后来,在六个月后对某些查询语句进行重构时,tenant_id过滤条件可能就被悄悄地删除了,而持续集成系统却能及时发现这种问题,人工进行的代码审查往往无法发现这类错误。

如果你的产品规模逐渐扩大,最终可能需要对每个租户的数据结构进行单独设计。但在初期阶段,行级数据隔离机制已经能够满足大多数应用场景的需求,而且其运行开销也远低于其他方案。

<完整的工作代码可以在GitHub上找到:nodejs-multitenant-saas-api

Comments are closed.