案例 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 还能调"的漏洞。
二、系统架构
┌────────────────────────────────────────────────────────────┐
│ 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 上下文包
# 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 权限配置示例
// 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 权限矩阵
# 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 越权测试模板
// 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 前后端权限一致性检查
# 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 场景:增加客户标签字段
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 修改步骤:
customer.entity.ts:增加tags: string[]字段create-customer.dto.ts和update-customer.dto.ts:增加@IsArray()+@IsEnum()验证customer.service.ts:创建和更新时验证标签枚举customer.e2e-spec.ts:增加 tags 相关测试- 前端
customer.vue:增加标签选择组件 - 前端
customer.ts:API 类型定义增加 tags
5.2 场景:修复权限漏洞
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 文档