Skip to content

Commit 0557c2f

Browse files
feat(cli,service-settings): os secret rewrap, the at-rest re-wrap of version-1 sys_secret ciphertext under each holder's producer scope (ADR-0128 §4.2, stage 2) (#21469)
Part of #21326 Clause-②: yes (widening) Stage 2 of the staged card: the at-rest re-wrap of version-1 `sys_secret` ciphertext, ADR-0128 §4.2, through `rotateKey`. Retiring version-1 opening is a later stage and is not here; #21326 stays open for it. ⛔ Security family: this body names classes and positions only. ## What lands - **`os secret rewrap`**, a new subcommand beside `os secret orphans`, with the same boot, connection, guards and output conventions. It is a dry run by default (a read-only boot that writes nothing), and `--apply` writes. The other flags are `--declared-datasources` / `--no-declared-datasources`, `-y, --yes`, `--json` and `--database-url`. Nothing on any boot or upgrade path invokes it, and it has no HTTP surface, so no refusal answers a request and no ADR-0112 ledger row is owed (A8). - **`ciphertextDerivationStatus`** on `@objectstack/service-settings`: `LocalCryptoProvider`'s own reading of which AAD derivation sealed a stored ciphertext (`current`, `superseded` or `unknown`). It is read off the marker without opening anything, and it is the same `readCiphertext` that `decrypt` dispatches on. The re-wrap classifies rows with it, so the CLI never restates the marker grammar. - `packages/cli/src/utils/sys-secret-rewrap.ts` holds the planner and executor (pure planning, injected ports), with its pins, the command's guards test and its concrete-driver contract test. - The CLI reference page `content/docs/deployment/cli.mdx` gains `os secret rewrap` next to `os secret orphans`. The page is hand-written, not generated. - The new command is registered in both `bootSchemaStack` family ledgers: `schema-migrate.one-shot-family.integration.test.ts` and `json-stdout-purity.e2e.test.ts`. - One changeset (cli and service-settings at `minor`) and one ADR-0128 anchor for the planner. ## A1: where version-1 ciphertext is stored (census at `1fd56645af`) - **`sys_secret.ciphertext` is the only store of ciphertext the provider seals.** All three producers write it: `SettingsService.set()`, the engine's secret-field path and the datasource binder's `bind()`. There are no other non-test `encrypt` call sites. Version 1 means no `:` marker. - **`sys_setting.value_enc` inline values are not provider-sealed.** They are sealed by the separate `CryptoAdapter` interface: no `CryptoContext`, no `ICryptoProvider` AAD, and its only in-tree implementation is the base64 `NoopCryptoAdapter`. They are not version-1 ciphertext of this provider, so `rotateKey` cannot reach them and they are out of this class. ⛔ No second sealing path was built. - **`sys_two_factor.backup_codes` is not provider-sealed either.** It is sealed by better-auth's own symmetric encryption under the auth secret. ## A2: per-row scope attribution `rotateKey(handle, ctx)` seals under the caller's scope, and a version-1 ciphertext binds no scope, so opening one proves nothing about its producer. The scope therefore comes from the holder, through the classification the orphan sweep already uses: the cross-producer reference union. **Each union reference already carries its holder family, so the classifier needed no extension.** The planner only groups the union's references by handle. ⛔ No second holder walk. `SCOPE_OF_HOLDER_FAMILY` is a `Record` over the closed family set (settings → `settings`, object-field → `object_secret_field`, datasource → `datasource_credential`), so a fourth family stops compiling until it has a scope. The order of the decision: 1. A row whose holders belong to different producers is left (`left_conflicting_scope`). 2. While the union is incomplete, every version-1 row is left (`left_union_incomplete`): a family that was not read may hold it too. `--apply` then refuses and names the family. 3. A row with no holder is left (`left_orphan`). 4. Otherwise the row is attempted under its one scope. Several holders of one scope are one attribution, and the row is re-wrapped once. ## A3: the properties, and the pin for each | Property | How it is met | Pin | |---|---|---| | Resumable and idempotent | All progress lives in the rows. A row reading `current` is skipped as done, and there is no run log. | A run is killed at its second write. A re-run finishes the rest, and a third run writes nothing. The real SQLite boot shows a second `--apply` is all `done` with the table unchanged. | | Safe against a live deployment | Each row is ONE `updateMany` keyed on `id` AND the exact ciphertext the run read. All five drivers serve that as a single filtered statement and answer the changed count (measured in source: SQL and Turso `UPDATE … WHERE`, Mongo `updateMany`, memory with no `await` between filter and write). A count of 0 is `write_conflict`, and the row is not overwritten. A driver without `updateMany` is refused before any row is opened. | A producer write lands between the read and the write: `write_conflict`, the producer's value is kept, and the other rows land. The real SQL driver: a stale ciphertext changes nothing (0), and the read one changes the row (1). | | Fails closed | A row that does not open, or that has an unknown marker, or whose stored fields do not form a handle, is refused and not written. The run finishes the rest and exits 1. Every write is one statement. | A row sealed under another key: `refused_unreadable`, its bytes unchanged, and the other rows re-wrapped. An unknown marker is never opened. | | Verify before write | The re-seal must keep the id, read `current`, carry a usable version, and open under the SAME context to the SAME plaintext, before the write. | A re-seal that opens to a different value, and one that does not open: both `refused_verify_failed`, never written. Positive control: the honest provider re-wraps the same row. | | Dry run | It is the default and boots read-only. Every attributed row is opened, re-sealed and verified in memory, so its counts are the ones `--apply` produces. Nothing is written. | The store is unchanged, and `updateMany` is never called. `--apply` over the same store lands exactly the dry run's counts. The one-shot family pin shows the dry run leaves the database byte-identical, boot included. | After `--apply`, each re-wrapped row is opened through its producer's own read path: the engine's `resolveSecret`, the binder's `resolve`, and the settings context. No other producer's context opens it. **The key.** The run resolves `LocalCryptoProvider` before the boot, from a key that already exists, in the strict posture with the auto-key opt-in withheld. It hands that provider to the settings service the boot composes, so no provider in the run mints a key. With no key, it hands that service one that refuses every call, and the run refuses before opening a row. Pinned with the real boot in a development posture with no key: `crypto_key_unavailable`, and no key file appears. ## A5: output shape Classes and counts only. ⛔ No plaintext, no ciphertext, no key material, and no row id. The `--json` document is `{ mode, report }`, where `report` holds: - `mode` and `keySource` (a source name, never a value); - `families`: per holder family, `status`, `referenceCount`, and `reason` on a gap; - `refusal`; - `counts`: `total`, `rewrap`, `done`, `left`, `refused`, `notWritten`; - `byClass`: one count for each of `rewrap`, `done`, `left_orphan`, `left_conflicting_scope`, `left_union_incomplete`, `refused_unreadable`, `refused_unknown_derivation`, `refused_verify_failed`, `write_conflict`, `write_failed`; - `rewrapByScope`; - `notes`. Refusals are one JSON document with an `error` key: `union_incomplete`, `driver_cannot_compare_and_set`, `crypto_key_unavailable`, `confirmation_required`, `declared_datasources_unreadable`, `boot_failed`, `no_engine`, `no_sys_secret_driver` or `scan_failed`. A pin asserts that the report holds none of the plaintexts, ciphertexts, row ids or holder coordinates of its fixture. ## A7: out of this stage, and untouched These are untouched: version-1 opening and the contract paragraph about it, `packages/spec/src/contracts/crypto-provider.ts` (the re-wrap needed no contract change), `packages/objectql/src/engine.ts`, `docs/adr/**`, and anything in cloud. ## Gates and tests (local, at `6a7c27c2f2`) **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 107 commands. Each exit code was written to disk before it was read, and all 107 exited 0. `--ran` reconciles: "✓ dispatch-gates --ran: 107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED (a DERIVED zero — all 107 recorded an exit code and none of them is 3)." **The gates named in the dispatch:** - `check-changeset-no-major.mjs --base origin/main`: "✓ This diff introduces no `major` bump." The level axis was driven offline, with this body as the event payload: "✓ LEVEL AXIS: this PR declares clause-② `yes (widening)`, and no package whose `packages/**/src/**` it moves is graded `patch`." (re-run at `219457c029`, after the review's fix round) - `check-adr-0087-registration.mjs --base origin/main`: "✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)." - `check-empty-changeset.mjs --base origin/main`: "✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)." - `pnpm check:changeset-gate-self-tests`: exit 0. - `pnpm check:doc-authoring`: "✓ doc authoring guard: sibling-package prose ids hold the baseline — 204 pinned site(s) across 62 file(s) …" - `pnpm check:nul-bytes`: "check-nul-bytes: OK (scanned 9800 text file(s) … no raw ASCII control bytes)." **Other gate readings:** - `check:cli-command-ids`: 65 command modules, all of them oclif commands. - `check:test-source-alias`: OK, the unaliased set unchanged. - `check:engine-double-contract`: "OK — 921 pinned". - `check:adr-anchors`: OK, 60 anchored files. - `check:type-check-debt`: OK. - `check:cross-package-test-inputs`: OK. **Tests:** - `@objectstack/service-settings`: `test` passed 33 files / 605 tests, and `typecheck` exited 0. - `@objectstack/cli` `typecheck` (`tsc --noEmit` plus `check:test-typecheck`): exit 0. - `@objectstack/cli` unit tier: 246 of 248 files passed on the first run. The other two, the `published-subpath-*.pin` files, refused on a missing cli `dist/` (a prerequisite, not a verdict). Once the cli was built, both passed (29 / 29). - `@objectstack/cli` integration tier, run on the touched files and the secret family (`sys-secret-rewrap`, `rewrap.guards`, `rewrap.driver-contract`, `orphans.guards`, `orphans.driver-contract`, `sys-secret-orphan-sweep`, `secret-reference-union`): 7 files / 87 tests. - The one-shot family pin, filtered to the new member and the family table: the dry run leaves the database byte-identical, no missing SQLite file is created, `--apply` runs no seed write, and the family table holds. - `json-stdout-purity.e2e` (nightly tier) was not run here. Its three assertions for the new member were measured by a direct invocation at `6a7c27c2f2`: stdout is one JSON document (the `scan_failed` refusal), no logger record is on stdout, and the three boot diagnostics are on stderr. In that run no database file was created, and the run's key home stayed empty. **Lint** was narrowed, and the narrowing is declared: - The population, read from `eslint.config.mjs`, is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. - `eslint --no-inline-config --format json` over the 10 changed TS files reported 10 files, 0 errors and 0 warnings. - The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Ablations Both legs ran from the committed state `6a7c27c2f2`, through `scripts/ablation-replace.mjs` in WRAP mode, inside the verify lock. Each was restored, and the restore was proven by blob. The expected direction, recorded before running, was RED for both legs. 1. **The scope-attribution refusal removed.** The no-holder branch of `attributeRewrapScope` returned a guessed `settings` scope instead of `left_orphan`. - On disk: anchor x1 → x0, replacement x0 → x1, blob `75d607ac5dc0` → `3c509ef4edb1`. - Result: exit 1, "6 failed | 8 passed (14)". The orphan pin failed at its `left_orphan` count ("expected +0 to be 1"). The count pins that include the orphan failed with it: the full apply, resumable, live-safe, fail-closed and report pins. - Restore: blob == HEAD (`75d607ac5dc0`), and `git diff HEAD` is empty. 2. **Verify-before-write removed.** The `resealHolds` check (a 3-line anchor) was deleted. - On disk: blob `75d607ac5dc0` → `4fd879b7d1a1`. - Result: exit 1, "1 failed | 13 passed (14)". Exactly the verify pin failed, at `refused_verify_failed` ("expected +0 to be 1"). - Restore: proven the same way. There is no build leg. The pins import the subject as `./sys-secret-rewrap.js` (relative source), so no `dist/` is on the resolution path. After both legs, the union above is green at `6a7c27c2f2`. ## Acceptance notes - **The rows the re-wrap leaves keep the older binding.** These are orphans, rows with conflicting holders, and every row while a family is a gap. The later stage that retires version-1 opening owes a decision about them, because they would stop opening. The re-wrap counts them by class, so that stage starts from a measured number. → noted for the next stage of #21326, not filed. - **Pre-existing: `os secret orphans` may mint a key file.** It composes the settings service with that service's default provider, and in a development posture with no key, that boot mints a key file in the key home. This was measured with this command before it handed the settings service its own provider: a key file appeared in this container's key home during that probe. The report says it writes nothing, which is true of the database. → noted, not filed (承接者:无). - ADR-0128's dated implementation note (Tier H) stays with its separate card. #21326 remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b82391 commit 0557c2f

