feature: RBAC + Clinical Audit Logging
This commit is contained in:
@@ -6,11 +6,19 @@ public class AlertService : IAlertService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public AlertService(AppDbContext db, IServiceProvider services)
|
||||
public AlertService(
|
||||
AppDbContext db,
|
||||
IServiceProvider services,
|
||||
ICurrentUserService currentUser,
|
||||
IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_services = services;
|
||||
_currentUser = currentUser;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
|
||||
@@ -75,6 +83,12 @@ public class AlertService : IAlertService
|
||||
|
||||
public async Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req)
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated)
|
||||
throw new ValidationException("Authentication required.", "AUTH_REQUIRED");
|
||||
|
||||
var displayName = _currentUser.DisplayName ?? _currentUser.Username
|
||||
?? throw new ValidationException("Authenticated user identity missing.", "AUTH_REQUIRED");
|
||||
|
||||
var alert = await _db.ClinicalAlerts.FindAsync(id);
|
||||
if (alert is null)
|
||||
throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
|
||||
@@ -84,9 +98,10 @@ public class AlertService : IAlertService
|
||||
$"Alert cannot be acknowledged from status '{alert.Status}'.",
|
||||
"ALERT_NOT_ACKNOWLEDGEABLE");
|
||||
|
||||
var previousStatus = alert.Status;
|
||||
alert.Status = AlertStatus.Acknowledged;
|
||||
alert.AcknowledgedAt = DateTimeOffset.UtcNow;
|
||||
alert.AcknowledgedBy = req.ClinicianId;
|
||||
alert.AcknowledgedBy = displayName;
|
||||
|
||||
// Write an outbox event so the Kafka consumer (Phase 6) can cancel the
|
||||
// pending RabbitMQ escalation timer when it sees this acknowledgment.
|
||||
@@ -98,7 +113,7 @@ public class AlertService : IAlertService
|
||||
{
|
||||
alertId = alert.Id,
|
||||
encounterId = alert.EncounterId,
|
||||
acknowledgedBy = req.ClinicianId,
|
||||
acknowledgedBy = displayName,
|
||||
acknowledgedAt = alert.AcknowledgedAt,
|
||||
note = req.Note
|
||||
}),
|
||||
@@ -120,6 +135,25 @@ public class AlertService : IAlertService
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.AlertAcknowledged,
|
||||
"ClinicalAlert",
|
||||
alert.Id,
|
||||
previousValue: new { status = previousStatus.ToDbString() },
|
||||
newValue: new { status = alert.Status.ToDbString(), alert.AcknowledgedBy },
|
||||
reason: req.Note);
|
||||
|
||||
if (alert.AlertType.IsSuppressible())
|
||||
{
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.SuppressionWindowSet,
|
||||
"ClinicalAlert",
|
||||
alert.Id,
|
||||
newValue: new { alert.AlertType, alert.EncounterId },
|
||||
reason: req.Note);
|
||||
}
|
||||
|
||||
return alert;
|
||||
}
|
||||
|
||||
@@ -157,6 +191,13 @@ public class AlertService : IAlertService
|
||||
alert.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.AlertResolved,
|
||||
"ClinicalAlert",
|
||||
alert.Id,
|
||||
previousValue: new { status = AlertStatus.Acknowledged.ToDbString() },
|
||||
newValue: new { status = alert.Status.ToDbString() });
|
||||
|
||||
return alert;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,16 @@ public class AlertThresholdService : IAlertThresholdService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public AlertThresholdService(AppDbContext db, IConnectionMultiplexer redis)
|
||||
public AlertThresholdService(
|
||||
AppDbContext db,
|
||||
IConnectionMultiplexer redis,
|
||||
IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_redis = redis;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<AlertThreshold> CreateAsync(AlertThresholdRequest req)
|
||||
@@ -35,6 +40,19 @@ public class AlertThresholdService : IAlertThresholdService
|
||||
_db.AlertThresholds.Add(threshold);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.ThresholdCreated,
|
||||
"AlertThreshold",
|
||||
threshold.Id,
|
||||
newValue: new
|
||||
{
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh
|
||||
});
|
||||
|
||||
await InvalidateCacheAsync(threshold.ObservationCode);
|
||||
return threshold;
|
||||
}
|
||||
@@ -56,6 +74,16 @@ public class AlertThresholdService : IAlertThresholdService
|
||||
if (threshold is null)
|
||||
throw new NotFoundException("Threshold not found.", "THRESHOLD_NOT_FOUND");
|
||||
|
||||
var previous = new
|
||||
{
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh,
|
||||
threshold.SuppressionWindowMinutes
|
||||
};
|
||||
|
||||
threshold.DisplayName = req.DisplayName;
|
||||
threshold.Unit = req.Unit;
|
||||
threshold.CriticalLow = req.CriticalLow;
|
||||
@@ -64,6 +92,22 @@ public class AlertThresholdService : IAlertThresholdService
|
||||
threshold.CriticalHigh = req.CriticalHigh;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.ThresholdUpdated,
|
||||
"AlertThreshold",
|
||||
threshold.Id,
|
||||
previousValue: previous,
|
||||
newValue: new
|
||||
{
|
||||
threshold.ObservationCode,
|
||||
threshold.CriticalLow,
|
||||
threshold.WarningLow,
|
||||
threshold.WarningHigh,
|
||||
threshold.CriticalHigh,
|
||||
threshold.SuppressionWindowMinutes
|
||||
});
|
||||
|
||||
await InvalidateCacheAsync(threshold.ObservationCode);
|
||||
return threshold;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
|
||||
public class AuditService : IAuditService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ICurrentUserService _currentUser;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public AuditService(
|
||||
AppDbContext db,
|
||||
ICurrentUserService currentUser,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task WriteAsync(
|
||||
AuditAction action,
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
object? previousValue = null,
|
||||
object? newValue = null,
|
||||
string? reason = null)
|
||||
{
|
||||
var correlationId = _http.HttpContext?.Items["CorrelationId"]?.ToString();
|
||||
|
||||
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Action = action,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
UserId = _currentUser.UserId,
|
||||
UserDisplayName = _currentUser.DisplayName ?? _currentUser.Username,
|
||||
PreviousValueJson = previousValue is null ? null : JsonSerializer.Serialize(previousValue),
|
||||
NewValueJson = newValue is null ? null : JsonSerializer.Serialize(newValue),
|
||||
Reason = reason,
|
||||
IpAddress = _currentUser.IpAddress,
|
||||
CorrelationId = correlationId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly JwtOptions _jwt;
|
||||
|
||||
public AuthService(AppDbContext db, IOptions<JwtOptions> jwt)
|
||||
{
|
||||
_db = db;
|
||||
_jwt = jwt.Value;
|
||||
}
|
||||
|
||||
public async Task<LoginResponse> LoginAsync(LoginRequest req)
|
||||
{
|
||||
var user = await _db.ClinicalUsers
|
||||
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
|
||||
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS");
|
||||
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.ClinicalAuditLogs.Add(new ClinicalAuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Action = AuditAction.UserLogin,
|
||||
EntityType = "ClinicalUser",
|
||||
EntityId = user.Id,
|
||||
UserId = user.Id,
|
||||
UserDisplayName = user.DisplayName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var expires = DateTimeOffset.UtcNow.AddMinutes(_jwt.ExpirationMinutes);
|
||||
var token = GenerateToken(user, expires);
|
||||
|
||||
return new LoginResponse(
|
||||
token,
|
||||
expires,
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Role.ToDbString());
|
||||
}
|
||||
|
||||
private string GenerateToken(ClinicalUser user, DateTimeOffset expires)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("display_name", user.DisplayName),
|
||||
new Claim("clinical_role", user.Role.ToDbString()),
|
||||
};
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SigningKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _jwt.Issuer,
|
||||
audience: _jwt.Audience,
|
||||
claims: claims,
|
||||
expires: expires.UtcDateTime,
|
||||
signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
public class CurrentUserService : ICurrentUserService
|
||||
{
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public CurrentUserService(IHttpContextAccessor http) => _http = http;
|
||||
|
||||
public Guid? UserId =>
|
||||
Guid.TryParse(_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
|
||||
? id : null;
|
||||
|
||||
public string? Username => _http.HttpContext?.User.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
public string? DisplayName => _http.HttpContext?.User.FindFirstValue("display_name");
|
||||
|
||||
public ClinicalRole? Role
|
||||
{
|
||||
get
|
||||
{
|
||||
var role = _http.HttpContext?.User.FindFirstValue("clinical_role");
|
||||
return role is null ? null : ClinicalRoleExtensions.FromDbString(role);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAuthenticated => _http.HttpContext?.User.Identity?.IsAuthenticated == true;
|
||||
|
||||
public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
@@ -16,15 +16,18 @@ public class EncounterService : IEncounterService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IQsofaService _qsofa;
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public EncounterService(
|
||||
AppDbContext db,
|
||||
IQsofaService qsofa,
|
||||
IExternalIdentifierService identifiers)
|
||||
IExternalIdentifierService identifiers,
|
||||
IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_qsofa = qsofa;
|
||||
_identifiers = identifiers;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<Encounter> GetByIdAsync(Guid id)
|
||||
@@ -171,6 +174,14 @@ public class EncounterService : IEncounterService
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.EncounterStatusChanged,
|
||||
"Encounter",
|
||||
encounterId,
|
||||
previousValue: new { status = previousStatus.ToDbString() },
|
||||
newValue: new { status = targetStatus.ToDbString(), dischargeDiagnosis });
|
||||
|
||||
return new EncounterStatusTransitionResult(encounterId, targetStatus);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
public interface IAuditService
|
||||
{
|
||||
Task WriteAsync(
|
||||
AuditAction action,
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
object? previousValue = null,
|
||||
object? newValue = null,
|
||||
string? reason = null);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponse> LoginAsync(LoginRequest req);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
string? Username { get; }
|
||||
string? DisplayName { get; }
|
||||
ClinicalRole? Role { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
string? IpAddress { get; }
|
||||
}
|
||||
@@ -5,11 +5,16 @@ public class PatientService : IPatientService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IExternalIdentifierService _identifiers;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public PatientService(AppDbContext db, IExternalIdentifierService identifiers)
|
||||
public PatientService(
|
||||
AppDbContext db,
|
||||
IExternalIdentifierService identifiers,
|
||||
IAuditService audit)
|
||||
{
|
||||
_db = db;
|
||||
_identifiers = identifiers;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
public async Task<Patient> RegisterAsync(RegisterPatientRequest req)
|
||||
@@ -31,6 +36,13 @@ public class PatientService : IPatientService
|
||||
};
|
||||
_db.Patients.Add(patient);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _audit.WriteAsync(
|
||||
AuditAction.PatientRegistered,
|
||||
"Patient",
|
||||
patient.Id,
|
||||
newValue: new { patient.Mrn, patient.FirstName, patient.LastName });
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user