From eaf68c86b06a89b631b2637b6255968c31005470 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 28 Jul 2026 14:17:37 +0200 Subject: [PATCH] Add data model, DbContext and initial migration (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema for identity, vaults, grants, hosts and the sync change log, verified against a real PostgreSQL 18 container rather than an in-memory provider: partial unique indexes, CHECK constraints, citext and identity-always columns are all provider behaviour that an in-memory fake would not exercise. Invariants pushed into the database, so they hold even when application code has a bug: - ck_host_relay_target is a security boundary, not tidiness. A host may carry a plaintext hostname and port ONLY when relay is deliberately enabled. Both directions are tested; the important one is that relay-disabled hosts cannot carry an address, since otherwise a bug would silently give the server infrastructure visibility it was never granted. - ck_vault_owner: exactly one of owner_user_id or team_id, or permission resolution would have no defined answer. - ck_vault_key_grant_recipient: member grants name a user; recovery and escrow grants are wrapped to a key and must not. - ck_user_key_wrap_kdf: a password-derived wrap without its parameters is permanently unopenable, so a partial write is rejected outright. Present from the first migration on purpose: - GrantKind (Member/Recovery/Escrow). Recovery cannot be bolted on later — every vault created before it existed would be unrecoverable by design. - team and team_membership, though team features are M3. Adding them later would mean introducing a foreign key on a live vault table. - Host.ContentKeyId, reserved for per-item content keys wrapped to individual users. - user_key as its own table, so key rotation does not require altering the user row. Two things verified rather than assumed: - Npgsql's UseXminAsConcurrencyToken helper no longer exists in EF 10, so xmin is mapped directly in XminConcurrency. The generated migration *looks* like it creates an xmin column; it does not. Confirmed by inspecting pg_attribute (attnum -2, a system column) and by grepping the emitted DDL. A test pins both, because had it created a real column PostgreSQL would have rejected the name. - EF Core is now pinned centrally. The Npgsql provider asks for 10.0.4 while EntityFrameworkCore.Design pulls 10.0.10, and because Design is PrivateAssets=all that higher version does not flow to referencing projects — producing a CS1705 in any test project referencing Infrastructure. Also commits artifacts/schema/v0.1.sql, the idempotent script, as the baseline for future upgrade tests. Verified: 0 warnings, 122 tests pass (27 new against Postgres), format clean. --- Directory.Packages.props | 22 + DodoSSH.slnx | 1 + artifacts/schema/v0.1.sql | 466 +++++++++ src/DodoSSH.Domain/Enums.cs | 165 +++ src/DodoSSH.Domain/Hosts.cs | 89 ++ src/DodoSSH.Domain/Identity.cs | 242 +++++ src/DodoSSH.Domain/SyncLog.cs | 120 +++ src/DodoSSH.Domain/Teams.cs | 79 ++ src/DodoSSH.Domain/Vaults.cs | 133 +++ .../HostAndSyncConfigurations.cs | 96 ++ .../Configurations/IdentityConfigurations.cs | 181 ++++ .../Configurations/VaultConfigurations.cs | 159 +++ src/DodoSSH.Infrastructure/DodoDbContext.cs | 75 ++ .../DodoDbContextFactory.cs | 30 + .../DodoSSH.Infrastructure.csproj | 9 + .../20260728113419_InitialSchema.Designer.cs | 971 ++++++++++++++++++ .../20260728113419_InitialSchema.cs | 583 +++++++++++ .../Migrations/DodoDbContextModelSnapshot.cs | 968 +++++++++++++++++ src/DodoSSH.Infrastructure/XminConcurrency.cs | 46 + src/DodoSSH.Infrastructure/packages.lock.json | 308 ++++++ .../DodoSSH.Infrastructure.Tests.csproj | 22 + .../PostgresFixture.cs | 58 ++ .../SchemaConstraintTests.cs | 571 ++++++++++ .../packages.lock.json | 449 ++++++++ 24 files changed, 5843 insertions(+) create mode 100644 artifacts/schema/v0.1.sql create mode 100644 src/DodoSSH.Domain/Enums.cs create mode 100644 src/DodoSSH.Domain/Hosts.cs create mode 100644 src/DodoSSH.Domain/Identity.cs create mode 100644 src/DodoSSH.Domain/SyncLog.cs create mode 100644 src/DodoSSH.Domain/Teams.cs create mode 100644 src/DodoSSH.Domain/Vaults.cs create mode 100644 src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs create mode 100644 src/DodoSSH.Infrastructure/Configurations/IdentityConfigurations.cs create mode 100644 src/DodoSSH.Infrastructure/Configurations/VaultConfigurations.cs create mode 100644 src/DodoSSH.Infrastructure/DodoDbContext.cs create mode 100644 src/DodoSSH.Infrastructure/DodoDbContextFactory.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs create mode 100644 src/DodoSSH.Infrastructure/XminConcurrency.cs create mode 100644 tests/DodoSSH.Infrastructure.Tests/DodoSSH.Infrastructure.Tests.csproj create mode 100644 tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs create mode 100644 tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs create mode 100644 tests/DodoSSH.Infrastructure.Tests/packages.lock.json diff --git a/Directory.Packages.props b/Directory.Packages.props index a752abe..f7c0bb1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs b/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs new file mode 100644 index 0000000..0d862ec --- /dev/null +++ b/tests/DodoSSH.Infrastructure.Tests/PostgresFixture.cs @@ -0,0 +1,58 @@ +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; +using Xunit; + +namespace DodoSSH.Infrastructure.Tests; + +/// +/// One PostgreSQL container per test assembly, migrated once. +/// +/// +/// A container per test would dominate the runtime. Tests that write must therefore use distinct +/// identifiers rather than assuming an empty database. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer container = new PostgreSqlBuilder() + .WithImage("postgres:18-alpine") + .WithDatabase("dodossh") + .WithUsername("postgres") + .WithPassword("test") + .Build(); + + /// Connection string for the running container. + public string ConnectionString => container.GetConnectionString(); + + /// + public async ValueTask InitializeAsync() + { + await container.StartAsync(); + + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + } + + /// + public async ValueTask DisposeAsync() => await container.DisposeAsync(); + + /// Creates a context against the container. + public DodoDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString, npgsql => + npgsql.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)) + .UseSnakeCaseNamingConvention() + .Options; + + return new DodoDbContext(options); + } +} + +/// Shares one container across every test class in the assembly. +[CollectionDefinition(Name)] +public sealed class PostgresCollection : ICollectionFixture +{ + /// Collection name. + public const string Name = "postgres"; +} diff --git a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs new file mode 100644 index 0000000..352576b --- /dev/null +++ b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs @@ -0,0 +1,571 @@ +using DodoSSH.Domain; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DodoSSH.Infrastructure.Tests; + +/// +/// Verifies the database enforces the invariants the design depends on. +/// +/// +/// These are not tests of EF. They assert that the constraints hold even if application code has +/// a bug, which is the whole reason for putting them in the schema. The relay-target constraint in +/// particular is a security boundary: see ADR 0004. +/// +[Collection(PostgresCollection.Name)] +public sealed class SchemaConstraintTests(PostgresFixture fixture) +{ + private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Migration_AppliedCleanly_WithNoPendingModelChanges() + { + await using var context = fixture.CreateContext(); + + var applied = await context.Database.GetAppliedMigrationsAsync(); + applied.ShouldNotBeEmpty(); + + (await context.Database.GetPendingMigrationsAsync()).ShouldBeEmpty(); + } + + [Fact] + public async Task Xmin_IsASystemColumnAndNotDeclaredInTheTable() + { + // Npgsql maps xmin as a concurrency token without emitting a column. A negative attnum + // means PostgreSQL's own system column; a positive one would mean we had created a real + // column, which PostgreSQL would in fact have rejected. + await using var context = fixture.CreateContext(); + + var attnum = await context.Database + .SqlQuery($""" + SELECT a.attnum AS "Value" + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'dodo' AND c.relname = 'vault' AND a.attname = 'xmin' + """) + .SingleAsync(); + + attnum.ShouldBeLessThan((short)0); + } + + // ---- ADR 0004: the relay must never learn an address it was not granted ---- + + [Fact] + public async Task Host_WithRelayEnabled_RequiresHostnameAndPort() + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: null, port: null)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_host_relay_target"); + } + + [Fact] + public async Task Host_WithRelayDisabled_MustNotCarryAnAddress() + { + // The important direction. If application code could store a plaintext address without + // the user opting into relay, the server would silently learn infrastructure it was never + // granted visibility of. + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.Hosts.Add(NewHost(vault, relayEnabled: false, hostname: "secret.internal", port: 22)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_host_relay_target"); + } + + [Fact] + public async Task Host_WithRelayEnabledAndAnAddress_IsAccepted() + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: "bastion.internal", port: 22)); + + await context.SaveChangesAsync(); + } + + [Fact] + public async Task Host_WithRelayDisabledAndNoAddress_IsAccepted() + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.Hosts.Add(NewHost(vault, relayEnabled: false, hostname: null, port: null)); + + await context.SaveChangesAsync(); + } + + [Theory] + [InlineData(0)] + [InlineData(65536)] + [InlineData(-1)] + public async Task Host_RejectsAnOutOfRangePort(int port) + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: "h.internal", port: port)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_host_port_range"); + } + + // ---- Vault ownership ---- + + [Fact] + public async Task Vault_MustHaveExactlyOneOwner() + { + await using var context = fixture.CreateContext(); + var user = await SeedUserAsync(context); + var team = SeedTeam(context, user.Id); + await context.SaveChangesAsync(); + + // Both owners set: permission resolution would have no defined answer. + context.Vaults.Add(new Vault + { + Id = Guid.CreateVersion7(), + Name = "ambiguous", + OwnerKind = VaultOwnerKind.Personal, + OwnerUserId = user.Id, + TeamId = team.Id, + CreatedAtUtc = Now, + UpdatedAtUtc = Now, + }); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_vault_owner"); + } + + [Fact] + public async Task Vault_PersonalWithoutAnOwnerUser_IsRejected() + { + await using var context = fixture.CreateContext(); + + context.Vaults.Add(new Vault + { + Id = Guid.CreateVersion7(), + Name = "orphan", + OwnerKind = VaultOwnerKind.Personal, + CreatedAtUtc = Now, + UpdatedAtUtc = Now, + }); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_vault_owner"); + } + + // ---- Grants ---- + + [Fact] + public async Task MemberGrant_RequiresARecipient() + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, recipientUserId: null)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_vault_key_grant_recipient"); + } + + [Fact] + public async Task RecoveryGrant_MustNotNameARecipient() + { + // Recovery and escrow grants are wrapped to a key, not to a user. Allowing a recipient + // would make it ambiguous whether revoking that user revokes recovery. + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + var user = await context.Users.FirstAsync(u => u.Id == vault.OwnerUserId); + + context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Recovery, recipientUserId: user.Id)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_vault_key_grant_recipient"); + } + + [Fact] + public async Task RecoveryGrant_IsAcceptedWithoutARecipient() + { + // Recovery must be expressible in the very first schema, or every vault created before it + // existed would be permanently unrecoverable. + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Recovery, recipientUserId: null)); + + await context.SaveChangesAsync(); + } + + [Fact] + public async Task MemberGrant_IsUniquePerRecipientPerGeneration() + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + var userId = vault.OwnerUserId!.Value; + + context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId)); + await context.SaveChangesAsync(); + + context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + exception.InnerException.ShouldBeOfType() + .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation); + } + + [Fact] + public async Task MemberGrant_MayBeReissuedAfterRevocation() + { + // The uniqueness filter is on revocation, not deletion: revoked grants are retained so + // audit history stays intact, and a rewrap must still be possible. + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + var userId = vault.OwnerUserId!.Value; + + var first = NewGrant(vault, GrantKind.Member, userId); + context.VaultKeyGrants.Add(first); + await context.SaveChangesAsync(); + + first.RevokedAtUtc = Now; + first.State = GrantState.Revoked; + await context.SaveChangesAsync(); + + context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId)); + await context.SaveChangesAsync(); + } + + // ---- Key wraps ---- + + [Fact] + public async Task PassphraseWrap_RequiresKdfParameters() + { + // A password-derived wrap without its parameters is permanently unopenable. + await using var context = fixture.CreateContext(); + var user = await SeedUserAsync(context); + + context.UserKeyWraps.Add(new UserKeyWrap + { + Id = Guid.CreateVersion7(), + UserId = user.Id, + Kind = UserKeyWrapKind.Passphrase, + Wrap = [1, 2, 3], + WrapVersion = 1, + CreatedAtUtc = Now, + }); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_user_key_wrap_kdf"); + } + + [Fact] + public async Task DeviceWrap_RequiresADevice() + { + await using var context = fixture.CreateContext(); + var user = await SeedUserAsync(context); + + context.UserKeyWraps.Add(new UserKeyWrap + { + Id = Guid.CreateVersion7(), + UserId = user.Id, + Kind = UserKeyWrapKind.Device, + Wrap = [1, 2, 3], + WrapVersion = 1, + CreatedAtUtc = Now, + }); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + ConstraintName(exception).ShouldBe("ck_user_key_wrap_device"); + } + + [Fact] + public async Task PassphraseWrap_WithParameters_IsAccepted() + { + await using var context = fixture.CreateContext(); + var user = await SeedUserAsync(context); + + context.UserKeyWraps.Add(new UserKeyWrap + { + Id = Guid.CreateVersion7(), + UserId = user.Id, + Kind = UserKeyWrapKind.Passphrase, + Wrap = [1, 2, 3], + WrapVersion = 1, + KdfAlgorithm = "argon2id", + KdfSalt = new byte[16], + KdfMemoryKibibytes = 262144, + KdfPasses = 4, + KdfParallelism = 1, + CreatedAtUtc = Now, + }); + + await context.SaveChangesAsync(); + } + + // ---- Identity uniqueness ---- + + [Fact] + public async Task User_IsUniquePerIssuerAndSubject() + { + await using var context = fixture.CreateContext(); + var subject = Guid.NewGuid().ToString(); + + context.Users.Add(NewUser("https://idp.example", subject)); + await context.SaveChangesAsync(); + + context.Users.Add(NewUser("https://idp.example", subject)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + exception.InnerException.ShouldBeOfType() + .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation); + } + + [Fact] + public async Task User_MayShareASubjectAcrossDifferentIssuers() + { + // Multi-issuer from the start: the issuer is part of the identity, not a detail. + await using var context = fixture.CreateContext(); + var subject = Guid.NewGuid().ToString(); + + context.Users.Add(NewUser("https://idp-a.example", subject)); + context.Users.Add(NewUser("https://idp-b.example", subject)); + + await context.SaveChangesAsync(); + } + + [Fact] + public async Task Email_IsCaseInsensitivelyUnique() + { + // citext: an attacker must not be able to register Alice@x with alice@x already present. + await using var context = fixture.CreateContext(); + var local = $"user{Guid.NewGuid():N}"; + + var first = NewUser("https://idp.example", Guid.NewGuid().ToString()); + first.Email = $"{local}@example.com"; + context.Users.Add(first); + await context.SaveChangesAsync(); + + var second = NewUser("https://idp.example", Guid.NewGuid().ToString()); + second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM"; + context.Users.Add(second); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + exception.InnerException.ShouldBeOfType() + .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation); + } + + [Fact] + public async Task UserKey_AllowsOnlyOneCurrentGenerationPerUser() + { + // Two current keys would make it ambiguous which one to wrap a vault key to. + await using var context = fixture.CreateContext(); + var user = await SeedUserAsync(context); + + context.UserKeys.Add(NewUserKey(user.Id, generation: 1, isCurrent: true)); + await context.SaveChangesAsync(); + + context.UserKeys.Add(NewUserKey(user.Id, generation: 2, isCurrent: true)); + + var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); + exception.InnerException.ShouldBeOfType() + .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation); + } + + [Fact] + public async Task UserKey_AllowsManySupersededGenerations() + { + await using var context = fixture.CreateContext(); + var user = await SeedUserAsync(context); + + context.UserKeys.Add(NewUserKey(user.Id, generation: 1, isCurrent: false)); + context.UserKeys.Add(NewUserKey(user.Id, generation: 2, isCurrent: false)); + context.UserKeys.Add(NewUserKey(user.Id, generation: 3, isCurrent: true)); + + await context.SaveChangesAsync(); + } + + // ---- Sync log ---- + + [Fact] + public async Task SyncChange_SequenceIsDatabaseAssignedAndMonotonic() + { + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + var first = NewChange(vault.Id); + var second = NewChange(vault.Id); + context.SyncChanges.AddRange(first, second); + await context.SaveChangesAsync(); + + first.Sequence.ShouldBeGreaterThan(0); + second.Sequence.ShouldBeGreaterThan(first.Sequence); + } + + [Fact] + public async Task SyncChange_RejectsACallerSuppliedSequence() + { + // Identity ALWAYS. If anything could choose its own sequence, cursor ordering would stop + // meaning anything and delta sync would silently skip changes. + await using var context = fixture.CreateContext(); + var vault = await SeedVaultAsync(context); + + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO dodo.sync_change + (sequence, vault_id, entity_type, entity_id, operation, revision, actor_user_id, occurred_at_utc) + VALUES (999999, @vault, 1, @entity, 1, 1, @actor, now()) + """; + command.Parameters.AddWithValue("vault", vault.Id); + command.Parameters.AddWithValue("entity", Guid.CreateVersion7()); + command.Parameters.AddWithValue("actor", vault.OwnerUserId!.Value); + + var exception = await Should.ThrowAsync(() => command.ExecuteNonQueryAsync()); + + // 428C9 is generated_always: PostgreSQL refuses a value for a GENERATED ALWAYS column. + exception.SqlState.ShouldBe("428C9"); + } + + // ---- Concurrency ---- + + [Fact] + public async Task Xmin_DetectsAConcurrentUpdate() + { + // Two clients racing a rekey must not silently overwrite one another. + await using var writer = fixture.CreateContext(); + var vault = await SeedVaultAsync(writer); + + await using var contextA = fixture.CreateContext(); + await using var contextB = fixture.CreateContext(); + + var asA = await contextA.Vaults.SingleAsync(v => v.Id == vault.Id); + var asB = await contextB.Vaults.SingleAsync(v => v.Id == vault.Id); + + asA.KeyGeneration = 2; + await contextA.SaveChangesAsync(); + + asB.KeyGeneration = 3; + await Should.ThrowAsync(() => contextB.SaveChangesAsync()); + } + + // ---- Helpers ---- + + private static string? ConstraintName(DbUpdateException exception) => + (exception.InnerException as PostgresException)?.ConstraintName; + + private static UserAccount NewUser(string issuer, string subject) => new() + { + Id = Guid.CreateVersion7(), + Issuer = issuer, + Subject = subject, + Status = UserStatus.Active, + CreatedAtUtc = Now, + UpdatedAtUtc = Now, + }; + + private static async Task SeedUserAsync(DodoDbContext context) + { + var user = NewUser("https://idp.example", Guid.NewGuid().ToString()); + context.Users.Add(user); + await context.SaveChangesAsync(); + return user; + } + + private static Team SeedTeam(DodoDbContext context, Guid createdBy) + { + var team = new Team + { + Id = Guid.CreateVersion7(), + Name = "Team", + Slug = $"team-{Guid.NewGuid():N}", + CreatedByUserId = createdBy, + CreatedAtUtc = Now, + }; + + context.Teams.Add(team); + return team; + } + + private static async Task SeedVaultAsync(DodoDbContext context) + { + var user = await SeedUserAsync(context); + + var vault = new Vault + { + Id = Guid.CreateVersion7(), + Name = "Personal", + OwnerKind = VaultOwnerKind.Personal, + OwnerUserId = user.Id, + KeyGeneration = 1, + CreatedAtUtc = Now, + UpdatedAtUtc = Now, + }; + + context.Vaults.Add(vault); + await context.SaveChangesAsync(); + return vault; + } + + private static Host NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new() + { + Id = Guid.CreateVersion7(), + VaultId = vault.Id, + Payload = [1, 2, 3, 4], + KeyGeneration = vault.KeyGeneration, + PayloadAadVersion = 1, + RelayEnabled = relayEnabled, + Hostname = hostname, + Port = port, + Version = 1, + CreatedAtUtc = Now, + UpdatedAtUtc = Now, + CreatedByUserId = vault.OwnerUserId!.Value, + UpdatedByUserId = vault.OwnerUserId!.Value, + }; + + private static VaultKeyGrant NewGrant(Vault vault, GrantKind kind, Guid? recipientUserId) => new() + { + Id = Guid.CreateVersion7(), + VaultId = vault.Id, + KeyGeneration = vault.KeyGeneration, + Kind = kind, + RecipientUserId = recipientUserId, + RecipientKeyFingerprint = new byte[32], + WrappedKey = [1, 2, 3], + GranterUserId = vault.OwnerUserId!.Value, + GranterKeyFingerprint = new byte[32], + Signature = new byte[64], + State = GrantState.Active, + CreatedAtUtc = Now, + }; + + private static UserKey NewUserKey(Guid userId, int generation, bool isCurrent) => new() + { + Id = Guid.CreateVersion7(), + UserId = userId, + Generation = generation, + EncryptionPublicKey = new byte[32], + SigningPublicKey = new byte[32], + FingerprintSha256 = Guid.NewGuid().ToByteArray().Concat(Guid.NewGuid().ToByteArray()).ToArray(), + Statement = "{}", + StatementSignature = new byte[64], + IsCurrent = isCurrent, + CreatedAtUtc = Now, + }; + + private static SyncChange NewChange(Guid vaultId) => new() + { + VaultId = vaultId, + EntityType = ChangeEntityType.Host, + EntityId = Guid.CreateVersion7(), + Operation = ChangeOperation.Upsert, + Revision = 1, + ActorUserId = Guid.CreateVersion7(), + OccurredAtUtc = Now, + }; +} diff --git a/tests/DodoSSH.Infrastructure.Tests/packages.lock.json b/tests/DodoSSH.Infrastructure.Tests/packages.lock.json new file mode 100644 index 0000000..8382bd2 --- /dev/null +++ b/tests/DodoSSH.Infrastructure.Tests/packages.lock.json @@ -0,0 +1,449 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==", + "dependencies": { + "Testcontainers": "4.13.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3", + "Docker.DotNet.Enhanced.LegacyHttp": "4.3.3", + "Docker.DotNet.Enhanced.NPipe": "4.3.3", + "Docker.DotNet.Enhanced.NativeHttp": "4.3.3", + "Docker.DotNet.Enhanced.Unix": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.13.0", + "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.3.3", + "Docker.DotNet.Enhanced.X509": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.domain": { + "type": "Project" + }, + "dodossh.infrastructure": { + "type": "Project", + "dependencies": { + "DodoSSH.Domain": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" + } + }, + "BouncyCastle.Cryptography": { + "type": "CentralTransitive", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + } + } + } +} \ No newline at end of file