feature: PHI Column Encryption + Access Logging
This commit is contained in:
@@ -11,6 +11,7 @@ public static class DbResetHelper
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await db.Database.ExecuteSqlRawAsync(@"
|
await db.Database.ExecuteSqlRawAsync(@"
|
||||||
|
DELETE FROM phi_access_logs;
|
||||||
DELETE FROM medication_administrations;
|
DELETE FROM medication_administrations;
|
||||||
DELETE FROM sepsis_bundle_elements;
|
DELETE FROM sepsis_bundle_elements;
|
||||||
DELETE FROM sepsis_bundles;
|
DELETE FROM sepsis_bundles;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
[Collection("Integration")]
|
||||||
|
public class PhiEncryptionTests
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
|
||||||
|
public PhiEncryptionTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatientPhi_StoredEncrypted_ReturnsDecryptedViaApi()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
client.ClearAuth();
|
||||||
|
client.AsAdmin();
|
||||||
|
|
||||||
|
var registerResp = await client.PostAsJsonAsync("/api/v1/patients", new
|
||||||
|
{
|
||||||
|
firstName = "Encrypted",
|
||||||
|
lastName = "Patient",
|
||||||
|
dateOfBirth = "1990-05-20",
|
||||||
|
gender = "female"
|
||||||
|
});
|
||||||
|
registerResp.EnsureSuccessStatusCode();
|
||||||
|
var body = await registerResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var patientId = body.GetProperty("data").GetProperty("id").GetGuid();
|
||||||
|
|
||||||
|
// Raw DB check — first_name should NOT equal plaintext
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var raw = await db.Database
|
||||||
|
.SqlQueryRaw<string>($"SELECT first_name AS \"Value\" FROM patients WHERE id = '{patientId}'")
|
||||||
|
.FirstAsync();
|
||||||
|
raw.Should().NotBe("Encrypted");
|
||||||
|
|
||||||
|
// API returns decrypted
|
||||||
|
var getResp = await client.GetAsync($"/api/v1/patients/{patientId}");
|
||||||
|
getResp.EnsureSuccessStatusCode();
|
||||||
|
var patient = await getResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
patient.GetProperty("data").GetProperty("firstName").GetString()
|
||||||
|
.Should().Be("Encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PatientView_WritesPhiAccessLog()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
client.ClearAuth();
|
||||||
|
var nurseId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
client.AsNurse(nurseId);
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync("/api/v1/patients", new
|
||||||
|
{
|
||||||
|
firstName = "PhiLog",
|
||||||
|
lastName = "TestPatient",
|
||||||
|
dateOfBirth = "1975-03-15",
|
||||||
|
gender = "male"
|
||||||
|
});
|
||||||
|
|
||||||
|
var listResp = await client.GetAsync("/api/v1/patients?pageSize=1");
|
||||||
|
listResp.EnsureSuccessStatusCode();
|
||||||
|
var list = await listResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var patientId = list.GetProperty("data").GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||||
|
|
||||||
|
await client.GetAsync($"/api/v1/patients/{patientId}");
|
||||||
|
|
||||||
|
client.ClearAuth();
|
||||||
|
client.AsAdmin();
|
||||||
|
var logsResp = await client.GetAsync($"/api/v1/phi-access-logs?patientId={patientId}");
|
||||||
|
logsResp.EnsureSuccessStatusCode();
|
||||||
|
var logs = await logsResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
logs.GetProperty("data").GetProperty("totalCount").GetInt32()
|
||||||
|
.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NameSearch_FindsEncryptedPatient()
|
||||||
|
{
|
||||||
|
var client = _fixture.CreateClient();
|
||||||
|
client.ClearAuth();
|
||||||
|
client.AsAdmin();
|
||||||
|
|
||||||
|
await client.PostAsJsonAsync("/api/v1/patients", new
|
||||||
|
{
|
||||||
|
firstName = "Searchable",
|
||||||
|
lastName = "UniqueName",
|
||||||
|
dateOfBirth = "1985-01-01",
|
||||||
|
gender = "male"
|
||||||
|
});
|
||||||
|
|
||||||
|
var resp = await client.GetAsync("/api/v1/patients?q=Searchable+UniqueName");
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("data").GetProperty("totalCount").GetInt32()
|
||||||
|
.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class PatientPhiMigrationService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<PatientPhiMigrationService> _logger;
|
||||||
|
|
||||||
|
public PatientPhiMigrationService(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<PatientPhiMigrationService> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var crypto = scope.ServiceProvider.GetRequiredService<IPhiEncryptionService>();
|
||||||
|
|
||||||
|
var patients = await db.Patients.ToListAsync(ct);
|
||||||
|
var migrated = 0;
|
||||||
|
|
||||||
|
foreach (var patient in patients)
|
||||||
|
{
|
||||||
|
if (patient.NameSearchToken is not null && crypto.IsEncrypted(patient.FirstName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
patient.NameSearchToken = crypto.ComputeNameSearchToken(
|
||||||
|
patient.FirstName, patient.LastName);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PHI migration: updated {Count} patient search tokens", migrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public static class EncryptPhiCommand
|
||||||
|
{
|
||||||
|
public static async Task RunAsync(IServiceProvider services)
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var crypto = scope.ServiceProvider.GetRequiredService<IPhiEncryptionService>();
|
||||||
|
|
||||||
|
var patients = await db.Patients.ToListAsync();
|
||||||
|
foreach (var p in patients)
|
||||||
|
{
|
||||||
|
p.NameSearchToken = crypto.ComputeNameSearchToken(p.FirstName, p.LastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Console.WriteLine($"Encrypted {patients.Count} patient records.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PHI access log query for compliance (Admin / Compliance).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/phi-access-logs")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.AuditRead)]
|
||||||
|
public class PhiAccessLogsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public PhiAccessLogsController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
/// <summary>Query PHI access logs — who viewed which patient records.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] Guid? patientId,
|
||||||
|
[FromQuery] Guid? userId,
|
||||||
|
[FromQuery] string? accessType,
|
||||||
|
[FromQuery] DateTimeOffset? from,
|
||||||
|
[FromQuery] DateTimeOffset? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50)
|
||||||
|
{
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
var query = _db.PhiAccessLogs.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
|
if (patientId.HasValue)
|
||||||
|
query = query.Where(l => l.PatientId == patientId);
|
||||||
|
if (userId.HasValue)
|
||||||
|
query = query.Where(l => l.UserId == userId);
|
||||||
|
if (!string.IsNullOrEmpty(accessType))
|
||||||
|
query = query.Where(l => l.AccessType.ToDbString() == accessType);
|
||||||
|
if (from.HasValue)
|
||||||
|
query = query.Where(l => l.AccessedAt >= from);
|
||||||
|
if (to.HasValue)
|
||||||
|
query = query.Where(l => l.AccessedAt <= to);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(l => l.AccessedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount = total,
|
||||||
|
totalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Access history for a specific patient — common compliance query.</summary>
|
||||||
|
/// <param name="patientId">Patient id.</param>
|
||||||
|
/// <param name="page">Page number (1-based).</param>
|
||||||
|
/// <param name="pageSize">Results per page.</param>
|
||||||
|
/// <returns>A paginated list of PHI access events for the patient.</returns>
|
||||||
|
[HttpGet("patients/{patientId:guid}")]
|
||||||
|
[AuthorizePermission(ClinicalPermissions.PatientsRead)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ForPatient(
|
||||||
|
Guid patientId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 50)
|
||||||
|
{
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
var query = _db.PhiAccessLogs.AsNoTracking()
|
||||||
|
.Where(l => l.PatientId == patientId);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(l => l.AccessedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount = total,
|
||||||
|
totalPages = (int)Math.Ceiling(total / (double)pageSize)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ public static class PatientPhiConverterConfigurator
|
|||||||
var entity = modelBuilder.Entity<Patient>();
|
var entity = modelBuilder.Entity<Patient>();
|
||||||
|
|
||||||
entity.Property(p => p.FirstName)
|
entity.Property(p => p.FirstName)
|
||||||
|
.HasColumnType("text")
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => crypto.Encrypt(v),
|
v => crypto.Encrypt(v),
|
||||||
v => crypto.Decrypt(v));
|
v => crypto.Decrypt(v));
|
||||||
|
|
||||||
entity.Property(p => p.LastName)
|
entity.Property(p => p.LastName)
|
||||||
|
.HasColumnType("text")
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => crypto.Encrypt(v),
|
v => crypto.Encrypt(v),
|
||||||
v => crypto.Decrypt(v));
|
v => crypto.Decrypt(v));
|
||||||
@@ -24,11 +26,13 @@ public static class PatientPhiConverterConfigurator
|
|||||||
v => v == null ? null : crypto.Decrypt(v));
|
v => v == null ? null : crypto.Decrypt(v));
|
||||||
|
|
||||||
entity.Property(p => p.EmergencyContactName)
|
entity.Property(p => p.EmergencyContactName)
|
||||||
|
.HasColumnType("text")
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => v == null ? null! : crypto.Encrypt(v),
|
v => v == null ? null! : crypto.Encrypt(v),
|
||||||
v => v == null ? null : crypto.Decrypt(v));
|
v => v == null ? null : crypto.Decrypt(v));
|
||||||
|
|
||||||
entity.Property(p => p.EmergencyContactPhone)
|
entity.Property(p => p.EmergencyContactPhone)
|
||||||
|
.HasColumnType("text")
|
||||||
.HasConversion(
|
.HasConversion(
|
||||||
v => v == null ? null! : crypto.Encrypt(v),
|
v => v == null ? null! : crypto.Encrypt(v),
|
||||||
v => v == null ? null : crypto.Decrypt(v));
|
v => v == null ? null : crypto.Decrypt(v));
|
||||||
|
|||||||
+1361
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareClinicalAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class WidenPhiEncryptedColumns : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "last_name",
|
||||||
|
table: "patients",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(100)",
|
||||||
|
oldMaxLength: 100);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "first_name",
|
||||||
|
table: "patients",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(100)",
|
||||||
|
oldMaxLength: 100);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "emergency_contact_phone",
|
||||||
|
table: "patients",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(20)",
|
||||||
|
oldMaxLength: 20,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "emergency_contact_name",
|
||||||
|
table: "patients",
|
||||||
|
type: "text",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(200)",
|
||||||
|
oldMaxLength: 200,
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "last_name",
|
||||||
|
table: "patients",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldMaxLength: 100);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "first_name",
|
||||||
|
table: "patients",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldMaxLength: 100);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "emergency_contact_phone",
|
||||||
|
table: "patients",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldMaxLength: 20,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "emergency_contact_name",
|
||||||
|
table: "patients",
|
||||||
|
type: "character varying(200)",
|
||||||
|
maxLength: 200,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldMaxLength: 200,
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -835,18 +835,18 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("EmergencyContactName")
|
b.Property<string>("EmergencyContactName")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)")
|
.HasColumnType("text")
|
||||||
.HasColumnName("emergency_contact_name");
|
.HasColumnName("emergency_contact_name");
|
||||||
|
|
||||||
b.Property<string>("EmergencyContactPhone")
|
b.Property<string>("EmergencyContactPhone")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)")
|
.HasColumnType("text")
|
||||||
.HasColumnName("emergency_contact_phone");
|
.HasColumnName("emergency_contact_phone");
|
||||||
|
|
||||||
b.Property<string>("FirstName")
|
b.Property<string>("FirstName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)")
|
.HasColumnType("text")
|
||||||
.HasColumnName("first_name");
|
.HasColumnName("first_name");
|
||||||
|
|
||||||
b.Property<string>("Gender")
|
b.Property<string>("Gender")
|
||||||
@@ -858,7 +858,7 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.Property<string>("LastName")
|
b.Property<string>("LastName")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)")
|
.HasColumnType("text")
|
||||||
.HasColumnName("last_name");
|
.HasColumnName("last_name");
|
||||||
|
|
||||||
b.Property<string>("Mrn")
|
b.Property<string>("Mrn")
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ public sealed class ClinicalMetrics
|
|||||||
"FHIR mapping failures.",
|
"FHIR mapping failures.",
|
||||||
labelNames: new[] { "reason" });
|
labelNames: new[] { "reason" });
|
||||||
|
|
||||||
|
public readonly Counter PhiAccessLogsTotal = Metrics.CreateCounter(
|
||||||
|
"phi_access_logs_total",
|
||||||
|
"PHI access log entries written.",
|
||||||
|
labelNames: new[] { "access_type" });
|
||||||
|
|
||||||
// --- Histograms ---
|
// --- Histograms ---
|
||||||
|
|
||||||
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
|
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ try
|
|||||||
builder.Services.AddHostedService<SepsisBundleMonitorService>();
|
builder.Services.AddHostedService<SepsisBundleMonitorService>();
|
||||||
builder.Services.AddHostedService<GcsScoringService>();
|
builder.Services.AddHostedService<GcsScoringService>();
|
||||||
builder.Services.AddHostedService<SofaScoringService>();
|
builder.Services.AddHostedService<SofaScoringService>();
|
||||||
|
builder.Services.AddHostedService<PatientPhiMigrationService>();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
|
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
|
||||||
@@ -343,6 +344,13 @@ try
|
|||||||
|
|
||||||
app.MapMetrics("/metrics");
|
app.MapMetrics("/metrics");
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
if (args.Contains("encrypt-phi"))
|
||||||
|
{
|
||||||
|
await EncryptPhiCommand.RunAsync(app.Services);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ public class PatientService : IPatientService
|
|||||||
AuditAction.PatientRegistered,
|
AuditAction.PatientRegistered,
|
||||||
"Patient",
|
"Patient",
|
||||||
patient.Id,
|
patient.Id,
|
||||||
newValue: new { patient.Mrn, patient.FirstName, patient.LastName });
|
newValue: new { patient.Mrn });
|
||||||
|
|
||||||
|
var path = _http.HttpContext?.Request.Path.Value ?? "/api/v1/patients";
|
||||||
|
await _phiAccess.LogCreateAsync(patient.Id, path);
|
||||||
|
|
||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
@@ -83,11 +86,21 @@ public class PatientService : IPatientService
|
|||||||
var query = _db.Patients.AsQueryable();
|
var query = _db.Patients.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(q))
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
var parts = q.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length == 2)
|
||||||
|
{
|
||||||
|
var token = _crypto.ComputeNameSearchToken(parts[0], parts[1]);
|
||||||
|
query = query.Where(p => p.Mrn == q || p.NameSearchToken == token);
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
query = query.Where(p =>
|
query = query.Where(p =>
|
||||||
p.Mrn == q ||
|
p.Mrn == q ||
|
||||||
EF.Functions.ILike(p.FirstName, $"%{q}%") ||
|
(p.NameSearchToken != null &&
|
||||||
EF.Functions.ILike(p.LastName, $"%{q}%"));
|
(p.NameSearchToken == _crypto.ComputeNameSearchToken(q, "") ||
|
||||||
|
p.NameSearchToken == _crypto.ComputeNameSearchToken("", q))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
@@ -97,6 +110,12 @@ public class PatientService : IPatientService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var path = _http.HttpContext?.Request.Path.Value ?? "/api/v1/patients";
|
||||||
|
await _phiAccess.LogListAsync(path, patients.Count, q);
|
||||||
|
|
||||||
|
foreach (var p in patients)
|
||||||
|
await _phiAccess.LogViewAsync(p.Id, $"{path}?page={page}");
|
||||||
|
|
||||||
return new PagedResult<Patient>(patients, page, pageSize, total);
|
return new PagedResult<Patient>(patients, page, pageSize, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +140,9 @@ public class PatientService : IPatientService
|
|||||||
if (req.EmergencyContactName is not null) patient.EmergencyContactName = req.EmergencyContactName;
|
if (req.EmergencyContactName is not null) patient.EmergencyContactName = req.EmergencyContactName;
|
||||||
if (req.EmergencyContactPhone is not null) patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
if (req.EmergencyContactPhone is not null) patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
||||||
|
|
||||||
|
if (req.FirstName is not null || req.LastName is not null)
|
||||||
|
SetNameSearchToken(patient);
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
await _audit.WriteAsync(
|
await _audit.WriteAsync(
|
||||||
@@ -135,6 +157,9 @@ public class PatientService : IPatientService
|
|||||||
patient.EmergencyContactName, patient.EmergencyContactPhone
|
patient.EmergencyContactName, patient.EmergencyContactPhone
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}";
|
||||||
|
await _phiAccess.LogUpdateAsync(patient.Id, path);
|
||||||
|
|
||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +172,9 @@ public class PatientService : IPatientService
|
|||||||
if (patient is null)
|
if (patient is null)
|
||||||
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||||
|
|
||||||
|
var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}";
|
||||||
|
await _phiAccess.LogViewAsync(patient.Id, path);
|
||||||
|
|
||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +241,8 @@ public class PatientService : IPatientService
|
|||||||
var existingId = await _identifiers.ResolveInternalIdAsync(
|
var existingId = await _identifiers.ResolveInternalIdAsync(
|
||||||
ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue);
|
ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue);
|
||||||
|
|
||||||
|
var fhirPath = _http.HttpContext?.Request.Path.Value ?? "/fhir/R4/Patient";
|
||||||
|
|
||||||
if (existingId.HasValue)
|
if (existingId.HasValue)
|
||||||
{
|
{
|
||||||
var patient = await _db.Patients.FindAsync(existingId.Value)
|
var patient = await _db.Patients.FindAsync(existingId.Value)
|
||||||
@@ -226,12 +256,13 @@ public class PatientService : IPatientService
|
|||||||
patient.Allergies = req.Allergies;
|
patient.Allergies = req.Allergies;
|
||||||
patient.EmergencyContactName = req.EmergencyContactName;
|
patient.EmergencyContactName = req.EmergencyContactName;
|
||||||
patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
||||||
|
SetNameSearchToken(patient);
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
await _phiAccess.LogUpdateAsync(patient.Id, fhirPath);
|
||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use hospital identifier value as MRN when it fits the column constraint (max 20 chars).
|
|
||||||
var mrn = req.IdentifierValue.Length <= 20
|
var mrn = req.IdentifierValue.Length <= 20
|
||||||
? req.IdentifierValue
|
? req.IdentifierValue
|
||||||
: await GenerateMrnAsync();
|
: await GenerateMrnAsync();
|
||||||
@@ -250,6 +281,7 @@ public class PatientService : IPatientService
|
|||||||
EmergencyContactPhone = req.EmergencyContactPhone,
|
EmergencyContactPhone = req.EmergencyContactPhone,
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
SetNameSearchToken(newPatient);
|
||||||
|
|
||||||
_db.Patients.Add(newPatient);
|
_db.Patients.Add(newPatient);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
@@ -260,6 +292,7 @@ public class PatientService : IPatientService
|
|||||||
req.IdentifierSystem,
|
req.IdentifierSystem,
|
||||||
req.IdentifierValue);
|
req.IdentifierValue);
|
||||||
|
|
||||||
|
await _phiAccess.LogCreateAsync(newPatient.Id, fhirPath);
|
||||||
return newPatient;
|
return newPatient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ public class PhiAccessLogService : IPhiAccessLogService
|
|||||||
private readonly ICurrentUserService _currentUser;
|
private readonly ICurrentUserService _currentUser;
|
||||||
private readonly IHttpContextAccessor _http;
|
private readonly IHttpContextAccessor _http;
|
||||||
private readonly PhiEncryptionOptions _options;
|
private readonly PhiEncryptionOptions _options;
|
||||||
|
private readonly ClinicalMetrics _metrics;
|
||||||
|
|
||||||
public PhiAccessLogService(
|
public PhiAccessLogService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
IHttpContextAccessor http,
|
IHttpContextAccessor http,
|
||||||
IOptions<PhiEncryptionOptions> options)
|
IOptions<PhiEncryptionOptions> options,
|
||||||
|
ClinicalMetrics metrics)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_http = http;
|
_http = http;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_metrics = metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
|
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
|
||||||
@@ -68,6 +71,8 @@ public class PhiAccessLogService : IPhiAccessLogService
|
|||||||
});
|
});
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_metrics.PhiAccessLogsTotal.WithLabels(accessType.ToDbString()).Inc();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string HashQuery(string query)
|
private static string HashQuery(string query)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
echo "Re-save all patients through EF to apply encryption converters..."
|
||||||
|
dotnet run --project "${ROOT_DIR}/VigilCareClinicalAPI" --no-build -- encrypt-phi
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/encrypt-existing-patient-phi.sh
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BASE_URL="${BASE_URL:-http://localhost:5270}"
|
||||||
|
|
||||||
|
echo "=== Phase 32 verification ==="
|
||||||
|
|
||||||
|
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \
|
||||||
|
--filter "FullyQualifiedName~PhiEncryption" --no-restore
|
||||||
|
|
||||||
|
TOKEN=$(curl -sf -X POST "${BASE_URL}/api/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin.demo","password":"DemoAdmin1!"}' \
|
||||||
|
| jq -r '.data.accessToken')
|
||||||
|
|
||||||
|
PATIENT_ID=$(curl -sf "${BASE_URL}/api/v1/patients?pageSize=1" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
| jq -r '.data.items[0].id')
|
||||||
|
|
||||||
|
echo "View patient ${PATIENT_ID}"
|
||||||
|
curl -sf "${BASE_URL}/api/v1/patients/${PATIENT_ID}" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" | jq -e '.data.firstName != null'
|
||||||
|
|
||||||
|
echo "Verify PHI access log"
|
||||||
|
curl -sf "${BASE_URL}/api/v1/phi-access-logs?patientId=${PATIENT_ID}" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" | jq -e '.data.totalCount >= 1'
|
||||||
|
|
||||||
|
echo "Verify raw DB encryption (requires psql)"
|
||||||
|
docker compose exec -T postgres psql -U vigilcare -d vigilcare -c \
|
||||||
|
"SELECT id, left(first_name, 20) AS encrypted_prefix FROM patients WHERE id = '${PATIENT_ID}';"
|
||||||
|
|
||||||
|
echo "Phase 32 verification complete."
|
||||||
Reference in New Issue
Block a user