Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/31007-one-shot-crypto-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/cli': patch
---

fix(cli): one-shot secret commands seal and open with the crypto provider `os serve` uses

Clause-②: no
Metadata-protocol: none

`os secret rewrap`, `os secret orphans` and the data migrations that compose the settings service (`os migrate files-to-references`, `os storage orphans`, `os migrate recompute-home-amounts`) used to build their own `LocalCryptoProvider`. On a deployment whose plugin registers a KMS- or Vault-backed provider as the `crypto-provider` service, they could open nothing `os serve` sealed, and a re-wrap could have re-sealed under the local key.

They now choose the provider with `os serve`'s own function. They read the host config's `plugins`, as `os serve` does without `--dev`:

- One plugin declares `providesServices: ['crypto-provider']`: the run boots it and seals and opens with the provider it registers.
- Two plugins declare it: the run refuses before either initializes, and names both.
- No plugin declares it: nothing changes. The run uses the local provider over the key that already exists, and never mints one.

The run fails closed and never falls back to the local key for a deployment that declares a provider. It refuses, names the provider, and writes nothing when:

- the declared plugin cannot boot in the run;
- the declared plugin boots but registers no provider;
- the host config, or one of its `plugins` entries, cannot be loaded.

`os secret rewrap` names the provider as the report's key source.
14 changes: 12 additions & 2 deletions packages/cli/src/commands/secret/orphans.driver-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,19 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
import '@objectstack/service-settings';
import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js';
import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js';
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
import { oneShotSettingsPlugins } from '../../utils/one-shot-settings.js';
import SecretOrphans from './orphans.js';

// [#31007] Pay the first load of what the one-shot crypto composition reaches through
// dynamic `import()`s (the host config reader and `serve.ts`) at MODULE LOAD, outside
// any clocked case.
import '@objectstack/driver-sql';
import '@objectstack/driver-turso';
import '@objectstack/plugin-security';
import '@objectstack/rest';
import '@objectstack/runtime';
import '@objectstack/service-datasource';

const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, '..', '..', '..');

Expand Down Expand Up @@ -182,7 +192,7 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
databaseUrl: `file:${dbFile}`,
// Byte-identical to `orphans.ts`'s own list — the boot has to be the
// command's, or the driver this file names is not the one it holds.
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
extraPlugins: [new PlatformObjectsPlugin(), ...await oneShotSettingsPlugins()],
});

const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined;
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/secret/orphans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
isExitSignal,
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
import { oneShotSettingsPlugins } from '../../utils/one-shot-settings.js';
import { absentTableReads, secretUnionReadView } from '../../utils/absent-table-reads.js';
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
Expand Down Expand Up @@ -230,7 +230,7 @@ export default class SecretOrphans extends Command {
// service's default provider: in a development posture with no key,
// that default mints a key file in the key home, and this report
// promises to write no row or table, and writes no key file either.
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
extraPlugins: [new PlatformObjectsPlugin(), ...await oneShotSettingsPlugins()],
// [#21391] The report boots READ-ONLY, the boot `os migrate plan`
// takes: `deferSchemaDdl` holds schema DDL back on every SQL
// datasource, and `readOnlyProbe` keeps a missing sqlite file from
Expand Down
99 changes: 97 additions & 2 deletions packages/cli/src/commands/secret/rewrap.driver-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,19 @@ import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/se
import type { CryptoContext } from '@objectstack/spec/contracts';
import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js';
import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js';
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
import { oneShotSettingsPlugins } from '../../utils/one-shot-settings.js';
import SecretRewrap from './rewrap.js';

// [#31007] Pay the first load of what the one-shot crypto composition reaches through
// dynamic `import()`s (the host config reader and `serve.ts`) at MODULE LOAD, outside
// any clocked case.
import '@objectstack/driver-sql';
import '@objectstack/driver-turso';
import '@objectstack/plugin-security';
import '@objectstack/rest';
import '@objectstack/runtime';
import '@objectstack/service-datasource';

const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, '..', '..', '..');

Expand Down Expand Up @@ -106,7 +116,7 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
// `rewrap.ts`'s own list: the one-shot settings composition, over the
// key this file declares in `OS_SECRET_KEY` (the command resolves the
// same key first and hands that instance in).
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
extraPlugins: [new PlatformObjectsPlugin(), ...await oneShotSettingsPlugins()],
});
const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined;
if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure');
Expand Down Expand Up @@ -205,6 +215,58 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
}, 180_000);

/**
* [#31007] A host config in the run's directory whose one plugin declares
* the crypto provider — `os serve`'s provider for this deployment. Removed
* again by the returned function.
*/
const declareProvider = (plugin: Record<string, unknown>): (() => void) => {
(globalThis as Record<string, unknown>).__os_rewrap_provider_plugin = plugin;
const file = join(dir, 'objectstack.config.mjs');
writeFileSync(file, "export default { name: 'rewrap_dc', plugins: [globalThis.__os_rewrap_provider_plugin] };\n");
return () => {
rmSync(file, { force: true });
delete (globalThis as Record<string, unknown>).__os_rewrap_provider_plugin;
};
};

