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. | 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;