Skip to content

Commit 3627535

Browse files
authored
Merge pull request #357 from cipherstash/encryption-migrations
feat(cli, migrate): add `stash encrypt` commands + @cipherstash/migrate
2 parents 601ac83 + f418563 commit 3627535

42 files changed

Lines changed: 5329 additions & 316 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'stash': minor
3+
'@cipherstash/migrate': minor
4+
---
5+
6+
Add `stash encrypt` command group and `@cipherstash/migrate` library for plaintext → encrypted column migrations.
7+
8+
New CLI commands:
9+
10+
- `stash encrypt status` — per-column migration status (phase, backfill progress, drift between intent and state, EQL registration).
11+
- `stash encrypt plan` — diff `.cipherstash/migrations.json` (intent) vs observed state.
12+
- `stash encrypt backfill --table <t> --column <c>` — resumable, idempotent, chunked encryption of plaintext into `<col>_encrypted`. Uses the user's encryption client (Protect/Stack). SIGINT-safe; re-run to resume. The first run on a column prompts to confirm dual-writes are deployed (or accept `--confirm-dual-writes-deployed` for non-interactive contexts), records the `dual_writing` transition in `cs_migrations`, then runs the chunked encryption loop. `--force` re-encrypts every plaintext row regardless of current state — recovery path for drift caused by an earlier backfill running before dual-writes were actually live.
13+
- `stash encrypt cutover --table <t> --column <c>` — runs `eql_v2.rename_encrypted_columns()` inside a transaction; optionally forces Proxy config refresh via `CIPHERSTASH_PROXY_URL`. After cutover, apps reading `<col>` transparently receive the encrypted column.
14+
- `stash encrypt drop --table <t> --column <c>` — generates a migration file that drops the old plaintext column.
15+
16+
`stash db install` now also installs a `cipherstash.cs_migrations` table used to track per-column migration runtime state (current phase, backfill cursor, rows processed). The table is append-only (event-log shape) and kept separate from `eql_v2_configuration` which remains the authoritative EQL intent store used by Proxy.
17+
18+
The new `@cipherstash/migrate` package exposes the same primitives as a library for users who want to embed backfill in their own workers or cron jobs — all commands are thin wrappers around its exports (`runBackfill`, `appendEvent`, `latestByColumn`, `progress`, `renameEncryptedColumns`, `reloadConfig`, `readManifest`, `writeManifest`).

docs/plans/encryption-migrations.md

