fix(health): 审计问题修复 — 权限守卫 + OAuth中间件 + FHIR声明 + SSE聚合
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- OAuthClientList/RealtimeMonitor/OfflineEventList/StatisticsDashboard 补权限守卫
- OAuth 中间件注入 TenantContext + FHIR scope→permission 映射
- FHIR CapabilityStatement 移除未实现的 $lastn 操作
- useVitalSSE 修复批量同步事件数据聚合逻辑
This commit is contained in:
iven
2026-05-04 12:02:50 +08:00
parent d436888ca5
commit 1135439403
7 changed files with 80 additions and 15 deletions

View File

@@ -41,15 +41,17 @@ export function useVitalSSE(options: UseVitalSSEOptions = {}): UseVitalSSEReturn
setPatientVitals((prev) => {
const next = new Map(prev);
if (data.device_model) {
const key = `${data.patient_id}_${data.device_model}`;
next.set(key, {
patient_id: data.patient_id,
device_type: data.device_model,
latest_value: data.count > 0 ? undefined : undefined,
updated_at: data.occurred_at ?? new Date().toISOString(),
});
}
const key = `${data.patient_id}_${data.device_model ?? 'unknown'}`;
const existing = next.get(key);
next.set(key, {
patient_id: data.patient_id,
device_type: data.device_model ?? 'unknown',
// 批量同步事件不含单值,用累计 count 表示活跃度
latest_value: data.count > 0
? (existing?.latest_value ?? 0) + data.count
: existing?.latest_value,
updated_at: data.occurred_at ?? new Date().toISOString(),
});
return next;
});
setLastUpdate(data);

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography } from 'antd';
import { Button, Form, Input, InputNumber, message, Modal, Popconfirm, Result, Select, Space, Switch, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
@@ -11,8 +11,10 @@ import {
FHIR_SCOPE_OPTIONS,
} from '../../api/health/oauthClients';
import { PageContainer } from '../../components/PageContainer';
import { usePermission } from '../../hooks/usePermission';
export default function OAuthClientList() {
const { hasPermission } = usePermission('health.oauth.manage');
const [data, setData] = useState<OAuthClient[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
@@ -162,6 +164,10 @@ export default function OAuthClientList() {
},
];
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有管理 FHIR API 合作方的权限" />;
}
return (
<PageContainer
title="FHIR API 合作方管理"

View File

@@ -31,6 +31,8 @@ import {
type CreateOfflineEventReq,
} from '../../api/health/points';
import { AuthButton } from '../../components/AuthButton';
import { usePermission } from '../../hooks/usePermission';
import { Result } from 'antd';
/** 活动状态映射 */
const STATUS_MAP: Record<string, { text: string; color: string }> = {
@@ -48,6 +50,7 @@ const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({
}));
export default function OfflineEventList() {
const { hasPermission } = usePermission('health.points.list');
const [data, setData] = useState<OfflineEvent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@@ -58,6 +61,10 @@ export default function OfflineEventList() {
const [editing, setEditing] = useState<OfflineEvent | null>(null);
const [form] = Form.useForm();
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有访问线下活动管理的权限" />;
}
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);

View File

@@ -1,7 +1,8 @@
import { useState, useCallback, useEffect } from 'react';
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty } from 'antd';
import { Card, Row, Col, Statistic, Tag, List, Select, Badge, Typography, Space, Empty, Result } from 'antd';
import { AlertOutlined } from '@ant-design/icons';
import { useVitalSSE } from '../../hooks/useVitalSSE';
import { usePermission } from '../../hooks/usePermission';
import { alertApi, type Alert } from '../../api/health/alerts';
import { PageContainer } from '../../components/PageContainer';
import { SEVERITY_COLOR, SEVERITY_LABEL, VITAL_CARD_METRICS } from '../../constants/health';
@@ -20,6 +21,7 @@ interface PatientAlertSummary {
* SSE 实时接收体征更新,按告警严重度排序患者列表。
*/
export default function RealtimeMonitor() {
const { hasPermission } = usePermission('health.alerts.list');
const [alerts, setAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPatientId, setSelectedPatientId] = useState<string | null>(null);
@@ -68,6 +70,10 @@ export default function RealtimeMonitor() {
const totalMedium = alertSummary.reduce((s, a) => s + a.medium, 0);
const totalLow = alertSummary.reduce((s, a) => s + a.low, 0);
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有访问实时体征监控的权限" />;
}
return (
<PageContainer
title="实时体征监控"

View File

@@ -1,4 +1,6 @@
import { useDashboardRole } from '../../hooks/useDashboardRole';
import { usePermission } from '../../hooks/usePermission';
import { Result } from 'antd';
import { DoctorDashboard } from './StatisticsDashboard/DoctorDashboard';
import { NurseDashboard } from './StatisticsDashboard/NurseDashboard';
import { AdminDashboard } from './StatisticsDashboard/AdminDashboard';
@@ -13,7 +15,13 @@ const DASHBOARD_MAP: Record<string, React.FC> = {
};
export default function StatisticsDashboard() {
const { hasPermission } = usePermission('health.dashboard.manage');
const role = useDashboardRole();
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有访问统计面板的权限" />;
}
const DashboardComponent = DASHBOARD_MAP[role];
return <DashboardComponent />;
}

View File

@@ -31,7 +31,7 @@ where
"mode": "server",
"resource": [
{ "type": "Patient", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "everything"}] },
{ "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "lastn"}] },
{ "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] },
@@ -40,8 +40,7 @@ where
{ "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] },
],
"operation": [
{ "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" },
{ "name": "lastn", "definition": "/fhir/R4/Observation/$lastn" },
{ "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" }
]
}]
});

View File

@@ -6,6 +6,7 @@ use axum::{
Json,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
/// Client Credentials JWT Claims
@@ -107,12 +108,48 @@ pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response {
let fhir_ctx = FhirAuthContext {
client_id: claims.sub,
tenant_id: claims.tid,
scopes: claims.scopes,
scopes: claims.scopes.clone(),
allowed_patient_ids: claims.allowed_patient_ids,
};
// 同时注入 TenantContext兼容 require_permission() 和 ctx.tenant_id
// 将 FHIR scope 映射为内部 permission code
let permissions: Vec<String> = claims
.scopes
.iter()
.flat_map(|s| scope_to_permissions(s))
.collect();
let tenant_ctx = erp_core::types::TenantContext {
tenant_id: claims.tid,
user_id: claims.sub,
roles: vec!["api_client".to_string()],
permissions,
department_ids: vec![],
permission_data_scopes: HashMap::new(),
};
let mut request = request;
request.extensions_mut().insert(tenant_ctx);
request.extensions_mut().insert(fhir_ctx);
next.run(request).await
}
/// FHIR scope → 内部 permission code 映射
fn scope_to_permissions(scope: &str) -> Vec<String> {
match scope {
"Patient.read" => vec!["health.patient.list".to_string()],
"Observation.read" => vec![
"health.device-readings.list".to_string(),
"health.health-data.list".to_string(),
],
"Device.read" => vec!["health.devices.list".to_string()],
"Practitioner.read" => vec!["health.doctor.list".to_string()],
"Appointment.read" => vec!["health.appointment.list".to_string()],
"DiagnosticReport.read" => vec!["health.health-data.list".to_string()],
"Encounter.read" => vec!["health.consultation.list".to_string()],
"Task.read" => vec!["health.follow-up.list".to_string()],
_ => vec![],
}
}