193 lines
9.0 KiB
C#
193 lines
9.0 KiB
C#
using System.Reflection;
|
|
using Baya.Application.Contracts.Common;
|
|
using Baya.Domain.Common;
|
|
using Baya.Domain.Entities.Booking;
|
|
using Baya.Domain.Entities.Identity;
|
|
using Baya.Domain.Entities.Payments;
|
|
using Baya.Domain.Entities.User;
|
|
using Baya.Domain.Entities.Verification;
|
|
using Baya.Infrastructure.Persistence.ValueConversion;
|
|
using Baya.SharedKernel.Extensions;
|
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Baya.Infrastructure.Persistence;
|
|
|
|
public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim, UserRole, UserLogin, RoleClaim, UserToken>
|
|
{
|
|
private readonly IFieldEncryptor _fieldEncryptor;
|
|
|
|
// The encryptor ends up captured inside the cached EF model (value converters), so it must be a
|
|
// process-wide singleton with stable keys — which is how the seam is registered.
|
|
public ApplicationDbContext(DbContextOptions options, IFieldEncryptor fieldEncryptor)
|
|
: base(options)
|
|
{
|
|
_fieldEncryptor = fieldEncryptor;
|
|
base.SavingChanges += OnSavingChanges;
|
|
}
|
|
|
|
private void OnSavingChanges(object sender, SavingChangesEventArgs e)
|
|
{
|
|
_cleanString();
|
|
_syncUserPhoneIntegrity();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps the deterministic <c>PhoneHash</c> lookup column in step with the encrypted phone, and
|
|
/// enforces the product rule that a phone change invalidates the Shahkar phone↔national-id binding
|
|
/// (reset to NULL so b6 re-verifies) — centrally, so no handler can forget it.
|
|
/// </summary>
|
|
private void _syncUserPhoneIntegrity()
|
|
{
|
|
foreach (var entry in ChangeTracker.Entries<User>())
|
|
{
|
|
if (entry.State == EntityState.Added)
|
|
{
|
|
entry.Entity.PhoneHash = _fieldEncryptor.Hash(entry.Entity.PhoneNumber);
|
|
}
|
|
else if (entry.State == EntityState.Modified)
|
|
{
|
|
var phone = entry.Property(u => u.PhoneNumber);
|
|
if (!string.Equals(phone.OriginalValue, phone.CurrentValue, StringComparison.Ordinal))
|
|
{
|
|
entry.Entity.PhoneHash = _fieldEncryptor.Hash(phone.CurrentValue);
|
|
entry.Entity.ShahkarVerifiedAt = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void _cleanString()
|
|
{
|
|
var changedEntities = ChangeTracker.Entries()
|
|
.Where(x => x.State == EntityState.Added || x.State == EntityState.Modified);
|
|
foreach (var item in changedEntities)
|
|
{
|
|
if (item.Entity == null)
|
|
continue;
|
|
|
|
var properties = item.Entity.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
|
.Where(p => p.CanRead && p.CanWrite && p.PropertyType == typeof(string));
|
|
|
|
foreach (var property in properties)
|
|
{
|
|
var propName = property.Name;
|
|
var val = (string)property.GetValue(item.Entity, null);
|
|
|
|
if (val.HasValue())
|
|
{
|
|
var newVal = val.Fa2En().FixPersianChars();
|
|
if (newVal == val)
|
|
continue;
|
|
property.SetValue(item.Entity, newVal, null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
var entitiesAssembly = typeof(IEntity).Assembly;
|
|
modelBuilder.RegisterAllEntities<IEntity>(entitiesAssembly);
|
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
|
|
modelBuilder.AddRestrictDeleteBehaviorConvention();
|
|
modelBuilder.AddPluralizingTableNameConvention();
|
|
|
|
// PII encrypted at rest via the seam. These columns are equality-unqueryable by design —
|
|
// phone lookups use PhoneHash. Applied here (not in UserConfig) because the converter needs
|
|
// the encryptor instance.
|
|
var encrypted = new EncryptedStringConverter(_fieldEncryptor);
|
|
modelBuilder.Entity<User>(builder =>
|
|
{
|
|
builder.Property(u => u.PhoneNumber).HasConversion(encrypted);
|
|
builder.Property(u => u.Email).HasConversion(encrypted);
|
|
builder.Property(u => u.NormalizedEmail).HasConversion(encrypted);
|
|
builder.Property(u => u.NationalId).HasConversion(encrypted);
|
|
});
|
|
|
|
// b3 PII: emergency contacts, clinical notes, IBAN and the account-holder name are encrypted at
|
|
// rest through the same seam. The IBAN's deterministic lookup uses the iban_hash column instead.
|
|
modelBuilder.Entity<CustomerProfile>(builder =>
|
|
{
|
|
builder.Property(c => c.DefaultEmergencyContactName).HasConversion(encrypted);
|
|
builder.Property(c => c.DefaultEmergencyContactPhone).HasConversion(encrypted);
|
|
});
|
|
modelBuilder.Entity<Patient>(builder =>
|
|
{
|
|
builder.Property(p => p.InitialMedicalNotes).HasConversion(encrypted);
|
|
});
|
|
modelBuilder.Entity<NurseBankAccount>(builder =>
|
|
{
|
|
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
|
|
builder.Property(a => a.Iban).HasConversion(encrypted);
|
|
});
|
|
|
|
// b4 address PII: street line, postal code and recipient contact are encrypted at rest through
|
|
// the same seam; the title label and coordinates are not PII and stay plaintext.
|
|
modelBuilder.Entity<CustomerAddress>(builder =>
|
|
{
|
|
builder.Property(a => a.AddressLine).HasConversion(encrypted);
|
|
builder.Property(a => a.PostalCode).HasConversion(encrypted);
|
|
builder.Property(a => a.RecipientName).HasConversion(encrypted);
|
|
builder.Property(a => a.RecipientPhone).HasConversion(encrypted);
|
|
});
|
|
|
|
// b6 credential PII: the license/membership number is encrypted at rest through the same seam and
|
|
// is never serialized on the wire (the trust badge exposes credential types, never numbers).
|
|
modelBuilder.Entity<NurseCredential>(builder =>
|
|
{
|
|
builder.Property(c => c.CredentialNumber).HasConversion(encrypted);
|
|
});
|
|
|
|
// b9 snapshot + clinical PII: the frozen address snapshot and every booking_care_instructions field
|
|
// are encrypted at rest through the same seam. The care fields are the stage-2 clinical disclosure —
|
|
// decrypted only in the gated care-instructions read (assigned nurse + admin, post-confirmation).
|
|
modelBuilder.Entity<Booking>(builder =>
|
|
{
|
|
builder.Property(b => b.AddressSnapshotJson).HasConversion(encrypted);
|
|
});
|
|
modelBuilder.Entity<BookingCareInstruction>(builder =>
|
|
{
|
|
builder.Property(c => c.CurrentConditions).HasConversion(encrypted);
|
|
builder.Property(c => c.Medications).HasConversion(encrypted);
|
|
builder.Property(c => c.Allergies).HasConversion(encrypted);
|
|
builder.Property(c => c.SpecialInstructions).HasConversion(encrypted);
|
|
builder.Property(c => c.EmergencyContactName).HasConversion(encrypted);
|
|
builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted);
|
|
});
|
|
|
|
// b10 gateway config: provider-selection/failover config (merchant id, terminal/IBAN registration,
|
|
// base url, sandbox flag) is encrypted at rest through the same seam and never logged in plaintext.
|
|
modelBuilder.Entity<PaymentGateway>(builder =>
|
|
{
|
|
builder.Property(g => g.ConfigJson).HasConversion(encrypted);
|
|
});
|
|
|
|
// b13 payout snapshot: the nurse's IBAN is frozen onto each payout at build time and encrypted at rest
|
|
// through the same seam. Reads mask it to the last 4 digits — the plaintext IBAN is never serialized.
|
|
modelBuilder.Entity<Baya.Domain.Entities.Payouts.NursePayout>(builder =>
|
|
{
|
|
builder.Property(p => p.IbanSnapshot).HasConversion(encrypted);
|
|
});
|
|
|
|
// b15 partner-center settlement account: the center's IBAN (only when merchant-of-record) is encrypted
|
|
// at rest through the same seam and never serialized in plaintext — reads mask it to the last 4 digits.
|
|
modelBuilder.Entity<Baya.Domain.Entities.PartnerCenters.PartnerCenter>(builder =>
|
|
{
|
|
builder.Property(c => c.SettlementIban).HasConversion(encrypted);
|
|
});
|
|
|
|
// refinement-phase-9 §9.5: ticket message bodies are the refund/dispute paper trail — users type phone
|
|
// numbers, addresses and clinical detail into them — so they are encrypted at rest through the same seam.
|
|
// The plaintext-length limit (4000) stays a boundary-validation rule; the stored ciphertext column is
|
|
// widened to nvarchar(max) in TicketMessageConfig. Body is never a search/filter predicate (the admin
|
|
// thread read decrypts per row), so losing SQL-searchability on it is an accepted trade-off.
|
|
modelBuilder.Entity<Baya.Domain.Entities.Messaging.TicketMessage>(builder =>
|
|
{
|
|
builder.Property(m => m.Body).HasConversion(encrypted);
|
|
});
|
|
}
|
|
} |