SaaS 后台的 CRUD 和权限模块看起来简单,实则是 Bug 重灾区:多租户数据隔离、RBAC 细粒度权限、前后端联调、接口版本兼容——任何一环出问题都是安全事故。本文以租户隔离和 RBAC 为核心场景,展示 Agent 如何在"增删改查"和"权限控制"之间安全地工作,包括越权测试、租户隔离验证和前后端联调的自动化闭环。

案例 002:用 Agent 维护 SaaS 后台的 CRUD 与权限模块

SaaS 后台的 CRUD 和权限模块看起来简单,实则是 Bug 重灾区:多租户数据隔离、RBAC 细粒度权限、前后端联调、接口版本兼容——任何一环出问题都是安全事故。本文以租户隔离和 RBAC 为核心场景,展示 Agent 如何在"增删改查"和"权限控制"之间安全地工作,包括越权测试、租户隔离验证和前后端联调的自动化闭环。

一、业务背景

一个面向中小企业的 SaaS CRM 系统,技术栈为 Node.js(NestJS)+ PostgreSQL + Redis + Vue 3(Element Plus)。系统服务 2000+ 租户,每个租户有独立的数据空间。

核心模块:

模块 复杂度 Agent 维护难度
客户管理 CRUD 需确保租户隔离
联系人 CRUD 标准 CRUD
角色权限管理 RBAC 规则复杂
数据看板 聚合查询需权限过滤
操作日志 只读查询

核心挑战:

  • 租户隔离:租户 A 绝不能看到租户 B 的数据。一个忘了加 tenant_id 过滤的查询就是数据泄露事故。
  • RBAC 细粒度:权限不仅控制"能不能访问",还控制"能看到哪些字段""能操作哪些状态"。
  • 前后端联调:前端权限控制和后端权限校验必须一致,否则出现"前端按钮隐藏了但 API 还能调"的漏洞。

二、系统架构

text
┌────────────────────────────────────────────────────────────┐
│                     SaaS CRM 后台                          │
│                                                            │
│  ┌──────────────────────────────────────────────────┐      │
│  │              Vue 3 前端(Element Plus)             │      │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────────────┐│      │
│  │  │ 权限指令  │  │ 路由守卫  │  │ 按钮级权限控制   ││      │
│  │  │v-permission│ │router.guard│ │ v-if="can('edit')"││      │
│  │  └──────────┘  └──────────┘  └──────────────────┘│      │
│  └──────────────────────┬───────────────────────────┘      │
│                         │ HTTP                              │
│  ┌──────────────────────▼───────────────────────────┐      │
│  │              NestJS 后端                           │      │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────────────┐│      │
│  │  │ JWT 认证  │  │ RBAC 守卫 │  │ 租户隔离中间件   ││      │
│  │  │ AuthGuard │  │RolesGuard │  │ TenantMiddleware ││      │
│  │  └──────────┘  └──────────┘  └──────────────────┘│      │
│  │                                                    │      │
│  │  ┌──────────────────────────────────────────────┐ │      │
│  │  │            业务模块(Controllers + Services)   │ │      │
│  │  │  CustomerModule | ContactModule | DashboardModule │  │
│  │  └──────────────────────────────────────────────┘ │      │
│  └──────────────────────┬───────────────────────────┘      │
│                         │                                   │
│  ┌──────────────────────▼───────────────────────────┐      │
│  │         PostgreSQL(Row-Level Security)           │      │
│  │  每个查询自动追加 WHERE tenant_id = current_tenant │      │
│  └──────────────────────────────────────────────────┘      │
└────────────────────────────────────────────────────────────┘

三、Agent 维护 CRUD 模块

3.1 标准 CRUD 上下文包

yaml
# crud-context-pack.yaml
name: "SaaS-CRUD-上下文包"
description: "Agent 维护 SaaS 后台 CRUD 模块时的标准上下文"

# 项目规范
project_rules:
  - "所有查询必须经过 TenantScope 中间件,自动追加 tenant_id 过滤"
  - "删除操作使用软删除(is_deleted = true),不用物理删除"
  - "创建和更新操作必须记录操作日志(operator_id, action, timestamp)"
  - "列表接口默认分页,page_size 不超过 100"
  - "敏感字段(手机号、邮箱)在列表接口中脱敏返回"

