From cae20ba14c903ff4b7c6244aa5d27ecdd03d4466 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 23:46:08 +0000 Subject: [PATCH 1/2] feat(spec): allow external.credentialsRef (and only it) on schemaMode 'managed' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling on #8153: the Studio wizard's createDatasource writes external.credentialsRef onto rows whose schemaMode defaults to 'managed', so the refinement now exempts the credentials reference while still refusing every federation key. The check judges effective federation content against the parsed-empty baseline (values, not key presence) so re-parses of served output — which materialize every default key — stay valid. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- ...diagnostics.managed-credentialsref.test.ts | 47 ++++++ packages/spec/src/data/datasource.test.ts | 138 +++++++++++++++++- packages/spec/src/data/datasource.zod.ts | 85 +++++++++-- 3 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 packages/metadata-protocol/src/metadata-diagnostics.managed-credentialsref.test.ts diff --git a/packages/metadata-protocol/src/metadata-diagnostics.managed-credentialsref.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.managed-credentialsref.test.ts new file mode 100644 index 0000000000..733d6d59d9 --- /dev/null +++ b/packages/metadata-protocol/src/metadata-diagnostics.managed-credentialsref.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8153 — the card's measurement, re-run on the fixed schema. + * + * The Studio wizard's `createDatasource` stores the secret in the secrets + * store and writes `external: { credentialsRef }` onto the row without + * consulting `schemaMode` (which defaults to `'managed'`). Until #8153 the + * datasource schema refused ANY `external` block on a managed row, so the + * measured happy path — POST a datasource with a password, get a 201 — left + * every such row badged `_diagnostics.valid:false` in the Studio metadata + * list, and `PUT /meta` answered 422 on the service's own output. + * + * Maintainer ruling (issue #8153, 2026-08-13): allow `external.credentialsRef` + * — and only it — on managed; keep refusing every federation key. These tests + * pin the diagnostics half of that: the exact persisted shape from the card + * now reads `valid: true`, while a managed row carrying federation content + * still reads `valid: false` at path `external`. + */ +import { describe, expect, it } from 'vitest'; +import { computeMetadataDiagnostics } from './metadata-diagnostics.js'; + +/** The exact persisted shape measured in #8153 — no `schemaMode` key. */ +const wizardRow = { + name: 'good_pg', + driver: 'postgres', + config: { host: 'db.internal', database: 'mydb', username: 'app' }, + origin: 'runtime', + external: { credentialsRef: 'sys_secret:bound' }, +}; + +describe('#8153 computeMetadataDiagnostics — wizard-created managed datasource', () => { + it('reads the exact shape createDatasource persists as valid', () => { + const diag = computeMetadataDiagnostics('datasource', wizardRow); + expect(diag).toEqual({ valid: true }); + }); + + it('still badges a managed row carrying federation content invalid, at path `external`', () => { + const diag = computeMetadataDiagnostics('datasource', { + ...wizardRow, + external: { credentialsRef: 'sys_secret:bound', allowWrites: true }, + }); + expect(diag?.valid).toBe(false); + const issue = diag?.errors?.find((e) => e.path === 'external'); + expect(issue?.message).toContain('allowWrites'); + }); +}); diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index e98f8b945d..180825cc05 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -539,7 +539,57 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { } }); - it('should forbid external settings when schemaMode === "managed"', () => { + // ── #8153 — the managed `credentialsRef` allowance ───────────────────────── + // + // The Studio wizard's `createDatasource` stores the secret in the secrets + // store and writes `external: { credentialsRef }` onto the row — without + // consulting `schemaMode`, which defaults to 'managed'. Until #8153 the + // refinement refused ANY `external` on managed, so every wizard-created + // datasource with a password was badged `_diagnostics.valid:false` and + // `PUT /meta` answered 422 on the service's own output. Maintainer ruling + // (issue #8153 comment, 2026-08-13): allow `external.credentialsRef` — and + // only it — on managed; keep refusing every federation key. + + it('accepts the exact shape createDatasource writes on a managed row (#8153 happy path)', () => { + // The measured persisted shape from #8153 — no `schemaMode`, so it + // defaults to 'managed'; `external` carries only the secrets-store ref. + const wizardRow = { + name: 'good_pg', + driver: 'postgres', + config: { host: 'db.internal', database: 'mydb', username: 'app' }, + origin: 'runtime', + external: { credentialsRef: 'sys_secret:bound' }, + } as const; + const result = DatasourceSchema.safeParse(wizardRow); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.schemaMode).toBe('managed'); + expect(result.data.external?.credentialsRef).toBe('sys_secret:bound'); + } + }); + + it('round-trips the wizard row: the parsed output (defaults applied) re-parses valid (#8153)', () => { + // `PUT /meta` re-parses what `GET` served — the parsed shape, with every + // `external` default key materialized. The allowance judges VALUES, not + // key presence, precisely so this round-trip stays valid. + const first = DatasourceSchema.parse({ + name: 'good_pg', + driver: 'postgres', + config: { host: 'db.internal', database: 'mydb', username: 'app' }, + origin: 'runtime', + external: { credentialsRef: 'sys_secret:bound' }, + }); + // Defaults really were applied — the second parse sees the full block. + expect(first.external?.allowWrites).toBe(false); + expect(first.external?.queryTimeoutMs).toBe(30_000); + const second = DatasourceSchema.safeParse(first); + expect(second.success).toBe(true); + if (second.success) { + expect(second.data.external?.credentialsRef).toBe('sys_secret:bound'); + } + }); + + it('still refuses `external.allowWrites` on a managed row, with the existing guidance (#8153)', () => { const result = DatasourceSchema.safeParse({ name: 'default', driver: 'postgres', @@ -549,10 +599,94 @@ describe('SchemaMode & External Federation (ADR-0015)', () => { }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues.some((i) => i.path.includes('external'))).toBe(true); + const issue = result.error.issues.find((i) => i.path.includes('external')); + expect(issue?.message).toContain(`'external' settings only apply when schemaMode != 'managed'.`); + expect(issue?.message).toContain('allowWrites'); } }); + it('still refuses `external.allowedSchemas` on a managed row (#8153)', () => { + const result = DatasourceSchema.safeParse({ + name: 'default', + driver: 'postgres', + config: { database: 'mydb' }, + external: { allowedSchemas: ['public'] }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path.includes('external')); + expect(issue?.message).toContain(`'external' settings only apply when schemaMode != 'managed'.`); + expect(issue?.message).toContain('allowedSchemas'); + } + }); + + it('still refuses non-default `validation` and `queryTimeoutMs` on a managed row (#8153)', () => { + const result = DatasourceSchema.safeParse({ + name: 'default', + driver: 'postgres', + config: { database: 'mydb' }, + external: { validation: { onMismatch: 'warn' }, queryTimeoutMs: 5_000 }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path.includes('external')); + expect(issue?.message).toContain('queryTimeoutMs'); + expect(issue?.message).toContain('validation'); + } + }); + + it('refuses credentialsRef + a federation key together — the allowance does not smuggle (#8153)', () => { + const result = DatasourceSchema.safeParse({ + name: 'default', + driver: 'postgres', + config: { database: 'mydb' }, + external: { credentialsRef: 'sys_secret:bound', allowWrites: true }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path.includes('external')); + // The refusal names the federation key, never the allowed ref. + expect(issue?.message).toContain('allowWrites'); + expect(issue?.message).not.toContain('remove: credentialsRef'); + } + }); + + it('accepts explicitly-written DEFAULT federation values on a managed row — inert content (#8153)', () => { + // Deliberate: the check judges effective federation content. An explicit + // `allowWrites: false` is byte-equal to the applied default and gates + // nothing — refusing it would 422 re-parses of served output, which + // materializes every default key. + const result = DatasourceSchema.safeParse({ + name: 'default', + driver: 'postgres', + config: { database: 'mydb' }, + external: { + credentialsRef: 'sys_secret:bound', + allowWrites: false, + queryTimeoutMs: 30_000, + validation: { onMismatch: 'fail', checkOnBoot: true }, + }, + }); + expect(result.success).toBe(true); + }); + + it('keeps full external acceptance on schemaMode="external" — unchanged by #8153', () => { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: 'postgres', + config: { url: 'postgres://user@warehouse.internal/analytics' }, + schemaMode: 'external', + external: { + allowedSchemas: ['public', 'mart'], + allowWrites: true, + validation: { onMismatch: 'warn', checkOnBoot: false, checkIntervalMs: 60_000 }, + credentialsRef: 'secret:warehouse/readonly', + queryTimeoutMs: 15_000, + }, + }); + expect(result.success).toBe(true); + }); + it('should require external settings for validate-only mode too', () => { const result = DatasourceSchema.safeParse({ name: 'warehouse', diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index d039341771..6734ff19ce 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -240,9 +240,12 @@ export type SchemaMode = z.input; /** * External Datasource Settings (ADR-0015) * - * Present only when `schemaMode !== 'managed'`. Carries the federation - * policy for a mature external database: write gating, schema whitelist, - * boot/drift validation behaviour, credentials reference, and query caps. + * The federation policy for a mature external database: write gating, schema + * whitelist, boot/drift validation behaviour, credentials reference, and + * query caps. The federation keys apply only when `schemaMode !== 'managed'`; + * `credentialsRef` alone is also valid on a managed datasource (#8153) — + * the Studio wizard's `createDatasource` stores the secret in the secrets + * store and keeps the reference here, whatever the schema mode. */ export const ExternalDatasourceSettingsSchema = strictObject( { @@ -309,11 +312,13 @@ export const ExternalDatasourceSettingsSchema = strictObject( }) .default({ onMismatch: 'fail', checkOnBoot: true }).describe('Boot/drift validation policy'), credentialsRef: z.string().optional() - .describe('Reference into the secrets store; never inline credentials.'), + .describe('Reference into the secrets store; never inline credentials. ' + + 'Valid in every schemaMode — the one `external` key a managed datasource may carry (#8153).'), queryTimeoutMs: z.number().default(30_000) .describe('Hard cap on per-query execution time.'), }) - .describe('External datasource federation settings (schemaMode != "managed")'); + .describe('External datasource settings: federation policy (schemaMode != "managed") ' + + 'plus the secrets-store credentials reference (valid in every schemaMode)'); export type ExternalDatasourceSettings = z.input; /** Post-parse shape of {@link ExternalDatasourceSettings} — defaults applied, transforms run (ADR-0122). */ @@ -503,7 +508,10 @@ export const DatasourceSchema = lazySchema(() => strictObject( /** * External Federation Settings (ADR-0015) - * Required when `schemaMode !== 'managed'`; forbidden otherwise. + * Required when `schemaMode !== 'managed'`. On a managed datasource the + * block may carry `credentialsRef` — and only it — because the wizard's + * `createDatasource` writes the secrets-store reference there whatever the + * schema mode; every federation key is still refused on managed (#8153). */ external: ExternalDatasourceSettingsSchema.optional(), @@ -548,14 +556,69 @@ export const DatasourceSchema = lazySchema(() => strictObject( }); } if (ds.schemaMode === 'managed' && ds.external) { - ctx.addIssue({ - code: 'custom', - path: ['external'], - message: `'external' settings only apply when schemaMode != 'managed'.`, - }); + const federationKeys = managedFederationContentKeys(ds.external); + if (federationKeys.length > 0) { + ctx.addIssue({ + code: 'custom', + path: ['external'], + message: `'external' settings only apply when schemaMode != 'managed'. ` + + `A managed datasource may carry 'external.credentialsRef' (and only it) — ` + + `remove: ${federationKeys.join(', ')}.`, + }); + } } })); +/** + * Parsed shape of an empty `external` block — every key a default. Lazily + * computed so the module does not pay a parse at load time. + */ +let parsedEmptyExternal: ExternalDatasourceSettingsParsed | undefined; + +/** + * The federation keys a managed datasource's `external` block effectively + * carries (#8153) — empty means the block is `credentialsRef` plus inert + * defaults, which is the exact shape the Studio wizard's `createDatasource` + * persists on managed rows (the secret goes to the secrets store; the row + * keeps the reference). + * + * The comparison is against the parsed-empty baseline — VALUES, not key + * presence — deliberately: this refinement runs post-parse, where defaults + * are already applied, so a re-parsed stored row (or a `PUT /meta` round-trip + * of served output) legitimately carries every default key. Refusing on key + * presence would 422 the exact round-trip this allowance exists to keep + * valid. An explicitly-written default is byte-equal to an applied one and + * semantically inert either way; any non-default federation value — write + * gating, schema whitelist, drift validation, query caps — still refuses. + */ +function managedFederationContentKeys(external: ExternalDatasourceSettingsParsed): string[] { + const baseline = (parsedEmptyExternal ??= ExternalDatasourceSettingsSchema.parse({})); + return Object.keys(external) + .filter((key) => key !== 'credentialsRef') + .filter((key) => !sameParsedValue( + (external as Record)[key], + (baseline as Record)[key], + )) + .sort(); +} + +/** Structural equality over parsed plain data (objects/arrays/primitives). */ +function sameParsedValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) || Array.isArray(b)) { + return Array.isArray(a) && Array.isArray(b) && a.length === b.length + && a.every((value, i) => sameParsedValue(value, b[i])); + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return aKeys.length === bKeys.length + && aKeys.every((key) => sameParsedValue( + (a as Record)[key], + (b as Record)[key], + )); +} + export type Datasource = z.input; /** Post-parse shape of {@link Datasource} — defaults applied, transforms run (ADR-0122). */ export type DatasourceParsed = z.infer; From 133b164614e3dfa54ab0c855328b6919292326f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 00:09:32 +0000 Subject: [PATCH 2/2] docs(spec): regenerate datasource reference; add changeset for the managed credentialsRef allowance Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .changeset/managed-credentialsref-allowance.md | 15 +++++++++++++++ content/docs/references/data/datasource.mdx | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .changeset/managed-credentialsref-allowance.md diff --git a/.changeset/managed-credentialsref-allowance.md b/.changeset/managed-credentialsref-allowance.md new file mode 100644 index 0000000000..25942efd0e --- /dev/null +++ b/.changeset/managed-credentialsref-allowance.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +--- + +`DatasourceSchema` now accepts `external.credentialsRef` — and only it — on a +`schemaMode: 'managed'` datasource. The Studio wizard's `createDatasource` stores the +secret in the secrets store and writes `external: { credentialsRef }` onto the row +(whose `schemaMode` defaults to `'managed'`), so the previous blanket refusal of +`external` on managed badged every wizard-created datasource with a password +`_diagnostics.valid: false` and answered 422 on `PUT /meta` for the service's own +output. Every federation key (`allowedSchemas`, `allowWrites`, `validation`, +`queryTimeoutMs`) is still refused on a managed row with the existing guidance; the +check judges effective federation content (values against the parsed-empty defaults, +not key presence), so re-parses of served output — which materialize every default +key — stay valid. Maintainer ruling on #8153. diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index 5502b0dd1f..cf38380f52 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -40,7 +40,7 @@ const result = DatasourceSchema.parse(data); | **active** | `boolean` | ✅ | Is datasource enabled | | **autoConnect** | `boolean` | ✅ | Force a live driver connection at boot even when managed + unrouted (ADR-0062 D2). | | **schemaMode** | `Enum<'managed' \| 'external' \| 'validate-only'>` | ✅ | Schema ownership mode | -| **external** | `{ allowedSchemas?: string[]; allowWrites: boolean; validation: object; credentialsRef?: string; … }` | optional | External datasource federation settings (schemaMode != "managed") | +| **external** | `{ allowedSchemas?: string[]; allowWrites: boolean; validation: object; credentialsRef?: string; … }` | optional | External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode) | | **origin** | `Enum<'code' \| 'runtime'>` | ✅ | Datasource provenance (server-managed, read-only) | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -79,7 +79,7 @@ Underlying driver identifier ## ExternalDatasourceSettings -External datasource federation settings (schemaMode != "managed") +External datasource settings: federation policy (schemaMode != "managed") plus the secrets-store credentials reference (valid in every schemaMode) ### Properties @@ -88,7 +88,7 @@ External datasource federation settings (schemaMode != "managed") | **allowedSchemas** | `string[]` | optional | Whitelist of remote schemas/databases that may be exposed. | | **allowWrites** | `boolean` | ✅ | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot: boolean; checkIntervalMs?: number }` | ✅ | Boot/drift validation policy | -| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. | +| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry (#8153). | | **queryTimeoutMs** | `number` | ✅ | Hard cap on per-query execution time. |