feature: PHI Column Encryption + Access Logging

This commit is contained in:
voltsrage
2026-06-22 23:43:48 +08:00
parent 46db694820
commit 1824e4eef1
15 changed files with 1832 additions and 11 deletions
@@ -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));
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 +
+8
View File
@@ -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)
+11
View File
@@ -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
+33
View File
@@ -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."