基于全景审计分析,产出 5 份跨领域设计规格: 1. 性能优化 — 后端批量INSERT/合并COUNT/告警预加载 + 前端N+1内联name 2. 安全纵深防御 — PostgreSQL RLS/行级数据范围/session_key Redis/审计哈希链 3. 事件驱动架构增强 — 6个业务域11个缺失事件补发 + Outbox LISTEN/NOTIFY 4. 前端工程化 — 14个大组件拆分 + 3个重复模式统一 + Bundle优化 5. 可观测性与运维 — 深度健康检查/Prometheus/OpenTelemetry/生产Docker
363 lines
13 KiB
Markdown
363 lines
13 KiB
Markdown
# 安全纵深防御设计规格
|
||
|
||
> 日期: 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<Mutex<HashMap<String, SessionEntry>>>` 缓存 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 = '<tenant_id_from_jwt>';
|
||
```
|
||
|
||
使用 `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<String>,
|
||
pub permissions: Vec<String>,
|
||
pub department_ids: Vec<Uuid>,
|
||
// 新增:权限码 → data_scope 映射
|
||
pub permission_data_scopes: HashMap<String, DataScope>,
|
||
}
|
||
|
||
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<Column>,
|
||
) -> 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<String> = 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<AppState>) -> Json<HealthResponse> {
|
||
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 加密 | 待实施 |
|