diff --git a/.changeset/27875-datasource-secret-resolver-service.md b/.changeset/27875-datasource-secret-resolver-service.md new file mode 100644 index 00000000000..b8e3b11e4a5 --- /dev/null +++ b/.changeset/27875-datasource-secret-resolver-service.md @@ -0,0 +1,23 @@ +--- +'@objectstack/types': patch +'@objectstack/cli': patch +'@objectstack/driver-odata': patch +--- + +feat: `os serve` publishes its datasource secret store's `resolve` as the kernel service `datasource-secret-resolver`, and `driver-odata` reads it when it is passed no secret store + +Clause-②: yes (widening) +Metadata-protocol: none + +A stack served by `os serve` that mounts `createODataDriverPlugin` could not resolve its datasource's `external.credentialsRef`: the server's `sys_secret` store was handed only to the datasource admin, so a `sys_secret:` handle stored through Setup → Datasources did not resolve for the plugin, and the stack had to pass a `resolve` of its own. It now resolves with no code: + +```ts +plugins: [createODataDriverPlugin({ name: 'erp', serviceRoot: 'https://erp.example.com/odata/', auth: { scheme: 'bearer' } })], +datasources: [{ name: 'erp', driver: 'erp', config: {}, schemaMode: 'external', + external: { credentialsRef: 'sys_secret:…' } }], +// no `secrets` option: under os serve the handle resolves through the server's own store +``` + +- **`@objectstack/cli`**: `os serve` registers the read half of the datasource secret binder it builds as the kernel service `datasource-secret-resolver` (plugin `com.objectstack.cli.datasource-secret-resolver`). Only `resolve` is published; storing and removing a credential stays the datasource admin's. It decrypts datasource credentials only: a settings secret or a secret field's row in `sys_secret` does not open through it. When `os serve` builds no binder (no crypto provider), the service is not registered. +- **`@objectstack/driver-odata`**: when `options.secrets` is absent, the plugin reads the `datasource-secret-resolver` service at the first request. An explicit `options.secrets` still wins. With neither, the request is refused as before, sent to nobody, and the message now names the service. +- **`@objectstack/types`**: `DATASOURCE_SECRET_RESOLVER_SERVICE` and `IDatasourceSecretResolver`, the contract a host publishes the resolver under and every plugin driver that dereferences a `credentialsRef` reads it by: an explicit store first, the service looked up at the first request (never at `init`), fail-closed when neither resolves. diff --git a/content/docs/data-modeling/odata-datasources.mdx b/content/docs/data-modeling/odata-datasources.mdx index 9fe261ffcdd..9d0b6b1c31f 100644 --- a/content/docs/data-modeling/odata-datasources.mdx +++ b/content/docs/data-modeling/odata-datasources.mdx @@ -43,7 +43,6 @@ The plugin builds the driver from its options and publishes it as the service ```typescript import { defineStack } from '@objectstack/spec'; import { createODataDriverPlugin } from '@objectstack/driver-odata'; -import { erpSecretStore } from './secrets'; // your host's secret store; see Credentials export default defineStack({ // …the datasource and objects of step 2… @@ -51,8 +50,7 @@ export default defineStack({ createODataDriverPlugin({ name: 'erp', // = the datasource name serviceRoot: 'https://erp.example.com/odata/v4/sales/', - auth: { scheme: 'bearer' }, - secrets: erpSecretStore, + auth: { scheme: 'bearer' }, // under os serve, no `secrets`: see Credentials }), ], }); @@ -64,7 +62,7 @@ export default defineStack({ | `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. | | `version` | `4.0` | The protocol version. `4.0` is the only value. | | `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. | -| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. Required for `bearer` and `basic`. | +| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. When absent, the host's `datasource-secret-resolver` service is used; `os serve` registers one. | | `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). | | `requestTimeoutMs` | `30000` | The timeout of each request. | @@ -167,7 +165,7 @@ What each piece does on this driver: ⛔ Never put a credential in the stack, in the plugin options, or in the `serviceRoot` URL. The secret is the datasource's `external.credentialsRef`, and the plugin resolves it -through the `secrets` store you pass: +through the host's secret store: - **`bearer`** sends `Authorization: Bearer `. - **`basic`** takes `username` from the options and the password from the secret. @@ -180,20 +178,23 @@ old token fails. The driver does not obtain or refresh tokens itself. A service issues short-lived OAuth2 access tokens needs something that keeps the stored secret current. -`secrets` is any object with `resolve(credentialsRef)`, answering the secret or -`undefined`: +Which store resolves it: -- **A host that composes its own kernel** passes the same binder it gives - `DatasourceAdminServicePlugin`. A `sys_secret:` handle that the Add Datasource - wizard minted then resolves, as on any federated datasource +- **Under `os serve`**, pass no `secrets`. The server publishes its own datasource secret + store as the kernel service `datasource-secret-resolver`, and the plugin reads it at the + first request. A `sys_secret:` handle that the Add Datasource wizard minted + resolves, as on any federated datasource ([Credentials](/docs/data-modeling/external-datasources#4-credentials)). -- **`os serve`** builds its secret store internally and does not hand it to a stack's - plugins. Pass a `resolve` backed by your host's own secret handling instead, and - declare `credentialsRef` as a handle that it resolves. +- **A host that composes its own kernel** passes `secrets`: the same binder it gives + `DatasourceAdminServicePlugin`, or any object with `resolve(credentialsRef)` answering + the secret or `undefined`. It can instead register that `resolve` as the + `datasource-secret-resolver` service (`DATASOURCE_SECRET_RESOLVER_SERVICE` in + `@objectstack/types`), which every plugin driver reading a `credentialsRef` uses the + same way. A `secrets` option passed to the plugin always wins over the service. Resolution fails closed. Each of these refuses the request rather than send it without -the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with no -`secrets`, and a handle that does not resolve. The caller is answered +the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with neither +`secrets` nor the service, and a handle that does not resolve. The caller is answered `503 ERR_DATASOURCE_UNAVAILABLE`, and the cause goes to the server log. ## 4. What the service is asked diff --git a/packages/cli/src/commands/serve-datasource-secret-resolver.test.ts b/packages/cli/src/commands/serve-datasource-secret-resolver.test.ts new file mode 100644 index 00000000000..4c0dbe5301e --- /dev/null +++ b/packages/cli/src/commands/serve-datasource-secret-resolver.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os serve` publishes its datasource secret binder's read half as the kernel + * service `datasource-secret-resolver`, so a plugin driver the stack composes + * (`@objectstack/driver-odata`) resolves a datasource's + * `external.credentialsRef` through the host's own `sys_secret` store. + * + * ## The gap + * + * `serve.ts` built the binder inline and handed it only to + * `DatasourceAdminServicePlugin`. No service exposed it, so under `os serve` a + * stack's OData plugin had no way to dereference a `sys_secret:` handle the + * datasource admin had stored, and the stack author had to write a `resolve` + * of their own. + * + * ## What this file pins + * + * The plugin runs inside a REAL `ObjectKernel` over a REAL binder + * (`createDatasourceSecretBinder` with `LocalCryptoProvider`): + * + * 1. a credential bound through the binder resolves through the service, to + * its cleartext; + * 2. the service is the read half only — no `bind`, no `unbind`; + * 3. a `sys_secret` row another producer sealed (a settings secret) does not + * open through it, and neither does a handle that is not the binder's; + * 4. the wiring inside `run()`: the plugin is mounted only when a binder was + * built, with that binder, and the plugin is defined once. + * + * The driver's side (reading the service at its first request, an explicit + * `options.secrets` winning, the fail-closed refusal with no service) is + * pinned in `@objectstack/driver-odata`'s own plugin tests. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ObjectKernel } from '@objectstack/core'; +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { createDatasourceSecretBinder } from '@objectstack/service-datasource'; +import { LocalCryptoProvider } from '@objectstack/service-settings'; +import { DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types'; + +// The repo's ONE code/prose separator: a comment naming the factory must not +// answer for code that does not call it. +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +import { createDatasourceSecretResolverPlugin } from './serve.js'; + +/** …/packages/cli/src/commands — seeded from `import.meta.url`. */ +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `serve.ts`'s CODE, with every comment span blanked. */ +const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8')); + +const KEY = Buffer.from('404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f', 'hex'); + +/** The one service a kernel refuses to start without. Nothing here reads data. */ +const DATA_STUB = { + name: 'com.example.datasource-secret-resolver.data-stub', + version: '1.0.0', + init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); }, + start: async () => {}, +}; + +/** An in-memory `sys_secret` table with the engine surface the binder calls. */ +function secretStore() { + const rows = new Map>(); + return { + rows, + async insert(_object: string, data: Record) { rows.set(String(data.id), data); return data; }, + async delete(_object: string, options: { where: Record }) { + assertEngineDeleteDispatch(options); + rows.delete(String(options.where.id)); + }, + async find(_object: string, query: Record) { + const where = (query.where ?? {}) as Record; + const hits = [...rows.values()].filter((row) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error('only plain equality filters are implemented by this double'); + } + return row[k] === v; + })); + return typeof query.limit === 'number' ? hits.slice(0, query.limit) : hits; + }, + }; +} + +/** Boot a real kernel with the resolver plugin over a real binder; hand back the service and the binder. */ +async function boot() { + const engine = secretStore(); + const cryptoProvider = new LocalCryptoProvider({ key: KEY }); + const binder = createDatasourceSecretBinder({ engine, cryptoProvider }); + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(DATA_STUB); + await kernel.use(createDatasourceSecretResolverPlugin(binder)); + await kernel.bootstrap(); + const resolver = kernel.getService(DATASOURCE_SECRET_RESOLVER_SERVICE); + return { kernel, engine, cryptoProvider, binder, resolver }; +} + +describe('createDatasourceSecretResolverPlugin — the binder\'s read half as a kernel service', () => { + it('a credential bound through the binder resolves through the service', async () => { + const { kernel, binder, resolver } = await boot(); + try { + const ref = await binder.bind({ value: 'token-from-sys-secret' }, { name: 'trippin' }); + expect(ref).toMatch(/^sys_secret:/); + expect(await resolver.resolve(ref)).toBe('token-from-sys-secret'); + } finally { + await kernel.shutdown(); + } + }); + + it('publishes resolve and nothing else of the binder', async () => { + const { kernel, resolver } = await boot(); + try { + expect(Object.keys(resolver)).toEqual(['resolve']); + } finally { + await kernel.shutdown(); + } + }); + + it('opens no row another producer sealed, and no handle that is not the binder\'s', async () => { + const { kernel, engine, cryptoProvider, resolver } = await boot(); + try { + // A settings secret in the same table, sealed under its own scope. + const handle = await cryptoProvider.encrypt('settings-cleartext', { scope: 'settings', namespace: 'mail', key: 'smtp_password' }); + await engine.insert('sys_secret', { + id: handle.id, namespace: 'mail', key: 'smtp_password', + kms_key_id: handle.kmsKeyId, alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, + }); + expect(await resolver.resolve(`sys_secret:${handle.id}`)).toBeUndefined(); + expect(await resolver.resolve('sys_secret:missing')).toBeUndefined(); + expect(await resolver.resolve('vault:somewhere-else')).toBeUndefined(); + } finally { + await kernel.shutdown(); + } + }); + + it('declares the service it registers (ADR-0116), so a consumer that requires it can name the provider', () => { + expect(createDatasourceSecretResolverPlugin({ resolve: async () => undefined }).providesServices) + .toEqual([DATASOURCE_SECRET_RESOLVER_SERVICE]); + }); +}); + +describe('the wiring inside `run()`', () => { + it('mounts the plugin only when a binder was built, handing it that binder', () => { + expect(SERVE).toMatch( + /if \(secrets\) \{\s*await kernel\.use\(createDatasourceSecretResolverPlugin\(secrets\)\);/, + ); + expect(SERVE.match(/'com\.objectstack\.cli\.datasource-secret-resolver'/g) ?? [], 'a second inline copy of the plugin').toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index aae4aded4cb..54fad5e582a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -29,7 +29,7 @@ import { // posture is the authoritative knob and `resolveTenancyPosture()` already folds // the legacy boolean in as its unset-fallback. serve's last direct reader of the // boolean was the banner, and that was exactly the drift #4801 fixed. -import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError, DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types'; import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel'; // [#22301] The `requires` token → provider table and its exact identity match, // shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS` @@ -5333,6 +5333,16 @@ export default class Serve extends Command { ); trackPlugin('DatasourceAdminServicePlugin'); + // The binder's read half, published as a kernel service so a plugin + // driver the stack composes (`@objectstack/driver-odata`) resolves + // its datasource's `external.credentialsRef` through this same + // store. Only when a binder was built: without one the service is + // absent, and such a driver refuses the request, naming it. + if (secrets) { + await kernel.use(createDatasourceSecretResolverPlugin(secrets)); + trackPlugin('DatasourceSecretResolver'); + } + // REST routes under /api/v1/datasources, mounted by a tiny plugin // during its init (same pattern as the hostname guard above). What // it reads, and which warning a boot without a server gets, lives @@ -6057,6 +6067,37 @@ export function createDatasourceAdminRoutesPlugin( }; } +/** + * The plugin `os serve` publishes its datasource secret binder's read half + * with: the kernel service {@link DATASOURCE_SECRET_RESOLVER_SERVICE}, whose + * one method is the binder's `resolve`. A plugin driver the stack composes + * reads it to dereference a datasource's `external.credentialsRef`, so a + * `sys_secret:` handle stored through the datasource admin resolves for that + * driver with no code from the stack author. + * + * Only `resolve` is published — `bind` and `unbind` stay with + * `DatasourceAdminServicePlugin`, which holds the whole binder. The binder + * decrypts under the `datasource_credential` scope alone, so the service + * cannot open a settings secret or a secret field's row. Registered in + * `init`, so it is in place before any request; a driver reads it at its + * first request, never at its own `init`. + */ +export function createDatasourceSecretResolverPlugin( + secrets: IDatasourceSecretResolver, +) { + const resolver: IDatasourceSecretResolver = { + resolve: (credentialsRef: string) => secrets.resolve(credentialsRef), + }; + return { + name: 'com.objectstack.cli.datasource-secret-resolver', + version: '1.0.0', + providesServices: [DATASOURCE_SECRET_RESOLVER_SERVICE], + init: async (ctx: { registerService(name: string, service: unknown): void }): Promise => { + ctx.registerService(DATASOURCE_SECRET_RESOLVER_SERVICE, resolver); + }, + }; +} + /** * What {@link createUnknownHostnameGuardPlugin} is constructed with. */ diff --git a/packages/drivers/driver-odata/README.md b/packages/drivers/driver-odata/README.md index 7989d77785f..a060365cae9 100644 --- a/packages/drivers/driver-odata/README.md +++ b/packages/drivers/driver-odata/README.md @@ -24,7 +24,7 @@ export const plugins = [ name: 'erp', // the datasource it serves serviceRoot: 'https://erp.example.com/odata/v4/sales/', auth: { scheme: 'bearer' }, // or { scheme: 'basic', username: 'svc_reader' } - secrets: datasourceSecretBinder, // the host's datasource secret store + // no `secrets`: credentialsRef resolves through the host's `datasource-secret-resolver` service }), ]; @@ -68,6 +68,7 @@ export default defineStack({ | `serviceRoot` | — | The service root URL (http or https, no credentials, no query). | | `version` | `4.0` | The protocol version. Only the v4 dialect ships. | | `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. Leave it `none` when the datasource borrows a connector instance's credential (below). | +| `secrets` | (none) | An object with `resolve(credentialsRef)` that the secret is resolved through. When absent, the kernel service `datasource-secret-resolver` is read at the first request; `os serve` registers it over its own `sys_secret` store. A host composing its own kernel passes the binder it gives `DatasourceAdminServicePlugin`, or registers that service. | | `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). | | `requestTimeoutMs` | `30000` | Per-request timeout. | diff --git a/packages/drivers/driver-odata/src/plugin.test.ts b/packages/drivers/driver-odata/src/plugin.test.ts index 500c5650edc..186c3d4a374 100644 --- a/packages/drivers/driver-odata/src/plugin.test.ts +++ b/packages/drivers/driver-odata/src/plugin.test.ts @@ -3,15 +3,17 @@ /** * The plugin: what it publishes, and where the credential comes from — the * datasource's `external.credentialsRef`, dereferenced through the host's - * secret store at the first request, or the connector instance its + * secret store at the first request (`options.secrets`, else the host's + * `datasource-secret-resolver` service), or the connector instance its * `external.connector` borrows from (ADR-0015 D7) — failing closed every way. */ import { describe, expect, it } from 'vitest'; -import type { PluginContext } from '@objectstack/core'; +import { ObjectKernel, type PluginContext } from '@objectstack/core'; import { needsExternalDriverFloor } from '@objectstack/objectql'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, + DATASOURCE_SECRET_RESOLVER_SERVICE, connectorCredentialRefusal, type BorrowedConnectorCredential, type IConnectorCredentialSource, @@ -93,6 +95,85 @@ describe('createODataDriverPlugin', () => { await expect(plugin.driver.find('trip_person', {})).rejects.toThrow(message); expect(requests).toHaveLength(0); }); + + describe('the host\'s published secret resolver', () => { + const DEFS: Def[] = [{ name: 'trippin', external: { credentialsRef: 'sys_secret:sec_trippin' } }]; + const HOST_RESOLVER = { + asked: [] as string[], + async resolve(ref: string) { + this.asked.push(ref); + return ref === 'sys_secret:sec_trippin' ? 'token-from-the-host' : undefined; + }, + }; + + it('resolves the handle through the host\'s service when the plugin was passed no secret store', async () => { + HOST_RESOLVER.asked = []; + const { plugin, requests } = await boot( + { name: 'trippin', auth: { scheme: 'bearer' } }, + DEFS, + { services: { [DATASOURCE_SECRET_RESOLVER_SERVICE]: HOST_RESOLVER } }, + ); + await plugin.driver.find('trip_person', {}); + expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer token-from-the-host', 'Bearer token-from-the-host']); + expect(HOST_RESOLVER.asked).toEqual(['sys_secret:sec_trippin']); + }); + + it('an explicit options.secrets wins: the host\'s service is never asked', async () => { + HOST_RESOLVER.asked = []; + const { plugin, requests } = await boot( + { name: 'trippin', auth: { scheme: 'bearer' }, secrets: SECRETS }, + DEFS, + { services: { [DATASOURCE_SECRET_RESOLVER_SERVICE]: HOST_RESOLVER } }, + ); + await plugin.driver.find('trip_person', {}); + expect(requests[0]!.headers.Authorization).toBe('Bearer token-from-the-store'); + expect(HOST_RESOLVER.asked).toEqual([]); + }); + + it('fails closed, sending nothing, when the host\'s service does not resolve the handle', async () => { + const { plugin, requests } = await boot( + { name: 'trippin', auth: { scheme: 'bearer' } }, + [{ name: 'trippin', external: { credentialsRef: 'sys_secret:gone' } }], + { services: { [DATASOURCE_SECRET_RESOLVER_SERVICE]: HOST_RESOLVER } }, + ); + await expect(plugin.driver.find('trip_person', {})).rejects.toThrow(/could not be resolved or decrypted/); + expect(requests).toHaveLength(0); + }); + + it('reads the service at the first request, so a resolver registered after the plugin\'s init is seen', async () => { + const { plugin, services, requests } = await boot({ name: 'trippin', auth: { scheme: 'bearer' } }, DEFS); + services.set(DATASOURCE_SECRET_RESOLVER_SERVICE, HOST_RESOLVER); + await plugin.driver.find('trip_person', {}); + expect(requests[0]!.headers.Authorization).toBe('Bearer token-from-the-host'); + }); + + // A real kernel's getService THROWS on a name nothing registered; the miss must still be the plugin's own + // fail-closed refusal naming the service, never the kernel's "not found" text, and nothing may be sent. + it('on a real kernel with neither a store nor the service, refuses with its own message and sends nothing', async () => { + const service = recordedService([METADATA_EXCHANGE, { url: 'People', body: page([]) }]); + const plugin = createODataDriverPlugin({ serviceRoot: SERVICE_ROOT, fetch: service.fetch, name: 'trippin', auth: { scheme: 'bearer' } }); + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use({ + name: 'com.example.odata-test.engine-stub', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('data', {}); + ctx.registerService('objectql', { listDatasourceDefs: () => DEFS } satisfies DatasourceDefinitionSource); + }, + }); + await kernel.use(plugin); + await kernel.bootstrap(); + try { + plugin.driver.registerExternalObject({ name: 'trip_person', external: { remoteName: 'People' } }); + const refusal = await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err as Error); + expect(refusal?.message).toMatch(/no secret store was passed to the plugin \(options\.secrets\) and none is registered as the 'datasource-secret-resolver' service/); + expect(refusal?.message).not.toMatch(/\[Kernel\]/); + expect(service.requests).toHaveLength(0); + } finally { + await kernel.shutdown(); + } + }); + }); }); /** diff --git a/packages/drivers/driver-odata/src/plugin.ts b/packages/drivers/driver-odata/src/plugin.ts index daeda0eecb1..e08b2485695 100644 --- a/packages/drivers/driver-odata/src/plugin.ts +++ b/packages/drivers/driver-odata/src/plugin.ts @@ -13,17 +13,20 @@ * The secret is the datasource's `external.credentialsRef` — never a key of * the config, never inline. The plugin finds the datasource definition in the * engine (`listDatasourceDefs()`) and dereferences the handle through the - * host's datasource secret store: the same `SecretBinder` the host gives - * `DatasourceAdminServicePlugin` (its `resolve`). It does so at the FIRST - * request, not at boot, so it never reads the definition registry while it is - * still filling; a 401 makes it resolve again, which picks up a rotated - * secret. + * host's datasource secret store: `options.secrets` when the host passes one, + * otherwise the kernel service `datasource-secret-resolver` + * (`DATASOURCE_SECRET_RESOLVER_SERVICE`, `@objectstack/types`) — the read half + * of the `SecretBinder` the host gives `DatasourceAdminServicePlugin`, which + * `os serve` publishes. It does so at the FIRST request, not at boot, so it + * never reads the definition or service registry while it is still filling; a + * 401 makes it resolve again, which picks up a rotated secret. * * Fail-closed, every way: an auth scheme with no `credentialsRef` on the - * datasource, a `credentialsRef` with no secret store, a handle that does not - * resolve, and a `credentialsRef` declared on a datasource whose scheme is - * `none` all refuse the request (the engine answers 503 and logs the cause) - * instead of sending it unauthenticated. + * datasource, a `credentialsRef` with neither `options.secrets` nor the + * service registered, a handle that does not resolve, and a `credentialsRef` + * declared on a datasource whose scheme is `none` all refuse the request (the + * engine answers 503 and logs the cause) instead of sending it + * unauthenticated. * * ## A borrowed credential * @@ -44,8 +47,10 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, + DATASOURCE_SECRET_RESOLVER_SERVICE, type ConnectorCredentialHandle, type IConnectorCredentialSource, + type IDatasourceSecretResolver, } from '@objectstack/types'; import { parseODataDriverConfig, type ODataDriverConfigInput } from './config.js'; import { ODataDriver } from './odata-driver.js'; @@ -62,7 +67,10 @@ export interface DatasourceDefinitionSource { } export interface ODataDriverPluginOptions extends ODataDriverConfigInput { - /** The secret store `external.credentialsRef` resolves through. Required for `bearer` and `basic`. */ + /** + * The secret store `external.credentialsRef` resolves through, for `bearer` and `basic`. When absent, the kernel + * service `datasource-secret-resolver` is read at the first request; this option wins over it. + */ secrets?: ODataSecretStore; /** The `fetch` the driver calls; the global one when absent. */ fetch?: FetchLike; @@ -71,6 +79,17 @@ export interface ODataDriverPluginOptions extends ODataDriverConfigInput { /** The engine's service name. */ const ENGINE_SERVICE = 'objectql'; +/** The host's published secret resolver, or `undefined` when none is registered (the kernel throws on a miss). */ +function hostSecretResolver(ctx: PluginContext): ODataSecretStore | undefined { + let service: IDatasourceSecretResolver | undefined; + try { + service = ctx.getService(DATASOURCE_SECRET_RESOLVER_SERVICE); + } catch { + return undefined; + } + return typeof service?.resolve === 'function' ? service : undefined; +} + export function createODataDriverPlugin(options: ODataDriverPluginOptions): Plugin & { readonly driver: ODataDriver } { const { secrets, fetch, ...input } = options; const config = parseODataDriverConfig(input); @@ -151,12 +170,15 @@ export function createODataDriverPlugin(options: ODataDriverPluginOptions): Plug `driver-odata: datasource '${config.name}' uses auth scheme '${config.auth.scheme}' and declares no external.credentialsRef`, ); } - if (!secrets) { + const store = secrets ?? hostSecretResolver(ctxRef); + if (!store) { throw new Error( - `driver-odata: datasource '${config.name}' declares a credentialsRef, but no secret store was passed to the plugin (options.secrets)`, + `driver-odata: datasource '${config.name}' declares a credentialsRef, but no secret store was passed to the plugin ` + + `(options.secrets) and none is registered as the '${DATASOURCE_SECRET_RESOLVER_SERVICE}' service; ` + + 'run under os serve, or pass options.secrets', ); } - const secret = await secrets.resolve(ref); + const secret = await store.resolve(ref); if (!secret) { throw new Error(`driver-odata: datasource '${config.name}': its credentialsRef could not be resolved or decrypted`); } diff --git a/packages/types/src/datasource-secret-resolver.ts b/packages/types/src/datasource-secret-resolver.ts new file mode 100644 index 00000000000..16a71614f2f --- /dev/null +++ b/packages/types/src/datasource-secret-resolver.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The host's datasource secret resolver — the read half of its binder + * + * A datasource never carries its credential. It carries + * `external.credentialsRef`, an opaque handle (`sys_secret:`) the host's + * datasource secret binder (`createDatasourceSecretBinder` in + * `@objectstack/service-datasource`) wrote when the credential was stored. + * Only that binder can turn the handle back into the value: it reads the + * `sys_secret` row and decrypts it under the host's crypto provider. + * + * A host that builds such a binder publishes its `resolve` — and nothing else + * of it — as the kernel service {@link DATASOURCE_SECRET_RESOLVER_SERVICE}. + * `os serve` does (`createDatasourceSecretResolverPlugin` in + * `@objectstack/cli`), beside the `DatasourceAdminServicePlugin` it hands the + * whole binder to. + * + * ## Who reads it + * + * A plugin driver that presents a datasource's `credentialsRef` to its + * upstream — `@objectstack/driver-odata` is the first — reads this service + * when its host passed it no secret store of its own, and every other plugin + * driver doing the same reads it the same way: + * + * - an explicit secret store handed to the plugin wins; + * - the service is looked up where the credential is needed (the first + * request), never at `init()`, while the service registry may still be + * filling (AGENTS.md, "Startup registry reads"); + * - no explicit store and no service, or a handle that does not resolve, + * refuses the request — never sends it unauthenticated. + * + * ## What it can reach + * + * Only datasource credentials: the binder decrypts under the + * `datasource_credential` crypto scope, so a `sys_secret` row another producer + * sealed (a settings secret, a secret field) fails authentication and answers + * `undefined`. `bind` and `unbind` are not on the service; storing and + * removing a credential stays the datasource admin's. + * + * ⛔ The value crosses this seam only as the return of `resolve`, into the + * caller's request. A caller never logs or stores it, and a refusal names the + * datasource, never the value. + */ + +/** The kernel service name the host's datasource secret resolver is registered under. */ +export const DATASOURCE_SECRET_RESOLVER_SERVICE = 'datasource-secret-resolver'; + +/** The read half of the host's datasource secret binder. */ +export interface IDatasourceSecretResolver { + /** + * The cleartext credential behind a datasource's `external.credentialsRef`, + * or `undefined` when the handle is not the binder's, its row is gone, or it + * does not decrypt. Never throws for those. + */ + resolve(credentialsRef: string): Promise; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 03fccd78cc9..655ab1dac1c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -90,6 +90,12 @@ export * from './unbacked-conflict-target.js'; // the pure enumerator both the hard stop (install seam) and the advisories // (`os doctor` / `os migrate plan`) read, so the three cannot drift apart. export * from './unique-scope-install-gate.js'; +// The read half of the host's datasource secret binder, published as a kernel +// service: the host (`os serve`) registers it, a plugin driver dereferencing a +// datasource's `external.credentialsRef` reads it. Neither imports the other; +// this package depends only on `@objectstack/spec`, so a driver (in this +// repository or out of it) adopting the seam takes no heavier edge. +export * from './datasource-secret-resolver.js'; // [#20919] The REST door's error CLASSIFICATION half (`mapDataError` through // `classifyDataError`), moved here from `@objectstack/rest` beside the // primitives it composes, so the bulk-import runner in `@objectstack/core` diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d71f941c971..004c5b68206 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1,6 +1,11 @@ { "$comment": "GENERATED — the RETAINED ledger of check-engine-double-contract.mjs (#9680). Regenerate with `node scripts/check-engine-double-contract.mjs --write`; never hand-edit. Each row is one (file, verb) whose engine double routes through the producer's dispatch predicate. This is the OPPOSITE polarity to engine-double-contract.baseline.json: that ledger records DEBT and may only shrink, this one records COVERAGE and may only grow. A row that disappears is a pinned double that left the population — the blind spot #9680 measured, where deleting a double's delete() member took 319 pinned to 318 with the gate green. Read a removal in this file's diff as a coverage loss and check it was intended.", "entries": [ + { + "file": "packages/cli/src/commands/serve-datasource-secret-resolver.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/cli/src/utils/organization-ownership-apply.test.ts", "verb": "delete",