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
19 changes: 19 additions & 0 deletions .changeset/27818-datasource-restore-publish-credentials-ref.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: `DatasourceSchema` keeps `external.credentialsRef` authorable, and the doors that make a stored body active refuse one value of it, a reference the row they write into does not hold now. A stored row is never re-judged at rest: it is read and served as before, and only the write that would make it the active row is judged. There is nothing for a migration to rewrite and no tombstone; the remedy is a door, the datasource admin door's `secret`, which the refusal names. -->

**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.
9 changes: 8 additions & 1 deletion packages/metadata-protocol/src/protocol-authoring-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
type: string,
name: string,
body: unknown,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
Loading
Loading