Public Access
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
432 lines
16 KiB
C#
432 lines
16 KiB
C#
using DodoSSH.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
|
|
namespace DodoSSH.Infrastructure.Configurations;
|
|
|
|
/// <summary>Maps <see cref="SshHost"/>.</summary>
|
|
public sealed class HostConfiguration : IEntityTypeConfiguration<SshHost>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<SshHost> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("host");
|
|
builder.HasKey(h => h.Id);
|
|
|
|
// Client-generated UUIDv7: items must be creatable offline, with their ids.
|
|
builder.Property(h => h.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(h => h.Payload).IsRequired();
|
|
builder.Property(h => h.Hostname).HasMaxLength(255);
|
|
|
|
builder.HasIndex(h => new { h.VaultId, h.ChangeSequence });
|
|
|
|
builder.HasIndex(h => h.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_host_vault_live");
|
|
|
|
// The relay resolves its target from this row, so an address is stored only when relay is
|
|
// deliberately enabled for the host. Enforced in the database rather than in application
|
|
// code: a bug that let a host carry a plaintext address without opting in would silently
|
|
// widen what the server can see. See ADR 0004.
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_relay_target",
|
|
"""
|
|
(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)
|
|
OR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)
|
|
"""));
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_port_range",
|
|
"port IS NULL OR (port BETWEEN 1 AND 65535)"));
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="VaultSshKey"/>.</summary>
|
|
/// <remarks>
|
|
/// Mirrors <see cref="HostConfiguration"/> in everything the two types share, and deliberately has no
|
|
/// analogue of the relay CHECK: a key carries no address, which is the reason it is its own table.
|
|
/// </remarks>
|
|
public sealed class SshKeyConfiguration : IEntityTypeConfiguration<VaultSshKey>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultSshKey> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("ssh_key");
|
|
builder.HasKey(k => k.Id);
|
|
|
|
// Client-generated UUIDv7: keys must be creatable offline, with their ids.
|
|
builder.Property(k => k.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(k => k.Payload).IsRequired();
|
|
|
|
// SHA256:base64 of a 32-byte digest is 50 characters; the ceiling leaves room for another
|
|
// algorithm without a migration, and refuses anything that is plainly not a fingerprint.
|
|
builder.Property(k => k.PublicKeyFingerprint).HasMaxLength(128);
|
|
|
|
builder.HasIndex(k => new { k.VaultId, k.ChangeSequence });
|
|
|
|
builder.HasIndex(k => k.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_ssh_key_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_ssh_key_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultCredential"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The narrowest shape any item table takes — shared now with <see cref="KnownHostKeyConfiguration"/> — and
|
|
/// deliberately so: no relay CHECK, and unlike <see cref="SshKeyConfiguration"/> not even a fingerprint
|
|
/// column. There is nothing about a password that is safe to hold in the clear, so there is nothing here but
|
|
/// the envelope and its bookkeeping.
|
|
/// </remarks>
|
|
public sealed class CredentialConfiguration : IEntityTypeConfiguration<VaultCredential>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultCredential> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("credential");
|
|
builder.HasKey(c => c.Id);
|
|
|
|
// Client-generated UUIDv7: credentials must be creatable offline, with their ids.
|
|
builder.Property(c => c.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(c => c.Payload).IsRequired();
|
|
|
|
builder.HasIndex(c => new { c.VaultId, c.ChangeSequence });
|
|
|
|
builder.HasIndex(c => c.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_credential_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_credential_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultKnownHostKey"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Identical to <see cref="CredentialConfiguration"/>, and the sameness is the statement: a pin has no
|
|
/// plaintext column either. It would have been easy to add a <c>host</c> column here for a "known hosts"
|
|
/// screen to list without decrypting, and that column would have handed the operator the map of every user's
|
|
/// estate. The client decrypts its own vault; it does not need the server's help to sort a list.
|
|
/// </remarks>
|
|
public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration<VaultKnownHostKey>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultKnownHostKey> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("known_host_key");
|
|
builder.HasKey(k => k.Id);
|
|
|
|
// Client-generated UUIDv7: a pin must be recordable offline, with its id.
|
|
builder.Property(k => k.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(k => k.Payload).IsRequired();
|
|
|
|
builder.HasIndex(k => new { k.VaultId, k.ChangeSequence });
|
|
|
|
builder.HasIndex(k => k.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_known_host_key_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_known_host_key_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultHostGroup"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same shape again, and by now the sameness is the design rather than a coincidence: most item types
|
|
/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is a
|
|
/// type where plaintext columns would have been tempting and are not defensible — a <c>name</c> here would
|
|
/// let the server order a list it never draws, and a <c>parent_id</c> would draw the shape of every user's
|
|
/// estate, both in exchange for telling the operator how each of them files their machines. Groups nest
|
|
/// through a pointer inside the payload; nothing about that is visible from this table.
|
|
/// </remarks>
|
|
public sealed class HostGroupConfiguration : IEntityTypeConfiguration<VaultHostGroup>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultHostGroup> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("host_group");
|
|
builder.HasKey(g => g.Id);
|
|
|
|
// Client-generated UUIDv7: a group must be creatable offline, with its id, because the host that
|
|
// names it is created offline too and needs something to point at.
|
|
builder.Property(g => g.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(g => g.Payload).IsRequired();
|
|
|
|
builder.HasIndex(g => new { g.VaultId, g.ChangeSequence });
|
|
|
|
builder.HasIndex(g => g.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_host_group_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_group_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultTag"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="HostGroupConfiguration"/> again, down to the index names. The one thing worth stating is what
|
|
/// is <em>not</em> here: no <c>host_tag</c> join table, because membership is a set inside each host's
|
|
/// payload — see <see cref="VaultTag"/> — so there is nothing to join and no second table to keep in step.
|
|
/// </remarks>
|
|
public sealed class TagConfiguration : IEntityTypeConfiguration<VaultTag>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultTag> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("tag");
|
|
builder.HasKey(t => t.Id);
|
|
|
|
// Client-generated UUIDv7: a tag must be creatable offline, with its id, because the host that
|
|
// wears it is created offline too and needs something to point at.
|
|
builder.Property(t => t.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(t => t.Payload).IsRequired();
|
|
|
|
builder.HasIndex(t => new { t.VaultId, t.ChangeSequence });
|
|
|
|
builder.HasIndex(t => t.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_tag_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_tag_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultSnippet"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Identical to <see cref="CredentialConfiguration"/>. A snippet's label would be harmless on its own and the
|
|
/// commands beside it would not be, and splitting one out would give the server half a map for no feature.
|
|
/// </remarks>
|
|
public sealed class SnippetConfiguration : IEntityTypeConfiguration<VaultSnippet>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultSnippet> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("snippet");
|
|
builder.HasKey(s => s.Id);
|
|
|
|
// Client-generated UUIDv7: a snippet must be writable offline, with its id.
|
|
builder.Property(s => s.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(s => s.Payload).IsRequired();
|
|
|
|
builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
|
|
|
|
builder.HasIndex(s => s.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_snippet_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_snippet_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultConnectionLogEntry"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The same envelope-and-bookkeeping shape as every other item kind, and this is the one where holding
|
|
/// something in the clear would have been most useful and least defensible: a <c>started_at</c> column would
|
|
/// let the server order a log without decrypting it, and would hand the operator every user's working hours.
|
|
/// The client sorts its own log.
|
|
/// </para>
|
|
/// <para>
|
|
/// The live index is not decoration here. This is the only table that grows without a person adding
|
|
/// anything to it — a busy user produces entries all day — so the query retention runs, "everything in this
|
|
/// vault that is not already a tombstone", is the one that has to stay cheap.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ConnectionLogEntryConfiguration : IEntityTypeConfiguration<VaultConnectionLogEntry>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultConnectionLogEntry> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("connection_log_entry");
|
|
builder.HasKey(e => e.Id);
|
|
|
|
// Client-generated UUIDv7, and here the ordering it carries is load-bearing rather than incidental:
|
|
// a log is read newest-first, and the id is the only creation time an item has.
|
|
builder.Property(e => e.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(e => e.Payload).IsRequired();
|
|
|
|
builder.HasIndex(e => new { e.VaultId, e.ChangeSequence });
|
|
|
|
builder.HasIndex(e => e.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_connection_log_entry_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_connection_log_entry_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultActivityLogEntry"/>.
|
|
/// </summary>
|
|
/// <inheritdoc cref="ConnectionLogEntryConfiguration" path="/remarks" />
|
|
public sealed class ActivityLogEntryConfiguration : IEntityTypeConfiguration<VaultActivityLogEntry>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultActivityLogEntry> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("activity_log_entry");
|
|
builder.HasKey(e => e.Id);
|
|
|
|
builder.Property(e => e.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(e => e.Payload).IsRequired();
|
|
|
|
builder.HasIndex(e => new { e.VaultId, e.ChangeSequence });
|
|
|
|
builder.HasIndex(e => e.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_activity_log_entry_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_activity_log_entry_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultObjectStore"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="CredentialConfiguration"/> again, which is the right comparison: a secret access key is a
|
|
/// password, and the endpoint beside it is an address on somebody's own network as often as it is Amazon's.
|
|
/// Neither belongs in a column.
|
|
/// </remarks>
|
|
public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration<VaultObjectStore>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultObjectStore> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("object_store");
|
|
builder.HasKey(o => o.Id);
|
|
|
|
// Client-generated UUIDv7: a bucket must be addable offline, with its id.
|
|
builder.Property(o => o.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(o => o.Payload).IsRequired();
|
|
|
|
builder.HasIndex(o => new { o.VaultId, o.ChangeSequence });
|
|
|
|
builder.HasIndex(o => o.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_object_store_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_object_store_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
|
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultChange> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("sync_change");
|
|
builder.HasKey(c => c.Sequence);
|
|
|
|
// Identity ALWAYS rather than BY DEFAULT: nothing may supply its own sequence value, or
|
|
// cursor ordering stops meaning anything.
|
|
builder.Property(c => c.Sequence).UseIdentityAlwaysColumn();
|
|
|
|
builder.Property(c => c.EntityType).HasConversion<int>();
|
|
builder.Property(c => c.Operation).HasConversion<int>();
|
|
|
|
// The delta-pull access path: everything after a cursor, for one vault.
|
|
builder.HasIndex(c => new { c.VaultId, c.Sequence });
|
|
|
|
// Latest change for a given entity, used when resolving a conflict.
|
|
builder.HasIndex(c => new { c.VaultId, c.EntityId, c.Sequence })
|
|
.IsDescending(false, false, true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="SyncOperationReceipt"/>.</summary>
|
|
public sealed class SyncOperationReceiptConfiguration : IEntityTypeConfiguration<SyncOperationReceipt>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<SyncOperationReceipt> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("sync_operation_receipt");
|
|
|
|
// The client-generated operation id is the key, which is what makes a retried push
|
|
// exactly-once per operation rather than per batch.
|
|
builder.HasKey(r => r.OperationId);
|
|
builder.Property(r => r.OperationId).ValueGeneratedNever();
|
|
|
|
builder.HasIndex(r => new { r.VaultId, r.CreatedAtUtc });
|
|
}
|
|
}
|