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)"));
}
}