Public Access
The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE — so one place enforces revisions, the change log and access control. The concurrency hazard, now proven rather than asserted: bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A can take sequence 5 while B takes 6 and commits first. A reader polling in between sees only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it would pass just as happily if the interleaving never occurred — then shows pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps. Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly worse than an error a client can resync from. Rejected: tampered tag, tampered payload, foreign signing key, a legitimately-issued cursor from another vault, truncation, and hostile input (never throws — cursors come from clients). Push semantics: - 200 even on partial failure, with per-operation status, so one stale item cannot block everything a client queued while offline. - Conflict returns the server's current row for client-side three-way merge. The server cannot merge ciphertext, so never last-writer-wins. - opId receipts make retries exactly-once per operation, not per batch — a client retrying a partially-overlapping batch after a timeout would otherwise double-apply what landed. - A tombstone beats a late upsert, and delete clears hostname/port: leaving the address would keep the server able to resolve a host the user believes they deleted. - Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather than a constraint violation surfacing as a 500. Authorization goes through IVaultAccessService, which returns the same answer for "absent" and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids. Team vaults are explicitly denied until M3 rather than falling through to a permissive default. JIT provisioning keys on (issuer, subject), never email, and handles the concurrent-first-request race via the unique index. Renamed two domain types: Host -> SshHost, because Host collides with Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange -> VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every use site would have been permanent friction. Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type names. The snapshot was regenerated and the emitted DDL diffed against the previous artifacts/schema/v0.1.sql to confirm the rename produced no schema change. Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing parallelization limits, which is why MA0004 is suppressed in test projects. Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean. Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two routes. The service-layer authorization and the concurrency property are covered.
572 lines
20 KiB
C#
572 lines
20 KiB
C#
using DodoSSH.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Npgsql;
|
|
|
|
namespace DodoSSH.Infrastructure.Tests;
|
|
|
|
/// <summary>
|
|
/// Verifies the database enforces the invariants the design depends on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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<short>($"""
|
|
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<DbUpdateException>(() => 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<DbUpdateException>(() => 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<DbUpdateException>(() => 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<DbUpdateException>(() => 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<DbUpdateException>(() => 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<DbUpdateException>(() => 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<DbUpdateException>(() => 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<DbUpdateException>(() => context.SaveChangesAsync());
|
|
exception.InnerException.ShouldBeOfType<PostgresException>()
|
|
.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<DbUpdateException>(() => 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<DbUpdateException>(() => 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.CreateVersion7().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<DbUpdateException>(() => context.SaveChangesAsync());
|
|
exception.InnerException.ShouldBeOfType<PostgresException>()
|
|
.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.CreateVersion7().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.CreateVersion7():N}";
|
|
|
|
var first = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
|
|
first.Email = $"{local}@example.com";
|
|
context.Users.Add(first);
|
|
await context.SaveChangesAsync();
|
|
|
|
var second = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
|
|
second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM";
|
|
context.Users.Add(second);
|
|
|
|
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
|
|
exception.InnerException.ShouldBeOfType<PostgresException>()
|
|
.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<DbUpdateException>(() => context.SaveChangesAsync());
|
|
exception.InnerException.ShouldBeOfType<PostgresException>()
|
|
.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.VaultChanges.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<PostgresException>(() => 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<DbUpdateConcurrencyException>(() => 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<UserAccount> SeedUserAsync(DodoDbContext context)
|
|
{
|
|
var user = NewUser("https://idp.example", Guid.CreateVersion7().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.CreateVersion7():N}",
|
|
CreatedByUserId = createdBy,
|
|
CreatedAtUtc = Now,
|
|
};
|
|
|
|
context.Teams.Add(team);
|
|
return team;
|
|
}
|
|
|
|
private static async Task<Vault> 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 SshHost 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.CreateVersion7().ToByteArray().Concat(Guid.CreateVersion7().ToByteArray()).ToArray(),
|
|
Statement = "{}",
|
|
StatementSignature = new byte[64],
|
|
IsCurrent = isCurrent,
|
|
CreatedAtUtc = Now,
|
|
};
|
|
|
|
private static VaultChange NewChange(Guid vaultId) => new()
|
|
{
|
|
VaultId = vaultId,
|
|
EntityType = ChangeEntityType.SshHost,
|
|
EntityId = Guid.CreateVersion7(),
|
|
Operation = ChangeOperation.Upsert,
|
|
Revision = 1,
|
|
ActorUserId = Guid.CreateVersion7(),
|
|
OccurredAtUtc = Now,
|
|
};
|
|
}
|