# Agent 可读写的文件
template_files:
  - "src/modules/{module}/entities/{entity}.entity.ts"
  - "src/modules/{module}/dto/create-{entity}.dto.ts"
  - "src/modules/{module}/dto/update-{entity}.dto.ts"
  - "src/modules/{module}/{module}.controller.ts"
  - "src/modules/{module}/{module}.service.ts"
  - "src/modules/{module}/{module}.module.ts"
  - "test/{module}/{module}.e2e-spec.ts"

# 自动生成检查
validation:
  - name: "租户隔离测试"
    command: "npm run test:e2e -- --grep 'tenant isolation'"
  - name: "权限测试"
    command: "npm run test:e2e -- --grep 'rbac'"
  - name: "CRUD 完整测试"
    command: "npm run test:e2e -- --grep '{module}'"

3.2 RBAC 权限配置示例

typescript
// src/modules/customer/customer.controller.ts
import { Controller, Get, Post, Put, Delete, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../../auth/auth.guard';
import { RolesGuard } from '../../auth/roles.guard';
import { TenantScope } from '../../tenant/tenant-scope.decorator';
import { Permission } from '../../auth/permission.decorator';

@Controller('customers')
@UseGuards(AuthGuard, RolesGuard)
@TenantScope()  // 自动注入 tenant_id 过滤
export class CustomerController {
  
  @Get()
  @Permission('customer:list')  // 需要 customer:list 权限
  async list(@Query() query: ListDto, @CurrentUser() user: User) {
    return this.customerService.findAll(query, user.tenantId);
  }

  @Post()
  @Permission('customer:create')
  async create(@Body() dto: CreateCustomerDto, @CurrentUser() user: User) {
    return this.customerService.create(dto, user.tenantId, user.id);
  }

  @Put(':id')
  @Permission('customer:update')
  async update(@Param('id') id: string, @Body() dto: UpdateCustomerDto) {
    return this.customerService.update(id, dto);
  }

  @Delete(':id')
  @Permission('customer:delete')
  async remove(@Param('id') id: string) {
    return this.customerService.softDelete(id);
  }
}

3.3 权限矩阵

yaml
# rbac-matrix.yaml
roles:
  admin:
    description: "租户管理员,拥有所有权限"
    permissions:
      - "customer:*"
      - "contact:*"
      - "dashboard:*"
      - "role:manage"
      - "log:view"

  manager:
    description: "部门经理,可管理下属数据"
    permissions:
      - "customer:list"
      - "customer:create"
      - "customer:update"
      - "customer:delete:own"      # 只能删除自己创建的
      - "contact:*"
      - "dashboard:view"

  sales:
    description: "销售人员,只能操作自己的客户"
    permissions:
      - "customer:list:own"        # 只能看自己的客户
      - "customer:create"
      - "customer:update:own"
      - "contact:list:own"
      - "contact:create"
      - "contact:update:own"

  viewer:
    description: "只读角色"
    permissions:
      - "customer:list:own"
      - "contact:list:own"
      - "dashboard:view"

# 字段级权限
field_permissions:
  customer:
    phone:
      visible_to: ["admin", "manager", "sales:own"]
      mask_for: ["viewer"]          # viewer 看到 138****1234
    revenue:
      visible_to: ["admin", "manager"]
      hidden_for: ["sales", "viewer"]  # 完全不返回字段

四、越权测试与租户隔离验证

4.1 越权测试模板

typescript
// test/rbac/authorization.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';

describe('越权测试 (Authorization)', () => {
  let app: INestApplication;
  let adminToken: string;
  let salesToken: string;
  let viewerToken: string;

  beforeAll(async () => {
    // 初始化测试应用
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    await app.init();

    // 获取不同角色的 Token
    adminToken = await getTestToken('admin', 'tenant-a');
    salesToken = await getTestToken('sales', 'tenant-a');
    viewerToken = await getTestToken('viewer', 'tenant-a');
  });

  describe('角色越权', () => {
    it('sales 角色不能删除客户', async () => {
      const customerId = await createTestCustomer(salesToken);
      
      await request(app.getHttpServer())
        .delete(`/customers/${customerId}`)
        .set('Authorization', `Bearer ${salesToken}`)
        .expect(403);  // 权限不足
    });

    it('viewer 角色不能创建客户', async () => {
      await request(app.getHttpServer())
        .post('/customers')
        .set('Authorization', `Bearer ${viewerToken}`)
        .send({ name: '测试客户', phone: '13800138000' })
        .expect(403);
    });

    it('sales 角色只能更新自己的客户', async () => {
      // admin 创建的客户
      const adminCustomerId = await createTestCustomer(adminToken);
      
      // sales 尝试更新 admin 的客户 → 应该失败
      await request(app.getHttpServer())
        .put(`/customers/${adminCustomerId}`)
        .set('Authorization', `Bearer ${salesToken}`)
        .send({ name: '被篡改' })
        .expect(404);  // 对其他租户/用户的数据返回 404(而不是 403,避免信息泄露)
    });
  });

  describe('租户隔离', () => {
    let tenantBToken: string;
    let tenantBCustomerId: string;

    beforeAll(async () => {
      tenantBToken = await getTestToken('admin', 'tenant-b');
      tenantBCustomerId = await createTestCustomer(tenantBToken);
    });

    it('租户 A 的 admin 不能看到租户 B 的客户', async () => {
      await request(app.getHttpServer())
        .get(`/customers/${tenantBCustomerId}`)
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(404);  // 跨租户数据不可见
    });

    it('租户 A 的列表接口不包含租户 B 的数据', async () => {
      const res = await request(app.getHttpServer())
        .get('/customers')
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);

      const ids = res.body.data.map(c => c.id);
      expect(ids).not.toContain(tenantBCustomerId);
    });

    it('直接构造 SQL 也无法跨租户查询(RLS 验证)', async () => {
      // 验证 PostgreSQL Row-Level Security 是否生效
      const result = await rawQuery(
        `SELECT * FROM customers WHERE id = '${tenantBCustomerId}'`,
        { tenantId: 'tenant-a' }  // 模拟租户 A 的数据库连接
      );
      expect(result.rows.length).toBe(0);  // RLS 自动过滤
    });
  });
});

