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
@@ -14,6 +14,7 @@ public static class ClinicalPermissions
public const string OrdersWrite = "orders:write";
public const string MedicationsWrite = "medications:write";
public const string FhirIngest = "fhir:ingest";
public const string FhirRead = "fhir:read";
public const string AuditRead = "audit:read";
public const string UsersAdmin = "users:admin";
}
@@ -48,6 +48,7 @@ public static class ClinicalRolePermissionMap
ClinicalPermissions.OrdersWrite,
ClinicalPermissions.MedicationsWrite,
ClinicalPermissions.FhirIngest,
ClinicalPermissions.FhirRead,
ClinicalPermissions.AuditRead,
ClinicalPermissions.UsersAdmin,
},
@@ -58,6 +59,7 @@ public static class ClinicalRolePermissionMap
ClinicalPermissions.ObservationsIngest,
ClinicalPermissions.MedicationsWrite,
ClinicalPermissions.FhirIngest,
ClinicalPermissions.FhirRead,
},
};
@@ -1,19 +1,57 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly ILogger<PermissionAuthorizationHandler> _logger;
private readonly IHttpContextAccessor _http;
private readonly ClinicalMetrics _metrics;
public PermissionAuthorizationHandler(
ILogger<PermissionAuthorizationHandler> logger,
IHttpContextAccessor http,
ClinicalMetrics metrics)
{
_logger = logger;
_http = http;
_metrics = metrics;
}
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var roleClaim = context.User.FindFirst("clinical_role")?.Value;
if (roleClaim is null)
{
LogAuthorizationFailure(context.User, "none", requirement.Permission);
return Task.CompletedTask;
}
var role = ClinicalRoleExtensions.FromDbString(roleClaim);
if (ClinicalRolePermissionMap.HasPermission(role, requirement.Permission))
{
context.Succeed(requirement);
}
else
{
LogAuthorizationFailure(context.User, roleClaim, requirement.Permission);
}
return Task.CompletedTask;
}
private void LogAuthorizationFailure(ClaimsPrincipal user, string role, string permission)
{
var username = user.FindFirst(ClaimTypes.Name)?.Value ?? "unknown";
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown";
var endpoint = _http.HttpContext?.GetEndpoint()?.DisplayName ?? "unknown";
_logger.LogWarning(
"Authorization denied: user={User} (id={UserId}), role={Role}, " +
"requiredPermission={Permission}, endpoint={Endpoint}",
username, userId, role, permission, endpoint);
_metrics.AuthorizationFailuresTotal.WithLabels(permission, role).Inc();
}
}
@@ -2,9 +2,12 @@ public class FhirOptions
{
public const string Section = "Fhir";
/// <summary>Shared secret for integration engine authentication (interim until RBAC).</summary>
/// <summary>Single API key (convenience shorthand — prefer ApiKeys array for rotation).</summary>
public string? ApiKey { get; set; }
/// <summary>Multiple active API keys for zero-downtime rotation. Both ApiKey and ApiKeys are checked.</summary>
public string[] ApiKeys { get; set; } = [];
/// <summary>Identifier systems accepted for Patient.identifier (hospital MRNs).</summary>
public string[] PatientIdentifierSystems { get; set; } =
[
@@ -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
};
}
@@ -2,13 +2,15 @@ public enum AuditAction
{
ThresholdCreated,
ThresholdUpdated,
ThresholdDeleted,
AlertAcknowledged,
AlertResolved,
EncounterStatusChanged,
PatientRegistered,
PatientUpdated,
SuppressionWindowSet,
UserLogin
UserLogin,
AuthorizationDenied
}
public static class AuditActionExtensions
@@ -17,6 +19,7 @@ public static class AuditActionExtensions
{
AuditAction.ThresholdCreated => "THRESHOLD_CREATED",
AuditAction.ThresholdUpdated => "THRESHOLD_UPDATED",
AuditAction.ThresholdDeleted => "THRESHOLD_DELETED",
AuditAction.AlertAcknowledged => "ALERT_ACKNOWLEDGED",
AuditAction.AlertResolved => "ALERT_RESOLVED",
AuditAction.EncounterStatusChanged => "ENCOUNTER_STATUS_CHANGED",
@@ -24,6 +27,7 @@ public static class AuditActionExtensions
AuditAction.PatientUpdated => "PATIENT_UPDATED",
AuditAction.SuppressionWindowSet => "SUPPRESSION_WINDOW_SET",
AuditAction.UserLogin => "USER_LOGIN",
AuditAction.AuthorizationDenied => "AUTHORIZATION_DENIED",
_ => throw new ArgumentOutOfRangeException(nameof(a))
};
@@ -31,6 +35,7 @@ public static class AuditActionExtensions
{
"THRESHOLD_CREATED" => AuditAction.ThresholdCreated,
"THRESHOLD_UPDATED" => AuditAction.ThresholdUpdated,
"THRESHOLD_DELETED" => AuditAction.ThresholdDeleted,
"ALERT_ACKNOWLEDGED" => AuditAction.AlertAcknowledged,
"ALERT_RESOLVED" => AuditAction.AlertResolved,
"ENCOUNTER_STATUS_CHANGED" => AuditAction.EncounterStatusChanged,
@@ -38,6 +43,7 @@ public static class AuditActionExtensions
"PATIENT_UPDATED" => AuditAction.PatientUpdated,
"SUPPRESSION_WINDOW_SET" => AuditAction.SuppressionWindowSet,
"USER_LOGIN" => AuditAction.UserLogin,
"AUTHORIZATION_DENIED" => AuditAction.AuthorizationDenied,
_ => throw new ArgumentOutOfRangeException(nameof(v))
};
}
@@ -1,4 +1,6 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Hl7.Fhir.Serialization;
using Microsoft.Extensions.Options;
using Task = System.Threading.Tasks.Task;
@@ -37,13 +39,15 @@ public class FhirApiKeyOrJwtMiddleware
return;
}
if (string.IsNullOrWhiteSpace(_options.ApiKey))
var configuredKeys = GetConfiguredKeys();
if (configuredKeys.Length == 0)
{
await _next(context);
return;
}
if (context.Request.Headers.TryGetValue("X-Api-Key", out var key) && key == _options.ApiKey)
if (context.Request.Headers.TryGetValue("X-Api-Key", out var suppliedKey)
&& MatchesAnyKey(suppliedKey!, configuredKeys))
{
var claims = new[]
{
@@ -68,4 +72,30 @@ public class FhirApiKeyOrJwtMiddleware
await _next(context);
}
private string[] GetConfiguredKeys()
{
var keys = new List<string>();
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
keys.Add(_options.ApiKey);
foreach (var k in _options.ApiKeys)
{
if (!string.IsNullOrWhiteSpace(k))
keys.Add(k);
}
return keys.ToArray();
}
private static bool MatchesAnyKey(string supplied, string[] configuredKeys)
{
var suppliedBytes = Encoding.UTF8.GetBytes(supplied);
var matched = false;
foreach (var configured in configuredKeys)
{
var configuredBytes = Encoding.UTF8.GetBytes(configured);
if (CryptographicOperations.FixedTimeEquals(suppliedBytes, configuredBytes))
matched = true;
}
return matched;
}
}
@@ -63,6 +63,16 @@ public sealed class ClinicalMetrics
"FHIR resource ingest operations.",
labelNames: new[] { "resource_type", "outcome" });
public readonly Counter FhirReadTotal = Metrics.CreateCounter(
"fhir_read_total",
"FHIR resource read/search operations.",
labelNames: new[] { "resource_type", "interaction", "outcome" });
public readonly Counter AuthorizationFailuresTotal = Metrics.CreateCounter(
"authorization_failures_total",
"Authorization failures by permission and role.",
labelNames: new[] { "permission", "role" });
public readonly Counter FhirMappingErrorsTotal = Metrics.CreateCounter(
"fhir_mapping_errors_total",
"FHIR mapping failures.",
+5
View File
@@ -27,6 +27,11 @@ try
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
if (string.IsNullOrWhiteSpace(jwtOptions.SigningKey)
|| Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException(
"Jwt:SigningKey must be configured and at least 256 bits (32 bytes) for HMAC-SHA256.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
@@ -112,6 +112,38 @@ public class AlertThresholdService : IAlertThresholdService
return threshold;
}
public async Task DeleteAsync(Guid id)
{
var threshold = await _db.AlertThresholds.FindAsync(id);
if (threshold is null)
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
var previous = new
{
threshold.ObservationCode,
threshold.DisplayName,
threshold.Unit,
threshold.CriticalLow,
threshold.WarningLow,
threshold.WarningHigh,
threshold.CriticalHigh,
threshold.SuppressionWindowMinutes
};
var observationCode = threshold.ObservationCode;
_db.AlertThresholds.Remove(threshold);
await _db.SaveChangesAsync();
await _audit.WriteAsync(
AuditAction.ThresholdDeleted,
"AlertThreshold",
id,
previousValue: previous);
await InvalidateCacheAsync(observationCode);
}
private async Task InvalidateCacheAsync(string observationCode)
{
var cache = _redis.GetDatabase();
@@ -4,4 +4,5 @@ public interface IAlertThresholdService
Task<List<AlertThreshold>> ListAsync();
Task<AlertThreshold> GetByIdAsync(Guid id);
Task<AlertThreshold> UpdateAsync(Guid id, AlertThresholdRequest req);
Task DeleteAsync(Guid id);
}