using DodoSSH.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace DodoSSH.Infrastructure.Configurations; /// Maps . public sealed class TeamConfiguration : IEntityTypeConfiguration { /// public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.ToTable("team"); builder.HasKey(t => t.Id); builder.Property(t => t.Id).ValueGeneratedNever(); builder.UseXminConcurrencyToken(); builder.Property(t => t.Name).HasMaxLength(256).IsRequired(); builder.Property(t => t.Slug).HasColumnType("citext").HasMaxLength(128).IsRequired(); builder.Property(t => t.Description).HasMaxLength(2048); builder.HasIndex(t => t.Slug) .IsUnique() .HasFilter("deleted_at_utc IS NULL"); builder.HasMany(t => t.Memberships) .WithOne(m => m.Team) .HasForeignKey(m => m.TeamId) .OnDelete(DeleteBehavior.Cascade); } } /// Maps . public sealed class TeamMembershipConfiguration : IEntityTypeConfiguration { /// public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.ToTable("team_membership"); builder.HasKey(m => m.Id); builder.Property(m => m.Id).ValueGeneratedNever(); builder.UseXminConcurrencyToken(); builder.Property(m => m.Role).HasConversion(); builder.Property(m => m.Status).HasConversion(); builder.HasOne(m => m.User) .WithMany() .HasForeignKey(m => m.UserId) .OnDelete(DeleteBehavior.Cascade); builder.HasIndex(m => new { m.TeamId, m.UserId }) .IsUnique() .HasFilter("deleted_at_utc IS NULL"); // Effective-permission resolution starts from the user, so this is the hot direction. builder.HasIndex(m => m.UserId); } } /// Maps . public sealed class TeamInvitationConfiguration : IEntityTypeConfiguration { /// public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.ToTable("team_invitation"); builder.HasKey(i => i.Id); builder.Property(i => i.Id).ValueGeneratedNever(); builder.UseXminConcurrencyToken(); // citext, matching user_account.email: the claim at sign-in compares an address the identity // provider chose the casing of against one a person typed, and lower() on both sides of that // is a rule somebody eventually forgets on one side. builder.Property(i => i.Email).HasColumnType("citext").HasMaxLength(320).IsRequired(); builder.Property(i => i.Role).HasConversion(); builder.HasOne(i => i.Team) .WithMany(t => t.Invitations) .HasForeignKey(i => i.TeamId) .OnDelete(DeleteBehavior.Cascade); // One live invitation per address per team. Filtered on the two tombstones rather than on a // deletion marker, as vault_key_grant is: an accepted or withdrawn invitation is kept, and // re-inviting an address whose invitation lapsed has to be possible. // // Expiry is deliberately not in this predicate. A partial index predicate must be IMMUTABLE, // so now() cannot appear in one; an expired invitation therefore still holds the slot, and // the service treats replacing one as a revoke-and-reissue rather than a second insert. builder.HasIndex(i => new { i.TeamId, i.Email }) .IsUnique() .HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL"); // The claim at sign-in knows the address and nothing else — it is looking for every team that // invited this person, across all of them — so the address is the hot direction here. builder.HasIndex(i => i.Email); } } /// Maps . public sealed class VaultConfiguration : IEntityTypeConfiguration { /// public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.ToTable("vault"); builder.HasKey(v => v.Id); builder.Property(v => v.Id).ValueGeneratedNever(); builder.UseXminConcurrencyToken(); builder.Property(v => v.Name).HasMaxLength(256).IsRequired(); builder.Property(v => v.OwnerKind).HasConversion(); builder.Property(v => v.RekeyReason).HasConversion(); builder.HasOne(v => v.OwnerUser) .WithMany() .HasForeignKey(v => v.OwnerUserId) .OnDelete(DeleteBehavior.Restrict); builder.HasOne(v => v.Team) .WithMany() .HasForeignKey(v => v.TeamId) .OnDelete(DeleteBehavior.Restrict); builder.HasIndex(v => v.OwnerUserId); builder.HasIndex(v => v.TeamId); // Exactly one owner. Without this a vault could end up owned by both or neither, and // permission resolution would have no defined answer. builder.ToTable(t => t.HasCheckConstraint( "ck_vault_owner", """ (owner_kind = 1 AND owner_user_id IS NOT NULL AND team_id IS NULL) OR (owner_kind = 2 AND team_id IS NOT NULL AND owner_user_id IS NULL) """)); builder.ToTable(t => t.HasCheckConstraint( "ck_vault_key_generation", "key_generation >= 1")); builder.HasMany(v => v.KeyGrants) .WithOne(g => g.Vault) .HasForeignKey(g => g.VaultId) .OnDelete(DeleteBehavior.Cascade); builder.HasMany(v => v.Hosts) .WithOne(h => h.Vault) .HasForeignKey(h => h.VaultId) .OnDelete(DeleteBehavior.Cascade); } } /// Maps . public sealed class VaultKeyGrantConfiguration : IEntityTypeConfiguration { /// public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.ToTable("vault_key_grant"); builder.HasKey(g => g.Id); builder.Property(g => g.Id).ValueGeneratedNever(); builder.UseXminConcurrencyToken(); builder.Property(g => g.Kind).HasConversion(); builder.Property(g => g.State).HasConversion(); builder.Property(g => g.RecipientKeyFingerprint).HasMaxLength(32).IsRequired(); builder.Property(g => g.WrappedKey).IsRequired(); builder.Property(g => g.GranterKeyFingerprint).HasMaxLength(32).IsRequired(); builder.Property(g => g.KeyLogHead).HasMaxLength(32); builder.Property(g => g.Signature).HasMaxLength(64).IsRequired(); builder.HasOne(g => g.RecipientUser) .WithMany() .HasForeignKey(g => g.RecipientUserId) .OnDelete(DeleteBehavior.Cascade); // One live member grant per recipient per generation. Revoked rows are retained, so the // filter is on revocation rather than on deletion. builder.HasIndex(g => new { g.VaultId, g.KeyGeneration, g.RecipientUserId }) .IsUnique() .HasFilter("revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL"); builder.HasIndex(g => g.RecipientUserId); // A member grant names a user; recovery and escrow grants do not. builder.ToTable(t => t.HasCheckConstraint( "ck_vault_key_grant_recipient", "(kind = 1 AND recipient_user_id IS NOT NULL) OR (kind <> 1 AND recipient_user_id IS NULL)")); } }