fixes: MRN generation race condition and Sepsis bundle creation race condition (TOCTOU)

This commit is contained in:
voltsrage
2026-06-21 16:37:22 +08:00
parent 4583bd7f44
commit 076e8364b2
8 changed files with 2635 additions and 7 deletions
@@ -0,0 +1,7 @@
public class PatientOptions
{
public const string Section = "Patient";
public string MrnPrefix { get; set; } = "MRN";
public int MrnDigits { get; set; } = 6;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddMrnSequence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DO $$
DECLARE
next_val bigint;
BEGIN
SELECT COALESCE(MAX(
CASE WHEN mrn ~ '^MRN-[0-9]+$'
THEN CAST(SUBSTRING(mrn FROM 5) AS bigint)
ELSE 0
END
), 0) + 1 INTO next_val FROM patients;
EXECUTE format('CREATE SEQUENCE mrn_seq START WITH %s INCREMENT BY 1', next_val);
END $$;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP SEQUENCE IF EXISTS mrn_seq;");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddSepsisBundleUniqueInProgressIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
CREATE UNIQUE INDEX ix_sepsis_bundles_encounter_in_progress
ON sepsis_bundles (encounter_id)
WHERE compliance_status = 'IN_PROGRESS';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP INDEX IF EXISTS ix_sepsis_bundles_encounter_in_progress;");
}
}
}
+3
View File
@@ -119,6 +119,9 @@ try
builder.Services.Configure<FhirOptions>(
builder.Configuration.GetSection(FhirOptions.Section));
builder.Services.Configure<PatientOptions>(
builder.Configuration.GetSection(PatientOptions.Section));
builder.Services.AddCors(options =>
{
options.AddPolicy("Dashboard", policy =>
@@ -1,29 +1,32 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class PatientService : IPatientService
{
private readonly AppDbContext _db;
private readonly IExternalIdentifierService _identifiers;
private readonly IAuditService _audit;
private readonly PatientOptions _options;
public PatientService(
AppDbContext db,
IExternalIdentifierService identifiers,
IAuditService audit)
IAuditService audit,
IOptions<PatientOptions> options)
{
_db = db;
_identifiers = identifiers;
_audit = audit;
_options = options.Value;
}
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
{
var mrn = await GenerateMrnAsync();
var patient = new Patient
{
Id = Guid.NewGuid(),
Mrn = mrn,
Mrn = await GenerateMrnAsync(),
FirstName = req.FirstName,
LastName = req.LastName,
DateOfBirth = req.DateOfBirth,
@@ -35,7 +38,18 @@ public class PatientService : IPatientService
CreatedAt = DateTimeOffset.UtcNow
};
_db.Patients.Add(patient);
await _db.SaveChangesAsync();
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsMrnUniqueViolation(ex))
{
_db.Entry(patient).State = EntityState.Detached;
patient.Mrn = await GenerateMrnAsync();
_db.Patients.Add(patient);
await _db.SaveChangesAsync();
}
await _audit.WriteAsync(
AuditAction.PatientRegistered,
@@ -46,6 +60,13 @@ public class PatientService : IPatientService
return patient;
}
private static bool IsMrnUniqueViolation(DbUpdateException ex)
{
return ex.InnerException is Npgsql.PostgresException pg
&& pg.SqlState == "23505"
&& pg.ConstraintName?.Contains("mrn") == true;
}
public async Task<PagedResult<Patient>> ListAsync(
string? q, int page, int pageSize)
{
@@ -196,7 +217,9 @@ public class PatientService : IPatientService
private async Task<string> GenerateMrnAsync()
{
var count = await _db.Patients.CountAsync();
return $"MRN-{(count + 1):D6}";
var seq = await _db.Database
.SqlQueryRaw<long>("SELECT nextval('mrn_seq') AS \"Value\"")
.SingleAsync();
return $"{_options.MrnPrefix}-{seq.ToString($"D{_options.MrnDigits}")}";
}
}
@@ -82,7 +82,18 @@ public class SepsisBundleService : ISepsisBundleService
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync(ct);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg
&& pg.ConstraintName == "ix_sepsis_bundles_encounter_in_progress")
{
_db.ChangeTracker.Clear();
return null;
}
return bundle;
}