feat(health): B5 个保法 §45 患者数据可携权导出

GET /health/patients/{id}/export?format=json|fhir 双格式同步导出:
- json: 明文 PII(解密不脱敏,可携权本意),聚合 7 段数据
- fhir: FHIR R4 Bundle(复用现有 converter,PII 天然脱敏)
- 安全边界:consent 门控 + patient 角色 self-scope + 审计 patient.exported(不含明文 PII)+ 日志不记 payload
- 权限 health.patient.export(医护=all, patient=self),迁移 m20260626_000171
- 事件 patient.exported;6 集成测试全绿

含顺手修复 auth_tests UserService::list 签名 drift(exclude_only_roles),解锁 integration crate 编译。
§47 删除权留后续。
This commit is contained in:
iven
2026-06-26 17:58:20 +08:00
parent 5d256fbf52
commit 15b6bec215
14 changed files with 792 additions and 4 deletions

View File

@@ -177,6 +177,7 @@ mod m20260526_000167_create_ai_knowledge_documents;
mod m20260527_000168_ai_knowledge_v2_menu;
mod m20260529_000169_supplement_rls_for_new_tables;
mod m20260626_000170_extend_device_readings_partitions;
mod m20260626_000171_seed_patient_export_permission;
pub struct Migrator;
@@ -361,6 +362,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration),
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
Box::new(m20260626_000170_extend_device_readings_partitions::Migration),
Box::new(m20260626_000171_seed_patient_export_permission::Migration),
]
}
}

View File

@@ -0,0 +1,95 @@
use sea_orm_migration::prelude::*;
/// 个保法 §45 数据可携权:注册 health.patient.export 权限并分配角色
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let sys = "00000000-0000-0000-0000-000000000000";
// 1) 注册 health.patient.export 权限(跨租户幂等)
db.execute_unprepared(&format!(
"INSERT INTO permissions (id, tenant_id, name, code, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), t.id, '患者数据导出(数据可携权)', 'health.patient.export', 'health', 'export', '个保法 §45 数据可携权:导出患者全量健康数据', NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \
FROM tenant t \
WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.tenant_id = t.id AND p.code = 'health.patient.export' AND p.deleted_at IS NULL)"
)).await?;
// 2) 医护和管理角色data_scope=all可导出任意患者数据
let staff_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"];
for role in staff_roles {
assign_single_perm(db, role, "health.patient.export").await?;
}
// 3) patient 角色data_scope=self仅导出自己的数据
// handler 层 enforce self-scopepatient.user_id == ctx.user_id
assign_perms_by_codes(db, "patient", &["health.patient.export"]).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 移除所有角色的 health.patient.export 关联
db.execute_unprepared(
"DELETE FROM role_permissions \
WHERE permission_id IN (SELECT id FROM permissions WHERE code = 'health.patient.export')",
)
.await?;
// 软删除权限
db.execute_unprepared(
"UPDATE permissions SET deleted_at = NOW() \
WHERE code = 'health.patient.export' AND deleted_at IS NULL",
)
.await?;
Ok(())
}
}
async fn assign_perms_by_codes(
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
role_code: &str,
perm_codes: &[&str],
) -> Result<(), DbErr> {
let codes_csv: String = perm_codes
.iter()
.map(|c| format!("'{}'", c))
.collect::<Vec<_>>()
.join(",");
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
)).await?;
Ok(())
}
async fn assign_single_perm(
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
role_code: &str,
perm_code: &str,
) -> Result<(), DbErr> {
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{perm_code}' AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
)).await?;
Ok(())
}