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
@@ -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;
}
@@ -84,10 +87,20 @@ public class PatientService : IPatientService
if (!string.IsNullOrWhiteSpace(q))
{
query = query.Where(p =>
p.Mrn == q ||
EF.Functions.ILike(p.FirstName, $"%{q}%") ||
EF.Functions.ILike(p.LastName, $"%{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 ||
(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)