feature: PHI Column Encryption + Access Logging
This commit is contained in:
@@ -11,6 +11,7 @@ public static class DbResetHelper
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
DELETE FROM phi_access_logs;
|
||||
DELETE FROM medication_administrations;
|
||||
DELETE FROM sepsis_bundle_elements;
|
||||
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>();
|
||||
|
||||
entity.Property(p => p.FirstName)
|
||||
.HasColumnType("text")
|
||||
.HasConversion(
|
||||
v => crypto.Encrypt(v),
|
||||
v => crypto.Decrypt(v));
|
||||
|
||||
entity.Property(p => p.LastName)
|
||||
.HasColumnType("text")
|
||||
.HasConversion(
|
||||
v => crypto.Encrypt(v),
|
||||
v => crypto.Decrypt(v));
|
||||
@@ -24,11 +26,13 @@ public static class PatientPhiConverterConfigurator
|
||||
v => v == null ? null : crypto.Decrypt(v));
|
||||
|
||||
entity.Property(p => p.EmergencyContactName)
|
||||
.HasColumnType("text")
|
||||
.HasConversion(
|
||||
v => v == null ? null! : crypto.Encrypt(v),
|
||||
v => v == null ? null : crypto.Decrypt(v));
|
||||
|
||||
entity.Property(p => p.EmergencyContactPhone)
|
||||
.HasColumnType("text")
|
||||
.HasConversion(
|
||||
v => v == null ? null! : crypto.Encrypt(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")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("emergency_contact_name");
|
||||
|
||||
b.Property<string>("EmergencyContactPhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("emergency_contact_phone");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("Gender")
|
||||
@@ -858,7 +858,7 @@ namespace VigilCareClinicalAPI.Migrations
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.Property<string>("Mrn")
|
||||
|
||||
@@ -78,6 +78,11 @@ public sealed class ClinicalMetrics
|
||||
"FHIR mapping failures.",
|
||||
labelNames: new[] { "reason" });
|
||||
|
||||
public readonly Counter PhiAccessLogsTotal = Metrics.CreateCounter(
|
||||
"phi_access_logs_total",
|
||||
"PHI access log entries written.",
|
||||
labelNames: new[] { "access_type" });
|
||||
|
||||
// --- Histograms ---
|
||||
|
||||
// Measures the full ingest transaction: Redis cache lookup + alert evaluation +
|
||||
|
||||
@@ -216,6 +216,7 @@ try
|
||||
builder.Services.AddHostedService<SepsisBundleMonitorService>();
|
||||
builder.Services.AddHostedService<GcsScoringService>();
|
||||
builder.Services.AddHostedService<SofaScoringService>();
|
||||
builder.Services.AddHostedService<PatientPhiMigrationService>();
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
|
||||
@@ -343,6 +344,13 @@ try
|
||||
|
||||
app.MapMetrics("/metrics");
|
||||
app.MapControllers();
|
||||
|
||||
if (args.Contains("encrypt-phi"))
|
||||
{
|
||||
await EncryptPhiCommand.RunAsync(app.Services);
|
||||
return;
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
}
|
||||
|
||||
@@ -65,7 +65,10 @@ public class PatientService : IPatientService
|
||||
AuditAction.PatientRegistered,
|
||||
"Patient",
|
||||
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;
|
||||
}
|
||||
@@ -83,11 +86,21 @@ public class PatientService : IPatientService
|
||||
var query = _db.Patients.AsQueryable();
|
||||
|
||||
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 =>
|
||||
p.Mrn == q ||
|
||||
EF.Functions.ILike(p.FirstName, $"%{q}%") ||
|
||||
EF.Functions.ILike(p.LastName, $"%{q}%"));
|
||||
(p.NameSearchToken != null &&
|
||||
(p.NameSearchToken == _crypto.ComputeNameSearchToken(q, "") ||
|
||||
p.NameSearchToken == _crypto.ComputeNameSearchToken("", q))));
|
||||
}
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
@@ -97,6 +110,12 @@ public class PatientService : IPatientService
|
||||
.Take(pageSize)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -121,6 +140,9 @@ public class PatientService : IPatientService
|
||||
if (req.EmergencyContactName is not null) patient.EmergencyContactName = req.EmergencyContactName;
|
||||
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 _audit.WriteAsync(
|
||||
@@ -135,6 +157,9 @@ public class PatientService : IPatientService
|
||||
patient.EmergencyContactName, patient.EmergencyContactPhone
|
||||
});
|
||||
|
||||
var path = _http.HttpContext?.Request.Path.Value ?? $"/api/v1/patients/{id}";
|
||||
await _phiAccess.LogUpdateAsync(patient.Id, path);
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
@@ -147,6 +172,9 @@ public class PatientService : IPatientService
|
||||
if (patient is null)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -213,6 +241,8 @@ public class PatientService : IPatientService
|
||||
var existingId = await _identifiers.ResolveInternalIdAsync(
|
||||
ExternalResourceType.Patient, req.IdentifierSystem, req.IdentifierValue);
|
||||
|
||||
var fhirPath = _http.HttpContext?.Request.Path.Value ?? "/fhir/R4/Patient";
|
||||
|
||||
if (existingId.HasValue)
|
||||
{
|
||||
var patient = await _db.Patients.FindAsync(existingId.Value)
|
||||
@@ -226,12 +256,13 @@ public class PatientService : IPatientService
|
||||
patient.Allergies = req.Allergies;
|
||||
patient.EmergencyContactName = req.EmergencyContactName;
|
||||
patient.EmergencyContactPhone = req.EmergencyContactPhone;
|
||||
SetNameSearchToken(patient);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _phiAccess.LogUpdateAsync(patient.Id, fhirPath);
|
||||
return patient;
|
||||
}
|
||||
|
||||
// Use hospital identifier value as MRN when it fits the column constraint (max 20 chars).
|
||||
var mrn = req.IdentifierValue.Length <= 20
|
||||
? req.IdentifierValue
|
||||
: await GenerateMrnAsync();
|
||||
@@ -250,6 +281,7 @@ public class PatientService : IPatientService
|
||||
EmergencyContactPhone = req.EmergencyContactPhone,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
SetNameSearchToken(newPatient);
|
||||
|
||||
_db.Patients.Add(newPatient);
|
||||
await _db.SaveChangesAsync();
|
||||
@@ -260,6 +292,7 @@ public class PatientService : IPatientService
|
||||
req.IdentifierSystem,
|
||||
req.IdentifierValue);
|
||||
|
||||
await _phiAccess.LogCreateAsync(newPatient.Id, fhirPath);
|
||||
return newPatient;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,20 @@ public class PhiAccessLogService : IPhiAccessLogService
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly PhiEncryptionOptions _options;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
|
||||
public PhiAccessLogService(
|
||||
AppDbContext db,
|
||||
ICurrentUserService currentUser,
|
||||
IHttpContextAccessor http,
|
||||
IOptions<PhiEncryptionOptions> options)
|
||||
IOptions<PhiEncryptionOptions> options,
|
||||
ClinicalMetrics metrics)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
|
||||
@@ -68,6 +71,8 @@ public class PhiAccessLogService : IPhiAccessLogService
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_metrics.PhiAccessLogsTotal.WithLabels(accessType.ToDbString()).Inc();
|
||||
}
|
||||
|
||||
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