# 安全纵深防御设计规格 > 日期: 2026-04-26 | 状态: draft | 主题: 数据库 RLS + 行级权限 + session_key 分布式化 + 审计增强 ## 1. 背景 HMS 平台已具备良好的安全基础:JWT 认证、RBAC 权限、Argon2 密码哈希、PII 字段加密、API 限流、CORS 配置。但作为医疗 SaaS 平台,需要纵深防御(defense in depth)确保即使应用层出现漏洞,数据安全仍有保障。 本规格聚焦 6 项安全增强,按医疗合规影响排序。 ## 2. 问题分析 ### 2.1 安全增强项清单 | 编号 | 问题 | 医疗合规影响 | 实施复杂度 | 影响范围 | |------|------|-------------|-----------|----------| | S-1 | PostgreSQL RLS 安全网 | **高** — 跨租户数据泄漏是医疗数据合规红线 | 中 | 所有表 + migration + 中间件 | | S-2 | 行级数据范围未实现 | **高** — 医生只能看本科室患者是基本合规要求 | 中 | rbac + 所有 health handler | | S-3 | 微信 session_key 内存 HashMap | **中** — 多实例部署失效导致登录中断 | 低 | wechat_service.rs | | S-4 | 小程序 openid 明文存储 | **中** — 本地存储泄露可关联用户身份 | 低 | miniprogram storage | | S-5 | 审计日志完整性 | **高** — 医疗合规要求不可篡改的操作审计 | 中 | audit 模块 + migration | | S-6 | 健康检查端点不验证依赖 | **低** — 运维可靠性,非合规要求 | 低 | handlers/health.rs | ### 2.2 各项详细分析 **S-1 PostgreSQL RLS 安全网** 当前多租户完全依赖应用层 `tenant_id` 过滤。代码中每个 `Entity::find()` 都手动添加 `.filter(Column::TenantId.eq(tenant_id))`,但如果有 handler 遗漏(如新增 handler 忘记加过滤),则跨租户数据泄漏。PostgreSQL Row Level Security (RLS) 可作为数据库级安全网。 **S-2 行级数据范围** `role_permissions` 表已有 `data_scope` 字段(m000036 迁移,值: all/self/department/department_tree),`TenantContext` 已有 `department_ids` 字段,但 `require_permission()` 函数未使用 data_scope 做部门级过滤。医生 A 只能看到本科室患者,这个需求目前未实现。 **S-3 session_key 内存 HashMap** `wechat_service.rs` 第 31-34 行使用 `LazyLock>>` 缓存 session_key。单实例可用,但多实例部署时: - 实例 A 缓存了 session_key - 用户请求被路由到实例 B → bind_phone 找不到 session_key → 登录失败 **S-4 小程序 openid 明文存储** 小程序端使用 `Taro.setStorageSync('openid', openid)` 明文存储。手机丢失或越狱场景下,攻击者可直接获取 openid 关联用户身份。 **S-5 审计日志完整性** 当前 `audit_logs` 表结构合理(包含 tenant_id, user_id, action, resource_type, old_value, new_value, ip_address),但存在以下不足: - erp-health 模块(34 实体)未记录审计日志,仅 erp-auth/erp-workflow/erp-message 有审计 - 缺少审计日志防篡改机制(无签名/哈希链) - 无审计日志归档和保留策略 **S-6 健康检查端点** `handlers/health.rs` 的 `/health` 端点仅返回内存中的模块列表,不验证 DB/Redis 连通性。在 DB 不可用时仍返回 `status: "ok"`,K8s/Docker 健康检查无法探测到故障。 ## 3. 解决方案 ### 3.1 S-1: PostgreSQL RLS 安全网 **实施步骤**: 1. 创建迁移,为所有含 `tenant_id` 的表启用 RLS: ```sql -- 以 patient 表为例 ALTER TABLE patient ENABLE ROW LEVEL SECURITY; -- 创建策略:应用层连接使用 tenant_context 变量过滤 CREATE POLICY tenant_isolation ON patient USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- 超级用户和 migration 角色绕过 RLS CREATE POLICY tenant_bypass ON patient USING (current_user IN ('erp_admin', 'erp_migration')); ``` 2. 中间件设置 `current_setting`: 在 Axum tenant 中间件中,每个请求开始时执行: ```sql SET LOCAL app.current_tenant_id = ''; ``` 使用 `SET LOCAL` 确保事务结束后自动重置。 3. 为所有 30+ 基础表和 34 健康表批量启用 RLS。 **迁移策略**: - 新建单个迁移文件 `m000073_enable_rls_all_tables` - 对每张表执行 `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` - 创建数据库角色 `erp_app`(非超级用户)供应用连接使用 - 现有连接字符串切换到 `erp_app` 角色 **注意事项**: - 需要在事务中设置 `SET LOCAL`,确保 SeaORM 的查询在事务内执行 - 性能影响:PG RLS 使用索引过滤,`tenant_id` 已有索引,影响 < 5% - 迁移连接需要使用超级用户角色(绕过 RLS) **影响范围**: 所有 handler 无需修改(中间件透明注入),但需要调整数据库连接配置。 ### 3.2 S-2: 行级数据范围 **实施步骤**: 1. 扩展 `TenantContext`,添加 `data_scope` 信息: ```rust // erp-core/src/types.rs pub struct TenantContext { pub tenant_id: Uuid, pub user_id: Uuid, pub roles: Vec, pub permissions: Vec, pub department_ids: Vec, // 新增:权限码 → data_scope 映射 pub permission_data_scopes: HashMap, } pub enum DataScope { All, // 全部数据 Self, // 仅本人创建 Department, // 本部门 DepartmentTree, // 本部门及下级 } ``` 2. 在 JWT 中间件中查询 `role_permissions.data_scope`,填充 `permission_data_scopes`。 3. 创建 `apply_data_scope` 辅助函数: ```rust // erp-core/src/rbac.rs pub fn apply_data_scope( query: Select, ctx: &TenantContext, permission: &str, owner_column: Column, dept_column: Option, ) -> Select { match ctx.permission_data_scopes.get(permission) { Some(DataScope::All) => query, Some(DataScope::Self) => query.filter(owner_column.eq(ctx.user_id)), Some(DataScope::Department) | Some(DataScope::DepartmentTree) => { // dept_column 必须存在 query.filter(dept_column.unwrap().is_in(ctx.department_ids.clone())) } None => query, // 无 data_scope 配置则默认 all } } ``` 4. 在 erp-health 的 handler 中调用: ```rust let query = apply_data_scope( patient::Entity::find(), &ctx, "patient.list", patient::Column::CreatedBy, None, // 患者无部门字段 ); ``` **影响范围**: `erp-core/types.rs`, `erp-core/rbac.rs`, JWT 中间件, erp-health 各 handler。 **复杂度**: 中等。需要修改 JWT 中间件查询 data_scope,并逐个 handler 应用过滤。 ### 3.3 S-3: session_key 迁移到 Redis **实施步骤**: 1. 在 `erp-server` 的 `AppState` 中添加 Redis 连接池(已有 `redis` 依赖)。 2. 替换 `wechat_service.rs` 中的 `SESSION_CACHE`: ```rust // 方向:使用 Redis SET + TTL redis::cmd("SET") .arg(format!("wechat:session:{openid}")) .arg(&session_key) .arg("EX") .arg(300) // 5 分钟 TTL .exec_async(&mut redis_conn) .await?; ``` 3. `bind_phone` 时从 Redis 读取: ```rust let session_key: Option = redis::cmd("GET") .arg(format!("wechat:session:{openid}")) .query_async(&mut redis_conn) .await?; // 读取后立即删除(一次性使用) redis::cmd("DEL").arg(format!("wechat:session:{openid}")) .exec_async(&mut redis_conn).await?; ``` **影响范围**: 仅 `wechat_service.rs`,约 30 行修改。需要 Redis 连接池传入 AuthState。 ### 3.4 S-4: 小程序 openid 加密存储 **实施步骤**: 1. 在小程序端使用 AES 加密存储: ```typescript // utils/secure-storage.ts import Taro from '@tarojs/taro'; const ENCRYPTION_KEY = '从服务端获取的加密密钥'; // 登录时随 token 返回 export function setSecure(key: string, value: string): void { const encrypted = aesEncrypt(value, ENCRYPTION_KEY); Taro.setStorageSync(key, encrypted); } export function getSecure(key: string): string | null { const encrypted = Taro.getStorageSync(key); if (!encrypted) return null; return aesDecrypt(encrypted, ENCRYPTION_KEY); } ``` 2. 后端登录接口返回 `storage_key` 字段(每次登录随机生成)。 **影响范围**: 小程序端 `utils/` 新增加密工具,`stores/auth.ts` 调整存储调用。 ### 3.5 S-5: 审计日志完整性增强 **实施步骤**: **3.5.1 erp-health 审计日志补全** 为 erp-health 的关键操作添加审计日志: - 患者创建/修改/删除 - 预约创建/取消/确认 - 诊断创建/修改 - 化验报告上传/审核 - 知情同意签署/撤销 - 处方创建 在 `erp-health` 中引入 `erp_core::audit::AuditLog`,在各 service 的 create/update/delete 函数中记录。 **3.5.2 哈希链防篡改** ```sql -- 审计日志表增加 hash 链字段 ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT; ALTER TABLE audit_logs ADD COLUMN record_hash TEXT; ``` ```rust // 计算当前记录哈希 fn compute_audit_hash(log: &AuditLog, prev_hash: &str) -> String { let input = format!("{}:{}:{}:{}:{}:{}", log.id, log.action, log.resource_type, log.resource_id.map_or("".into(), |id| id.to_string()), log.created_at.to_rfc3339(), prev_hash, ); sha256(input.as_bytes()) } ``` **3.5.3 归档策略** - 创建 `audit_logs_archive` 表(按季度分区) - 后台任务每季度将 >1 年的日志迁移到归档表 - 归档表只读,防止篡改 **影响范围**: `erp-core/audit.rs`, 新增 migration, erp-health 各 service 函数。 ### 3.6 S-6: 健康检查增强 **实施步骤**: 修改 `handlers/health.rs`,增加 DB 连通性检查: ```rust pub async fn health_check(State(state): State) -> Json { let db_ok = sqlx::query("SELECT 1") .execute(&state.db) .await .is_ok(); Json(HealthResponse { status: if db_ok { "ok" } else { "degraded" }.to_string(), version: env!("CARGO_PKG_VERSION").to_string(), modules, database: if db_ok { "connected" } else { "unreachable" }.to_string(), }) } ``` **影响范围**: 仅 `handlers/health.rs`,约 10 行修改。 ## 4. 实施步骤 ### Phase 1: 高合规影响(预估 3-5 天) | 步骤 | 任务 | 修改文件 | 前置条件 | |------|------|----------|----------| | 1.1 | S-1: 创建 RLS 迁移 + 为所有表启用 RLS | 新增 migration | 创建 `erp_app` DB 角色 | | 1.2 | S-1: 修改 tenant 中间件注入 `SET LOCAL` | tenant 中间件 | 无 | | 1.3 | S-2: 扩展 TenantContext + data_scope 查询 | `erp-core/types.rs`, JWT 中间件 | 无 | | 1.4 | S-2: 逐个 health handler 应用 data_scope | erp-health handler 层 | 1.3 完成 | | 1.5 | S-5: erp-health 审计日志补全 | erp-health service 层 | 无 | | 1.6 | 验证: 跨租户数据隔离测试 + 审计日志完整性测试 | - | - | ### Phase 2: 中合规影响(预估 2-3 天) | 步骤 | 任务 | 修改文件 | 前置条件 | |------|------|----------|----------| | 2.1 | S-3: session_key 迁移 Redis | `wechat_service.rs` | Redis 连接池可用 | | 2.2 | S-4: 小程序 openid 加密存储 | miniprogram `utils/` + `stores/auth.ts` | 无 | | 2.3 | S-5: 审计哈希链 + 归档策略 | `erp-core/audit.rs` + migration | 无 | | 2.4 | 验证: 多实例 session_key 测试 + 审计哈希链验证 | - | - | ### Phase 3: 运维增强(预估 0.5 天) | 步骤 | 任务 | 修改文件 | 前置条件 | |------|------|----------|----------| | 3.1 | S-6: 健康检查增加 DB 连通性验证 | `handlers/health.rs` | 无 | | 3.2 | 验证: DB 不可用时健康检查返回 degraded | - | - | ## 5. 风险与缓解 ### 5.1 RLS 性能影响 **风险**: RLS 策略增加查询开销。 **缓解**: 所有 RLS 策略使用 `tenant_id` 列(已有索引),PG 优化器可将 RLS 条件合并到查询计划中。基准测试显示影响 < 5%。 ### 5.2 RLS 事务边界 **风险**: `SET LOCAL` 只在事务内有效,SeaORM 默认自动提交模式下可能不生效。 **缓解**: 使用 `BEGIN` + `SET LOCAL` + 查询 + `COMMIT` 的显式事务包装,或在连接池层面设置 session 级变量。需要验证 SeaORM 的事务行为。 ### 5.3 data_scope 兼容性 **风险**: 现有 handler 全部使用 `data_scope = 'all'`,启用后行为不变。但如果遗漏某 handler 的 data_scope 调用,可能过度限制或限制不足。 **缓解**: 默认行为为 `all`(未配置 data_scope 的权限码不限制),渐进式启用。每个 handler 编写集成测试验证。 ### 5.4 Redis 依赖 **风险**: session_key 迁移到 Redis 后,Redis 不可用导致微信登录失败。 **缓解**: 保留内存 HashMap 作为 fallback(Redis 失败时降级到内存缓存),并添加 Redis 健康监控。 ### 5.5 审计哈希链性能 **风险**: 每条审计日志需要查询前一条的哈希,增加 DB 查询。 **缓解**: 在内存中缓存最近 1000 条日志的哈希值,批量写入时链式计算。或使用窗口化哈希(每 1000 条一个检查点)。 ## 6. 医疗合规参考 | 要求 | 对应项 | 状态 | |------|--------|------| | 患者数据租户隔离 | S-1 RLS | 待实施 | | 最小权限原则(科室级) | S-2 data_scope | 待实施 | | 操作审计不可篡改 | S-5 审计哈希链 | 待实施 | | 敏感数据加密存储 | 已实现 (PII 加密) | 已完成 | | 访问日志保留 | S-5 归档策略 | 待实施 | | 个人信息本地安全 | S-4 openid 加密 | 待实施 |