Lines changed: 244 additions & 0 deletions
Large diffs are not rendered by default.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
},
4242
"dependencies": {
4343
"@cipherstash/auth": "catalog:repo",
44+
"@cipherstash/migrate": "workspace:*",
4445
"@clack/prompts": "0.10.1",
4546
"dotenv": "16.4.7",
4647
"jiti": "2.6.1",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env bash
2+
# End-to-end smoke test for `stash encrypt`.
3+
#
4+
# Requires a local Postgres you have superuser on. Creates & destroys
5+
# `stash_e2e_test`. Requires CipherStash credentials in the environment
6+
# for the actual encryption step (CS_CLIENT_ACCESS_KEY etc).
7+
#
8+
# Usage: bash packages/cli/scripts/e2e-encrypt.sh
9+
10+
set -euo pipefail
11+
12+
DB=${STASH_E2E_DB:-stash_e2e_test}
13+
HOST=${STASH_E2E_HOST:-localhost}
14+
DATABASE_URL="postgres://${USER}@${HOST}/${DB}"
15+
STASH="$(cd "$(dirname "$0")/../dist/bin" && pwd)/stash.js"
16+
FIXTURES="$(cd "$(dirname "$0")/fixtures" && pwd)"
17+
18+
if [ ! -x "$STASH" ]; then
19+
echo "CLI not built. Run: pnpm --filter @cipherstash/cli build" >&2
20+
exit 1
21+
fi
22+
23+
psql -h "$HOST" -d postgres -c "DROP DATABASE IF EXISTS ${DB}" >/dev/null
24+
psql -h "$HOST" -d postgres -c "CREATE DATABASE ${DB}" >/dev/null
25+
26+
export DATABASE_URL
27+
28+
echo "==> 1. Install EQL + cs_migrations"
29+
"$STASH" db install --force
30+
31+
echo "==> 2. Seed 5000 plaintext users"
32+
psql "$DATABASE_URL" -f "$FIXTURES/seed-users.sql" >/dev/null
33+
psql "$DATABASE_URL" -c "ALTER TABLE users ADD COLUMN email_encrypted eql_v2_encrypted" >/dev/null
34+
35+
echo "==> 3. Backfill with interrupt/resume (dual-writes confirmed via flag for non-interactive run)"
36+
"$STASH" encrypt backfill --table users --column email --chunk-size 500 --confirm-dual-writes-deployed &
37+
PID=$!
38+
sleep 2
39+
kill -INT "$PID" || true
40+
wait "$PID" || true
41+
"$STASH" encrypt backfill --table users --column email
42+
43+
REMAINING=$(psql "$DATABASE_URL" -At -c "SELECT count(*) FROM users WHERE email_encrypted IS NULL")
44+
if [ "$REMAINING" != "0" ]; then
45+
echo "FAIL: ${REMAINING} rows still unencrypted" >&2
46+
exit 1
47+
fi
48+
echo "OK: all 5000 rows encrypted"
49+
50+
echo "==> 4. Status"
51+
"$STASH" encrypt status
52+
53+
echo "==> 5. Cutover"
54+
"$STASH" encrypt cutover --table users --column email
55+
56+
echo "==> 6. Drop"
57+
"$STASH" encrypt drop --table users --column email --migrations-dir "$(pwd)/drizzle"
58+
59+
echo "==> Done."
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Seed a users table with plaintext emails for e2e backfill testing.
2+
--
3+
-- The encrypted target column must be created separately (drizzle-kit /
4+
-- stash db push route), after which the backfill encrypts `email` → `email_encrypted`.
5+
6+
DROP TABLE IF EXISTS users CASCADE;
7+
8+
CREATE TABLE users (
9+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
10+
email text NOT NULL,
11+
created_at timestamptz NOT NULL DEFAULT now()
12+
);
13+
14+
INSERT INTO users (email)
15+
SELECT 'user-' || g || '@example.com'
16+
FROM generate_series(1, 5000) AS g;

packages/cli/src/bin/stash.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,21 @@ Commands:
7979
8080
db install Scaffold stash.config.ts (if missing) and install EQL extensions
8181
db upgrade Upgrade EQL extensions to the latest version
82-
db push Push encryption schema to database (CipherStash Proxy only)
82+
db push Push encryption schema (writes pending if active config already exists)
83+
db activate Promote pending → active without renames (use after additive db push)
8384
db validate Validate encryption schema
8485
db migrate Run pending encrypt config migrations
8586
db status Show EQL installation status
8687
db test-connection Test database connectivity
8788
8889
schema build Build an encryption schema from your database
8990
91+
encrypt status Show per-column migration status (phase, progress, drift)
92+
encrypt plan Diff intent (.cipherstash/migrations.json) vs observed state
93+
encrypt backfill Resumably encrypt plaintext into the encrypted column
94+
encrypt cutover Rename swap encrypted → primary column
95+
encrypt drop Generate a migration to drop the plaintext column
96+
9097
env (experimental) Print production env vars for deployment
9198
9299
Options:
@@ -198,6 +205,13 @@ async function runDbCommand(
198205
await pushCommand({ dryRun: flags['dry-run'], databaseUrl })
199206
break
200207
}
208+
case 'activate': {
209+
const { activateCommand } = await requireStack(
210+
() => import('../commands/db/activate.js'),
211+
)
212+
await activateCommand({ databaseUrl })
213+
break
214+
}
201215
case 'validate': {
202216
const { validateCommand } = await requireStack(
203217
() => import('../commands/db/validate.js'),
@@ -226,6 +240,90 @@ async function runDbCommand(
226240
}
227241
}
228242

243+
async function runEncryptCommand(
244+
sub: string | undefined,
245+
flags: Record<string, boolean>,
246+
values: Record<string, string>,
247+
) {
248+
switch (sub) {
249+
case 'status': {
250+
const { statusCommand } = await requireStack(
251+
() => import('../commands/encrypt/status.js'),
252+
)
253+
await statusCommand()
254+
break
255+
}
256+
case 'plan': {
257+
const { planCommand } = await requireStack(
258+
() => import('../commands/encrypt/plan.js'),
259+
)
260+
await planCommand()
261+
break
262+
}
263+
case 'backfill': {
264+
const table = requireValue(values, 'table')
265+
const column = requireValue(values, 'column')
266+
const { backfillCommand } = await requireStack(
267+
() => import('../commands/encrypt/backfill.js'),
268+
)
269+
await backfillCommand({
270+
table,
271+
column,
272+
pkColumn: values['pk-column'],
273+
chunkSize: values['chunk-size']
274+
? Number(values['chunk-size'])
275+
: undefined,
276+
encryptedColumn: values['encrypted-column'],
277+
schemaColumnKey: values['schema-column-key'],
278+
confirmDualWritesDeployed: flags['confirm-dual-writes-deployed'],
279+
force: flags.force,
280+
})
281+
break
282+
}
283+
case 'cutover': {
284+
const table = requireValue(values, 'table')
285+
const column = requireValue(values, 'column')
286+
const { cutoverCommand } = await requireStack(
287+
() => import('../commands/encrypt/cutover.js'),
288+
)
289+
await cutoverCommand({
290+
table,
291+
column,
292+
proxyUrl: values['proxy-url'],
293+
migrationsDir: values['migrations-dir'],
294+
})
295+
break
296+
}
297+
case 'drop': {
298+
const table = requireValue(values, 'table')
299+
const column = requireValue(values, 'column')
300+
const { dropCommand } = await requireStack(
301+
() => import('../commands/encrypt/drop.js'),
302+
)
303+
await dropCommand({
304+
table,
305+
column,
306+
migrationsDir: values['migrations-dir'],
307+
})
308+
break
309+
}
310+
default:
311+
p.log.error(`Unknown encrypt subcommand: ${sub ?? '(none)'}`)
312+
console.log()
313+
console.log(HELP)
314+
process.exit(1)
315+
}
316+
}
317+
318+
function requireValue(values: Record<string, string>, key: string): string {
319+
const v = values[key]
320+
if (!v) {
321+
p.log.error(`Missing required --${key} value.`)
322+
process.exit(1)
323+
}
324+
return v
325+
}
326+
229327
async function runSchemaCommand(
230328
sub: string | undefined,
231329
flags: Record<string, boolean>,
@@ -277,6 +375,9 @@ async function main() {
277375
case 'db':
278376
await runDbCommand(subcommand, flags, values)
279377
break
378+
case 'encrypt':
379+
await runEncryptCommand(subcommand, flags, values)
380+
break
280381
case 'schema':
281382
await runSchemaCommand(subcommand, flags, values)
282383
break
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
2+
import { loadStashConfig } from '@/config/index.js'
3+
import { activateConfig, migrateConfig } from '@cipherstash/migrate'
4+
import * as p from '@clack/prompts'
5+
import pg from 'pg'
6+
7+
/**
8+
* `stash db activate` — promote the pending EQL configuration to active
9+
* **without** renaming any columns.
10+
*
11+
* Used after `stash db push` when the new config is purely additive
12+
* (e.g. registering a brand-new encrypted column on a project that
13+
* already has an active config) — there are no `<col>_encrypted` twins
14+
* to rename, so the cut-over rename step is unnecessary.
15+
*
16+
* For path 3 (existing populated column → encrypted via lifecycle), use
17+
* `stash encrypt cutover` instead. Cutover does the same activation but
18+
* also runs the physical rename.
19+
*
20+
* Mechanics: chains `eql_v2.migrate_config()` (pending → encrypting) and
21+
* `eql_v2.activate_config()` (encrypting → active) inside a single
22+
* transaction. Errors out clearly when there is no pending config to
23+
* activate.
24+
*/
25+
export interface ActivateCommandOptions {
26+
databaseUrl?: string
27+
}
28+
29+
export async function activateCommand(
30+
options: ActivateCommandOptions,
31+
): Promise<void> {
32+
p.intro(runnerCommand(detectPackageManager(), 'stash db activate'))
33+
34+
const stashConfig = await loadStashConfig({
35+
databaseUrlFlag: options.databaseUrl,
36+
})
37+
const client = new pg.Client({ connectionString: stashConfig.databaseUrl })
38+
let exitCode = 0
39+
40+
try {
41+
await client.connect()
42+
43+
const pending = await client.query<{ exists: boolean }>(
44+
"SELECT EXISTS(SELECT 1 FROM public.eql_v2_configuration WHERE state = 'pending') AS exists",
45+
)
46+
if (pending.rows[0]?.exists !== true) {
47+
p.log.error(
48+
'No pending EQL configuration to activate. Run `stash db push` first to register a change.',
49+
)
50+
exitCode = 1
51+
return
52+
}
53+
54+
await client.query('BEGIN')
55+
try {
56+
await migrateConfig(client)
57+
await activateConfig(client)
58+
await client.query('COMMIT')
59+
} catch (err) {
60+
await client.query('ROLLBACK').catch(() => {})
61+
throw err
62+
}
63+
64+
p.log.success('Pending configuration promoted to active.')
65+
p.outro('Done.')
66+
} catch (error) {
67+
p.log.error(error instanceof Error ? error.message : 'Activation failed.')
68+
exitCode = 1
69+
} finally {
70+
await client.end()
71+
}
72+
if (exitCode) process.exit(exitCode)
73+
}

0 commit comments

Comments
 (0)