4.2 前后端权限一致性检查

python
# scripts/check_permission_consistency.py
"""
检查前端权限控制和后端权限配置是否一致。
不一致的情况包括:
1. 后端有权限点但前端没有对应控制(前端暴露了按钮)
2. 前端有权限控制但后端没有校验(前端隐藏了但 API 能调)
"""
import re
import json
from pathlib import Path

def extract_backend_permissions():
    """从后端代码提取所有 @Permission 装饰器"""
    permissions = set()
    for f in Path("src/modules").rglob("*.controller.ts"):
        content = f.read_text()
        matches = re.findall(r"@Permission\(['\"](.+?)['\"]\)", content)
        permissions.update(matches)
    return permissions

def extract_frontend_permissions():
    """从前端代码提取所有权限控制点"""
    permissions = set()
    # v-permission 指令
    for f in Path("frontend/src").rglob("*.vue"):
        content = f.read_text()
        matches = re.findall(r"v-permission=['\"](.+?)['\"]", content)
        permissions.update(matches)
    # can('xxx') 函数
    for f in Path("frontend/src").rglob("*.{vue,ts}"):
        content = f.read_text()
        matches = re.findall(r"can\(['\"](.+?)['\"]\)", content)
        permissions.update(matches)
    return permissions

def check_consistency():
    backend = extract_backend_permissions()
    frontend = extract_frontend_permissions()
    
    # 后端有但前端没有 → 前端暴露了按钮
    backend_only = backend - frontend
    if backend_only:
        print("⚠️  后端有权限点但前端未控制(可能暴露按钮):")
        for p in sorted(backend_only):
            print(f"   - {p}")
    
    # 前端有但后端没有 → 前端隐藏了但 API 能调
    frontend_only = frontend - backend
    if frontend_only:
        print("⚠️  前端有权限控制但后端未校验(API 可能未保护):")
        for p in sorted(frontend_only):
            print(f"   - {p}")
    
    if not backend_only and not frontend_only:
        print("✅ 前后端权限配置一致")
    
    return {
        "backend_only": sorted(backend_only),
        "frontend_only": sorted(frontend_only),
        "consistent": len(backend_only) == 0 and len(frontend_only) == 0,
    }

if __name__ == "__main__":
    result = check_consistency()
    print(json.dumps(result, indent=2))

五、Agent 修复 CRUD 的典型流程

5.1 场景:增加客户标签字段

yaml
task: "为客户模块增加 tags 字段(多选标签)"

