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
15 changes: 15 additions & 0 deletions .changeset/managed-credentialsref-allowance.md
Original file line numberDiff line numberDiff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/references/data/datasource.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand DownExpand Up@@ -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

Expand All@@ -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. |


Expand Down
Original file line numberDiff line numberDiff line change
@@ -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');
});
});
138 changes: 136 additions & 2 deletions packages/spec/src/data/datasource.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand All@@ -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',
Expand Down
85 changes: 74 additions & 11 deletions packages/spec/src/data/datasource.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -240,9 +240,12 @@ export type SchemaMode = z.input<typeof SchemaModeSchema>;
/**
* 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(
{
Expand DownExpand Up@@ -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<typeof ExternalDatasourceSettingsSchema>;
/** Post-parse shape of {@link ExternalDatasourceSettings} — defaults applied, transforms run (ADR-0122). */
Expand DownExpand Up@@ -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(),

Expand DownExpand Up@@ -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<string, unknown>)[key],
(baseline as Record<string, unknown>)[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<string, unknown>)[key],
(b as Record<string, unknown>)[key],
));
}

export type Datasource = z.input<typeof DatasourceSchema>;
/** Post-parse shape of {@link Datasource} — defaults applied, transforms run (ADR-0122). */
export type DatasourceParsed = z.infer<typeof DatasourceSchema>;
Expand Down
Loading