feature: RBAC + Clinical Audit Logging

This commit is contained in:
voltsrage
2026-06-21 15:46:55 +08:00
parent a43db52813
commit 5af6ab490e
83 changed files with 4281 additions and 70 deletions
@@ -19,6 +19,8 @@ public class AppDbContext : DbContext
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalAuditLogConfiguration : IEntityTypeConfiguration<ClinicalAuditLog>
{
public void Configure(EntityTypeBuilder<ClinicalAuditLog> builder)
{
builder.ToTable("clinical_audit_logs");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(a => a.Action).HasColumnName("action").HasMaxLength(50).IsRequired()
.HasConversion(v => v.ToDbString(), v => AuditActionExtensions.FromDbString(v));
builder.Property(a => a.EntityType).HasColumnName("entity_type").HasMaxLength(100).IsRequired();
builder.Property(a => a.EntityId).HasColumnName("entity_id").IsRequired();
builder.Property(a => a.UserId).HasColumnName("user_id");
builder.Property(a => a.UserDisplayName).HasColumnName("user_display_name").HasMaxLength(200);
builder.Property(a => a.PreviousValueJson).HasColumnName("previous_value_json").HasColumnType("jsonb");
builder.Property(a => a.NewValueJson).HasColumnName("new_value_json").HasColumnType("jsonb");
builder.Property(a => a.Reason).HasColumnName("reason");
builder.Property(a => a.IpAddress).HasColumnName("ip_address").HasMaxLength(45);
builder.Property(a => a.CorrelationId).HasColumnName("correlation_id").HasMaxLength(100);
builder.Property(a => a.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
// Append-only — no UPDATE/DELETE from application code
builder.HasIndex(a => a.EntityType);
builder.HasIndex(a => a.EntityId);
builder.HasIndex(a => a.UserId);
builder.HasIndex(a => a.CreatedAt);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalUserConfiguration : IEntityTypeConfiguration<ClinicalUser>
{
public void Configure(EntityTypeBuilder<ClinicalUser> builder)
{
builder.ToTable("clinical_users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(u => u.Username).HasColumnName("username").HasMaxLength(100).IsRequired();
builder.Property(u => u.PasswordHash).HasColumnName("password_hash").HasMaxLength(500).IsRequired();
builder.Property(u => u.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
builder.Property(u => u.Role).HasColumnName("role").HasMaxLength(20).IsRequired()
.HasConversion(v => v.ToDbString(), v => ClinicalRoleExtensions.FromDbString(v));
builder.Property(u => u.IsActive).HasColumnName("is_active").HasDefaultValue(true);
builder.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(u => u.LastLoginAt).HasColumnName("last_login_at");
builder.HasIndex(u => u.Username).IsUnique();
}
}
@@ -97,6 +97,7 @@ public static class DataSeeder
public static async Task SeedThresholdsOnlyAsync(AppDbContext db, IConnectionMultiplexer redis)
{
await db.Database.ExecuteSqlRawAsync("DELETE FROM alert_thresholds");
var thresholds = BuildDefaultThresholds();
db.AlertThresholds.AddRange(thresholds);
await db.SaveChangesAsync();
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
public static class UserSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
if (await db.ClinicalUsers.AnyAsync())
return;
db.ClinicalUsers.AddRange(
new ClinicalUser
{
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
Username = "nurse.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
DisplayName = "Demo Nurse",
Role = ClinicalRole.Nurse,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
Username = "physician.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
DisplayName = "Dr. Demo Physician",
Role = ClinicalRole.Physician,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("33333333-3333-3333-3333-333333333333"),
Username = "admin.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
DisplayName = "Demo Admin",
Role = ClinicalRole.Admin,
CreatedAt = DateTimeOffset.UtcNow
},
new ClinicalUser
{
Id = Guid.Parse("44444444-4444-4444-4444-444444444444"),
Username = "integration.mirth",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("MirthIntegration1!"),
DisplayName = "Mirth Connect",
Role = ClinicalRole.Integration,
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
}