diff --git a/.changeset/27721-secret-orphans-holder-report.md b/.changeset/27721-secret-orphans-holder-report.md new file mode 100644 index 00000000000..0b2b0f8166f --- /dev/null +++ b/.changeset/27721-secret-orphans-holder-report.md @@ -0,0 +1,16 @@ +--- +'@objectstack/cli': patch +--- + +feat(cli): `os secret orphans` also reports the `sys_secret` handles more than one holder names, and the ones a column other than their own holds + +Clause-②: no +Metadata-protocol: none + +The orphan report answered which `sys_secret` rows nothing names. Its report mode now also lists, from the same cross-producer reference union, the rows something does name in a way no producer writes: + +- **`shared`** — a handle more than one holder record names. A producer seals one handle per value it writes. +- **`misplaced`** — a handle held by a column other than the one its `(namespace, key)` names: the settings row's own pair, or the object and field name for a `secret` field. A datasource holder is not judged for placement, because the datasource binder records the pair its caller passes. +- **`sharedInOwnColumn`**, listed apart — a handle every holder of which is a row of its own minting column. That is what a predicate update writes (one sealed value onto every row it matched), and stored rows cannot tell it from a reference copied between rows of the same column, so it is listed and not judged. + +Each entry carries the handle id, its `(namespace, key)`, the holder count and every holder's object, field and record. Never a secret and never cipher material. Under `--json` the section is a new top-level `holders` key beside `plan`. Report mode only: it changes nothing `--delete` removes, and it repairs nothing. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index fd67ae920a1..bdda74dc7bf 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -664,6 +664,19 @@ how you state there are none. Rows nothing attributes are never deleted, nor are whose setting still resolves through a legacy inline value, nor rows that were merely re-wrapped in place (a re-wrap keeps the handle and is not a retirement). +**The report also lists the rows something names in a way no producer writes.** A +handle more than one holder record names is listed as *shared*, since a producer seals +one handle per value. A handle a column holds whose `(namespace, key)` names another +column is listed as *misplaced*: settings records the setting's own pair, and an +object's `secret` field records the object and field name. A datasource holder is not +judged for placement, because the datasource binder records the pair its caller passes. +A predicate update writes one handle onto every row it matched, all in the column the +handle was minted for, so a share of exactly that shape is listed apart and not judged: +stored rows cannot tell it from a reference copied between rows of the same column. +Each entry names the handle, its holder count and every holder's object, field and +record, never a secret. Under `--json` this is the `holders` key. It is report-only: it +changes nothing `--delete` removes and repairs nothing. + **This does not retire an exposed credential, and the export is not optional.** On the pre-fix rotation path the handle was never repointed, so the value **still in force** is diff --git a/packages/cli/src/commands/secret/orphans.driver-contract.test.ts b/packages/cli/src/commands/secret/orphans.driver-contract.test.ts index 57f5041d42c..97e09f4fbfb 100644 --- a/packages/cli/src/commands/secret/orphans.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/orphans.driver-contract.test.ts @@ -41,6 +41,12 @@ * A run that reported `total: 0` with an empty `legacyInlineRows` would be * indistinguishable from a broken read, which is why both are asserted with * seeded values rather than merely for absence of an error. + * + * ## [#27721] The holder section, through the same run + * + * One more seeded handle, named by two settings rows, has to arrive under the + * report's `holders.shared` with both holders' coordinates; and nothing the + * command prints may carry any seeded cipher material. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -96,6 +102,43 @@ const SEEDED_SETTING = { value_enc: 'ENC(v1:inline-legacy-ciphertext)', }; +/** + * [#27721] A handle two `sys_setting` rows name: the shape the holder report + * lists as shared. Settings mints one handle per row write, so this is never a + * share a producer made. + */ +const SEEDED_SHARED_SECRET = { + id: 'sec_27721_shared_probe', + namespace: 'mail', + key: 'smtp_password', + alg: 'aes-256-gcm', + version: 1, + kms_key_id: 'kms_local', + ciphertext: 'ENC(v1:shared-probe-cipher-material)', +}; +const SEEDED_SHARING_SETTINGS = [ + { id: 'set_27721_tenant', namespace: 'mail', key: 'smtp_password', scope: 'tenant', value_enc: SEEDED_SHARED_SECRET.id }, + { id: 'set_27721_user', namespace: 'mail', key: 'smtp_password', scope: 'user', user_id: 'usr_27721', value_enc: SEEDED_SHARED_SECRET.id }, +]; + +/** The `--json` report document, as far as these assertions read it. */ +interface ReportPayload { + mode?: string; + error?: string; + plan?: { + counts: { total: number }; + rows: Array<{ id: string }>; + legacyInlineRows: Array<{ namespace: string; key: string }>; + }; + holders?: { + shared: Array<{ + id: string; + holderCount: number; + holders: Array<{ object: string; field: string; record: string | null; placement: string }>; + }>; + }; +} + interface DriverProbe { find(object: string, query: Record): Promise; create(object: string, data: Record): Promise; @@ -153,6 +196,10 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)', await secretDriver.create('sys_secret', { ...SEEDED_SECRET }); // The row names an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). await settingDriver.create('sys_setting', { ...SEEDED_SETTING, organization_id: 'org_fixture' }); + await secretDriver.create('sys_secret', { ...SEEDED_SHARED_SECRET }); + for (const row of SEEDED_SHARING_SETTINGS) { + await settingDriver.create('sys_setting', { ...row, organization_id: 'org_fixture' }); + } }, 180_000); afterAll(async () => { @@ -230,7 +277,12 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)', expect((await secretDriver.find('sys_secret', {})) as unknown[]).not.toEqual([]); }, 60_000); - it('the command runs against this database and BOTH former call sites carry their rows', async () => { + /** + * Run the command once, `--json`, against this database. Memoized: every + * assertion below reads the one run, so the boot is paid once. + */ + let report: Promise<{ stdout: string; payload: ReportPayload }> | undefined; + const runReport = () => (report ??= (async () => { const chunks: string[] = []; const stdout = vi.spyOn(process.stdout, 'write').mockImplementation( ((chunk: unknown, ...rest: unknown[]) => { @@ -252,15 +304,13 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)', } const lines = chunks.join('').split('\n').filter((l) => l.trim() !== ''); - const payload = JSON.parse(lines[lines.length - 1]) as { - mode?: string; - error?: string; - plan?: { - counts: { total: number }; - rows: Array<{ id: string }>; - legacyInlineRows: Array<{ namespace: string; key: string }>; - }; - }; + const payload = JSON.parse(lines[lines.length - 1]) as ReportPayload; + + return { stdout: chunks.join(''), payload }; + })()); + + it('the command runs against this database and BOTH former call sites carry their rows', async () => { + const { payload } = await runReport(); // A boot or read failure lands as an `error` envelope; naming it here beats // a downstream `undefined` that reads like a shape change. @@ -278,4 +328,24 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)', expect.objectContaining({ namespace: SEEDED_SETTING.namespace, key: SEEDED_SETTING.key }), ); }, 180_000); + + it('[#27721] lists the handle two settings rows name, and prints no cipher material', async () => { + const { stdout, payload } = await runReport(); + expect(payload.error).toBeUndefined(); + + const shared = payload.holders?.shared.find((f) => f.id === SEEDED_SHARED_SECRET.id); + expect(shared?.holderCount).toBe(2); + expect(shared?.holders.map((h) => [h.object, h.field, h.record, h.placement]).sort()).toEqual([ + ['sys_setting', 'value_enc', 'set_27721_tenant', 'in_place'], + ['sys_setting', 'value_enc', 'set_27721_user', 'in_place'], + ]); + + // Anti-vacuity: the run did read the rows whose cipher material is checked. + expect(payload.plan!.rows.map((r) => r.id)).toEqual( + expect.arrayContaining([SEEDED_SECRET.id, SEEDED_SHARED_SECRET.id]), + ); + for (const cipher of [SEEDED_SECRET.ciphertext, SEEDED_SHARED_SECRET.ciphertext, SEEDED_SETTING.value_enc]) { + expect(stdout).not.toContain(cipher); + } + }, 180_000); }); diff --git a/packages/cli/src/commands/secret/orphans.ts b/packages/cli/src/commands/secret/orphans.ts index fb5b20335a7..7580db853bd 100644 --- a/packages/cli/src/commands/secret/orphans.ts +++ b/packages/cli/src/commands/secret/orphans.ts @@ -32,6 +32,7 @@ import type { SettingRowSnapshot, SysSecretSweepPlan, } from '../../utils/sys-secret-orphan-sweep.js'; +import type { SysSecretHolderReport } from '../../utils/sys-secret-holder-report.js'; /** * `os secret orphans` — the operator-run half of #8103. @@ -93,11 +94,20 @@ import type { * make the mistake describable and still permanent. `--delete` without * `--export` is refused, the file is written with owner-only permissions, and * it is READ BACK and checked against the plan before a single row is removed. + * + * ## The rows something DOES name (#27721) + * + * The report also lists, from the same union, each handle more than one holder + * names and each handle a column other than its own holds + * (`sys-secret-holder-report.ts`), with the predicate-update share listed + * apart. Report mode only, and ⛔ nothing acts on it: it is not an input to the + * deletion plan, and no repair follows from it here. */ export default class SecretOrphans extends Command { static override description = 'Report `sys_secret` rows no producer references any more, and (only with --delete) remove the ' - + 'ones attributable to settings. Report-only by default: it writes no row or table and deletes nothing.'; + + 'ones attributable to settings. Report-only by default: it writes no row or table and deletes nothing. ' + + 'The report also lists handles more than one holder names, or a column other than their own holds.'; static override examples = [ '<%= config.bin %> secret orphans', @@ -200,6 +210,7 @@ export default class SecretOrphans extends Command { const { collectSecretReferenceUnion } = await import('../../utils/secret-reference-union.js'); const { buildPreDeleteExport, planSysSecretOrphanSweep, useHandlePredicate } = await import('../../utils/sys-secret-orphan-sweep.js'); + const { reportSysSecretHolders } = await import('../../utils/sys-secret-holder-report.js'); const { collectEncryptedSpecifierRefs, isSecretHandle } = await import('@objectstack/service-settings'); const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); @@ -340,8 +351,10 @@ export default class SecretOrphans extends Command { reads.notice(json); if (!flags.delete) { - if (json) { await emitJson({ mode: 'report', plan }, 0, { compact: true }); return; } + const holders = reportSysSecretHolders({ secrets, union }); + if (json) { await emitJson({ mode: 'report', plan, holders }, 0, { compact: true }); return; } renderPlan(plan); + renderHolders(holders); printInfo(`Report only — no row or table was written or deleted.${journalNote} (${timer.display()})`); printInfo('Re-run with --delete --export to remove the deletable rows.'); return; @@ -583,3 +596,25 @@ function renderPlan(plan: SysSecretSweepPlan): void { console.log(chalk.bold('\n Read before acting')); for (const note of plan.notes) printWarning(note); } + +/** Render the holder report for a human. Handle ids and holder coordinates only. */ +function renderHolders(report: SysSecretHolderReport): void { + console.log(chalk.bold('\n Named handles — shared, or held out of place')); + printInfo( + `named ${report.counts.named} · shared ${report.counts.shared} · misplaced ${report.counts.misplaced} · ` + + `shared in own column ${report.counts.sharedInOwnColumn}`, + ); + const sections: Array<[string, SysSecretHolderReport['shared']]> = [ + ['SHARED ', report.shared], + ['MISPLACED', report.misplaced], + ['OWN COL ', report.sharedInOwnColumn], + ]; + for (const [tag, findings] of sections) { + for (const finding of findings) { + console.log(` ${chalk.yellow(tag)} ${finding.id} (${finding.namespace}.${finding.key}) · ${finding.holderCount} holder(s)`); + for (const holder of finding.holders) console.log(chalk.dim(` ${holder.placement}: ${holder.family}: ${holder.holder}`)); + console.log(chalk.dim(` ${finding.reason}`)); + } + } + for (const note of report.notes) printWarning(note); +} diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts index 0b710a3211e..292e744eb83 100644 --- a/packages/cli/src/utils/secret-reference-union.test.ts +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -348,6 +348,10 @@ describe('family 1 — settings (`sys_setting.value_enc` and `sys_platform_setti const ref = union.references.find((r) => r.handleId === rt.settingsHandleId); expect(ref?.family).toBe('settings'); expect(ref?.holder).toContain('sys_setting(namespace=smtp,key=password'); + // [#27721] The same place as data: the record, and the pair settings mints for it. + expect(ref?.column).toEqual({ + object: 'sys_setting', field: 'value_enc', record: 'set_1', mintedPair: { namespace: 'smtp', key: 'password' }, + }); // No other family holds it — so removing family 1's enumeration reds this. expect(union.references.filter((r) => r.handleId === rt.settingsHandleId)).toHaveLength(1); }); @@ -394,6 +398,9 @@ describe('family 1 — settings (`sys_setting.value_enc` and `sys_platform_setti expect(refs).toHaveLength(1); expect(refs[0].family).toBe('settings'); expect(refs[0].holder).toBe('sys_platform_setting(namespace=smtp,key=password)'); + expect(refs[0].column).toEqual({ + object: 'sys_platform_setting', field: 'value_enc', record: 'ps_1', mintedPair: { namespace: 'smtp', key: 'password' }, + }); }); it('an unreadable sys_platform_setting gaps the WHOLE settings family', async () => { @@ -419,6 +426,10 @@ describe('family 2 — the engine secret-field channel (`secret:` on a busin const ref = union.references.find((r) => r.handleId === rt.objectFieldHandleId); expect(ref?.family).toBe('object-field'); expect(ref?.holder).toBe('smtp.password#rec_1'); + // [#27721] The engine mints a secret field's handle under the object and field name. + expect(ref?.column).toEqual({ + object: 'smtp', field: 'password', record: 'rec_1', mintedPair: { namespace: 'smtp', key: 'password' }, + }); expect(union.references.filter((r) => r.handleId === rt.objectFieldHandleId)).toHaveLength(1); }); @@ -492,6 +503,10 @@ describe('family 3 — datasource artefacts (`external.credentialsRef`)', () => const ref = union.references.find((r) => r.handleId === rt.datasourceHandleId); expect(ref?.family).toBe('datasource'); expect(ref?.holder).toBe('datasource(main).external.credentialsRef'); + // [#27721] No minted pair: the binder records whatever pair its caller passes. + expect(ref?.column).toEqual({ + object: 'datasource', field: 'external.credentialsRef', record: 'main', mintedPair: null, + }); expect(union.references.filter((r) => r.handleId === rt.datasourceHandleId)).toHaveLength(1); }); @@ -500,7 +515,12 @@ describe('family 3 — datasource artefacts (`external.credentialsRef`)', () => { name: 'analytics', external: { credentialsRef: 'sys_secret:sec_declared_1' } }, ]); expect(result.references).toEqual([ - { handleId: 'sec_declared_1', family: 'datasource', holder: 'datasource(analytics).external.credentialsRef' }, + { + handleId: 'sec_declared_1', + family: 'datasource', + holder: 'datasource(analytics).external.credentialsRef', + column: { object: 'datasource', field: 'external.credentialsRef', record: 'analytics', mintedPair: null }, + }, ]); }); diff --git a/packages/cli/src/utils/secret-reference-union.ts b/packages/cli/src/utils/secret-reference-union.ts index 1081f4d14b4..eed4be02ab0 100644 --- a/packages/cli/src/utils/secret-reference-union.ts +++ b/packages/cli/src/utils/secret-reference-union.ts @@ -133,6 +133,35 @@ export interface SecretReference { * `datasource(main).external.credentialsRef`. */ holder: string; + /** The same place as data, for a reader that compares coordinates rather than printing them. */ + column: SecretHolderColumn; +} + +/** + * [#27721] The coordinates of the column that holds a reference. + * + * {@link SecretReference.holder} is for printing; this is for comparing, so a + * reader asks "which column holds it" without parsing a printed string back. + * Collected at the same read as the reference, and nothing more is read for it. + */ +export interface SecretHolderColumn { + /** The holding object (`sys_setting`, a business object), or `datasource` for an artefact. */ + object: string; + /** The holding column (`value_enc`, the `secret` field, `external.credentialsRef`). */ + field: string; + /** The holding record: the row id, or the datasource name. `null` when the read returned none. */ + record: string | null; + /** + * The `(namespace, key)` its producer records on a `sys_secret` row it mints + * FOR this column: the settings row's own pair, or the object and field name + * (`encryptSecretFields`). + * + * `null` when the producer lets its caller choose the pair, so the pair names + * no column: the datasource binder records whatever its caller passes + * (`SecretInput.namespace` / `key`, and the binder's own `namespace` option), + * defaulting only when none is passed. + */ + mintedPair: { namespace: string; key: string } | null; } /** A family whose references were fully enumerated. */ @@ -380,10 +409,10 @@ export async function collectSettingsSecretReferences( } // `sys_platform_setting` has no `scope` and no `user_id`: one row per - // `(namespace, key)` for the deployment. + // `(namespace, key)` for the deployment. `id` names the holding record. const fields = holder === 'sys_setting' - ? ['namespace', 'key', 'scope', 'user_id', 'value_enc'] - : ['namespace', 'key', 'value_enc']; + ? ['id', 'namespace', 'key', 'scope', 'user_id', 'value_enc'] + : ['id', 'namespace', 'key', 'value_enc']; let result: unknown; try { result = await driver.find(holder, { fields }); @@ -405,6 +434,12 @@ export async function collectSettingsSecretReferences( holder: `${holder}(namespace=${String(row.namespace)},key=${String(row.key)}` + `${row.scope == null ? '' : `,scope=${String(row.scope)}`}` + `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`, + column: { + object: holder, + field: 'value_enc', + record: row.id == null ? null : String(row.id), + mintedPair: { namespace: String(row.namespace), key: String(row.key) }, + }, }); } } @@ -481,6 +516,12 @@ export async function collectObjectFieldSecretReferences( handleId, family, holder: `${objectName}.${field}#${String(row.id)}`, + column: { + object: objectName, + field, + record: row.id == null ? null : String(row.id), + mintedPair: { namespace: objectName, key: field }, + }, }); } } @@ -535,7 +576,17 @@ export function collectDatasourceSecretReferences( const key = JSON.stringify([handleId, holder]); if (seen.has(key)) continue; // the same reference, reached through two sources seen.add(key); - references.push({ handleId, family, holder }); + references.push({ + handleId, + family, + holder, + column: { + object: 'datasource', + field: 'external.credentialsRef', + record: artefact.name == null ? null : String(artefact.name), + mintedPair: null, + }, + }); } return { family, status: 'enumerated', references }; diff --git a/packages/cli/src/utils/sys-secret-holder-report.test.ts b/packages/cli/src/utils/sys-secret-holder-report.test.ts new file mode 100644 index 00000000000..5d85cf9383d --- /dev/null +++ b/packages/cli/src/utils/sys-secret-holder-report.test.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27721 — pins for the report-only holder pass over the reference union. + * + * Every handle below is minted by the producer that really mints it — the REAL + * `ObjectQL` write path for a `secret` field (a predicate update included), the + * REAL `LocalCryptoProvider` for the settings rows and the REAL datasource + * binder — and the union that reads them back is the REAL collector, over a + * minimal in-memory driver double. The two out-of-place refs are copied the + * only way a ref still moves: a system-principal write, which passes a ref + * through as written. + * + * The predicate-update share is the pin the card turns on: the engine itself + * writes one handle onto two rows here, and the report must list that share + * apart from `shared` and `misplaced`, not inside them. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { createDatasourceSecretBinder } from '@objectstack/service-datasource'; +import { LocalCryptoProvider } from '@objectstack/service-settings'; +import { + buildSecretReferenceUnion, + collectSecretReferenceUnion, + type SecretReferenceEngineLike, +} from './secret-reference-union.js'; +import { reportSysSecretHolders, type SysSecretHolderReport } from './sys-secret-holder-report.js'; + +type Row = Record; + +/** + * An in-memory driver double: the reads the union makes, and the writes the + * engine's secret-field path makes (insert, by-id and predicate update, and the + * release of a retired handle). + */ +function makeDriver() { + const stores = new Map>(); + const storeFor = (object: string) => { + let s = stores.get(object); + if (!s) { s = new Map(); stores.set(object, s); } + return s; + }; + // Scalar equality and `$in` only. Any other operator, and every combinator, + // is refused rather than answered wrong. + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) throw new Error(`driver double: combinator ${k} is not implemented`); + if (v && typeof v === 'object') { + const ops = Object.keys(v as Row); + if (ops.length !== 1 || ops[0] !== '$in') throw new Error(`driver double: operator ${ops.join(',')} is not implemented`); + if (!((v as { $in: unknown[] }).$in).includes(row[k])) return false; + continue; + } + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const copy = (r: Row): Row => ({ ...r }); + let n = 0; + + const driver = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async syncSchema() {}, + async find(object: string, ast?: Row) { + const matched = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // The caller's bound by PRESENCE, after the filter and before the copy. + const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; + return page.map(copy); + }, + async findOne(object: string, ast?: Row) { return (await this.find(object, ast))[0] ?? null; }, + async create(object: string, data: Row) { + n += 1; + const row = { ...data, id: (data.id as string) ?? `${object}_${n}` }; + storeFor(object).set(String(row.id), row); + return copy(row); + }, + async update(object: string, id: string, data: Row) { + const row = { ...storeFor(object).get(String(id)), ...data, id }; + storeFor(object).set(String(id), row); + return copy(row); + }, + async updateMany(object: string, ast: Row | undefined, data: Row) { + let k = 0; + for (const [id, row] of storeFor(object)) { + if (!matches(row, ast?.where)) continue; + storeFor(object).set(id, { ...row, ...data, id }); + k += 1; + } + return k; + }, + async delete(object: string, id: string) { return storeFor(object).delete(String(id)); }, + async count(object: string, ast?: Row) { return (await this.find(object, ast)).length; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + + return { + driver, + seed(object: string, row: Row) { storeFor(object).set(String(row.id), { ...row }); }, + rowsOf(object: string) { return Array.from(storeFor(object).values()).map(copy); }, + }; +} + +const TEST_PACKAGE_ID = 'com.objectstack.test.27721'; +const text = (name: string) => ({ name, label: name, type: 'text' as const }); +const secret = (name: string) => ({ name, label: name, type: 'secret' as const }); +const objectOf = (name: string, fields: string[], secrets: string[] = []) => ({ + name, + label: name, + fields: { + ...Object.fromEntries(fields.map((f) => [f, text(f)])), + ...Object.fromEntries(secrets.map((f) => [f, secret(f)])), + }, +}); + +/** Distinctive plaintexts, so their absence from the report means something. */ +const PLAIN = { + r1: 'pt-27721-vault-r1', + r2: 'pt-27721-vault-r2', + r3: 'pt-27721-vault-r3', + r4: 'pt-27721-vault-r4', + predicate: 'pt-27721-predicate-shared', + mailPassword: 'pt-27721-mail-password', + mailToken: 'pt-27721-mail-token', + datasource: 'pt-27721-datasource', +} as const; + +const SYSTEM = { context: { isSystem: true } } as never; + +async function buildRuntime() { + const store = makeDriver(); + const engine = new ObjectQL(); + engine.registerDriver(store.driver as never, true); + await engine.init(); + const objects = [ + objectOf('sys_secret', ['id', 'namespace', 'key', 'kms_key_id', 'alg', 'ciphertext', 'created_at', 'rotated_at', 'version']), + objectOf('sys_setting', ['id', 'namespace', 'key', 'scope', 'user_id', 'value', 'value_enc']), + objectOf('sys_platform_setting', ['id', 'namespace', 'key', 'value', 'value_enc']), + objectOf('sys_metadata', ['id', 'name', 'type', 'scope', 'metadata', 'state']), + objectOf('vault', ['id', 'tier'], ['token']), + objectOf('ledger', ['id'], ['api_key']), + ]; + for (const object of objects) engine.registry.registerObject(object as never, TEST_PACKAGE_ID); + const crypto = new LocalCryptoProvider({ mode: 'test' }); + engine.setCryptoProvider(crypto as never); + + const handleOf = (object: string, id: string, field: string) => { + const value = store.rowsOf(object).find((r) => r.id === id)?.[field]; + return String(value).slice('secret:'.length); + }; + const seedSettingsSecret = async (namespace: string, key: string, plain: string) => { + const handle = await crypto.encrypt(plain, { scope: 'settings', namespace, key }); + store.seed('sys_secret', { + id: handle.id, namespace, key, kms_key_id: handle.kmsKeyId, + alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, + }); + return handle.id; + }; + + // The engine's own channel: four records, each sealing its own handle. + await engine.insert('vault', { id: 'r1', tier: 'gold', token: PLAIN.r1 }); + await engine.insert('vault', { id: 'r2', tier: 'gold', token: PLAIN.r2 }); + await engine.insert('vault', { id: 'r3', tier: 'silver', token: PLAIN.r3 }); + await engine.insert('vault', { id: 'r4', tier: 'silver', token: PLAIN.r4 }); + // [#27495] A REAL predicate update: one value sealed once, its ref written + // onto every row the predicate matched. + await engine.update('vault', { token: PLAIN.predicate }, { where: { tier: 'gold' }, multi: true } as never); + + // One settings handle that two settings rows name. + const settingsShared = await seedSettingsSecret('mail', 'smtp_password', PLAIN.mailPassword); + store.seed('sys_setting', { id: 'set_a', namespace: 'mail', key: 'smtp_password', scope: 'tenant', user_id: null, value_enc: settingsShared }); + store.seed('sys_setting', { id: 'set_b', namespace: 'mail', key: 'smtp_password', scope: 'user', user_id: 'u1', value_enc: settingsShared }); + + // r3's handle, copied onto another object's column by a system write. + await engine.insert('ledger', { id: 'l1', api_key: `secret:${handleOf('vault', 'r3', 'token')}` }, SYSTEM); + // A settings-minted handle no settings row names, held only by a business column. + const mailToken = await seedSettingsSecret('mail', 'api_token', PLAIN.mailToken); + await engine.insert('ledger', { id: 'l2', api_key: `secret:${mailToken}` }, SYSTEM); + + // The datasource binder, through its default pair. + const binder = createDatasourceSecretBinder({ engine: engine as never, cryptoProvider: crypto as never }); + const credentialsRef = await binder.bind({ value: PLAIN.datasource }, { name: 'main' }); + store.seed('sys_metadata', { + id: 'meta_1', name: 'main', type: 'datasource', scope: 'platform', state: 'active', + metadata: JSON.stringify({ name: 'main', driver: 'postgres', external: { credentialsRef } }), + }); + + return { + engine: engine as unknown as SecretReferenceEngineLike, + store, + ids: { + predicate: handleOf('vault', 'r1', 'token'), + r3: handleOf('vault', 'r3', 'token'), + r4: handleOf('vault', 'r4', 'token'), + settingsShared, + mailToken, + datasource: credentialsRef.slice('sys_secret:'.length), + }, + }; +} + +type Runtime = Awaited>; + +const secretsOf = (rt: Runtime) => rt.store.rowsOf('sys_secret').map((r) => ({ + id: String(r.id), namespace: String(r.namespace), key: String(r.key), +})); + +async function reportOf(rt: Runtime): Promise { + const union = await collectSecretReferenceUnion({ engine: rt.engine, declaredDatasources: [] }); + expect(union.complete).toBe(true); + return reportSysSecretHolders({ secrets: secretsOf(rt), union }); +} + +const idsIn = (list: SysSecretHolderReport['shared']) => list.map((f) => f.id); + +describe('sys_secret holder report — the fixture is what the producers wrote', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('the predicate update really wrote ONE handle onto both rows it matched', () => { + const rows = rt.store.rowsOf('vault'); + const ref = (id: string) => rows.find((r) => r.id === id)?.token; + expect(ref('r1')).toBe(`secret:${rt.ids.predicate}`); + expect(ref('r2')).toBe(ref('r1')); + // …and the handle it replaced on each row is gone, so the share is the only one. + expect(new Set(Object.values(rt.ids)).size).toBe(Object.values(rt.ids).length); + expect(rt.store.rowsOf('sys_secret').map((r) => r.id)).toContain(rt.ids.predicate); + }); +}); + +describe('shared — a handle named by more than one holder record', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('lists the settings handle two settings rows name, with the count and each holder object/field', async () => { + const report = await reportOf(rt); + const finding = report.shared.find((f) => f.id === rt.ids.settingsShared); + expect(finding).toBeDefined(); + expect(finding!.holderCount).toBe(2); + expect(finding!.holders.map((h) => [h.object, h.field, h.record, h.placement])).toEqual([ + ['sys_setting', 'value_enc', 'set_a', 'in_place'], + ['sys_setting', 'value_enc', 'set_b', 'in_place'], + ]); + // Both rows are the column its pair names, so it is shared and not misplaced. + expect(idsIn(report.misplaced)).not.toContain(rt.ids.settingsShared); + }); + + it('lists a handle two different columns name', async () => { + const report = await reportOf(rt); + const finding = report.shared.find((f) => f.id === rt.ids.r3); + expect(finding?.holderCount).toBe(2); + expect(finding?.holders.map((h) => h.holder).sort()).toEqual(['ledger.api_key#l1', 'vault.token#r3']); + }); + + it('a handle one holder names is not shared (control)', async () => { + const report = await reportOf(rt); + for (const id of [rt.ids.r4, rt.ids.mailToken, rt.ids.datasource]) { + expect(idsIn(report.shared)).not.toContain(id); + expect(idsIn(report.sharedInOwnColumn)).not.toContain(id); + } + }); +}); + +describe('misplaced — a holder column that is not the column the handle\'s (namespace, key) names', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('lists the copied handle, naming the holder out of place and the one in place', async () => { + const report = await reportOf(rt); + const finding = report.misplaced.find((f) => f.id === rt.ids.r3); + expect(finding).toMatchObject({ namespace: 'vault', key: 'token' }); + expect(finding!.holders.map((h) => [h.holder, h.placement]).sort()).toEqual([ + ['ledger.api_key#l1', 'misplaced'], + ['vault.token#r3', 'in_place'], + ]); + }); + + it('lists a handle whose ONLY holder is out of place — another producer\'s pair on a business column', async () => { + const report = await reportOf(rt); + const finding = report.misplaced.find((f) => f.id === rt.ids.mailToken); + expect(finding).toMatchObject({ namespace: 'mail', key: 'api_token', holderCount: 1 }); + expect(finding!.holders).toEqual([ + expect.objectContaining({ family: 'object-field', object: 'ledger', field: 'api_key', record: 'l2', placement: 'misplaced' }), + ]); + }); + + it('a handle held by its own column is not misplaced (control)', async () => { + const report = await reportOf(rt); + expect(idsIn(report.misplaced)).not.toContain(rt.ids.r4); + expect(idsIn(report.misplaced).sort()).toEqual([rt.ids.r3, rt.ids.mailToken].sort()); + }); + + it('a datasource holder is not judged: the binder records the pair its caller passes', async () => { + const report = await reportOf(rt); + expect(idsIn(report.misplaced)).not.toContain(rt.ids.datasource); + expect(report.counts.placementNotJudged).toBe(1); + expect(report.notes.some((n) => n.includes('not judged for placement'))).toBe(true); + }); +}); + +describe('the predicate-update share (#27495) is listed apart', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('lists the share the engine wrote under sharedInOwnColumn, and in neither other class', async () => { + const report = await reportOf(rt); + const finding = report.sharedInOwnColumn.find((f) => f.id === rt.ids.predicate); + expect(finding).toBeDefined(); + expect(finding!.holderCount).toBe(2); + expect(finding!.holders.map((h) => [h.holder, h.placement])).toEqual([ + ['vault.token#r1', 'in_place'], + ['vault.token#r2', 'in_place'], + ]); + expect(idsIn(report.shared)).not.toContain(rt.ids.predicate); + expect(idsIn(report.misplaced)).not.toContain(rt.ids.predicate); + }); + + it('says how it tells the case apart', async () => { + const report = await reportOf(rt); + expect(report.notes.some((n) => n.startsWith('Told apart:'))).toBe(true); + }); + + it('a share across settings rows is not given the predicate-update exemption', async () => { + // The settings producer seals one handle per row write: only the engine's + // secret-field channel writes one handle onto several rows. + const report = await reportOf(rt); + expect(idsIn(report.sharedInOwnColumn)).toEqual([rt.ids.predicate]); + }); +}); + +describe('counts, and what never leaves the report', () => { + let rt: Runtime; + beforeEach(async () => { rt = await buildRuntime(); }); + + it('counts each class by handle', async () => { + const report = await reportOf(rt); + expect(report.counts).toEqual({ + total: rt.store.rowsOf('sys_secret').length, + named: 6, + shared: 2, + misplaced: 2, + sharedInOwnColumn: 1, + placementNotJudged: 1, + }); + }); + + it('carries no plaintext and no cipher material, and writes nothing', async () => { + const before = JSON.stringify(rt.store.rowsOf('sys_secret')); + const report = await reportOf(rt); + const printed = JSON.stringify(report); + + for (const plain of Object.values(PLAIN)) expect(printed).not.toContain(plain); + const ciphertexts = rt.store.rowsOf('sys_secret').map((r) => String(r.ciphertext)); + // Anti-vacuity: there IS cipher material in the store to leak. + expect(ciphertexts.length).toBeGreaterThan(0); + for (const cipher of ciphertexts) expect(printed).not.toContain(cipher); + expect(printed).not.toMatch(/ciphertext/i); + + expect(JSON.stringify(rt.store.rowsOf('sys_secret'))).toBe(before); + }); + + it('an incomplete union is reported on what was enumerated, and names the missing family', () => { + const union = buildSecretReferenceUnion({ + settings: { family: 'settings', status: 'enumerated', references: [] }, + 'object-field': { + family: 'object-field', + status: 'gap', + reason: 'reading secret field(s) of `vault` threw — Error: table is locked', + references: [ + { handleId: 'sec_x', family: 'object-field', holder: 'vault.token#r1', column: { object: 'vault', field: 'token', record: 'r1', mintedPair: { namespace: 'vault', key: 'token' } } }, + { handleId: 'sec_x', family: 'object-field', holder: 'ledger.api_key#l1', column: { object: 'ledger', field: 'api_key', record: 'l1', mintedPair: { namespace: 'ledger', key: 'api_key' } } }, + ], + }, + datasource: { family: 'datasource', status: 'enumerated', references: [] }, + }); + const report = reportSysSecretHolders({ secrets: [{ id: 'sec_x', namespace: 'vault', key: 'token' }], union }); + expect(idsIn(report.shared)).toEqual(['sec_x']); + expect(idsIn(report.misplaced)).toEqual(['sec_x']); + expect(report.notes.some((n) => n.includes('incomplete') && n.includes('object-field'))).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/sys-secret-holder-report.ts b/packages/cli/src/utils/sys-secret-holder-report.ts new file mode 100644 index 00000000000..41807fcf008 --- /dev/null +++ b/packages/cli/src/utils/sys-secret-holder-report.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27721 — REPORT-ONLY: the `sys_secret` handles more than one holder names, + * and the ones a column other than their own holds. + * + * A non-system write to a `secret` field may now only seal a new value or keep + * its own record's ref, but values stored before that rule are not + * re-examined. The orphan report answers which handles NOTHING names; this + * answers the two questions it leaves open about the handles something does + * name: + * + * - **shared** — named by more than one holder record. Every producer mints + * one handle per value it seals, so two records naming one handle did not + * each seal it. + * - **misplaced** — held by a column other than the one its `(namespace, key)` + * names. A producer records on the row the pair of the column it minted the + * handle for ({@link SecretHolderColumn.mintedPair}), and the engine's + * release asks the same question before it removes a handle: a handle held + * elsewhere is one that column's release does not count. + * + * ## The one share a producer makes, listed apart + * + * A predicate update seals ONE value and writes its ref onto every row it + * matched, all in the column the handle was minted for. That is the only + * producer path that puts one handle on more than one record, and its stored + * shape is exact: every holder is in the `object-field` family and in place, + * so every holder is a row of the handle's own `OBJECT.FIELD`. A share of that + * shape is listed in {@link SysSecretHolderReport.sharedInOwnColumn} and in + * neither class above. Stored rows cannot tell it from a ref copied between + * rows of that same column before non-system writes were limited to their own + * record's ref, and the report says so rather than judging it. + * + * ## Read-only, and never a verdict + * + * A pure pass over the readings the command already made: the `sys_secret` + * snapshots and the reference union's holder coordinates. Nothing here reads, + * writes, decrypts or deletes, and nothing here feeds the deletion plan. ⛔ No + * repair path belongs here; whether one follows is a maintainer decision. + * + * ⛔ Never cipher material and never a plaintext. A finding carries the handle + * id, its `(namespace, key)` and its holders' coordinates — what the orphan + * report already prints — and the input type {@link SecretRowSnapshot} has no + * `ciphertext` member to carry. + */ + +import type { SecretRowSnapshot } from '@objectstack/service-settings'; +import type { + SecretReference, + SecretReferenceFamily, + SecretReferenceUnion, +} from './secret-reference-union.js'; + +/** + * Whether one holder is the column its handle's `(namespace, key)` names. + * + * - `in_place` — the pair this holder's producer mints for it is the row's pair. + * - `misplaced` — it is another pair, and the producer fixes the pair. + * - `not_judged` — the producer lets its caller choose the pair, so a pair + * that differs shows nothing about where the ref came from. + */ +export type HolderPlacement = 'in_place' | 'misplaced' | 'not_judged'; + +/** One holder of a reported handle. Coordinates only. */ +export interface ReportedHolder { + family: SecretReferenceFamily; + /** The union's printable coordinate. */ + holder: string; + object: string; + field: string; + record: string | null; + placement: HolderPlacement; +} + +/** One reported handle. */ +export interface HolderFinding { + /** `sys_secret.id`. */ + id: string; + namespace: string; + key: string; + /** How many holder records name it. */ + holderCount: number; + /** Every holder that names it, each with its placement. */ + holders: ReportedHolder[]; + /** Justification, safe to print. */ + reason: string; +} + +/** The report. Counts, handle ids and holder coordinates — never cipher material. */ +export interface SysSecretHolderReport { + counts: { + /** `sys_secret` rows read. */ + total: number; + /** Of those, rows at least one holder names. */ + named: number; + shared: number; + misplaced: number; + sharedInOwnColumn: number; + /** Holders whose placement was not judged. */ + placementNotJudged: number; + }; + /** Handles named by more than one holder record, other than an own-column share. */ + shared: HolderFinding[]; + /** Handles at least one holder of which is not the column their pair names. */ + misplaced: HolderFinding[]; + /** Listed apart: every holder is a row of the handle's own minting column. */ + sharedInOwnColumn: HolderFinding[]; + /** What the counts depend on. */ + notes: string[]; +} + +/** The placement of one holder against the row it names. */ +function placementOf(ref: SecretReference, secret: SecretRowSnapshot): HolderPlacement { + const pair = ref.column.mintedPair; + if (pair === null) return 'not_judged'; + return pair.namespace === secret.namespace && pair.key === secret.key ? 'in_place' : 'misplaced'; +} + +/** + * Report every named `sys_secret` row that is shared or misplaced. + * + * @param input.secrets every `sys_secret` row, read unscoped. + * @param input.union the cross-producer reference union. Incomplete is legal: + * the report covers the holders that were enumerated and says which family + * was not. + */ +export function reportSysSecretHolders(input: { + secrets: readonly SecretRowSnapshot[]; + union: SecretReferenceUnion; +}): SysSecretHolderReport { + const secrets = input.secrets ?? []; + const { union } = input; + + const refsById = new Map(); + for (const ref of union.references) { + const list = refsById.get(ref.handleId); + if (list) list.push(ref); + else refsById.set(ref.handleId, [ref]); + } + + const shared: HolderFinding[] = []; + const misplaced: HolderFinding[] = []; + const sharedInOwnColumn: HolderFinding[] = []; + let named = 0; + let placementNotJudged = 0; + + for (const secret of secrets) { + const refs = refsById.get(secret.id); + if (!refs || refs.length === 0) continue; // the orphan report's question, not this one + named += 1; + + const holders: ReportedHolder[] = refs.map((ref) => ({ + family: ref.family, + holder: ref.holder, + object: ref.column.object, + field: ref.column.field, + record: ref.column.record, + placement: placementOf(ref, secret), + })); + placementNotJudged += holders.filter((h) => h.placement === 'not_judged').length; + const finding = (reason: string): HolderFinding => ({ + id: secret.id, + namespace: secret.namespace, + key: secret.key, + holderCount: holders.length, + holders, + reason, + }); + + const away = holders.filter((h) => h.placement === 'misplaced'); + if (away.length > 0) { + misplaced.push(finding( + `held by ${away.length} column(s) other than the one its (namespace, key) names: ` + + `${away.map((h) => `${h.object}.${h.field}`).join(', ')}`, + )); + } + + if (holders.length < 2) continue; + const ownColumn = holders.every((h) => h.family === 'object-field' && h.placement === 'in_place'); + if (ownColumn) { + sharedInOwnColumn.push(finding( + `named by ${holders.length} rows of ${secret.namespace}.${secret.key}, the column it was minted ` + + 'for: the shape a predicate update writes. Not judged: stored rows cannot tell it from a ref ' + + 'copied between rows of that column before writes were limited to their own record\'s ref.', + )); + } else { + shared.push(finding( + `named by ${holders.length} holder records (${[...new Set(holders.map((h) => h.family))].join(', ')}); ` + + 'a producer seals one handle per value, and the one share a producer makes (a predicate ' + + 'update, every holder a row of the handle\'s own column) is not this one.', + )); + } + } + + const notes = [ + 'Report-only: nothing is repaired or removed, and nothing here changes what --delete removes.', + 'Told apart: a predicate update writes one handle onto every row it matched, so a handle whose ' + + 'every holder is a row of its own minting column (object and field = its namespace and key) is ' + + 'listed under sharedInOwnColumn, not under shared or misplaced. Stored rows cannot tell that ' + + 'from a ref copied between rows of the same column, so it is listed, not judged.', + 'Placement compares the pair each producer records for the column it mints into: the settings ' + + 'row\'s (namespace, key), and the object and field name for a secret field. The pair is a name: ' + + 'a handle whose pair matches another producer\'s column reads as in place there.', + ]; + if (placementNotJudged > 0) { + notes.push( + `${placementNotJudged} datasource holder(s) not judged for placement: the datasource binder ` + + 'records the pair its caller passes, so that pair names no column.', + ); + } + if (!union.complete) { + notes.push( + `The reference union is incomplete (${union.gaps.map((g) => g.family).join(', ')} could not be ` + + 'enumerated). A holder in that family is not counted, so shared and misplaced may be ' + + 'under-reported.', + ); + } + + return { + counts: { + total: secrets.length, + named, + shared: shared.length, + misplaced: misplaced.length, + sharedInOwnColumn: sharedInOwnColumn.length, + placementNotJudged, + }, + shared, + misplaced, + sharedInOwnColumn, + notes, + }; +} diff --git a/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts b/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts index fbc0e9a12eb..8ba5630c557 100644 --- a/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts +++ b/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts @@ -393,7 +393,12 @@ describe('the falsifiable criterion — an incomplete union refuses, and NAMES t family: 'object-field', status: 'gap', reason: 'reading secret field(s) of `other` threw — Error: table is locked', - references: [{ handleId: 'sec_partial', family: 'object-field', holder: 'smtp.password#rec_1' }], + references: [{ + handleId: 'sec_partial', + family: 'object-field', + holder: 'smtp.password#rec_1', + column: { object: 'smtp', field: 'password', record: 'rec_1', mintedPair: { namespace: 'smtp', key: 'password' } }, + }], }; const union: SecretReferenceUnion = buildSecretReferenceUnion({ settings: { family: 'settings', status: 'enumerated', references: [] },