13 files changed

Lines changed: 2298 additions & 1 deletion

‎.changeset/21326-secret-rewrap.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/service-settings': minor
4+
---
5+
6+
feat(cli): `os secret rewrap` re-wraps version-1 `sys_secret` ciphertext under the current AAD derivation, each row under its holder's producer scope (ADR-0128 §4.2, #21326 stage 2)
7+
8+
Clause-②: yes (widening)
9+
10+
A ciphertext sealed before ADR-0128 D1–D3 carries the older binding over
11+
`(namespace, key)` alone, and still opens in this release. `os secret rewrap` moves
12+
the stored values to the current binding through `rotateKey`, the seam ADR-0128 §4
13+
names. It is an operator command: a dry run by default, `--apply` to write, and
14+
nothing on any boot or upgrade path invokes it. It has no HTTP surface.
15+
16+
- **The scope comes from the holder.** `sys_secret` records no producer, and a
17+
version-1 ciphertext binds no scope, so each row is re-sealed under the scope of
18+
the producer whose holder references it: `settings` for a `sys_setting.value_enc`
19+
handle, `object_secret_field` for a `secret:` ref on a business row,
20+
`datasource_credential` for a `sys_secret:` `credentialsRef`. The holders come from
21+
the same cross-producer reference union `os secret orphans` reads. A row nothing
22+
references, a row whose holders belong to different producers, and every row while
23+
a holder family could not be read are left as they are and counted, never re-sealed
24+
under a guessed scope. `--apply` refuses an incomplete union and names the family.
25+
- **Resumable.** A row already sealed under the current derivation is skipped as
26+
done, so a stopped run finishes the rest when re-run and a finished run writes
27+
nothing.
28+
- **Safe against a live deployment.** Each row is written by one conditional update,
29+
keyed on its id and the ciphertext the run read. A row a producer changed in
30+
between is not overwritten, and a re-run picks it up. A driver with no
31+
`updateMany` is refused before any row is opened.
32+
- **Fails closed.** A row that does not open, or whose re-seal does not open to the
33+
same plaintext under the same scope, is not written. The run finishes the rest and
34+
exits 1. The check happens before the write.
35+
- **Output is classes and counts only.** It never prints a plaintext, a ciphertext
36+
or a row id.
37+
38+
The command resolves its data key from `OS_SECRET_KEY`, `OS_DEV_CRYPTO_KEY` or the
39+
persisted key file, in the strict posture: it never mints a key, and it hands the
40+
settings service it boots the same provider so that service does not mint one
41+
either. With no key it refuses before opening any row.
42+
43+
`@objectstack/service-settings` publishes `ciphertextDerivationStatus` (and its
44+
`CiphertextDerivationStatus` type). It is `LocalCryptoProvider`'s own reading of
45+
which derivation sealed a stored ciphertext, read off its marker without opening it:
46+
`current`, `superseded` or `unknown`. The re-wrap classifies rows with it rather than
47+
restating the marker grammar.