it('[#31007] a declared provider that boots re-wraps the rows, and the local key is never read', async () => {
const opened: string[] = [];
const inner = new LocalCryptoProvider({ key: KEY });
const kms = {
encrypt: (plain: string, ctx: CryptoContext) => inner.encrypt(plain, ctx),
decrypt: (handle: Parameters<typeof inner.decrypt>[0], ctx: CryptoContext) => { opened.push(ctx.scope); return inner.decrypt(handle, ctx); },
rotateKey: (handle: Parameters<typeof inner.rotateKey>[0], ctx: CryptoContext) => inner.rotateKey(handle, ctx),
digest: (plain: string) => inner.digest(plain),
keyedDigest: (plain: string) => inner.keyedDigest(plain),
};
const removeConfig = declareProvider({
name: 'com.example.kms',
version: '1.0.0',
providesServices: ['crypto-provider'],
init: async (ctx: { registerService(name: string, service: unknown): void }) => {
ctx.registerService('crypto-provider', kms);
},
});
const savedKey = process.env.OS_SECRET_KEY;
// No local key at all: only the registered provider can open these rows.
delete process.env.OS_SECRET_KEY;
try {
const before = await secretDriver.find('sys_secret', {});
const { payload, exitCode } = await runJson(['--declared-datasources', declaredFile]);

expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined();
expect(payload.report.byClass).toMatchObject({ rewrap: 2, refused_unreadable: 0 });
expect(payload.report.keySource).toBe("crypto provider 'com.example.kms'");
expect(exitCode).toBe(0);
expect(opened.length).toBeGreaterThan(0);
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
} finally {
process.env.OS_SECRET_KEY = savedKey;
removeConfig();
}
}, 180_000);

it('with no data key, the run refuses before opening a row, and NO provider in its boot mints one', async () => {
// A development posture with no key anywhere: the posture in which a
// default provider mints a key file. The settings service the boot
Expand Down Expand Up @@ -277,4 +339,37 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
expect(again.exitCode).toBe(0);
expect(await secretDriver.find('sys_secret', {})).toEqual(tableBefore);
}, 180_000);
// LAST on purpose: a boot that fails keeps this process's stdout reserved
// for the JSON payload (`bootSchemaStack`'s `jsonOutput`), as the one-shot
// process exits right after it, so no run may follow it in this file.
it('[#31007] a declared provider that cannot boot: --apply refuses naming it, and every stored value is byte-equal', async () => {
// A held version-1 row the earlier --apply has not re-wrapped, so a run
// that fell back to the local key — still present in `OS_SECRET_KEY` —
// would re-wrap it.
const engine = stack!.kernel.getService('objectql') as SecretReferenceEngineLike;
const settingDriver = engine.getDriverForObject('sys_setting') as unknown as DriverProbe;
const held = v1Row('sec_rewrap_dc_31007', 'smtp', 'username', 'dc-31007-plain');
await secretDriver.create('sys_secret', { ...held });
await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'username', value_enc: held.id, organization_id: 'org_fixture' });

const removeConfig = declareProvider({
name: 'com.example.kms-offline',
version: '1.0.0',
providesServices: ['crypto-provider'],
init: async () => { throw new Error('KMS endpoint unreachable'); },
});
try {
const before = await secretDriver.find('sys_secret', {});
const { payload, exitCode } = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]);

expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBe('boot_failed');
expect(payload.message).toContain("'com.example.kms-offline'");
expect(payload.message).toContain('Nothing was opened or written');
expect(payload.message).not.toContain(KEY.toString('hex'));
expect(exitCode).toBe(1);
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
} finally {
removeConfig();
}
}, 180_000);
});
43 changes: 36 additions & 7 deletions packages/cli/src/commands/secret/rewrap.guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ import SecretRewrap from './rewrap.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';

// [#31007] Pay the first load of what the one-shot crypto composition reaches through
// dynamic `import()`s (the host config reader and `serve.ts`) at MODULE LOAD, outside
// any clocked case.
import '@objectstack/driver-sql';
import '@objectstack/driver-turso';
import '@objectstack/plugin-security';
import '@objectstack/rest';
import '@objectstack/runtime';
import '@objectstack/service-datasource';

vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
// Constructed and handed to the (mocked) boot, never used.
vi.mock('@objectstack/platform-objects/plugin', () => ({ PlatformObjectsPlugin: class {} }));
Expand Down Expand Up @@ -137,16 +147,35 @@ function wireBoot(
},
};
const absent = new Set(opts.absent ?? []);
vi.mocked(bootSchemaStack).mockResolvedValue({
// The primary datasource's driver, as `SchemaStack.driver` carries it.
...(opts.driverConfig ? { driver: { config: opts.driverConfig } } : {}),
kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) },
tableAbsent: (objectName: string) => absent.has(objectName),
shutdown: async () => {},
} as never);
vi.mocked(bootSchemaStack).mockImplementation((async (bootOpts: { extraPlugins?: unknown[] }) => {
await driveHostCrypto(bootOpts.extraPlugins ?? []);
return {
// The primary datasource's driver, as `SchemaStack.driver` carries it.
...(opts.driverConfig ? { driver: { config: opts.driverConfig } } : {}),
kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) },
tableAbsent: (objectName: string) => absent.has(objectName),
shutdown: async () => {},
};
}) as never);
return harness;
}

/**
* [#31007] What the replaced boot still does for the crypto: the host's
* crypto-provider seam (`createHostCryptoProvider`, `os serve`'s) is
* initialized and its `kernel:ready` hook fired, as a kernel would, with no
* plugin registering a provider — so the run's provider is the local one,
* chosen by the function `serve` chooses with.
*/
async function driveHostCrypto(plugins: readonly unknown[]): Promise<void> {
const host = plugins.find((p) => (p as { name?: unknown }).name === 'com.objectstack.cli.host-crypto-provider') as
{ init(ctx: unknown): Promise<void> } | undefined;
if (!host) throw new Error('the run composed no host crypto-provider seam');
const ready: Array<() => unknown> = [];
await host.init({ getServices: () => new Map(), hook: (_name: string, handler: () => unknown) => ready.push(handler) });
for (const handler of ready) await handler();
}

async function run(argv: string[]): Promise<{ payload: Record<string, any>; exitCode: number }> {
const chunks: string[] = [];
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(
Expand Down
Loading
Loading