diff --git a/.changeset/27818-datasource-restore-publish-credentials-ref.md b/.changeset/27818-datasource-restore-publish-credentials-ref.md new file mode 100644 index 00000000000..5baa1556904 --- /dev/null +++ b/.changeset/27818-datasource-restore-publish-credentials-ref.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a datasource restored or published from a stored body may name only the credentials reference its row holds now (#27818) + +Clause-②: no (narrowing) — the restore doors and the draft publish refuse a stored `datasource` body whose `external.credentialsRef` is not the reference the active row they write into holds now, where they wrote it back as stored before. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing on the doors that make a stored body the active row, completing the rule #27762 applied to the metadata save door (the #27582 own-reference rule: a non-system write may only seal a new value or keep its own record's stored reference). These doors seal nothing, so the one reference the body they write may name is the one the active row holds now. + +- **Before.** A history version brought back by `rollbackMetaItem` (`POST /api/v1/meta/datasource/:name/rollback`) or by `revertCommit`'s restore limb (`POST /api/v1/packages/:id/commits/:commitId/revert`), and a draft promoted by the per-item publish (`POST /api/v1/meta/datasource/:name/publish`) or the package publish, became the active row with whatever `external.credentialsRef` it was stored with. +- **Now.** Each is judged as an active write into the row it lands in, with the save door's judge: a body naming a reference that row does not hold now is refused with `VALIDATION_ERROR` / `400` before anything is written. The message names the datasource and the key, never the value, and the answer is the same whatever the value names. A rollback answers the refusal; a commit revert reports it per item in `failed[]`; a package publish aborts on it. +- **Unchanged.** A version or draft naming the reference the row holds now is written and keeps it. One naming no reference is written as it stands, with no credential bound, as a save omitting the key is. The save door's own rule and its server-stated pass-through are unchanged, and no server-stated restore or publish exists. Rows already stored are not re-judged at rest. +- **Who sees it.** An operator who rolls back or reverts a datasource to a version whose reference its row no longer holds, because the reference was rotated through the datasource admin door since, or because the version was stored before the save door judged the key: that restore is refused. An author who publishes a datasource draft after its active row's reference was rotated, or a draft stored before the save door judged the key: that publish is refused. + +**The remedy.** For a refused restore, restore a version that names the reference a read of the datasource serves, or save the version's settings through the metadata door with the key omitted. For a refused publish, save the draft again with the key omitted, or discard it, then publish. Either way, to bind a credential, send the secret to the datasource admin door (`POST /api/v1/datasources`, or `PATCH /api/v1/datasources/:name`), which mints and stores the reference. diff --git a/packages/metadata-protocol/src/protocol-authoring-gate.ts b/packages/metadata-protocol/src/protocol-authoring-gate.ts index c477eabc961..fedb2bf0851 100644 --- a/packages/metadata-protocol/src/protocol-authoring-gate.ts +++ b/packages/metadata-protocol/src/protocol-authoring-gate.ts @@ -48,6 +48,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js'; import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js'; +import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js'; import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; import type { RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js'; import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name'; @@ -114,7 +115,7 @@ export function restoredBodyWriter( /** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */ export async function refuseRestoredBodyTheSaveDoorRefuses( - this: Pick, + this: Pick, type: string, name: string, body: unknown, @@ -131,6 +132,12 @@ export async function refuseRestoredBodyTheSaveDoorRefuses( if (bindingRefusal) throw bindingRefusal; const datasourceRefusal = await this.unknownDatasourceSaveRefusal(type, body, name); if (datasourceRefusal) throw datasourceRefusal; + const repo = this.getOverlayRepo(); + const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({ + headAt: (...a) => repo.headAt(...a), type, name, item: body, + mode: 'publish', packageId: binding, source: undefined, door: 'restore', + }); + if (credentialsRefRefusal) throw credentialsRefRefusal; const elevatedRefusal = await this.anonymousElevatedFlowRefusal(type, body, name); if (elevatedRefusal) throw elevatedRefusal; const channel = this.credentialChannelFor(type); diff --git a/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts b/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts index 50cff4f4131..244a4d11467 100644 --- a/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts +++ b/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts @@ -40,6 +40,18 @@ * reference of their own. The server-stated rewrites of a row this store * already holds (`source` set: the stored-metadata migration) keep their * pass-through, as the system principal does under the #27582 rule. + * + * [#27818] The doors that make a STORED body the active row ask the same + * judge, as `door: 'restore'` and `door: 'publish'`: the restore doors + * (`rollbackMetaItem` and `revertCommit`'s restore limb, through the restore + * path's save-door refusal family) on the history body they write back, and + * the draft publish (the per-item publish and the package publish) on the + * draft it promotes. Each is judged as an active write into the row it lands + * in, so the one reference the body may name is the one that row holds NOW: + * a version or draft stored before the save door judged the key, or before + * the row's own reference moved, is refused rather than brought back live. A + * body naming no reference is written as it stands, as a save omitting the + * key is. No server-stated restore or publish exists, so none passes through. */ import { itemDiscriminator } from '@objectstack/metadata-core'; @@ -69,6 +81,12 @@ export async function datasourceCredentialsRefSaveRefusal(args: { packageId: string | null | undefined; /** The server-stated `source` of a rewrite; `undefined` for every authoring save. */ source: string | undefined; + /** + * [#27818] The door asking: the save (omitted), a restore writing a stored + * version back, or a publish promoting a stored draft. It changes only the + * refusal's words, never what is judged. + */ + door?: 'restore' | 'publish'; }): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> { if (args.type !== 'datasource' || args.source !== undefined) return undefined; const named = namedCredentialsRef(args.item); @@ -83,13 +101,23 @@ export async function datasourceCredentialsRefSaveRefusal(args: { } const own = namedCredentialsRef(head?.body); if (typeof own === 'string' && own === named) return undefined; - const err = new Error( - `Datasource '${args.name}' was not saved: \`external.credentialsRef\` may only name the credentials ` - + `reference this datasource's own stored definition already holds, because a reference is minted by the ` - + `platform when a secret is bound. Send back the reference a read of this datasource serves, or omit the ` - + `key to save it with no credential bound. To bind a credential, send the secret to the datasource admin ` - + `door instead (Setup → Datasources, \`POST /api/v1/datasources\` or \`PATCH /api/v1/datasources/${args.name}\`).`, - ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; + const adminDoor = `To bind a credential, send the secret to the datasource admin door instead (Setup → Datasources, ` + + `\`POST /api/v1/datasources\` or \`PATCH /api/v1/datasources/${args.name}\`).`; + const why = 'because a reference is minted by the platform when a secret is bound.'; + const message = args.door === 'restore' + ? `Datasource '${args.name}' was not restored: the version restored names an \`external.credentialsRef\` ` + + `this datasource's stored definition does not hold now, and a restore may only bring back the credentials ` + + `reference that definition holds, ${why} Restore a version that names the reference a read of this ` + + `datasource serves, or one that names none to restore it with no credential bound. ${adminDoor}` + : args.door === 'publish' + ? `Datasource '${args.name}' was not published: its draft names an \`external.credentialsRef\` this ` + + `datasource's active definition does not hold now, and a publish may only make live the credentials ` + + `reference that definition holds, ${why} Save the draft again with the key omitted, or discard it, ` + + `to publish it with no credential bound. ${adminDoor}` + : `Datasource '${args.name}' was not saved: \`external.credentialsRef\` may only name the credentials ` + + `reference this datasource's own stored definition already holds, ${why} Send back the reference a ` + + `read of this datasource serves, or omit the key to save it with no credential bound. ${adminDoor}`; + const err = new Error(message) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; err.code = 'VALIDATION_ERROR'; err.status = 400; return err; diff --git a/packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts b/packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts new file mode 100644 index 00000000000..0016a067d1d --- /dev/null +++ b/packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27818] The own-reference rule for a `datasource`'s + * `external.credentialsRef` at the doors that make a STORED body the active + * row: the restore doors (`rollbackMetaItem`, `revertCommit`'s restore limb) + * and the draft publish (`publishMetaItem`). + * + * The rule is the one #27582 set and #27762 applied to the metadata save + * door: a non-system write may only seal a new value or keep its own record's + * stored reference. These doors seal nothing, so the one reference the body + * they write may name is the one the active row they write into holds now: + * + * - refused, `VALIDATION_ERROR` / 400, before anything is written: a version + * (or a draft) naming a reference another datasource holds, one no row + * holds, one its row held before and holds no longer, and one where its row + * holds none. One answer whatever the reference names, and the reference is + * never echoed; + * - accepted: a version (or a draft) naming the reference its row holds now, + * and one naming none (it stores none, as a save that omits the key does). + * + * Staging. A stored body the rule refuses today exists in a deployment in two + * ways: it was stored before the save door judged the key, or the row's own + * reference moved after it was stored (the datasource admin door binds and + * rotates the reference by writing the active row directly). Both are staged + * here by rewriting the stored bytes in the double, as such rows sit at rest. + * + * The double is copied from `protocol.restore-doors-save-refusal-family.test.ts`, + * not imported, so each pin can fail on its own. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, imported from +// `@objectstack/metadata-core` and NOT from `@objectstack/objectql`: objectql +// depends on this package, so that import would close a dependency cycle. +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + assertEngineFindOnePredicate, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +interface HistoryRow { + id: string; + type: string; + name: string; + version: number; + organization_id: string | null; + operation_type: string; + metadata?: string | null; +} + +/** ADR-0048 overlay key — (type, name, org, state, package_id). */ +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** Top-level eq + `$or` + `$in` + explicit-NULL, the subset these paths emit. */ +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + const actual = (r as unknown as Record)[k]; + if (v !== null && typeof v === 'object' && Array.isArray((v as { $in?: unknown }).$in)) { + if (!(v as { $in: unknown[] }).$in.includes(actual)) return false; + continue; + } + if (v === null ? actual !== null && actual !== undefined : actual !== v) return false; + } + return true; +} + +/** A double that STORES rows and history, and whose registry keys an item by its own `name`. */ +function makeStubEngine() { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + const registered = new Map>(); + let nextId = 0; + let commit: unknown = null; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + if (r) return { key: k, row: r }; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const matchesHistory = (h: HistoryRow, w: Record): boolean => { + if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false; + if (w.type !== undefined && h.type !== w.type) return false; + if (w.name !== undefined && h.name !== w.name) return false; + if (w.version !== undefined && h.version !== w.version) return false; + if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false; + return true; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table === 'sys_metadata_commit') return commit; + if (table === 'sys_metadata_history') { + return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null; + } + if (table !== 'sys_metadata') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + // Hold the caller's bound AFTER the filter, by PRESENCE — the + // objectql-double-limit contract. + const bound = (all: T[]): T[] => + typeof opts?.limit === 'number' ? all.slice(0, opts.limit) : all; + if (table === 'sys_metadata_history') { + return bound(historyRows.filter((h) => matchesHistory(h, opts?.where ?? {}))); + } + if (table !== 'sys_metadata') return []; + return bound(Array.from(rows.values()).filter((r) => matchesWhere(r, opts?.where ?? {}))); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_history') { + nextId += 1; + const h = { ...(data as unknown as HistoryRow), id: `h_${nextId}` }; + historyRows.push(h); + return { id: h.id }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { ...(data as unknown as Row), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as Row) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async count() { return 0; }, + getDriverByName: () => undefined, + async syncObjectSchema() { return true; }, + async dropObjectSchema() { return true; }, + registry: { + registerItem: (type: string, item: any) => { + if (!registered.has(type)) registered.set(type, new Map()); + registered.get(type)!.set(item?.name, item); + }, + registerObject: () => {}, + unregisterObject: () => true, + removeObjectOverlay: () => {}, + removeRuntimeShadow: () => false, + removeOverlayEntry: () => true, + listItems: (type: string) => Array.from(registered.get(type)?.values() ?? []), + getItem: (type: string, name: string) => registered.get(type)?.get(name), + getAllObjects: () => [], + getObject: () => undefined, + getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }, + }; + return { engine, rows, historyRows, registered, serveCommit: (c: unknown) => { commit = c; } }; +} + +function makeProtocol() { + const h = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(h.engine, () => new Map(), undefined) as any; + // `OS_METADATA_WRITABLE` is memoised process-wide; clear it rather than assume. + ObjectStackProtocolImplementation.resetEnvWritableCache(); + return { protocol, ...h }; +} + +type Harness = ReturnType; + +/** The reference `ledger`'s row holds now. */ +const OWN_REF = 'sys_secret:ledger_own_27818'; +/** The reference `ledger`'s row held before its reference was rotated. */ +const FORMER_REF = 'sys_secret:ledger_former_27818'; +/** The reference another datasource's row (`vault`) holds. */ +const OTHER_REF = 'sys_secret:vault_own_27818'; +/** A reference no row holds. */ +const UNHELD_REF = 'sys_secret:held_by_no_row_27818'; +const ALL_REFS = [OWN_REF, FORMER_REF, OTHER_REF, UNHELD_REF]; + +/** A schema-valid datasource body, so every refusal below is this rule's and never a 422. */ +const datasource = (name: string, label: string, credentialsRef?: string) => ({ + name, + label, + driver: 'postgres', + config: { host: 'db.internal', database: name, username: 'app' }, + ...(credentialsRef === undefined ? {} : { external: { credentialsRef } }), +}); + +const rowOf = (h: Harness, name: string, state: 'active' | 'draft' = 'active') => + Array.from(h.rows.values()).find((r) => r.type === 'datasource' && r.name === name && r.state === state); + +const storedRefOf = (h: Harness, name: string, state: 'active' | 'draft' = 'active') => { + const row = rowOf(h, name, state); + return row ? JSON.parse(row.metadata).external?.credentialsRef : undefined; +}; + +/** Set the reference a stored row holds, in place — the way the datasource admin door writes the row. */ +function bindRow(h: Harness, name: string, credentialsRef: string | undefined, state: 'active' | 'draft' = 'active') { + const row = rowOf(h, name, state); + if (!row) throw new Error(`no ${state} row datasource/${name}`); + const { external: _external, ...rest } = JSON.parse(row.metadata); + row.metadata = JSON.stringify(credentialsRef === undefined ? rest : { ...rest, external: { credentialsRef } }); +} + +/** Set the reference one stored history version names, in place. */ +function bindVersion(h: Harness, name: string, version: number, credentialsRef: string | undefined) { + const row = h.historyRows.find((r) => r.type === 'datasource' && r.name === name && r.version === version); + if (!row?.metadata) throw new Error(`no history row datasource/${name}@${version}`); + const { external: _external, ...rest } = JSON.parse(row.metadata); + row.metadata = JSON.stringify(credentialsRef === undefined ? rest : { ...rest, external: { credentialsRef } }); +} + +/** + * `ledger` saved three times through the save door (history versions 1–3), + * then bound by the admin door to `OWN_REF`; `vault` bound to `OTHER_REF`. + */ +async function stage(h: Harness): Promise { + for (const label of ['Ledger v1', 'Ledger v2', 'Ledger v3']) { + await h.protocol.saveMetaItem({ type: 'datasource', name: 'ledger', item: datasource('ledger', label) }); + } + await h.protocol.saveMetaItem({ type: 'datasource', name: 'vault', item: datasource('vault', 'Vault') }); + bindRow(h, 'ledger', OWN_REF); + bindRow(h, 'vault', OTHER_REF); +} + +/** Everything a refused write must leave as it found it. */ +const snapshot = (h: Harness) => JSON.stringify({ + rows: Array.from(h.rows.values()).map((r) => [r.type, r.name, r.state, r.metadata]), + history: h.historyRows.length, + registered: Array.from(h.registered.entries()).map(([t, m]) => [t, Array.from(m.keys())]), +}); + +const commitRow = (prevVersion: number) => ({ + id: 'c1', + commit_id: 'c1', + organization_id: null, + operation: 'apply', + message: 'the commit under revert', + created_at: '2026-01-01T00:00:00Z', + items: JSON.stringify([{ type: 'datasource', name: 'ledger', existedBefore: true, prevVersion }]), +}); + +const expectRuleEnvelope = (r: { code: unknown; status?: unknown; message: string }) => { + expect({ code: r.code, status: r.status }, r.message).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + // The named subject, never the prose: the refusal names the datasource and the key, and no reference. + expect(r.message).toContain("'ledger'"); + expect(r.message).toContain('external.credentialsRef'); + for (const ref of ALL_REFS) expect(r.message).not.toContain(ref); +}; + +/** The references a restored version may name that the row it is restored into does not hold now. */ +const refusedVersions: Array<{ title: string; rowHolds: string | undefined; versionNames: string }> = [ + { title: 'a reference another datasource holds', rowHolds: OWN_REF, versionNames: OTHER_REF }, + { title: 'a reference no row holds', rowHolds: OWN_REF, versionNames: UNHELD_REF }, + { title: 'the reference its row held before and holds no longer', rowHolds: OWN_REF, versionNames: FORMER_REF }, + { title: 'a reference where its row holds none now', rowHolds: undefined, versionNames: FORMER_REF }, +]; + +afterEach(() => vi.restoreAllMocks()); + +/** What one door answered a refused write. */ +interface Answer { code: unknown; status: unknown; message: string } + +/** A draft of `ledger` saved through the save door with no reference, then holding `draftNames` at rest. */ +async function stageDraft(h: Harness, draftNames: string | undefined) { + await stage(h); + await h.protocol.saveMetaItem({ type: 'datasource', name: 'ledger', mode: 'draft', item: datasource('ledger', 'Ledger v1') }); + bindRow(h, 'ledger', draftNames, 'draft'); +} + +/** + * The three doors that make a stored `ledger` body the active row. `stage` + * leaves a stored body (history version 1, or the draft) naming `bodyNames`; + * `write` makes it the active row and returns the refusal, or `null` when the + * door wrote it. + */ +const doors: Array<{ + door: string; + stage: (h: Harness, bodyNames: string | undefined) => Promise; + write: (h: Harness) => Promise; +}> = [ + { + door: 'rollbackMetaItem', + async stage(h, bodyNames) { + await stage(h); + bindVersion(h, 'ledger', 1, bodyNames); + }, + async write(h) { + try { + const res = await h.protocol.rollbackMetaItem({ type: 'datasource', name: 'ledger', toVersion: 1 }); + expect(res.success).toBe(true); + return null; + } catch (err: any) { + return { code: err.code, status: err.status, message: String(err.message) }; + } + }, + }, + { + door: 'revertCommit (its restore limb)', + async stage(h, bodyNames) { + await stage(h); + bindVersion(h, 'ledger', 1, bodyNames); + h.serveCommit(commitRow(1)); + }, + async write(h) { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const res = await h.protocol.revertCommit({ commitId: 'c1' }); + if (res.failed.length === 0) { + expect(res.revertedCount).toBe(1); + return null; + } + expect(res.success).toBe(false); + expect(res.revertedCount).toBe(0); + expect(res.failed).toEqual([{ type: 'datasource', name: 'ledger', error: expect.any(String), code: 'VALIDATION_ERROR' }]); + // `failed[]` carries the refusal's code and text, not its status: the code is the standard catalog's 400. + return { code: res.failed[0].code, status: 400, message: String(res.failed[0].error) }; + }, + }, + { + door: 'publishMetaItem (the draft publish)', + stage: stageDraft, + async write(h) { + try { + await h.protocol.publishMetaItem({ type: 'datasource', name: 'ledger' }); + return null; + } catch (err: any) { + return { code: err.code, status: err.status, message: String(err.message) }; + } + }, + }, +]; + +describe('[#27818] datasource `external.credentialsRef`: a stored body made active may name only the reference its row holds now', () => { + for (const d of doors) { + describe(d.door, () => { + /** One refused case on a fresh store: nothing is written, and the row keeps the reference it holds. */ + const refused = async (c: (typeof refusedVersions)[number]): Promise => { + const h = makeProtocol(); + await d.stage(h, c.versionNames); + bindRow(h, 'ledger', c.rowHolds); + const before = snapshot(h); + const answer = await d.write(h); + expect(answer, 'the write was accepted').not.toBeNull(); + expect(snapshot(h)).toBe(before); + expect(storedRefOf(h, 'ledger')).toBe(c.rowHolds); + return answer!; + }; + + for (const c of refusedVersions) { + it(`refuses a body naming ${c.title}, VALIDATION_ERROR / 400, never echoing it`, async () => { + expectRuleEnvelope(await refused(c)); + }); + } + + it('answers the same whatever the reference names', async () => { + const answers: Answer[] = []; + for (const c of refusedVersions) answers.push(await refused(c)); + for (const answer of answers) expect(answer).toEqual(answers[0]); + }); + + it('writes a body naming the reference its row holds now, and keeps it', async () => { + const h = makeProtocol(); + await d.stage(h, OWN_REF); + expect(await d.write(h)).toBeNull(); + expect(JSON.parse(rowOf(h, 'ledger')!.metadata).label).toBe('Ledger v1'); + expect(storedRefOf(h, 'ledger')).toBe(OWN_REF); + }); + + it('writes a body naming no reference, which stores none (as a save omitting the key does)', async () => { + const h = makeProtocol(); + await d.stage(h, undefined); + expect(await d.write(h)).toBeNull(); + expect(JSON.parse(rowOf(h, 'ledger')!.metadata).label).toBe('Ledger v1'); + expect(storedRefOf(h, 'ledger')).toBeUndefined(); + }); + }); + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ba3a635cbdf..79f2b269c5e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7017,6 +7017,9 @@ export class ObjectStackProtocolImplementation implements * - [#26724] an object restored with a `datasource` that names no * datasource here, when that is not the binding it holds now * ({@link unknownDatasourceSaveRefusal}); + * - [#27818] a datasource restored with an `external.credentialsRef` the + * active row it is restored into does not hold now + * ({@link datasourceCredentialsRefSaveRefusal}, in its restore words); * - a `runAs: 'system'` flow an anonymous endpoint names * ({@link anonymousElevatedFlowRefusal}): a restore writes an ACTIVE * row, the case the save door asks it of; @@ -7070,6 +7073,7 @@ export class ObjectStackProtocolImplementation implements credentialChannelFor: (...a) => this.credentialChannelFor(...a), defaultRecordPageSaveRefusal: (...a) => this.defaultRecordPageSaveRefusal(...a), externalBindingSaveRefusal: (...a) => this.externalBindingSaveRefusal(...a), + getOverlayRepo: (...a) => this.getOverlayRepo(...a), permissionSetNameCollisionRefusal: (...a) => this.permissionSetNameCollisionRefusal(...a), runAuthoringGate: (...a) => this.runAuthoringGate(...a), unknownDatasourceSaveRefusal: (...a) => this.unknownDatasourceSaveRefusal(...a), @@ -23102,6 +23106,15 @@ export class ObjectStackProtocolImplementation implements ); if (datasourceRefusal) throw datasourceRefusal; } + // [#27818] …and a datasource draft whose `external.credentialsRef` the active row it is promoted into + // (`promoteInto`) does not hold now: refused before `repo.promoteDraft` writes. See {@link datasourceCredentialsRefSaveRefusal}. + if (draftForGate) { + const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({ + headAt: (...a) => repo.headAt(...a), type: singularType, name: request.name, item: draftForGate.body, + mode: 'publish', packageId: promoteInto, source: undefined, door: 'publish', + }); + if (credentialsRefRefusal) throw credentialsRefRefusal; + } // [ADR-0138 D2b] …and a flow draft that would go `active` with // `runAs: 'system'` while an anonymous endpoint of `type: 'flow'` names // it: the draft save let it through (a draft does not execute), so its diff --git a/packages/qa/dogfood/test/datasource-credentials-ref-restore-doors.dogfood.test.ts b/packages/qa/dogfood/test/datasource-credentials-ref-restore-doors.dogfood.test.ts new file mode 100644 index 00000000000..2947057b7de --- /dev/null +++ b/packages/qa/dogfood/test/datasource-credentials-ref-restore-doors.dogfood.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27818] A datasource made active from a STORED body — a history version +// brought back by `POST /api/v1/meta/datasource/:name/rollback`, or a draft +// promoted by `POST /api/v1/meta/datasource/:name/publish` — may name, in +// `external.credentialsRef`, only the reference its active row holds now: the +// own-reference rule #27582 set (a non-system write may only seal a new value +// or keep its own record's stored reference), pinned at the REST doors of a +// booted stack as a signed-in non-system caller. +// `packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts` +// drives every case at each door; this file holds the rule through the doors +// a client writes through: +// +// - a rollback to a version naming a reference the row does not hold now is +// refused (`VALIDATION_ERROR` / 400) before anything is written, and the +// reference is never echoed; a rollback to a version naming the reference +// the row holds keeps it; +// - a publish of a draft naming a reference the active row does not hold is +// refused the same way; a publish of a draft naming the one it holds keeps +// it. +// +// The stored rows are seeded through the protocol's server-stated rewrite (the +// system pass-through the save door keeps), as rows stored before the rule, or +// before the row's reference moved, sit at rest: this harness composes no +// secret binder, so no door here mints a reference. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const fixture = defineStack({ + manifest: { + id: 'com.dogfood.datasource-credentials-ref-restore', namespace: 'dcrr', version: '0.0.0', type: 'app', + name: 'Datasource credentials reference restore fixture', description: 'An app with one object, for its metadata doors.', + }, + objects: [ + ObjectSchema.create({ + name: 'dcrr_note', label: 'Note', pluralLabel: 'Notes', + fields: { name: Field.text({ label: 'Name', required: true }) }, + }), + ], +} as never); + +/** Restored by rollback. */ +const ROLLED = 'dcrr_rolled_27818'; +/** Published from a draft. */ +const DRAFTED = 'dcrr_drafted_27818'; +/** The reference each row holds now. */ +const OWN_REF = 'sys_secret:dcrr_own_27818'; +/** The reference `ROLLED` held before its reference moved. */ +const FORMER_REF = 'sys_secret:dcrr_former_27818'; +/** A reference no row holds. */ +const UNHELD_REF = 'sys_secret:dcrr_held_by_no_row_27818'; + +const SYS = { isSystem: true }; + +/** A schema-valid datasource body, so every refusal below is the rule's and never a 422. */ +const body = (name: string, label: string, credentialsRef?: string) => ({ + name, + label, + driver: 'postgres', + config: { host: 'db.internal', database: name, username: 'app' }, + ...(credentialsRef === undefined ? {} : { external: { credentialsRef } }), +}); + +type Answer = { status: number; json: any; text: string }; + +describe('[#27818] datasource `external.credentialsRef`: a restore or publish may bring back only the reference its row holds now', () => { + let stack: VerifyStack; + let ql: any; + let protocol: any; + let token = ''; + /** The history version of `ROLLED` that names each label. */ + const versionOf = new Map(); + + const call = async (method: string, path: string, payload?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, payload); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + /** `{ status, code }` from the door's ADR-0112 envelope. */ + const codeOf = (a: Answer) => { + const error = a.json?.error; + return typeof error === 'object' && error !== null ? error.code : a.json?.code; + }; + const activeBody = async (name: string) => { + const rows: Array> = await ql.find('sys_metadata', { where: { type: 'datasource', name, state: 'active' }, context: SYS }); + expect(rows).toHaveLength(1); + const raw = rows[0].metadata; + return typeof raw === 'string' ? JSON.parse(raw) : (raw as any); + }; + const expectRuleRefusal = (a: Answer) => { + expect({ status: a.status, code: codeOf(a) }, a.text).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + for (const ref of [OWN_REF, FORMER_REF, UNHELD_REF]) expect(a.text).not.toContain(ref); + }; + + beforeAll(async () => { + stack = await bootStack(fixture); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + protocol = await stack.kernel.getServiceAsync('protocol'); + const seed = (name: string, label: string, ref?: string, mode?: 'draft') => + protocol.saveMetaItem({ type: 'datasource', name, item: body(name, label, ref), packageId: null, source: 'migrate-stored', ...(mode ? { mode } : {}) }); + // `ROLLED`: a version naming its former reference, then two naming the one it holds now. + await seed(ROLLED, 'v1 (former reference)', FORMER_REF); + await seed(ROLLED, 'v2 (own reference)', OWN_REF); + await seed(ROLLED, 'v3 (own reference)', OWN_REF); + const history: Array> = await ql.find('sys_metadata_history', { where: { type: 'datasource', name: ROLLED }, context: SYS }); + for (const row of history) { + const raw = row.metadata; + const label = (typeof raw === 'string' ? JSON.parse(raw) : (raw as any))?.label; + if (typeof label === 'string') versionOf.set(label, Number(row.version)); + } + expect([...versionOf.keys()].sort()).toEqual(['v1 (former reference)', 'v2 (own reference)', 'v3 (own reference)']); + // `DRAFTED`: holds its own reference, with no draft yet. + await seed(DRAFTED, 'Drafted', OWN_REF); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('a rollback to a version naming a reference the row does not hold now is refused before anything is written', async () => { + const refused = await call('POST', `/meta/datasource/${ROLLED}/rollback`, { toVersion: versionOf.get('v1 (former reference)') }); + expectRuleRefusal(refused); + const active = await activeBody(ROLLED); + expect(active.label).toBe('v3 (own reference)'); + expect(active.external?.credentialsRef).toBe(OWN_REF); + }); + + it('a rollback to a version naming the reference the row holds now keeps it', async () => { + const restored = await call('POST', `/meta/datasource/${ROLLED}/rollback`, { toVersion: versionOf.get('v2 (own reference)') }); + expect(restored.status, restored.text).toBe(200); + const active = await activeBody(ROLLED); + expect(active.label).toBe('v2 (own reference)'); + expect(active.external?.credentialsRef).toBe(OWN_REF); + }); + + it('a publish of a draft naming a reference the active row does not hold is refused before anything is written', async () => { + await protocol.saveMetaItem({ + type: 'datasource', name: DRAFTED, item: body(DRAFTED, 'Drafted (unheld)', UNHELD_REF), packageId: null, source: 'migrate-stored', mode: 'draft', + }); + const refused = await call('POST', `/meta/datasource/${DRAFTED}/publish`, {}); + expectRuleRefusal(refused); + const active = await activeBody(DRAFTED); + expect(active.label).toBe('Drafted'); + expect(active.external?.credentialsRef).toBe(OWN_REF); + }); + + it('a publish of a draft naming the reference the active row holds keeps it', async () => { + await protocol.saveMetaItem({ + type: 'datasource', name: DRAFTED, item: body(DRAFTED, 'Drafted (own)', OWN_REF), packageId: null, source: 'migrate-stored', mode: 'draft', + }); + const published = await call('POST', `/meta/datasource/${DRAFTED}/publish`, {}); + expect(published.status, published.text).toBe(200); + const active = await activeBody(DRAFTED); + expect(active.label).toBe('Drafted (own)'); + expect(active.external?.credentialsRef).toBe(OWN_REF); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 8aa9cbc1a0e..d71f941c971 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -616,6 +616,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.datasource-delete-prolongation.test.ts", "verb": "delete",