agent_context:
  allowed_files:
    - src/modules/customer/entities/customer.entity.ts
    - src/modules/customer/dto/*.dto.ts
    - src/modules/customer/customer.service.ts
    - src/modules/customer/customer.controller.ts
    - test/customer/customer.e2e-spec.ts
    - frontend/src/views/customer/*.vue
    - frontend/src/api/customer.ts
  
  business_rules:
    - "tags 是字符串数组,最多 10 个标签"
    - "标签值只能是预定义列表中的值(tag_enum)"
    - "列表接口中 tags 字段全量返回(不脱敏)"
    - "创建和更新时验证标签值是否在枚举中"
  
  validation:
    - "npm run test:e2e -- --grep 'customer'"
    - "python scripts/check_permission_consistency.py"
    - "npm run lint"

Agent 修改步骤:

  1. customer.entity.ts:增加 tags: string[] 字段
  2. create-customer.dto.tsupdate-customer.dto.ts:增加 @IsArray() + @IsEnum() 验证
  3. customer.service.ts:创建和更新时验证标签枚举
  4. customer.e2e-spec.ts:增加 tags 相关测试
  5. 前端 customer.vue:增加标签选择组件
  6. 前端 customer.ts:API 类型定义增加 tags

5.2 场景:修复权限漏洞

yaml
task: "安全审计发现:/customers/export 接口没有权限校验"

agent_context:
  issue: "SEC-2024-015"
  severity: "HIGH"
  
  allowed_files:
    - src/modules/customer/customer.controller.ts
  
  business_rules:
    - "数据导出需要 customer:export 权限"
    - "导出操作必须记录审计日志"
    - "单次导出不超过 10000 条"
  
  constraints:
    approval_required: true  # 安全修复需要审批
    must_add_test: true
  
  validation:
    - "npm run test:e2e -- --grep 'export permission'"
    - "semgrep --config=auto src/"

六、真实经验与踩坑

6.1 软删除的查询陷阱

场景:Agent 给客户列表接口增加了"按标签筛选"功能,修改了查询条件。 问题:Agent 直接在 TypeORM 的 QueryBuilder 中加条件,覆盖了 TenantScope 中间件自动注入的 WHERE tenant_id = ? 条件。结果列表返回了所有租户的数据。 解决方案:项目中统一使用 BaseService.findAll() 方法(内部已包含租户过滤),不允许在 Controller 中直接写 QueryBuilder。在 project_rules 中明确:"所有查询必须通过 BaseService 方法,不得直接构造查询条件"。

6.2 RBAC 权限点命名不一致

场景:后端定义权限点为 customer:list,前端用的是 customer_list(下划线 vs 冒号)。 问题:权限一致性检查脚本报了大量"不一致",但其实两边都是对的,只是命名风格不同。 解决方案:统一权限点命名规范为 module:action(冒号分隔),写一个 Lint 规则自动检测不一致的命名。同时在 CLAUDE.md 中记录这个规范,Agent 生成代码时自动遵循。

6.3 前端权限控制不能替代后端校验

场景:Agent 修复一个"按钮不该显示"的问题时,只在前端增加了 v-if="can('customer:delete')" 条件。 问题:这只是前端隐藏了按钮,但 API 仍然没有权限校验。任何人知道 API 路径都能直接调用。 解决方案:在 Agent 的 Prompt 中明确:"前端权限控制是 UX 优化,不是安全措施。任何涉及权限的修复,后端必须同步增加 @Permission() 装饰器校验。" 同时把前后端一致性检查加入 CI。

七、参数说明表

参数 类型 默认值 说明
module_name string 必填 模块名(customer / contact / ...)
allowed_files list 必填 Agent 可读写的文件范围
rbac_action string "list,create,update,delete" 操作类型
tenant_isolation bool true 是否启用租户隔离
field_masking bool true 是否启用敏感字段脱敏
soft_delete bool true 是否使用软删除
audit_log bool true 是否记录操作日志
permission_prefix string "module:action" 权限点命名格式
max_page_size int 100 列表接口最大分页
export_limit int 10000 单次导出最大条数
check_consistency bool true 是否运行前后端权限一致性检查

八、落地检查清单

  • 所有查询经过 TenantScope 中间件,租户隔离生效
  • 所有 API 有 @Permission() 装饰器,无裸露接口
  • 越权测试覆盖:角色越权 + 租户越权 + 数据越权
  • 软删除逻辑正确,查询自动过滤 is_deleted = true
  • 敏感字段在列表接口中脱敏(手机号、邮箱)
  • 前后端权限一致性检查脚本已集成到 CI
  • 操作日志记录了所有写操作(创建、更新、删除)
  • RBAC 权限点命名统一为 module:action 格式
  • 数据导出接口有权限校验和数量限制
  • Agent 修改后回归测试全部通过

九、系列导航

上一篇:案例 001:用 Agent 改造电商订单系统的缺陷修复流程 下一篇:案例 003:用 Agent 生成和维护 OpenAPI 文档