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
16 changes: 16 additions & 0 deletions .changeset/27721-secret-orphans-holder-report.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
**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
Expand Down
90 changes: 80 additions & 10 deletions packages/cli/src/commands/secret/orphans.driver-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>): Promise<unknown>;
create(object: string, data: Record<string, unknown>): Promise<unknown>;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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[]) => {
Expand All @@ -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.
Expand All @@ -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);
});
39 changes: 37 additions & 2 deletions packages/cli/src/commands/secret/orphans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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 <path> to remove the deletable rows.');
return;
Expand Down Expand Up @@ -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);
}
22 changes: 21 additions & 1 deletion packages/cli/src/utils/secret-reference-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -419,6 +426,10 @@ describe('family 2 — the engine secret-field channel (`secret:<id>` 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);
});

Expand Down Expand Up @@ -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);
});

Expand All @@ -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 },
},
]);
});

Expand Down
59 changes: 55 additions & 4 deletions packages/cli/src/utils/secret-reference-union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 });
Expand All @@ -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) },
},
});
}
}
Expand Down Expand Up @@ -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 },
},
});
}
}
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading