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:
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-scope:patient.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(())
|
||||
}
|
||||
Reference in New Issue
Block a user