-- ============================================================================ -- MijnBesluit - databaseschema voor MariaDB 10.6 of nieuwer -- -- Aanmaken (in cmd, NIET in PowerShell - die kent "<" niet): -- mysql -u root -p < 01_schema.sql -- In PowerShell of HeidiSQL: zie docs/Website-testen.md. -- -- Het script mag meerdere keren gedraaid worden; bestaande tabellen blijven ongemoeid. -- -- De schemanaam staat hieronder één keer. Wil je een bestaande database -- gebruiken, vervang dan `mijnbesluit` overal (of laat CREATE DATABASE weg -- en zet er een USE ; voor in de plaats). -- -- Opzet: -- * Alles utf8mb4 en InnoDB, zodat namen met accenten en emoji goed gaan. -- * Geheimen staan als VARBINARY in de tabel: versleuteld met AES-256-GCM, -- nooit leesbaar. Zie docs/Beveiliging.md voor hoe de sleutels lopen. -- * Datums worden in UTC bewaard (DATETIME, geen TIMESTAMP), zodat zomertijd -- nooit een bezorgmoment kan verschuiven. -- ============================================================================ CREATE DATABASE IF NOT EXISTS `mijnbesluit` DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE `mijnbesluit`; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------------------------------------------- personen --- -- Een profiel. Kan bestaan zonder account: dan is het aangemaakt door iemand -- anders en wacht het op een uitnodiging. CREATE TABLE IF NOT EXISTS `persons` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `first_name` VARCHAR(80) NOT NULL DEFAULT '', `last_name` VARCHAR(80) NOT NULL DEFAULT '', `nickname` VARCHAR(80) NULL, `email` VARCHAR(255) NULL, `mobile_number` VARCHAR(50) NULL, `birth_date` DATE NULL, `date_of_death` DATE NULL, -- Wie het profiel aanmaakte (historie) en wie het nu beheert. `created_by_user_id` BIGINT NULL, `managed_by_user_id` BIGINT NULL, -- Zodra dit gevuld is, is het profiel van de persoon zelf. `claimed_by_user_id` BIGINT NULL, `claimed_at` DATETIME(3) NULL, -- Aantal meldingen dat nodig is om overlijden te bevestigen, en hoeveel dagen -- de betrokkene daarna nog heeft om bezwaar te maken. `required_death_declarations` INT NOT NULL DEFAULT 3, `death_objection_days` INT NOT NULL DEFAULT 14, `death_confirmed_at` DATETIME(3) NULL, `death_objected_at` DATETIME(3) NULL, -- Wie heeft gezegd dat deze aantekening niet over hem gaat. Zie db/17_aantekening_afgewezen.sql: -- dan komt die vraag niet bij elke volgende keer opnieuw. `link_declined_by_user_id` BIGINT NULL, `link_declined_at` DATETIME(3) NULL, `created_at` DATETIME(3) NOT NULL, `updated_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), -- Met opzet NIET uniek. Twee mensen mogen allebei hun eigen aantekening over dezelfde -- persoon hebben; ze worden pas één zodra die persoon zelf beide uitnodigingen aanneemt. -- Zie docs/Identiteit.md en db/15_adres_niet_uniek.sql. Bij `users.email` is uniek juist -- wél goed: dat is een account. KEY `ix_persons_email` (`email`), KEY `ix_persons_claimed_by` (`claimed_by_user_id`), KEY `ix_persons_managed_by` (`managed_by_user_id`), KEY `ix_persons_death` (`death_confirmed_at`) ) ENGINE=InnoDB; -- ---------------------------------------------------------------- accounts --- CREATE TABLE IF NOT EXISTS `users` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `person_id` BIGINT NOT NULL, `email` VARCHAR(255) NOT NULL, `security_stamp` CHAR(36) NOT NULL, -- De datasleutel van deze gebruiker, versleuteld met de mastersleutel. `wrapped_data_key` VARBINARY(255) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, -- 0 actief, 1 geblokkeerd, 2 overleden `email_confirmed_at` DATETIME(3) NULL, `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, `failed_login_count` INT NOT NULL DEFAULT 0, `locked_until` DATETIME(3) NULL, `last_login_at` DATETIME(3) NULL, `created_at` DATETIME(3) NOT NULL, `updated_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_users_email` (`email`), UNIQUE KEY `ux_users_person` (`person_id`), CONSTRAINT `fk_users_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ) ENGINE=InnoDB; -- `persons` en `users` verwijzen naar elkaar, dus deze twee sleutels kunnen pas gelegd worden -- als beide tabellen bestaan. ALTER TABLE kent geen "IF NOT EXISTS" voor een constraint, dus -- wordt er eerst gekeken of hij er al ligt: zo kun je dit script gerust nog een keer draaien. SET @fk_claimed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND CONSTRAINT_NAME = 'fk_persons_claimed_by'); SET @sql := IF(@fk_claimed = 0, 'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_claimed_by` FOREIGN KEY (`claimed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL', 'DO 0'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @fk_managed := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND CONSTRAINT_NAME = 'fk_persons_managed_by'); SET @sql := IF(@fk_managed = 0, 'ALTER TABLE `persons` ADD CONSTRAINT `fk_persons_managed_by` FOREIGN KEY (`managed_by_user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL', 'DO 0'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- ---------------------------------------------------------------- families --- CREATE TABLE IF NOT EXISTS `families` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(120) NOT NULL, `owner_person_id` BIGINT NOT NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `created_at` DATETIME(3) NOT NULL, `updated_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), KEY `ix_families_owner` (`owner_person_id`), CONSTRAINT `fk_families_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `family_members` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `family_id` BIGINT NOT NULL, `person_id` BIGINT NOT NULL, `relation` INT NOT NULL DEFAULT 0, `relation_label` VARCHAR(50) NULL, `added_by_user_id` BIGINT NULL, `created_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_family_member` (`family_id`, `person_id`), KEY `ix_family_members_person` (`person_id`), CONSTRAINT `fk_fm_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_fm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- ------------------------------------------------------------ uitnodigingen --- -- Alleen de hash van het token staat hier; het token zelf bestaat enkel in de e-mail. CREATE TABLE IF NOT EXISTS `invitations` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `person_id` BIGINT NOT NULL, `family_id` BIGINT NOT NULL, `email` VARCHAR(255) NOT NULL, `token_hash` VARBINARY(32) NOT NULL, `status` INT NOT NULL DEFAULT 0, `created_by_user_id` BIGINT NOT NULL, `created_at` DATETIME(3) NOT NULL, `sent_at` DATETIME(3) NULL, `expires_at` DATETIME(3) NOT NULL, `accepted_at` DATETIME(3) NULL, `resend_count` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `ux_invitations_token` (`token_hash`), KEY `ix_invitations_person` (`person_id`, `status`), CONSTRAINT `fk_inv_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_inv_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- ------------------------------------------------------------------- kluis --- -- Bijlagen: de bytes staan versleuteld buiten de database, hier alleen de verwijzing. CREATE TABLE IF NOT EXISTS `vault_files` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `owner_person_id` BIGINT NOT NULL, `file_name` VARCHAR(255) NOT NULL, `content_type` VARCHAR(120) NOT NULL DEFAULT 'application/octet-stream', `size_bytes` BIGINT NOT NULL DEFAULT 0, `storage_key` VARCHAR(255) NOT NULL, `nonce` VARBINARY(12) NOT NULL, `tag` VARBINARY(16) NOT NULL, `wrapped_content_key` VARBINARY(96) NOT NULL, `sha256` VARBINARY(32) NOT NULL, `created_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), KEY `ix_vault_files_owner` (`owner_person_id`), CONSTRAINT `fk_vf_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- Kluisitems. `title` en `category` zijn leesbaar zodat er gezocht kan worden; -- de inhoud zit versleuteld in `ciphertext`. CREATE TABLE IF NOT EXISTS `vault_items` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `owner_person_id` BIGINT NOT NULL, `category` INT NOT NULL, `title` VARCHAR(150) NOT NULL, `ciphertext` LONGBLOB NOT NULL, `nonce` VARBINARY(12) NOT NULL, `tag` VARBINARY(16) NOT NULL, `wrapped_content_key` VARBINARY(96) NOT NULL, `release_policy` INT NOT NULL DEFAULT 0, -- 0 alleen ikzelf, 1 na overlijden, 2 op datum `release_at` DATETIME(3) NULL, `vault_file_id` BIGINT NULL, `created_at` DATETIME(3) NOT NULL, `updated_at` DATETIME(3) NOT NULL, `deleted_at` DATETIME(3) NULL, PRIMARY KEY (`id`), KEY `ix_vault_items_owner` (`owner_person_id`, `category`), KEY `ix_vault_items_release` (`release_policy`, `release_at`), CONSTRAINT `fk_vi_owner` FOREIGN KEY (`owner_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_vi_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB; -- Toegang van een ander tot een item: dezelfde contentsleutel, ingepakt met -- de datasleutel van die ander. Zonder rij hier is er geen toegang, punt. CREATE TABLE IF NOT EXISTS `content_grants` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `resource_type` VARCHAR(30) NOT NULL, -- VaultItem | VaultFile | Message `resource_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `wrapped_content_key` VARBINARY(96) NOT NULL, `reason` VARCHAR(50) NOT NULL, -- ontvanger | nalatenschap | gedeeld `granted_at` DATETIME(3) NOT NULL, `revoked_at` DATETIME(3) NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_grant` (`resource_type`, `resource_id`, `user_id`), KEY `ix_grants_user` (`user_id`), CONSTRAINT `fk_grant_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- ---------------------------------------------------------------- berichten --- CREATE TABLE IF NOT EXISTS `messages` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `from_person_id` BIGINT NOT NULL, `to_person_id` BIGINT NOT NULL, `kind` INT NOT NULL DEFAULT 0, -- 0 tekst, 1 video, 2 audio, 3 document `subject` VARCHAR(200) NOT NULL, `ciphertext` LONGBLOB NOT NULL, `nonce` VARBINARY(12) NOT NULL, `tag` VARBINARY(16) NOT NULL, `wrapped_content_key` VARBINARY(96) NOT NULL, `vault_file_id` BIGINT NULL, `deliver_at` DATETIME(3) NULL, `deliver_after_death` TINYINT(1) NOT NULL DEFAULT 0, `status` INT NOT NULL DEFAULT 0, -- 0 concept, 1 gepland, 2 bezorgd, 3 gelezen, 4 ingetrokken `delivered_at` DATETIME(3) NULL, `read_at` DATETIME(3) NULL, `created_at` DATETIME(3) NOT NULL, `updated_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), KEY `ix_messages_to` (`to_person_id`, `status`), KEY `ix_messages_from` (`from_person_id`), -- De index waarop de bezorgtaak draait. KEY `ix_messages_due` (`status`, `deliver_at`, `deliver_after_death`), CONSTRAINT `fk_msg_from` FOREIGN KEY (`from_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_msg_to` FOREIGN KEY (`to_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_msg_file` FOREIGN KEY (`vault_file_id`) REFERENCES `vault_files` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB; -- ------------------------------------------------------- overlijden & nalatenschap --- -- Eén melding per gebruiker per profiel; dat dwingt de unieke sleutel af. CREATE TABLE IF NOT EXISTS `death_declarations` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `person_id` BIGINT NOT NULL, `declared_by_user_id` BIGINT NOT NULL, `date_of_death` DATE NULL, `note` VARCHAR(500) NULL, `declared_at` DATETIME(3) NOT NULL, `revoked_at` DATETIME(3) NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_declaration` (`person_id`, `declared_by_user_id`), CONSTRAINT `fk_dd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_dd_user` FOREIGN KEY (`declared_by_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS `legacy_managers` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `person_id` BIGINT NOT NULL, `manager_person_id` BIGINT NOT NULL, `priority` INT NOT NULL DEFAULT 1, `accepted_at` DATETIME(3) NULL, `access_granted_at` DATETIME(3) NULL, `created_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_legacy_manager` (`person_id`, `manager_person_id`), CONSTRAINT `fk_lm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_lm_manager` FOREIGN KEY (`manager_person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- ------------------------------------------------------------------ sessies --- CREATE TABLE IF NOT EXISTS `refresh_tokens` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `token_hash` VARBINARY(32) NOT NULL, `device` VARCHAR(120) NULL, `created_by_ip` VARCHAR(45) NULL, `created_at` DATETIME(3) NOT NULL, `expires_at` DATETIME(3) NOT NULL, `revoked_at` DATETIME(3) NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_refresh_token` (`token_hash`), KEY `ix_refresh_user` (`user_id`, `expires_at`), CONSTRAINT `fk_rt_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- ---------------------------------------------------------------- sessies --- -- Een aanmelding van de app. Staat in de database en niet in het geheugen van de SocketServer, -- want anders logt elke herstart van de service iedereen uit - en dan is de vingerafdruk waarmee -- je de app opent niets waard. Het token zelf staat er niet in, alleen de SHA-256 ervan. CREATE TABLE IF NOT EXISTS `sessies` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `token_hash` VARBINARY(32) NOT NULL, `user_id` BIGINT NOT NULL, `person_id` BIGINT NOT NULL, `toestel` VARCHAR(120) NULL, `aangemaakt_op` DATETIME(3) NOT NULL, `verloopt_op` DATETIME(3) NOT NULL, `laatst_gezien` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_sessies_token` (`token_hash`), KEY `ix_sessies_verloopt` (`verloopt_op`), CONSTRAINT `fk_sessies_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB; -- ------------------------------------------------------------- inlogcodes --- -- Er zijn geen wachtwoorden: je logt in met een code die per e-mail komt. -- De code staat hier niet leesbaar in, alleen de hash ervan. CREATE TABLE IF NOT EXISTS `login_codes` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `email` VARCHAR(255) NOT NULL, `purpose` INT NOT NULL DEFAULT 0, -- 0 inloggen, 1 registreren `code_hash` VARCHAR(255) NOT NULL, `first_name` VARCHAR(80) NULL, -- alleen bij registreren `last_name` VARCHAR(80) NULL, `created_at` DATETIME(3) NOT NULL, `expires_at` DATETIME(3) NOT NULL, `consumed_at` DATETIME(3) NULL, `attempts` INT NOT NULL DEFAULT 0, `requested_from_ip` VARCHAR(45) NULL, PRIMARY KEY (`id`), -- De index waarop het opzoeken van een openstaande code draait. KEY `ix_login_codes_open` (`email`, `purpose`, `expires_at`), KEY `ix_login_codes_created` (`email`, `created_at`) ) ENGINE=InnoDB; -- ----------------------------------------------------------------- logboek --- -- Alleen toevoegen, nooit wijzigen. Bij een kluis wil je kunnen nagaan wie wat wanneer deed. CREATE TABLE IF NOT EXISTS `audit_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NULL, `action` INT NOT NULL, `entity_type` VARCHAR(50) NULL, `entity_id` BIGINT NULL, `ip_address` VARCHAR(45) NULL, `user_agent` VARCHAR(255) NULL, `details` VARCHAR(1000) NULL, `created_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`), KEY `ix_audit_user_time` (`user_id`, `created_at`), KEY `ix_audit_entity` (`entity_type`, `entity_id`) ) ENGINE=InnoDB; SET FOREIGN_KEY_CHECKS = 1;