fix security
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user