fix security

This commit is contained in:
voltsrage
2026-06-21 19:53:19 +08:00
parent a37fad0e57
commit 7170b6efad
14 changed files with 422 additions and 27 deletions
@@ -73,4 +73,19 @@ public class AlertThresholdsController : ControllerBase
var threshold = await _thresholds.UpdateAsync(id, req);
return Ok(ApiResponse<AlertThreshold>.Ok(threshold));
}
/// <summary>
/// Deletes an alert threshold. Clinical entities (patients, encounters, observations,
/// alerts, scores) are immutable by design and do not support deletion.
/// </summary>
/// <param name="id">Threshold id.</param>
[HttpDelete("{id:guid}")]
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id)
{
await _thresholds.DeleteAsync(id);
return NoContent();
}
}
@@ -30,7 +30,7 @@ public class FhirMetadataController : ControllerBase
Software = new CapabilityStatement.SoftwareComponent { Name = "VigilCare Clinical" },
Implementation = new CapabilityStatement.ImplementationComponent
{
Description = "VigilCare Clinical FHIR R4 inbound facade",
Description = "VigilCare Clinical FHIR R4 facade",
Url = $"{Request.Scheme}://{Request.Host}/fhir/R4"
},
FhirVersion = FHIRVersion.N4_0_1,
@@ -42,8 +42,14 @@ public class FhirMetadataController : ControllerBase
Mode = CapabilityStatement.RestfulCapabilityMode.Server,
Resource = new List<CapabilityStatement.ResourceComponent>
{
ResourceCapability("Patient", TypeRestfulInteraction.Create),
ResourceCapability("Encounter", TypeRestfulInteraction.Create),
ResourceCapability("Patient",
TypeRestfulInteraction.Create,
TypeRestfulInteraction.Read,
TypeRestfulInteraction.SearchType),
ResourceCapability("Encounter",
TypeRestfulInteraction.Create,
TypeRestfulInteraction.Read,
TypeRestfulInteraction.SearchType),
ResourceCapability("Observation", TypeRestfulInteraction.Create),
ResourceCapability("MedicationAdministration", TypeRestfulInteraction.Create),
new CapabilityStatement.ResourceComponent
@@ -63,13 +69,12 @@ public class FhirMetadataController : ControllerBase
}
private static CapabilityStatement.ResourceComponent ResourceCapability(
string type, TypeRestfulInteraction interaction) =>
string type, params TypeRestfulInteraction[] interactions) =>
new()
{
Type = type,
Interaction = new List<CapabilityStatement.ResourceInteractionComponent>
{
new() { Code = interaction }
}
Interaction = interactions
.Select(i => new CapabilityStatement.ResourceInteractionComponent { Code = i })
.ToList()
};
}
@@ -0,0 +1,222 @@
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
/// <summary>
/// FHIR R4 read and search interactions for Patient and Encounter resources.
/// </summary>
[ApiController]
[Route("fhir/R4")]
[AuthorizePermission(ClinicalPermissions.FhirRead)]
[ServiceFilter(typeof(FhirExceptionFilter))]
public class FhirReadController : ControllerBase
{
private static readonly FhirJsonSerializer Serializer = new();
private readonly AppDbContext _db;
private readonly IExternalIdentifierService _identifiers;
private readonly PatientFhirMapper _patientMapper;
private readonly EncounterFhirMapper _encounterMapper;
private readonly FhirOptions _options;
private readonly ClinicalMetrics _metrics;
public FhirReadController(
AppDbContext db,
IExternalIdentifierService identifiers,
PatientFhirMapper patientMapper,
EncounterFhirMapper encounterMapper,
IOptions<FhirOptions> options,
ClinicalMetrics metrics)
{
_db = db;
_identifiers = identifiers;
_patientMapper = patientMapper;
_encounterMapper = encounterMapper;
_options = options.Value;
_metrics = metrics;
}
/// <summary>
/// Reads a Patient resource by internal ID.
/// </summary>
[HttpGet("Patient/{id:guid}")]
[Produces("application/fhir+json")]
[ProducesResponseType(typeof(Hl7.Fhir.Model.Patient), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReadPatient(Guid id)
{
var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
if (patient is null)
throw new NotFoundException("Patient not found.", "FHIR_RESOURCE_NOT_FOUND");
var hospitalId = await _identifiers.FindPrimaryIdentifierAsync(
ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems);
var resource = _patientMapper.ToFhirResponse(patient, hospitalId);
_metrics.FhirReadTotal.WithLabels("Patient", "read", "success").Inc();
return Serialize(resource);
}
/// <summary>
/// Searches for Patient resources by identifier (system|value).
/// </summary>
[HttpGet("Patient")]
[Produces("application/fhir+json")]
[ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchPatient(
[FromQuery] string? identifier,
[FromQuery] int _count = 20)
{
_count = Math.Clamp(_count, 1, 100);
var bundle = new Bundle
{
Type = Bundle.BundleType.Searchset,
Timestamp = DateTimeOffset.UtcNow
};
if (identifier is not null)
{
var parts = identifier.Split('|', 2);
if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0]) && !string.IsNullOrWhiteSpace(parts[1]))
{
var internalId = await _identifiers.ResolveInternalIdAsync(
ExternalResourceType.Patient, parts[0], parts[1]);
if (internalId.HasValue)
{
var patient = await _db.Patients.AsNoTracking().FirstOrDefaultAsync(p => p.Id == internalId.Value);
if (patient is not null)
{
var hospitalId = await _identifiers.FindPrimaryIdentifierAsync(
ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems);
var resource = _patientMapper.ToFhirResponse(patient, hospitalId);
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{Request.Scheme}://{Request.Host}/fhir/R4/Patient/{patient.Id}",
Resource = resource
});
}
}
}
}
else
{
var patients = await _db.Patients
.AsNoTracking()
.OrderBy(p => p.LastName).ThenBy(p => p.FirstName)
.Take(_count)
.ToListAsync();
foreach (var patient in patients)
{
var hospitalId = await _identifiers.FindPrimaryIdentifierAsync(
ExternalResourceType.Patient, patient.Id, _options.PatientIdentifierSystems);
var resource = _patientMapper.ToFhirResponse(patient, hospitalId);
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{Request.Scheme}://{Request.Host}/fhir/R4/Patient/{patient.Id}",
Resource = resource
});
}
}
bundle.Total = bundle.Entry.Count;
_metrics.FhirReadTotal.WithLabels("Patient", "search", "success").Inc();
return Serialize(bundle);
}
/// <summary>
/// Reads an Encounter resource by internal ID.
/// </summary>
[HttpGet("Encounter/{id:guid}")]
[Produces("application/fhir+json")]
[ProducesResponseType(typeof(Hl7.Fhir.Model.Encounter), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OperationOutcome), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReadEncounter(Guid id)
{
var encounter = await _db.Encounters
.AsNoTracking()
.Include(e => e.Patient)
.FirstOrDefaultAsync(e => e.Id == id);
if (encounter is null)
throw new NotFoundException("Encounter not found.", "FHIR_RESOURCE_NOT_FOUND");
var hospitalId = await _identifiers.FindPrimaryIdentifierAsync(
ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems);
var resource = _encounterMapper.ToFhirResponse(encounter, hospitalId);
_metrics.FhirReadTotal.WithLabels("Encounter", "read", "success").Inc();
return Serialize(resource);
}
/// <summary>
/// Searches for Encounter resources by patient reference.
/// </summary>
[HttpGet("Encounter")]
[Produces("application/fhir+json")]
[ProducesResponseType(typeof(Bundle), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchEncounter(
[FromQuery] string? patient,
[FromQuery] string? status,
[FromQuery] int _count = 20)
{
_count = Math.Clamp(_count, 1, 100);
var query = _db.Encounters.AsNoTracking().Include(e => e.Patient).AsQueryable();
if (patient is not null && Guid.TryParse(patient, out var patientId))
query = query.Where(e => e.PatientId == patientId);
if (status is not null)
{
var fhirStatus = status.ToLowerInvariant() switch
{
"in-progress" => EncounterStatus.Active,
"finished" => EncounterStatus.Discharged,
"cancelled" => EncounterStatus.Cancelled,
_ => (EncounterStatus?)null
};
if (fhirStatus.HasValue)
query = query.Where(e => e.Status == fhirStatus.Value);
}
var encounters = await query
.OrderByDescending(e => e.AdmittedAt)
.Take(_count)
.ToListAsync();
var bundle = new Bundle
{
Type = Bundle.BundleType.Searchset,
Total = encounters.Count,
Timestamp = DateTimeOffset.UtcNow
};
foreach (var encounter in encounters)
{
var hospitalId = await _identifiers.FindPrimaryIdentifierAsync(
ExternalResourceType.Encounter, encounter.Id, _options.EncounterIdentifierSystems);
var resource = _encounterMapper.ToFhirResponse(encounter, hospitalId);
bundle.Entry.Add(new Bundle.EntryComponent
{
FullUrl = $"{Request.Scheme}://{Request.Host}/fhir/R4/Encounter/{encounter.Id}",
Resource = resource
});
}
_metrics.FhirReadTotal.WithLabels("Encounter", "search", "success").Inc();
return Serialize(bundle);
}
private ContentResult Serialize(Resource resource, int statusCode = StatusCodes.Status200OK) =>
new()
{
Content = Serializer.SerializeToString(resource),
ContentType = "application/fhir+json",
StatusCode = statusCode
};
}