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/27762-datasource-meta-save-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 saved through the metadata door may name only the credentials reference its own stored row holds (#27762)

Clause-②: no (narrowing) — the metadata save door refuses a `datasource` body whose `external.credentialsRef` is not the reference its own stored row already holds, where it stored the value as given 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 save door refuses one value of it, a reference the save's own stored row does not hold. A stored row is never re-judged: it is read and served as before, and a save that sends its reference back keeps it. 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 metadata save door (`saveMetaItem`: `PUT /api/v1/meta/datasource/:name`, the runtime dispatcher's `/meta` door, MCP and in-process saves), the rule #27582 set for `secret` fields and #27722 applied to the datasource admin door's create: a non-system write may only seal a new value or keep its own record's stored reference.

- **Before.** A `datasource` body's `external.credentialsRef` was stored as given.
- **Now.** A save may name only the reference its own stored row already holds. A create (no stored row), an edit naming another reference or naming one where its row holds none, and a draft naming one the active row it overlays does not hold, are refused with `VALIDATION_ERROR` / `400` before anything is written. The message names the key and never the value, and the answer is the same whatever the value names. The package copy and the unmanaged import, which save new rows through the same door, are judged the same way.
- **Unchanged.** The GET → edit → PUT round trip, which sends back the reference the read serves, keeps it. A body that names no reference saves as before. The server-stated rewrites (`source` set, the stored-metadata migration) keep their pass-through. Rows already stored are not re-judged.
- **Who sees it.** A caller of the metadata save door that writes a datasource's `external.credentialsRef` by hand. The Console's datasource page saves through the datasource admin door, which this does not change.

**The remedy.** Send back the reference a read of the 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 (`POST /api/v1/datasources`, or `PATCH /api/v1/datasources/:name`), which mints and stores the reference.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27762] The own-reference rule on the metadata save door, for a
* `datasource` body's `external.credentialsRef`.
*
* The rule is the one #27582 set for the engine's secret fields and #27722
* applied to the datasource admin door's create: **a non-system write may only
* seal a new value or keep its own record's stored reference.** A credentials
* reference is minted by the platform when a secret is bound (the datasource
* admin door's `secret`), and the readers of a stored datasource act on
* whatever reference its row holds. This door seals nothing, so the one
* reference a save through it may name is the one its own stored row already
* holds:
*
* - a create (no stored row) naming any reference is refused;
* - an edit naming a reference other than its row's, or naming one where its
* row holds none, is refused;
* - an edit sending back its row's own reference — the GET → edit → PUT
* round trip, since the read serves the reference — keeps it, and so does a
* first draft sending back the reference of the active row it overlays;
* - a body that names no reference is not judged here (what it stores is
* unchanged by this rule).
*
* "Its own stored row" is the row the save writes, read the way
* {@link authoredLockFamily} reads it for the lock family: the repository's
* write address for the save's state, package and bundle member, and for a
* draft with no draft row yet, the active row it overlays. Stored rows only:
* a code-defined datasource is refused by the package door before this is
* asked, and its declaration is no stored row.
*
* Refused `VALIDATION_ERROR` / 400 before anything is written, with ONE answer
* whatever the reference names: the judge compares the value with the save's
* own row and nothing else, never looks the reference up, and the message
* never echoes it.
*
* Every save through `saveMetaItem` is judged — the REST `PUT` doors, the
* runtime dispatcher's `/meta` door, MCP and in-process authoring, and the
* package copy and unmanaged import, which write new rows that hold no
* 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.
*/

import { itemDiscriminator } from '@objectstack/metadata-core';
import type { SysMetadataRepository } from './sys-metadata-repository.js';

/** The `external.credentialsRef` a body names, or `undefined` when it names none. */
function namedCredentialsRef(body: unknown): unknown {
if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
const external = (body as { external?: unknown }).external;
if (!external || typeof external !== 'object' || Array.isArray(external)) return undefined;
return (external as { credentialsRef?: unknown }).credentialsRef;
}

/**
* The refusal a `datasource` save owes when its body names an
* `external.credentialsRef` its own stored row does not hold, else `undefined`.
* See the module header for the rule, the row it reads and who passes.
*/
export async function datasourceCredentialsRefSaveRefusal(args: {
/** The repository's write-address read — the one store access this judge makes; it writes nothing. */
headAt: SysMetadataRepository['headAt'];
/** Singular type name. */
type: string;
name: string;
item: unknown;
mode: 'draft' | 'publish';
packageId: string | null | undefined;
/** The server-stated `source` of a rewrite; `undefined` for every authoring save. */
source: string | undefined;
}): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> {
if (args.type !== 'datasource' || args.source !== undefined) return undefined;
const named = namedCredentialsRef(args.item);
if (named === undefined) return undefined;
const ref = { type: args.type, name: args.name, org: 'env' } as Parameters<SysMetadataRepository['headAt']>[0];
const state: 'active' | 'draft' = args.mode === 'draft' ? 'draft' : 'active';
const member = itemDiscriminator(args.type, args.item);
const target = await args.headAt(ref, { state, packageId: args.packageId ?? null, member });
let head = target.head;
if (!head && state === 'draft') {
head = (await args.headAt(ref, { state: 'active', packageId: target.packageId, member })).head;
}
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 };
err.code = 'VALIDATION_ERROR';
err.status = 400;
return err;
}
Loading
Loading