|
| 1 | +# Legacy pkg api_keys → postguard-business migration |
| 2 | + |
| 3 | +Implements the migration half of encryption4all/postguard#141. The |
| 4 | +`pg-pkg` side (validator changes) is tracked separately in |
| 5 | +encryption4all/postguard#140. |
| 6 | + |
| 7 | +## Schemas |
| 8 | + |
| 9 | +### Source (legacy `pg-pkg`, Postgres) |
| 10 | + |
| 11 | +`api_keys` — see `pg-pkg/migrations/20260316000000_create_api_keys.up.sql` |
| 12 | + |
| 13 | +| column | type | notes | |
| 14 | +| ------------------------ | ------------ | ------------------------- | |
| 15 | +| id | uuid | pk | |
| 16 | +| api_key | varchar(128) | **plaintext**, unique | |
| 17 | +| email | varchar(256) | not null | |
| 18 | +| organisation_name | varchar(256) | nullable | |
| 19 | +| phone_number | varchar(32) | nullable | |
| 20 | +| kvk_number | varchar(32) | nullable | |
| 21 | +| organisation_name_public | bool | is this attribute signed? | |
| 22 | +| phone_number_public | bool | is this attribute signed? | |
| 23 | +| kvk_number_public | bool | is this attribute signed? | |
| 24 | +| expires_at | timestamp | not null | |
| 25 | + |
| 26 | +### Target (`postguard-business`, Postgres) |
| 27 | + |
| 28 | +`organizations` + `business_api_keys` — see |
| 29 | +`src/lib/server/db/schema.ts`. The relevant columns: |
| 30 | + |
| 31 | +``` |
| 32 | +organizations(id, name, domain UNIQUE, email, contact_name, phone, |
| 33 | + kvk_number, status) |
| 34 | +business_api_keys(id, key_hash UNIQUE, key_prefix, name, org_id FK, |
| 35 | + signing_attrs JSONB, expires_at, revoked_at, created_by) |
| 36 | +``` |
| 37 | + |
| 38 | +Key hashes are SHA-256 of the raw key (see `scripts/seed.ts:85`). |
| 39 | + |
| 40 | +## Mapping |
| 41 | + |
| 42 | +| legacy column | new location | |
| 43 | +| ------------------------------------------ | ---------------------------------------------- | |
| 44 | +| `api_key` (plaintext) | `business_api_keys.key_hash` = sha256(api_key) | |
| 45 | +| `api_key[0..10]` | `business_api_keys.key_prefix` | |
| 46 | +| `email`, `organisation_name`, `kvk_number` | `organizations.*` (grouped — see below) | |
| 47 | +| `organisation_name_public` etc. | `business_api_keys.signing_attrs` booleans | |
| 48 | +| `expires_at` | `business_api_keys.expires_at` | |
| 49 | +| `phone_number` | `organizations.phone` | |
| 50 | + |
| 51 | +### Grouping legacy rows into organisations |
| 52 | + |
| 53 | +The legacy schema is per-key; the new schema is per-organisation with a |
| 54 | +`UNIQUE` domain. Multiple legacy rows can belong to the same real-world |
| 55 | +org, so the script groups rows using the first available of: |
| 56 | + |
| 57 | +1. `kvk_number` (Dutch Chamber of Commerce number — unambiguous) |
| 58 | +2. case-insensitive `organisation_name` |
| 59 | +3. email domain |
| 60 | +4. full email (last-resort: one synthetic org per user) |
| 61 | + |
| 62 | +`organizations.domain` is derived deterministically: |
| 63 | + |
| 64 | +- if the grouped rows share a plausible email domain, use it verbatim; |
| 65 | +- otherwise synthesise `<slug>.legacy.postguard.local` where `<slug>` is |
| 66 | + a kebab-case slug of the kvk number / org name / email. |
| 67 | + |
| 68 | +If two groups happen to collide on a synthesised domain, an 8-hex-char |
| 69 | +disambiguating suffix is appended (derived from the grouping key's hash, |
| 70 | +so the result is still deterministic across runs). |
| 71 | + |
| 72 | +Migrated orgs are created with `status = 'active'` — these are existing |
| 73 | +users and do not need to re-verify. |
| 74 | + |
| 75 | +### Signing attributes |
| 76 | + |
| 77 | +`email` is always signed (it is hardcoded as a public attribute in |
| 78 | +`pg-pkg/src/middleware/auth.rs:212-215`). Each of `orgName`, `phone`, |
| 79 | +`kvkNumber` is signed iff **both**: |
| 80 | + |
| 81 | +- the legacy row had the corresponding `*_public` flag set to `true`, and |
| 82 | +- the corresponding source column was non-null. |
| 83 | + |
| 84 | +## Operator runbook |
| 85 | + |
| 86 | +```bash |
| 87 | +# 1. Point the script at BOTH databases. |
| 88 | +export DATABASE_URL='postgres://...business-db' |
| 89 | +export LEGACY_DATABASE_URL='postgres://...pkg-db' |
| 90 | + |
| 91 | +# 2. Dry run. Reads both DBs, writes nothing. |
| 92 | +tsx scripts/migrate-legacy-api-keys.ts --dry-run |
| 93 | + |
| 94 | +# 3. Review the printed plan. Pay attention to: |
| 95 | +# - number of orgs created vs reused, |
| 96 | +# - any rows in the "Skipped" section, |
| 97 | +# - any synthetic .legacy.postguard.local domains (these flag groups |
| 98 | +# that the migration could not tie to a real domain). |
| 99 | + |
| 100 | +# 4. When the plan looks sane, apply it. |
| 101 | +tsx scripts/migrate-legacy-api-keys.ts --live |
| 102 | +``` |
| 103 | + |
| 104 | +The live run is wrapped in a single transaction — if any insert fails |
| 105 | +the whole migration rolls back. |
| 106 | + |
| 107 | +Running `--live` twice is safe: the script looks up each `key_hash` |
| 108 | +before inserting and will skip keys already present, and `organizations` |
| 109 | +are upserted by `domain`. |
| 110 | + |
| 111 | +## Open product questions |
| 112 | + |
| 113 | +All resolved — confirmed by @rubenhensen. |
| 114 | + |
| 115 | +1. **Re-keying.** **Resolved:** preserve existing keys. All legacy keys |
| 116 | + have the `PG-API-` prefix, which passes the new `PG-` prefix check |
| 117 | + in postguard#142. No re-keying needed. |
| 118 | + |
| 119 | +2. **Grouping heuristic.** **Resolved:** the `kvk → orgname → |
| 120 | + email-domain → email` fallback chain is fine. |
| 121 | + |
| 122 | +3. **Synthetic org status.** **Resolved:** migrated orgs are set to |
| 123 | + `active` — these are existing users and should be grandfathered in. |
| 124 | + |
| 125 | +4. **`created_by`.** **Resolved:** left NULL is fine for now. |
| 126 | + |
| 127 | +## What this PR does NOT do |
| 128 | + |
| 129 | +- It does **not** touch the `pg-pkg` validator (`postguard#140`). |
| 130 | +- It does **not** drop the legacy `api_keys` table. That is a separate |
| 131 | + cleanup PR that should only run after `#140` is merged AND the |
| 132 | + transition window has elapsed. |
| 133 | +- It does **not** generate or mail new keys. |
0 commit comments