Files
DodoSSH/artifacts/schema/v0.1.sql
T
jaap-jan eaf68c86b0 Add data model, DbContext and initial migration (M1)
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.
2026-07-28 14:17:37 +02:00

467 lines
18 KiB
SQL

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
CREATE SCHEMA dodo;
END IF;
END $EF$;
CREATE TABLE IF NOT EXISTS dodo."__EFMigrationsHistory" (
migration_id character varying(150) NOT NULL,
product_version character varying(32) NOT NULL,
CONSTRAINT pk___ef_migrations_history PRIMARY KEY (migration_id)
);
START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
CREATE SCHEMA dodo;
END IF;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
CREATE SCHEMA dodo;
END IF;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE EXTENSION IF NOT EXISTS citext;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.key_log (
sequence bigint GENERATED ALWAYS AS IDENTITY,
user_id uuid NOT NULL,
generation integer NOT NULL,
encryption_public_key bytea NOT NULL,
signing_public_key bytea NOT NULL,
statement_signature bytea NOT NULL,
previous_hash bytea NOT NULL,
hash bytea NOT NULL,
created_at_utc timestamp with time zone NOT NULL,
CONSTRAINT pk_key_log PRIMARY KEY (sequence)
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.sync_change (
sequence bigint GENERATED ALWAYS AS IDENTITY,
vault_id uuid NOT NULL,
entity_type integer NOT NULL,
entity_id uuid NOT NULL,
operation integer NOT NULL,
revision integer NOT NULL,
actor_user_id uuid NOT NULL,
occurred_at_utc timestamp with time zone NOT NULL,
CONSTRAINT pk_sync_change PRIMARY KEY (sequence)
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.sync_operation_receipt (
operation_id uuid NOT NULL,
vault_id uuid NOT NULL,
applied_change_sequence bigint,
result_version integer,
created_at_utc timestamp with time zone NOT NULL,
CONSTRAINT pk_sync_operation_receipt PRIMARY KEY (operation_id)
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.team (
id uuid NOT NULL,
name character varying(256) NOT NULL,
slug citext NOT NULL,
description character varying(2048),
created_by_user_id uuid NOT NULL,
created_at_utc timestamp with time zone NOT NULL,
deleted_at_utc timestamp with time zone,
CONSTRAINT pk_team PRIMARY KEY (id)
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.user_account (
id uuid NOT NULL,
issuer character varying(512) NOT NULL,
subject character varying(256) NOT NULL,
email citext,
display_name character varying(256),
status integer NOT NULL,
enrolled_at_utc timestamp with time zone,
created_at_utc timestamp with time zone NOT NULL,
updated_at_utc timestamp with time zone NOT NULL,
last_seen_at_utc timestamp with time zone,
deleted_at_utc timestamp with time zone,
CONSTRAINT pk_user_account PRIMARY KEY (id)
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.device (
id uuid NOT NULL,
user_id uuid NOT NULL,
name character varying(256) NOT NULL,
platform integer NOT NULL,
public_key bytea NOT NULL,
enrolled_at_utc timestamp with time zone NOT NULL,
last_seen_at_utc timestamp with time zone,
revoked_at_utc timestamp with time zone,
CONSTRAINT pk_device PRIMARY KEY (id),
CONSTRAINT fk_device_users_user_id FOREIGN KEY (user_id) REFERENCES dodo.user_account (id) ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.team_membership (
id uuid NOT NULL,
team_id uuid NOT NULL,
user_id uuid NOT NULL,
role integer NOT NULL,
status integer NOT NULL,
invited_by_user_id uuid,
joined_at_utc timestamp with time zone,
created_at_utc timestamp with time zone NOT NULL,
deleted_at_utc timestamp with time zone,
CONSTRAINT pk_team_membership PRIMARY KEY (id),
CONSTRAINT fk_team_membership_team_team_id FOREIGN KEY (team_id) REFERENCES dodo.team (id) ON DELETE CASCADE,
CONSTRAINT fk_team_membership_users_user_id FOREIGN KEY (user_id) REFERENCES dodo.user_account (id) ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.user_key (
id uuid NOT NULL,
user_id uuid NOT NULL,
generation integer NOT NULL,
encryption_public_key bytea NOT NULL,
signing_public_key bytea NOT NULL,
fingerprint_sha256 bytea NOT NULL,
statement jsonb NOT NULL,
statement_signature bytea NOT NULL,
identity_provider_binding jsonb,
is_current boolean NOT NULL,
created_at_utc timestamp with time zone NOT NULL,
revoked_at_utc timestamp with time zone,
CONSTRAINT pk_user_key PRIMARY KEY (id),
CONSTRAINT fk_user_key_user_account_user_id FOREIGN KEY (user_id) REFERENCES dodo.user_account (id) ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.vault (
id uuid NOT NULL,
name character varying(256) NOT NULL,
owner_kind integer NOT NULL,
owner_user_id uuid,
team_id uuid,
key_generation integer NOT NULL,
rekey_required boolean NOT NULL,
rekey_reason integer NOT NULL,
created_at_utc timestamp with time zone NOT NULL,
updated_at_utc timestamp with time zone NOT NULL,
deleted_at_utc timestamp with time zone,
CONSTRAINT pk_vault PRIMARY KEY (id),
CONSTRAINT ck_vault_key_generation CHECK (key_generation >= 1),
CONSTRAINT ck_vault_owner CHECK ((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)),
CONSTRAINT fk_vault_team_team_id FOREIGN KEY (team_id) REFERENCES dodo.team (id) ON DELETE RESTRICT,
CONSTRAINT fk_vault_user_account_owner_user_id FOREIGN KEY (owner_user_id) REFERENCES dodo.user_account (id) ON DELETE RESTRICT
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.user_key_wrap (
id uuid NOT NULL,
user_id uuid NOT NULL,
kind integer NOT NULL,
device_id uuid,
wrap bytea NOT NULL,
wrap_version integer NOT NULL,
kdf_algorithm character varying(64),
kdf_salt bytea,
kdf_memory_kibibytes integer,
kdf_passes integer,
kdf_parallelism integer,
created_at_utc timestamp with time zone NOT NULL,
last_used_at_utc timestamp with time zone,
CONSTRAINT pk_user_key_wrap PRIMARY KEY (id),
CONSTRAINT ck_user_key_wrap_device CHECK ((kind = 2 AND device_id IS NOT NULL) OR (kind <> 2 AND device_id IS NULL)),
CONSTRAINT ck_user_key_wrap_kdf CHECK ((kind IN (1, 3) AND kdf_algorithm IS NOT NULL AND kdf_salt IS NOT NULL
AND kdf_memory_kibibytes IS NOT NULL AND kdf_passes IS NOT NULL
AND kdf_parallelism IS NOT NULL)
OR (kind IN (2, 4) AND kdf_algorithm IS NULL AND kdf_salt IS NULL)),
CONSTRAINT fk_user_key_wrap_device_device_id FOREIGN KEY (device_id) REFERENCES dodo.device (id) ON DELETE CASCADE,
CONSTRAINT fk_user_key_wrap_user_account_user_id FOREIGN KEY (user_id) REFERENCES dodo.user_account (id) ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.host (
id uuid NOT NULL,
vault_id uuid NOT NULL,
payload bytea NOT NULL,
data_key_wrap bytea,
content_key_id uuid,
key_generation integer NOT NULL,
payload_aad_version smallint NOT NULL,
relay_enabled boolean NOT NULL,
hostname character varying(255),
port integer,
group_id uuid,
version integer NOT NULL,
change_sequence bigint NOT NULL,
created_at_utc timestamp with time zone NOT NULL,
updated_at_utc timestamp with time zone NOT NULL,
deleted_at_utc timestamp with time zone,
created_by_user_id uuid NOT NULL,
updated_by_user_id uuid NOT NULL,
CONSTRAINT pk_host PRIMARY KEY (id),
CONSTRAINT ck_host_port_range CHECK (port IS NULL OR (port BETWEEN 1 AND 65535)),
CONSTRAINT ck_host_relay_target CHECK ((relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)
OR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)),
CONSTRAINT ck_host_version CHECK (version >= 1),
CONSTRAINT fk_host_vaults_vault_id FOREIGN KEY (vault_id) REFERENCES dodo.vault (id) ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE TABLE dodo.vault_key_grant (
id uuid NOT NULL,
vault_id uuid NOT NULL,
key_generation integer NOT NULL,
kind integer NOT NULL,
recipient_user_id uuid,
recipient_key_fingerprint bytea NOT NULL,
wrapped_key bytea NOT NULL,
granter_user_id uuid NOT NULL,
granter_key_fingerprint bytea NOT NULL,
key_log_head bytea,
signature bytea NOT NULL,
state integer NOT NULL,
created_at_utc timestamp with time zone NOT NULL,
revoked_at_utc timestamp with time zone,
CONSTRAINT pk_vault_key_grant PRIMARY KEY (id),
CONSTRAINT ck_vault_key_grant_recipient CHECK ((kind = 1 AND recipient_user_id IS NOT NULL) OR (kind <> 1 AND recipient_user_id IS NULL)),
CONSTRAINT fk_vault_key_grant_user_account_recipient_user_id FOREIGN KEY (recipient_user_id) REFERENCES dodo.user_account (id) ON DELETE CASCADE,
CONSTRAINT fk_vault_key_grant_vault_vault_id FOREIGN KEY (vault_id) REFERENCES dodo.vault (id) ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_device_user_id ON dodo.device (user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_host_vault_id_change_sequence ON dodo.host (vault_id, change_sequence);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_host_vault_live ON dodo.host (vault_id) WHERE deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_key_log_hash ON dodo.key_log (hash);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_key_log_user_id ON dodo.key_log (user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_sync_change_vault_id_entity_id_sequence ON dodo.sync_change (vault_id, entity_id, sequence DESC);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_sync_change_vault_id_sequence ON dodo.sync_change (vault_id, sequence);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_sync_operation_receipt_vault_id_created_at_utc ON dodo.sync_operation_receipt (vault_id, created_at_utc);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_team_slug ON dodo.team (slug) WHERE deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_team_membership_team_id_user_id ON dodo.team_membership (team_id, user_id) WHERE deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_team_membership_user_id ON dodo.team_membership (user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_account_email ON dodo.user_account (email) WHERE email IS NOT NULL AND deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_account_issuer_subject ON dodo.user_account (issuer, subject);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_current ON dodo.user_key (user_id) WHERE is_current;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_fingerprint_sha256 ON dodo.user_key (fingerprint_sha256);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_user_id_generation ON dodo.user_key (user_id, generation);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_user_key_wrap_device_id ON dodo.user_key_wrap (device_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_wrap_user_device ON dodo.user_key_wrap (user_id, device_id) WHERE device_id IS NOT NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_wrap_user_kind ON dodo.user_key_wrap (user_id, kind) WHERE device_id IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_vault_owner_user_id ON dodo.vault (owner_user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_vault_team_id ON dodo.vault (team_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE INDEX ix_vault_key_grant_recipient_user_id ON dodo.vault_key_grant (recipient_user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
CREATE UNIQUE INDEX ix_vault_key_grant_vault_id_key_generation_recipient_user_id ON dodo.vault_key_grant (vault_id, key_generation, recipient_user_id) WHERE revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
INSERT INTO dodo."__EFMigrationsHistory" (migration_id, product_version)
VALUES ('20260728113419_InitialSchema', '10.0.10');
END IF;
END $EF$;
COMMIT;