feature: PHI Column Encryption + Access Logging
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user