‎content/docs/deployment/cli.mdx‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,54 @@ named afterwards: the export carries the cipher material, is written owner-only,
577577
read back and checked before any row is removed. Keep it until you are certain.
578578
</Callout>
579579
580+
#### `os secret rewrap`
581+
582+
Re-wraps the `sys_secret` ciphertext sealed before secrets were bound to the producer
583+
that wrote them, so it carries the current binding (ADR-0128). Each row is re-sealed
584+
under the scope of the producer whose holder references it: a setting, an object's
585+
`secret` field, or a datasource credential. **A dry run by default: without `--apply`
586+
it writes nothing.** It is never run for you; nothing on any boot or upgrade path
587+
invokes it.
588+
589+
```bash
590+
os secret rewrap --no-declared-datasources # dry run (writes nothing)
591+
os secret rewrap --json --no-declared-datasources # the same, machine-readable
592+
os secret rewrap --declared-datasources ./datasources.json
593+
os secret rewrap --apply --no-declared-datasources # write the re-wrapped rows
594+
```
595+
596+
**Options:**
597+
- `--apply` — write the re-wrapped rows (default off)
598+
- `--declared-datasources <path>` — JSON file (an array, or `{"datasources": [...]}`) of the datasource artefacts this host declares in code
599+
- `--no-declared-datasources` — state that this host declares none
600+
- `-y, --yes` — skip the confirmation prompt
601+
- `--database-url <url>`, `--json`
602+
603+
Run it with the data key the deployment seals with (`OS_SECRET_KEY`, or the persisted
604+
key file). It never mints a key, and with none it refuses before opening any row.
605+
606+
What a run guarantees:
607+
608+
- **The scope comes from the holder, never from a guess.** A row that nothing
609+
references, a row whose holders belong to different producers, and every row while a
610+
holder family could not be read are **left as they are** and counted. Like
611+
`os secret orphans`, it reads all three holder families, and a host that says nothing
612+
about its code-declared datasources leaves that family a gap. `--apply` then refuses
613+
and names the family.
614+
- **Resumable.** A row already sealed under the current binding is skipped as done. A run
615+
that stopped part-way finishes the rest when re-run, and a finished run writes nothing.
616+
- **Safe against a live deployment.** Each row is written in one statement, and only if
617+
it still holds the ciphertext the run read. A row a producer changed during the run is
618+
not overwritten. It is counted, and a re-run picks it up.
619+
- **Fails closed.** A row that does not open, or whose re-seal does not open to the same
620+
value under the same scope, is not written. The run finishes the rest and exits 1.
621+
622+
The dry run opens, re-seals and verifies every attributed row in memory, so its counts
623+
are the ones `--apply` would produce. Output is classes and counts only: re-wrap,
624+
done, left (orphan, conflicting scope, union incomplete), refused (unreadable, unknown
625+
derivation, verify failed) and, under `--apply`, not written (changed during the run,
626+
write failed). It never prints a value, a ciphertext or a row id.
627+
580628
### Build & Validate
581629
582630
| Command | Description |
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0128 §4.2 — `os secret rewrap` against the CONCRETE driver its own boot
5+
* resolves, end to end.
6+
*
7+
* The planner's pins (`utils/sys-secret-rewrap.test.ts`) run over a driver
8+
* double. Two facts are only true or false of the real store, so they are read
9+
* here, through `bootSchemaStack` with the command's own plugin list, against
10+
* a real SQLite file:
11+
*
12+
* - **The conditional write really is conditional.** `updateMany` keyed on
13+
* `id` AND the ciphertext the run read answers 0, and changes nothing,
14+
* when the stored ciphertext is any other value. It answers 1 when it is
15+
* the same. Live-safety rests on exactly this, and a driver that ignored
16+
* one `where` key would overwrite a producer's concurrent value.
17+
* - **The command end to end.** The dry run writes nothing (the whole table
18+
* is read back unchanged). `--apply` re-wraps exactly the attributable
19+
* version-1 rows, under their holders' scopes, and leaves the orphan
20+
* byte-for-byte as it was. A second `--apply` is all `done` and writes
21+
* nothing.
22+
*
23+
* The version-1 rows are sealed here as every release before ADR-0128 sealed
24+
* them, under the key this file hands the run in `OS_SECRET_KEY`.
25+
*/
26+
27+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
28+
import { createCipheriv, randomBytes } from 'node:crypto';
29+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
30+
import { tmpdir } from 'node:os';
31+
import { dirname, join, resolve } from 'node:path';
32+
import { fileURLToPath } from 'node:url';
33+
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
34+
import { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } from '@objectstack/service-settings';
35+
import type { CryptoContext } from '@objectstack/spec/contracts';
36+
import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js';
37+
import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js';
38+
import SecretRewrap from './rewrap.js';
39+
40+
const HERE = dirname(fileURLToPath(import.meta.url));
41+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
42+
43+
/** Env that would point the boot at another database or another state directory. */
44+
const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const;
45+
46+
type Row = Record<string, unknown>;
47+
48+
interface DriverProbe {
49+
find(object: string, query: Row): Promise<Row[]>;
50+
create(object: string, data: Row): Promise<unknown>;
51+
updateMany(object: string, query: Row, data: Row): Promise<number>;
52+
}
53+
54+
const KEY = randomBytes(32);
55+
56+
function sealVersion1(plain: string, namespace: string, key: string): string {
57+
const iv = randomBytes(12);
58+
const cipher = createCipheriv('aes-256-gcm', KEY, iv);
59+
cipher.setAAD(Buffer.from([namespace, key].join('|'), 'utf8'));
60+
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
61+
return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64');
62+
}
63+
64+
const v1Row = (id: string, namespace: string, key: string, plain: string): Row => ({
65+
id, namespace, key, alg: 'aes-256-gcm', version: 1, kms_key_id: 'local:v1',
66+
ciphertext: sealVersion1(plain, namespace, key),
67+
});
68+
69+
/** Held by a setting. */
70+
const SETTINGS_ROW = v1Row('sec_rewrap_dc_settings', 'smtp', 'password', 'dc-settings-plain');
71+
/** Held by a datasource the host declares in code. */
72+
const DATASOURCE_ROW = v1Row('sec_rewrap_dc_datasource', 'datasource', 'warehouse', 'dc-datasource-plain');
73+
/** Held by nothing. */
74+
const ORPHAN_ROW = v1Row('sec_rewrap_dc_orphan', 'smtp', 'retired_token', 'dc-orphan-plain');
75+
/** A row only the conditional-write probe touches. */
76+
const PROBE_ROW = v1Row('sec_rewrap_dc_probe', 'probe', 'probe', 'dc-probe-plain');
77+
78+
describe('os secret rewrap — the concrete driver and the command, end to end (ADR-0128 §4.2)', () => {
79+
let dir: string;
80+
let dbFile: string;
81+
let declaredFile: string;
82+
let stack: SchemaStack | null = null;
83+
let secretDriver: DriverProbe;
84+
const savedEnv: Record<string, string | undefined> = {};
85+
const savedCwd = process.cwd();
86+
87+
beforeAll(async () => {
88+
dir = mkdtempSync(join(tmpdir(), 'os-rewrap-dc-'));
89+
dbFile = join(dir, 'rewrap.db');
90+
declaredFile = join(dir, 'datasources.json');
91+
92+
for (const key of OVERRIDING_ENV) {
93+
savedEnv[key] = process.env[key];
94+
delete process.env[key];
95+
}
96+
for (const key of ['OS_ARTIFACT_PATH', 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
97+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
98+
process.env.NODE_ENV = 'production';
99+
process.env.OS_SECRET_KEY = KEY.toString('hex');
100+
process.chdir(dir);
101+
102+
stack = await bootSchemaStack({
103+
jsonOutput: false,
104+
databaseUrl: `file:${dbFile}`,
105+
// Byte-identical to `rewrap.ts`'s own list.
106+
extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })],
107+
});
108+
const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined;
109+
if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure');
110+
secretDriver = engine.getDriverForObject('sys_secret') as unknown as DriverProbe;
111+
const settingDriver = engine.getDriverForObject('sys_setting') as unknown as DriverProbe;
112+
if (!secretDriver || !settingDriver) throw new Error('sys_secret / sys_setting resolved no driver');
113+
114+
for (const row of [SETTINGS_ROW, DATASOURCE_ROW, ORPHAN_ROW, PROBE_ROW]) {
115+
await secretDriver.create('sys_secret', { ...row });
116+
}
117+
await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'password', value_enc: SETTINGS_ROW.id });
118+
writeFileSync(declaredFile, JSON.stringify([
119+
{ name: 'warehouse', external: { credentialsRef: `sys_secret:${String(DATASOURCE_ROW.id)}` } },
120+
]));
121+
}, 180_000);
122+
123+
afterAll(async () => {
124+
try { await stack?.shutdown(); } catch { /* torn down either way */ }
125+
stack = null;
126+
process.chdir(savedCwd);
127+
for (const [key, value] of Object.entries(savedEnv)) {
128+
if (value === undefined) delete process.env[key];
129+
else process.env[key] = value;
130+
}
131+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
132+
});
133+
134+
const rowOf = async (id: unknown): Promise<Row> => {
135+
const [row] = await secretDriver.find('sys_secret', { where: { id } });
136+
if (!row) throw new Error(`sys_secret row ${String(id)} is gone`);
137+
return row;
138+
};
139+
140+
const runJson = async (argv: string[]): Promise<{ payload: Record<string, any>; exitCode: number }> => {
141+
const chunks: string[] = [];
142+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(
143+
((chunk: unknown, ...rest: unknown[]) => {
144+
chunks.push(String(chunk));
145+
const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined;
146+
done?.(null);
147+
return true;
148+
}) as never,
149+
);
150+
const savedExitCode = process.exitCode;
151+
let exitCode = 0;
152+
try {
153+
await SecretRewrap.run(['--json', '--database-url', `file:${dbFile}`, ...argv], { root: CLI_ROOT });
154+
exitCode = Number(process.exitCode ?? 0);
155+
} finally {
156+
stdout.mockRestore();
157+
process.exitCode = savedExitCode;
158+
}
159+
const lines = chunks.join('').split('\n').filter((l) => l.trim() !== '');
160+
return { payload: JSON.parse(lines[lines.length - 1]) as Record<string, any>, exitCode };
161+
};
162+
163+
it('names the concrete driver behind the write', () => {
164+
expect((secretDriver as unknown as { name?: unknown }).name).toBe('com.objectstack.driver.sql');
165+
expect(typeof secretDriver.updateMany).toBe('function');
166+
});
167+
168+
it('the conditional write is conditional: a stale ciphertext changes nothing, the read one changes the row', async () => {
169+
const before = await rowOf(PROBE_ROW.id);
170+
171+
const stale = await secretDriver.updateMany(
172+
'sys_secret',
173+
{ where: { id: PROBE_ROW.id, ciphertext: 'v2:a-value-this-run-never-read' } },
174+
{ ciphertext: 'v2:must-not-land', version: 99 },
175+
);
176+
expect(stale).toBe(0);
177+
const unchanged = await rowOf(PROBE_ROW.id);
178+
expect(unchanged.ciphertext).toBe(before.ciphertext);
179+
expect(Number(unchanged.version)).toBe(Number(before.version));
180+
181+
// POSITIVE CONTROL: the same statement keyed on the ciphertext actually stored.
182+
const matched = await secretDriver.updateMany(
183+
'sys_secret',
184+
{ where: { id: PROBE_ROW.id, ciphertext: before.ciphertext } },
185+
{ kms_key_id: 'local:v1' },
186+
);
187+
expect(matched).toBe(1);
188+
}, 60_000);
189+
190+
it('the dry run writes nothing, and counts what --apply would do', async () => {
191+
const before = await secretDriver.find('sys_secret', {});
192+
const { payload, exitCode } = await runJson(['--declared-datasources', declaredFile]);
193+
194+
expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined();
195+
expect(payload.mode).toBe('dry-run');
196+
expect(payload.report.families.settings.status).toBe('enumerated');
197+
expect(payload.report.families.datasource.status).toBe('enumerated');
198+
expect(payload.report.byClass).toMatchObject({ rewrap: 2, left_orphan: 2, done: 0 });
199+
expect(payload.report.rewrapByScope).toEqual({ settings: 1, object_secret_field: 0, datasource_credential: 1 });
200+
expect(exitCode).toBe(0);
201+
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
202+
}, 180_000);
203+
204+
it('with no data key, the run refuses before opening a row, and NO provider in its boot mints one', async () => {
205+
// A development posture with no key anywhere: the posture in which a
206+
// default provider mints a key file. The settings service the boot
207+
// composes is handed this run's provider, so nothing may mint here.
208+
const home = mkdtempSync(join(tmpdir(), 'os-rewrap-nokey-'));
209+
const saved = { NODE_ENV: process.env.NODE_ENV, OS_SECRET_KEY: process.env.OS_SECRET_KEY, OS_HOME: process.env.OS_HOME };
210+
process.env.NODE_ENV = 'development';
211+
delete process.env.OS_SECRET_KEY;
212+
process.env.OS_HOME = home;
213+
try {
214+
const before = await secretDriver.find('sys_secret', {});
215+
const { payload, exitCode } = await runJson(['--declared-datasources', declaredFile]);
216+
217+
expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBe('crypto_key_unavailable');
218+
expect(exitCode).toBe(1);
219+
expect(existsSync(join(home, 'dev-crypto-key'))).toBe(false);
220+
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
221+
222+
// POSITIVE CONTROL for the measurement: a default-posture provider in the
223+
// same kind of home does mint, so an absent file above means none did.
224+
const control = mkdtempSync(join(tmpdir(), 'os-rewrap-mint-'));
225+
try {
226+
new LocalCryptoProvider({ env: { OS_HOME: control }, mode: 'development' });
227+
expect(existsSync(join(control, 'dev-crypto-key'))).toBe(true);
228+
} finally {
229+
rmSync(control, { recursive: true, force: true });
230+
}
231+
} finally {
232+
for (const [key, value] of Object.entries(saved)) {
233+
if (value === undefined) delete process.env[key];
234+
else process.env[key] = value;
235+
}
236+
rmSync(home, { recursive: true, force: true });
237+
}
238+
}, 180_000);
239+
240+
it('--apply re-wraps each held row under its holder\'s scope, leaves the orphan as it was, and a re-run is all done', async () => {
241+
const orphanBefore = await rowOf(ORPHAN_ROW.id);
242+
const { payload, exitCode } = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]);
243+
244+
expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined();
245+
expect(payload.report.byClass).toMatchObject({ rewrap: 2, left_orphan: 2, write_conflict: 0, write_failed: 0 });
246+
expect(exitCode).toBe(0);
247+
248+
const provider = new LocalCryptoProvider({ key: KEY });
249+
const opens = async (row: Row, ctx: CryptoContext) => {
250+
const stored = await rowOf(row.id);
251+
expect(ciphertextDerivationStatus(stored.ciphertext)).toBe('current');
252+
return provider.decrypt({
253+
id: String(stored.id), kmsKeyId: String(stored.kms_key_id), alg: String(stored.alg),
254+
version: Number(stored.version), ciphertext: String(stored.ciphertext),
255+
}, ctx);
256+
};
257+
expect(await opens(SETTINGS_ROW, { scope: 'settings', namespace: 'smtp', key: 'password' }))
258+
.toBe('dc-settings-plain');
259+
expect(await opens(DATASOURCE_ROW, { scope: 'datasource_credential', namespace: 'datasource', key: 'warehouse' }))
260+
.toBe('dc-datasource-plain');
261+
// Bound to its holder's scope: another producer's context does not open it.
262+
await expect(opens(DATASOURCE_ROW, { scope: 'settings', namespace: 'datasource', key: 'warehouse' })).rejects.toThrow();
263+
264+
// The orphan is exactly as it was.
265+
const orphanAfter = await rowOf(ORPHAN_ROW.id);
266+
expect(orphanAfter.ciphertext).toBe(orphanBefore.ciphertext);
267+
expect(Number(orphanAfter.version)).toBe(Number(orphanBefore.version));
268+
269+
// Re-run: everything held is done, nothing is written.
270+
const tableBefore = await secretDriver.find('sys_secret', {});
271+
const again = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]);
272+
expect(again.payload.report.byClass).toMatchObject({ rewrap: 0, done: 2, left_orphan: 2 });
273+
expect(again.exitCode).toBe(0);
274+
expect(await secretDriver.find('sys_secret', {})).toEqual(tableBefore);
275+
}, 180_000);
276+
});

0 commit comments

Comments
 (0)