begin: PHI Column Encryption
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
public interface IPhiAccessLogService
|
||||
{
|
||||
Task LogViewAsync(Guid patientId, string resourcePath);
|
||||
Task LogListAsync(string resourcePath, int resultCount, string? searchQuery = null);
|
||||
Task LogCreateAsync(Guid patientId, string resourcePath);
|
||||
Task LogUpdateAsync(Guid patientId, string resourcePath);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
public interface IPhiEncryptionService
|
||||
{
|
||||
string Encrypt(string plaintext);
|
||||
string Decrypt(string ciphertext);
|
||||
string ComputeNameSearchToken(string firstName, string lastName);
|
||||
bool IsEncrypted(string value);
|
||||
}
|
||||
@@ -8,17 +8,26 @@ public class PatientService : IPatientService
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly PatientOptions _options;
|
||||
private readonly IPhiEncryptionService _crypto;
|
||||
private readonly IPhiAccessLogService _phiAccess;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public PatientService(
|
||||
AppDbContext db,
|
||||
IExternalIdentifierService identifiers,
|
||||
IAuditService audit,
|
||||
IOptions<PatientOptions> options)
|
||||
IOptions<PatientOptions> options,
|
||||
IPhiEncryptionService crypto,
|
||||
IPhiAccessLogService phiAccess,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_db = db;
|
||||
_identifiers = identifiers;
|
||||
_audit = audit;
|
||||
_options = options.Value;
|
||||
_crypto = crypto;
|
||||
_phiAccess = phiAccess;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||
@@ -37,6 +46,7 @@ public class PatientService : IPatientService
|
||||
EmergencyContactPhone = req.EmergencyContactPhone,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
SetNameSearchToken(patient);
|
||||
_db.Patients.Add(patient);
|
||||
|
||||
try
|
||||
@@ -260,4 +270,10 @@ public class PatientService : IPatientService
|
||||
.SingleAsync();
|
||||
return $"{_options.MrnPrefix}-{seq.ToString($"D{_options.MrnDigits}")}";
|
||||
}
|
||||
|
||||
private void SetNameSearchToken(Patient patient)
|
||||
{
|
||||
patient.NameSearchToken = _crypto.ComputeNameSearchToken(
|
||||
patient.FirstName, patient.LastName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class PhiAccessLogService : IPhiAccessLogService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly PhiEncryptionOptions _options;
|
||||
|
||||
public PhiAccessLogService(
|
||||
AppDbContext db,
|
||||
ICurrentUserService currentUser,
|
||||
IHttpContextAccessor http,
|
||||
IOptions<PhiEncryptionOptions> options)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
|
||||
await WriteAsync(PhiAccessType.View, patientId, resourcePath);
|
||||
|
||||
public async Task LogCreateAsync(Guid patientId, string resourcePath) =>
|
||||
await WriteAsync(PhiAccessType.Create, patientId, resourcePath);
|
||||
|
||||
public async Task LogUpdateAsync(Guid patientId, string resourcePath) =>
|
||||
await WriteAsync(PhiAccessType.Update, patientId, resourcePath);
|
||||
|
||||
public async Task LogListAsync(string resourcePath, int resultCount, string? searchQuery = null)
|
||||
{
|
||||
if (!_options.LogListAccess)
|
||||
return;
|
||||
|
||||
var accessType = string.IsNullOrWhiteSpace(searchQuery)
|
||||
? PhiAccessType.List
|
||||
: PhiAccessType.Search;
|
||||
|
||||
await WriteAsync(accessType, null, resourcePath, resultCount, searchQuery);
|
||||
}
|
||||
|
||||
private async Task WriteAsync(
|
||||
PhiAccessType accessType,
|
||||
Guid? patientId,
|
||||
string resourcePath,
|
||||
int? resultCount = null,
|
||||
string? searchQuery = null)
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated || _currentUser.UserId is null)
|
||||
return; // machine/integration paths may skip — Phase 31 Integration role should still auth
|
||||
|
||||
_db.PhiAccessLogs.Add(new PhiAccessLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccessType = accessType,
|
||||
PatientId = patientId,
|
||||
UserId = _currentUser.UserId.Value,
|
||||
UserDisplayName = _currentUser.DisplayName ?? _currentUser.Username ?? "Unknown",
|
||||
ResourcePath = resourcePath,
|
||||
SearchQueryHash = searchQuery is null ? null : HashQuery(searchQuery),
|
||||
ResultCount = resultCount,
|
||||
IpAddress = _currentUser.IpAddress,
|
||||
CorrelationId = _http.HttpContext?.Items["CorrelationId"]?.ToString(),
|
||||
AccessedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string HashQuery(string query)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(query.Trim().ToLowerInvariant()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class PhiEncryptionService : IPhiEncryptionService
|
||||
{
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly byte[] _searchKey;
|
||||
|
||||
public PhiEncryptionService(
|
||||
IDataProtectionProvider provider,
|
||||
IOptions<PhiEncryptionOptions> options)
|
||||
{
|
||||
var opts = options.Value;
|
||||
_protector = provider.CreateProtector(opts.ProtectorPurpose);
|
||||
_searchKey = Convert.FromBase64String(
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(opts.SearchTokenKey))[..44]); // normalize dev key
|
||||
}
|
||||
|
||||
public string Encrypt(string plaintext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintext))
|
||||
return plaintext;
|
||||
return _protector.Protect(plaintext);
|
||||
}
|
||||
|
||||
public string Decrypt(string ciphertext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ciphertext))
|
||||
return ciphertext;
|
||||
if (!IsEncrypted(ciphertext))
|
||||
return ciphertext; // migration transition: plaintext rows still readable
|
||||
return _protector.Unprotect(ciphertext);
|
||||
}
|
||||
|
||||
public bool IsEncrypted(string value) =>
|
||||
value.StartsWith("CfDJ8", StringComparison.Ordinal) || // DataProtection prefix
|
||||
value.Length > 50; // heuristic for protected payloads
|
||||
|
||||
public string ComputeNameSearchToken(string firstName, string lastName)
|
||||
{
|
||||
var normalized = $"{firstName.Trim().ToLowerInvariant()}|{lastName.Trim().ToLowerInvariant()}";
|
||||
using var hmac = new HMACSHA256(_searchKey);
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalized));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user