diff --git a/.changeset/27746-datasource-borrows-connector-credential.md b/.changeset/27746-datasource-borrows-connector-credential.md new file mode 100644 index 00000000000..83418580bf9 --- /dev/null +++ b/.changeset/27746-datasource-borrows-connector-credential.md @@ -0,0 +1,28 @@ +--- +'@objectstack/types': patch +'@objectstack/service-automation': patch +'@objectstack/service-datasource': patch +'@objectstack/driver-odata': patch +--- + +feat: a datasource that names a connector instance in `external.connector` borrows that instance's credential at runtime, and its driver presents it per request + +Clause-②: yes +Metadata-protocol: none + +The runtime half of ADR-0015 amendment D7. A datasource over a vendor API that names a declared connector instance now reads with that instance's credential — the one set under Setup → Connector credentials (or the instance's environment variable, or the host's resolver) — so one credential serves the connector's flow actions and the datasource's virtual objects: + +```ts +connectors: [{ name: 'sap_s4', type: 'api', provider: 'openapi', providerConfig: { spec: './sap-openapi.json' }, + auth: { type: 'bearer', credentialRef: 'sap_token' } }], +datasources: [{ name: 'sap_orders', driver: 'sap_orders', config: {}, + schemaMode: 'external', external: { connector: 'sap_s4' } }], +// served by createODataDriverPlugin({ name: 'sap_orders', serviceRoot: '…' }), its auth left at 'none' +``` + +- **`@objectstack/service-automation`** registers a `connector-credential-source` service that lends a declared instance's credential (bearer, API key, basic), resolved through the same chain the connector itself uses. A credential set or changed through the credential door is picked up by the datasource's next request, with no restart. +- **`@objectstack/service-datasource`** borrows it when such a datasource connects (a plugin driver the engine already adopted from its `driver.` service is judged at that connect too), is probed (Test connection) or is saved from the admin door, and hands the driver factory a credential handle on `DatasourceConnectionSpec.credential`. A datasource whose instance is not declared, is a catalog descriptor, has no `auth` credential or is disabled — or a stack with no automation service to lend it — is refused, fail-closed, with a reason naming the fix: it never connects to fail its first read with an upstream 401. +- **`@objectstack/driver-odata`** presents it: a datasource it serves that names `external.connector` borrows the handle at its first request and sends every request through `callWithBorrowedCredential`, in the form the instance's `auth` declares (bearer, basic, or an API key as a header or query parameter). An upstream's error text is stripped of every spelling of the presented credential before it is thrown (`withoutBorrowedCredential`), so an API key sent as a query parameter never reaches an error body through a service that echoes the request URI. `external.credentialsRef` keeps its path; both channels at once, or an `auth.scheme` set on the plugin beside the connector, refuse the request. +- **`@objectstack/types`** carries the contract (`IConnectorCredentialSource`, `ConnectorCredentialHandle`, `CONNECTOR_CREDENTIAL_SOURCE_SERVICE`) and `callWithBorrowedCredential`, the one rule a plugin driver presents a borrowed credential by: refreshed when it has expired, refreshed once and retried on a `401`, and a `503 SERVICE_UNAVAILABLE` naming the instance when the fresh one is refused too. + +Nothing changes for a datasource that binds its own secret with `external.credentialsRef`. The handle answers the grant-bearing kinds (OAuth2 and the app-credential exchange) through the same interface when connector instances can declare them; a driver written against it today needs no change then. diff --git a/packages/drivers/driver-odata/README.md b/packages/drivers/driver-odata/README.md index ffaa362984a..12ddc7395be 100644 --- a/packages/drivers/driver-odata/README.md +++ b/packages/drivers/driver-odata/README.md @@ -65,10 +65,38 @@ export default defineStack({ | `name` | `odata` | The datasource name, the driver name and its `driver.` service suffix. | | `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`. | +| `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). | | `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). | | `requestTimeoutMs` | `30000` | Per-request timeout. | +## Borrowing a connector instance's credential + +When a connector instance already authorizes the vendor, the datasource can name it in +`external.connector` instead of binding a secret of its own (ADR-0015 amendment D7): one +credential, set under Setup → Connector credentials, serves the connector's flow actions and +the datasource's records. + +```typescript +connectors: [{ + name: 'erp_api', type: 'api', provider: 'openapi', providerConfig: { spec: './erp-openapi.json' }, + auth: { type: 'bearer', credentialRef: 'erp_token' }, +}], +datasources: [{ + name: 'erp', driver: 'erp', schemaMode: 'external', config: {}, + external: { connector: 'erp_api' }, // no credentialsRef: exactly one channel +}], +``` + +The plugin is created with `auth` left at `none`: the instance's `auth` decides how the credential +is sent (a bearer token, basic auth, or an API key as a header or a query parameter). It borrows +from the `connector-credential-source` service, which `@objectstack/service-automation` +registers, at the first request, and presents it on every request through +`callWithBorrowedCredential`: refreshed when it has expired, refreshed once and retried on a +`401`, and refused with a `503` naming the instance when the fresh one is refused too. A stack with +no automation service, an instance that cannot lend (not declared, disabled, no `auth`), an `auth` +scheme set on the plugin beside the connector, and a `credentialsRef` beside it all refuse the +request instead of sending it. + ## What is pushed down `$filter`: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$null`, diff --git a/packages/drivers/driver-odata/package.json b/packages/drivers/driver-odata/package.json index 43d0657adcc..8073ed8acd3 100644 --- a/packages/drivers/driver-odata/package.json +++ b/packages/drivers/driver-odata/package.json @@ -37,6 +37,7 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", "fast-xml-parser": "^5.10.1", "zod": "^4.6.6" }, diff --git a/packages/drivers/driver-odata/src/odata-driver.ts b/packages/drivers/driver-odata/src/odata-driver.ts index 1b8b6af09d8..c11c5714254 100644 --- a/packages/drivers/driver-odata/src/odata-driver.ts +++ b/packages/drivers/driver-odata/src/odata-driver.ts @@ -54,6 +54,7 @@ import type { DriverOptions } from '@objectstack/spec/data'; import type { EdmEntitySet, EdmEntityType, EdmProperty, ODataDialect, ODataServiceModel } from './dialect.js'; import { odataV4Dialect } from './dialect-v4.js'; import { translateWhere, type FilterTarget } from './filter.js'; +import type { ConnectorCredentialHandle } from '@objectstack/types'; import { ODataRequestError, ODataTransport, type FetchLike, type ODataResponse } from './transport.js'; import type { ODataDriverConfig } from './config.js'; @@ -71,6 +72,8 @@ export interface ODataDriverRuntime { fetch?: FetchLike; /** The secret behind the datasource's `external.credentialsRef`; see `plugin.ts`. */ credential?: () => Promise; + /** The credential borrowed from the connector instance the datasource names in `external.connector`; see `plugin.ts`. */ + borrowed?: () => Promise; } /** An introspected table, with the navigation properties the import offers as lookups. */ @@ -141,6 +144,7 @@ export class ODataDriver implements IDataDriver { pageSize: config.pageSize, ...(runtime.fetch ? { fetch: runtime.fetch } : {}), ...(runtime.credential ? { credential: runtime.credential } : {}), + ...(runtime.borrowed ? { borrowed: runtime.borrowed } : {}), }); } diff --git a/packages/drivers/driver-odata/src/plugin.test.ts b/packages/drivers/driver-odata/src/plugin.test.ts index ea969803de0..500c5650edc 100644 --- a/packages/drivers/driver-odata/src/plugin.test.ts +++ b/packages/drivers/driver-odata/src/plugin.test.ts @@ -3,21 +3,35 @@ /** * 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, failing closed every way. + * secret store at the first request, 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 { needsExternalDriverFloor } from '@objectstack/objectql'; +import { + CONNECTOR_CREDENTIAL_SOURCE_SERVICE, + connectorCredentialRefusal, + type BorrowedConnectorCredential, + type IConnectorCredentialSource, +} from '@objectstack/types'; import { createODataDriverPlugin, type DatasourceDefinitionSource, type ODataDriverPluginOptions } from './plugin.js'; -import { METADATA_EXCHANGE, SERVICE_ROOT, page, recordedService } from './__fixtures__/recorded-service.js'; +import { METADATA_EXCHANGE, SERVICE_ROOT, page, recordedService, type Exchange } from './__fixtures__/recorded-service.js'; type Def = ReturnType[number]; -async function boot(options: Omit, defs: Def[]) { - const service = recordedService([METADATA_EXCHANGE, { url: 'People', body: page([]) }]); +async function boot( + options: Omit, + defs: Def[], + extra: { exchanges?: Exchange[]; services?: Record } = {}, +) { + const service = recordedService(extra.exchanges ?? [METADATA_EXCHANGE, { url: 'People', body: page([]) }]); const plugin = createODataDriverPlugin({ serviceRoot: SERVICE_ROOT, fetch: service.fetch, ...options }); - const services = new Map([['objectql', { listDatasourceDefs: () => defs } satisfies DatasourceDefinitionSource]]); + const services = new Map([ + ['objectql', { listDatasourceDefs: () => defs } satisfies DatasourceDefinitionSource], + ...Object.entries(extra.services ?? {}), + ]); const ctx = { registerService: (name: string, value: unknown) => { services.set(name, value); }, getService: (name: string) => services.get(name), @@ -80,3 +94,139 @@ describe('createODataDriverPlugin', () => { expect(requests).toHaveLength(0); }); }); + +/** + * A lender double standing in for `@objectstack/service-automation`'s source: + * `get` answers the current credential, `refresh` moves to the next one, the + * shape a grant-bearing (OAuth2) instance answers through. + */ +function lender(answers: BorrowedConnectorCredential[], refuse?: Error) { + const calls = { borrow: [] as string[], get: 0, refresh: 0 }; + let current = 0; + const source: IConnectorCredentialSource = { + async borrow(connector) { + calls.borrow.push(connector); + if (refuse) throw refuse; + return { + connector, + async get() { calls.get += 1; return answers[current]!; }, + async refresh() { calls.refresh += 1; current = Math.min(current + 1, answers.length - 1); return answers[current]!; }, + }; + }, + }; + return { source, calls, services: { [CONNECTOR_CREDENTIAL_SOURCE_SERVICE]: source } }; +} + +const BORROWING: Def[] = [{ name: 'trippin', external: { connector: 'trippin_api' } }]; +const bearer = (token: string, expiresAt?: number): BorrowedConnectorCredential => + ({ auth: { type: 'bearer', token }, ...(expiresAt !== undefined ? { expiresAt } : {}) }); + +describe('createODataDriverPlugin — a credential borrowed from external.connector (ADR-0015 D7)', () => { + it('sends the connector instance\'s credential, borrowed once, and never asks the secret store', async () => { + const { calls, services } = lender([bearer('tok-1')]); + const secrets = { resolve: async () => { throw new Error('the secret store was asked'); } }; + const { plugin, requests } = await boot({ name: 'trippin', secrets }, BORROWING, { services }); + await plugin.driver.find('trip_person', {}); + expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-1']); + expect(calls.borrow).toEqual(['trippin_api']); + expect(calls.refresh).toBe(0); + }); + + it('presents the instance\'s auth as it declares it: basic, an API key header, an API key query parameter', async () => { + const basic = lender([{ auth: { type: 'basic', username: 'svc', password: 'pw' } }]); + const one = await boot({ name: 'trippin' }, BORROWING, { services: basic.services }); + await one.plugin.driver.find('trip_person', {}); + expect(one.requests[0]!.headers.Authorization).toBe(`Basic ${Buffer.from('svc:pw').toString('base64')}`); + + const header = lender([{ auth: { type: 'api-key', key: 'k-1', headerName: 'APIKey' } }]); + const two = await boot({ name: 'trippin' }, BORROWING, { services: header.services }); + await two.plugin.driver.find('trip_person', {}); + expect(two.requests[0]!.headers.APIKey).toBe('k-1'); + expect(two.requests[0]!.headers.Authorization).toBeUndefined(); + + const query = lender([{ auth: { type: 'api-key', key: 'k-2', headerName: 'X-API-Key', paramName: 'apikey' } }]); + const three = await boot({ name: 'trippin' }, BORROWING, { + services: query.services, + exchanges: [{ ...METADATA_EXCHANGE, url: '$metadata?apikey=k-2' }, { url: 'People?apikey=k-2', body: page([]) }], + }); + await three.plugin.driver.find('trip_person', {}); + expect(three.requests.map((r) => r.url)).toEqual(['$metadata?apikey=k-2', 'People?apikey=k-2']); + expect(three.requests[0]!.headers['X-API-Key']).toBeUndefined(); + }); + + it('refreshes once on a 401 and retries with the credential obtained afresh', async () => { + const { calls, services } = lender([bearer('tok-1'), bearer('tok-2')]); + const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { + services, + exchanges: [{ url: '$metadata', status: 401, body: { error: { message: 'expired' } }, once: true }, METADATA_EXCHANGE, { url: 'People', body: page([]) }], + }); + await plugin.driver.find('trip_person', {}); + expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-2', 'Bearer tok-2']); + expect(calls.refresh).toBe(1); + }); + + it('refreshes a credential at its expiry before sending it', async () => { + const { calls, services } = lender([bearer('tok-stale', Date.now() - 1), bearer('tok-fresh')]); + const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { services }); + await plugin.driver.find('trip_person', {}); + expect(requests[0]!.headers.Authorization).toBe('Bearer tok-fresh'); + expect(calls.refresh).toBe(1); + }); + + it('ends a second 401 as a 503 naming the instance, never the upstream\'s text', async () => { + const { services } = lender([bearer('tok-1'), bearer('tok-2')]); + const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { + services, + exchanges: [{ url: '$metadata', status: 401, body: { error: { message: 'token tok-2 rejected' } } }], + }); + const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number; code?: string }; + expect(failure).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(failure.message).toContain("connector instance 'trippin_api'"); + expect(failure.message).not.toContain('tok-2'); + expect(requests).toHaveLength(2); + }); + + it('withholds a query-parameter API key from an upstream refusal that echoes the request URI', async () => { + const key = 'k-2/x+y=='; + const { services } = lender([{ auth: { type: 'api-key', key, headerName: 'X-API-Key', paramName: 'apikey' } }]); + const echoed = new URL(`People?apikey=${encodeURIComponent(key)}`, SERVICE_ROOT).toString(); + const { plugin } = await boot({ name: 'trippin' }, BORROWING, { + services, + exchanges: [ + { ...METADATA_EXCHANGE, url: `$metadata?apikey=${key}` }, + { url: `People?apikey=${key}`, status: 404, body: { error: { code: 'NotFound', message: `No resource at '${echoed}' (apikey=${key}, form ${new URLSearchParams({ apikey: key }).toString()})` } } }, + ], + }); + const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number }; + expect(failure).toBeInstanceOf(Error); + expect(failure.status).toBe(404); + expect(failure.message).toContain('[credential withheld]'); + for (const spelling of [key, encodeURIComponent(key), new URLSearchParams({ v: key }).toString().slice(2)]) { + expect(failure.message).not.toContain(spelling); + } + }); + + const refusals: Array<[string, Omit, Def[], Record, RegExp]> = [ + ['no connector credential source registered', { name: 'trippin' }, BORROWING, {}, /no connector credential source is registered/], + ['both credential channels', { name: 'trippin', secrets: SECRETS }, [{ name: 'trippin', external: { connector: 'trippin_api', credentialsRef: 'sys_secret:sec_trippin' } }], lender([bearer('tok-1')]).services, /names both external\.connector and external\.credentialsRef/], + ['an auth scheme beside the connector', { name: 'trippin', auth: { scheme: 'bearer' } }, BORROWING, lender([bearer('tok-1')]).services, /set auth\.scheme to 'none'/], + ['an instance the source refuses to lend', { name: 'trippin' }, BORROWING, lender([], connectorCredentialRefusal("connector instance 'trippin_api' is disabled", 'VALIDATION_ERROR', 422)).services, /'trippin_api' is disabled/], + ['a credential the handle cannot answer', { name: 'trippin' }, BORROWING, { [CONNECTOR_CREDENTIAL_SOURCE_SERVICE]: { borrow: async (connector: string) => ({ connector, get: async () => { throw connectorCredentialRefusal("connector instance 'trippin_api': credential not set", 'SERVICE_UNAVAILABLE', 503); }, refresh: async () => { throw new Error('unreached'); } }) } }, /credential not set/], + ]; + + it.each(refusals)('fails closed, sending nothing, on %s', async (_label, options, defs, services, message) => { + const { plugin, requests } = await boot(options, defs, { services }); + await expect(plugin.driver.find('trip_person', {})).rejects.toThrow(message); + expect(requests).toHaveLength(0); + }); + + it('answers a lend refusal without its 422, so the engine reads it as the connection\'s condition, and asks again next request', async () => { + const refused = lender([], connectorCredentialRefusal("connector instance 'trippin_api' is disabled", 'VALIDATION_ERROR', 422)); + const { plugin } = await boot({ name: 'trippin' }, BORROWING, { services: refused.services }); + const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number }; + expect(failure).toBeInstanceOf(Error); + expect(failure.status).toBeUndefined(); + await plugin.driver.find('trip_person', {}).catch(() => {}); + expect(refused.calls.borrow).toEqual(['trippin_api', 'trippin_api']); + }); +}); diff --git a/packages/drivers/driver-odata/src/plugin.ts b/packages/drivers/driver-odata/src/plugin.ts index 3af587fc77c..daeda0eecb1 100644 --- a/packages/drivers/driver-odata/src/plugin.ts +++ b/packages/drivers/driver-odata/src/plugin.ts @@ -24,9 +24,29 @@ * 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 + * + * A datasource may instead name a declared connector instance in + * `external.connector` and borrow that instance's credential (ADR-0015 + * amendment D7): one Connect serves the connector's actions and this + * datasource's records. The plugin borrows a handle from the kernel service + * `connector-credential-source` (registered by `@objectstack/service-automation`) + * at the first request, and every request presents it through + * `callWithBorrowedCredential` (`@objectstack/types`): refreshed on expiry, + * once on a 401, then a 503. The instance's `auth` decides how it is sent, so + * the driver config's own `auth.scheme` stays `none` beside it. A source that + * is not registered, an instance it refuses to lend, a scheme set beside the + * connector, and a `credentialsRef` beside it (two credential channels) all + * refuse the request, the same way. */ import type { Plugin, PluginContext } from '@objectstack/core'; +import { + CONNECTOR_CREDENTIAL_SOURCE_SERVICE, + type ConnectorCredentialHandle, + type IConnectorCredentialSource, +} from '@objectstack/types'; import { parseODataDriverConfig, type ODataDriverConfigInput } from './config.js'; import { ODataDriver } from './odata-driver.js'; import type { FetchLike } from './transport.js'; @@ -38,7 +58,7 @@ export interface ODataSecretStore { /** The one engine read this plugin makes: the declared datasource definitions. */ export interface DatasourceDefinitionSource { - listDatasourceDefs(): Array<{ name: string; external?: { credentialsRef?: string } }>; + listDatasourceDefs(): Array<{ name: string; external?: { credentialsRef?: string; connector?: string } }>; } export interface ODataDriverPluginOptions extends ODataDriverConfigInput { @@ -56,9 +76,8 @@ export function createODataDriverPlugin(options: ODataDriverPluginOptions): Plug const config = parseODataDriverConfig(input); let ctxRef: PluginContext | undefined; - const credential = async (): Promise => { - if (!ctxRef) throw new Error(`driver-odata: datasource '${config.name}' was used before its plugin initialised`); - const engine = ctxRef.getService(ENGINE_SERVICE); + const definition = (ctx: PluginContext) => { + const engine = ctx.getService(ENGINE_SERVICE); if (!engine || typeof engine.listDatasourceDefs !== 'function') { throw new Error(`driver-odata: datasource '${config.name}': the engine offers no datasource definitions to read its credentialsRef from`); } @@ -66,7 +85,58 @@ export function createODataDriverPlugin(options: ODataDriverPluginOptions): Plug if (!def) { throw new Error(`driver-odata: datasource '${config.name}' is not declared, so it has no credentialsRef`); } - const ref = def.external?.credentialsRef; + return def; + }; + + // The handle, borrowed once per connector name; a refused borrow is not kept, so the next request asks again. + let lent: { connector: string; handle: Promise } | undefined; + const borrowed = async (): Promise => { + if (!ctxRef) throw new Error(`driver-odata: datasource '${config.name}' was used before its plugin initialised`); + const external = definition(ctxRef).external; + const connector = external?.connector; + if (!connector) return undefined; + if (external.credentialsRef) { + throw new Error( + `driver-odata: datasource '${config.name}' names both external.connector and external.credentialsRef; ` + + 'exactly one of them supplies the credential — remove one', + ); + } + if (config.auth.scheme !== 'none') { + throw new Error( + `driver-odata: datasource '${config.name}' borrows connector instance '${connector}''s credential, whose own auth ` + + `decides how it is sent; its auth scheme '${config.auth.scheme}' would send a second one — set auth.scheme to 'none'`, + ); + } + let entry = lent; + if (entry?.connector !== connector) { + let source: IConnectorCredentialSource | undefined; + try { + source = ctxRef.getService(CONNECTOR_CREDENTIAL_SOURCE_SERVICE); + } catch { + source = undefined; + } + if (!source || typeof source.borrow !== 'function') { + throw new Error( + `driver-odata: datasource '${config.name}' borrows connector instance '${connector}''s credential, but no ` + + `connector credential source is registered ('${CONNECTOR_CREDENTIAL_SOURCE_SERVICE}'); add the automation ` + + 'service plugin (@objectstack/service-automation)', + ); + } + // A lend refusal is a 422 at the authoring doors; on a read it is a condition the request cannot change, so + // it is rethrown without a status, which the engine's floor answers 503. + const handle = source.borrow(connector).catch((err: unknown) => { + throw new Error(`driver-odata: datasource '${config.name}': ${err instanceof Error ? err.message : String(err)}`); + }); + const borrowing = { connector, handle }; + lent = entry = borrowing; + handle.catch(() => { if (lent === borrowing) lent = undefined; }); + } + return entry.handle; + }; + + const credential = async (): Promise => { + if (!ctxRef) throw new Error(`driver-odata: datasource '${config.name}' was used before its plugin initialised`); + const ref = definition(ctxRef).external?.credentialsRef; if (config.auth.scheme === 'none') { if (ref) { throw new Error( @@ -96,6 +166,7 @@ export function createODataDriverPlugin(options: ODataDriverPluginOptions): Plug const driver = new ODataDriver(config, { ...(fetch ? { fetch } : {}), credential, + borrowed, }); return { diff --git a/packages/drivers/driver-odata/src/transport.ts b/packages/drivers/driver-odata/src/transport.ts index 6ba9e313c5f..b5a0cb55309 100644 --- a/packages/drivers/driver-odata/src/transport.ts +++ b/packages/drivers/driver-odata/src/transport.ts @@ -20,6 +20,12 @@ * a read refusal's message may be shown to the caller. */ +import { + callWithBorrowedCredential, + withoutBorrowedCredential, + type BorrowedConnectorAuth, + type ConnectorCredentialHandle, +} from '@objectstack/types'; import type { ODataDialect } from './dialect.js'; /** The `fetch` this transport calls — the global one unless the host passes its own. */ @@ -54,6 +60,36 @@ export interface ODataTransportOptions { * the first request that needs it and again after a 401, never at boot. */ credential?: () => Promise; + /** + * The credential borrowed from the connector instance the datasource names + * in `external.connector` (ADR-0015 D7), or `undefined` when it names none. + * When it answers a handle, every request is sent through + * `callWithBorrowedCredential` — refreshed on expiry, once on a 401, then a + * 503 — and {@link credential} is not asked. + */ + borrowed?: () => Promise; +} + +/** Where one request carries its credential: a header, or (an API key declared that way) a query parameter. */ +interface PresentedCredential { + header?: readonly [string, string]; + query?: readonly [string, string]; +} + +/** A borrowed credential as the instance declares it is sent. */ +function presentBorrowed(auth: BorrowedConnectorAuth): PresentedCredential { + if (auth.type === 'bearer') return { header: ['Authorization', `Bearer ${auth.token}`] }; + if (auth.type === 'basic') { + return { header: ['Authorization', `Basic ${Buffer.from(`${auth.username}:${auth.password}`, 'utf8').toString('base64')}`] }; + } + return auth.paramName ? { query: [auth.paramName, auth.key] } : { header: [auth.headerName, auth.key] }; +} + +/** A 401 the borrowed-credential rule retries: its body is dropped, never read. */ +function isUnauthorizedResponse(response: Response): boolean { + if (response.status !== 401) return false; + void response.body?.cancel().catch(() => {}); + return true; } export interface ODataResponse { @@ -117,26 +153,48 @@ export class ODataTransport { } async request(req: ODataRequest): Promise { - let response = await this.send(req); - if (response.status === 401 && this.opts.auth.scheme !== 'none') { - // The credential may have been rotated in the secret store: resolve it once more. - this.reset(); - response = await this.send(req); + const borrowed = this.opts.borrowed ? await this.opts.borrowed() : undefined; + let response: Response; + let presented: BorrowedConnectorAuth | undefined; + if (borrowed) { + // [ADR-0015 D7] The connector instance's credential, by the one rule every + // driver presents a borrowed credential through. A second 401 ends as its + // 503, which the engine's floor answers ERR_DATASOURCE_UNAVAILABLE. + response = await callWithBorrowedCredential( + borrowed, + (auth) => { + presented = auth; + return this.send(req, presentBorrowed(auth)); + }, + { isUnauthorized: isUnauthorizedResponse }, + ); + } else { + response = await this.send(req, await this.authorization()); + if (response.status === 401 && this.opts.auth.scheme !== 'none') { + // The credential may have been rotated in the secret store: resolve it once more. + this.reset(); + response = await this.send(req, await this.authorization()); + } } const body = await readBody(response, req.text === true); if (response.ok || req.accept?.includes(response.status)) { return { status: response.status, headers: response.headers, body }; } - const { code, message } = this.opts.dialect.readError(body); + const refusal = this.opts.dialect.readError(body); + // The upstream's own text can reach the caller (the floor answers a 400 / + // 404 / 422 refusal with it), and a service that echoes the request URI + // carries an API key presented as a query parameter: withheld first. + const scrub = (text: string | undefined) => (text && presented ? withoutBorrowedCredential(text, presented) : text); + const message = scrub(refusal.message); throw new ODataRequestError( `OData service refused ${req.method} on '${req.subject}' (HTTP ${response.status})` + (message ? `: ${message}` : ''), response.status, - code, + scrub(refusal.code), ); } - private async send(req: ODataRequest): Promise { + private async send(req: ODataRequest, credential: PresentedCredential | undefined): Promise { const headers: Record = { ...this.opts.dialect.requestHeaders(), ...(req.text ? { Accept: 'application/xml' } : {}), @@ -144,10 +202,14 @@ export class ODataTransport { ...(req.body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...req.headers, }; - const authorization = await this.authorization(); - if (authorization) headers.Authorization = authorization; + if (credential?.header) headers[credential.header[0]] = credential.header[1]; + let url = req.url; + if (credential?.query) { + url = new URL(req.url); + url.searchParams.set(credential.query[0], credential.query[1]); + } try { - return await this.fetchImpl(req.url.toString(), { + return await this.fetchImpl(url.toString(), { method: req.method, headers, ...(req.body !== undefined ? { body: JSON.stringify(req.body) } : {}), @@ -162,7 +224,7 @@ export class ODataTransport { } } - private async authorization(): Promise { + private async authorization(): Promise { const { auth } = this.opts; if (!this.opts.credential) { if (auth.scheme === 'none') return undefined; @@ -184,9 +246,11 @@ export class ODataTransport { this.secret = undefined; throw new Error(`driver-odata: the credential for auth scheme '${auth.scheme}' resolved to nothing`); } - return auth.scheme === 'bearer' - ? `Bearer ${secret}` - : `Basic ${Buffer.from(`${auth.username}:${secret}`, 'utf8').toString('base64')}`; + return { + header: ['Authorization', auth.scheme === 'bearer' + ? `Bearer ${secret}` + : `Basic ${Buffer.from(`${auth.username}:${secret}`, 'utf8').toString('base64')}`], + }; } } diff --git a/packages/services/service-automation/src/connector-credential-source.test.ts b/packages/services/service-automation/src/connector-credential-source.test.ts new file mode 100644 index 00000000000..b63d1e9bec6 --- /dev/null +++ b/packages/services/service-automation/src/connector-credential-source.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0015 D7] The connector credential source: a datasource borrows a + * declared connector instance's credential through the kernel service this + * plugin registers, resolved by the chain the connector itself uses. + * + * - the plugin registers the source in `init()`, and names it in + * `providesServices`; + * - a borrow refuses an undeclared instance, a catalog descriptor, an + * instance with no credential and a disabled one (`422`), naming the fix; + * - a borrowed static credential resolves from the environment and from a + * stored `sys_connector_credential` row (a real ObjectQL over SQLite, so + * the `Field.secret` seam is the engine's), for each static kind; + * - a credential not set yet lends a handle whose `get` refuses `503` with + * the remedy, and answers once the credential is set — no new borrow; + * - a credential write is picked up without a `refresh()`; a value changed + * where the plugin cannot see it (the environment) is picked up by one; + * - nothing a refusal says carries the value. + * + * The values below are obvious dummies. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysSecret } from '@objectstack/platform-objects'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; +import { + CONNECTOR_CREDENTIAL_SOURCE_SERVICE, + callWithBorrowedCredential, + type IConnectorCredentialSource, +} from '@objectstack/types'; + +import { AutomationServicePlugin, type CredentialResolver } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; +import { SysConnectorCredential } from './sys-connector-credential.object.js'; +import { SysFlowCredential } from './sys-flow-credential.object.js'; +import { createConnectorCredentialSource } from './connector-credential-source.js'; + +const REF = 'DUMMY_SAP_TOKEN_D7'; +const DUMMY_VALUE = 'dummy-value-d7-aaaa-0001'; +const DUMMY_VALUE_2 = 'dummy-value-d7-bbbb-0002'; +const DUMMY_ENV_VALUE = 'dummy-env-d7-cccc-0003'; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** Reversible stand-in cipher — the seam's behaviour is the engine's, not the cipher's. */ +function fakeCrypto(): ICryptoProvider { + let n = 0; + return { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; + }, + async decrypt(handle: CryptoHandle): Promise { + return [...handle.ciphertext].reverse().join(''); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest: (plain: string) => `d:${plain.length}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; +} + +/** A provider-bound instance, as the registry serves it. */ +function sap(overrides: Record = {}) { + return { name: 'sap_s4', label: 'SAP', type: 'api', provider: 'fake', providerConfig: {}, auth: { type: 'bearer', credentialRef: REF }, ...overrides }; +} + +const engines: ObjectQL[] = []; +const kernels: LiteKernel[] = []; +const envKeys: string[] = []; +afterEach(async () => { + for (const k of kernels.splice(0)) await k.shutdown().catch(() => {}); + for (const e of engines.splice(0)) { + try { await (e as any).destroy?.(); } catch { /* noop */ } + } + for (const key of envKeys.splice(0)) delete process.env[key]; +}); + +function setEnv(key: string, value: string): void { + envKeys.push(key); + process.env[key] = value; +} + +async function dataEngine(): Promise { + const ql = new ObjectQL(); + engines.push(ql); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + await ql.init(); + (ql as any).registry.registerObject(SysSecret as never, 'test'); + (ql as any).registry.registerObject(SysConnectorCredential as never, 'test'); + (ql as any).registry.registerObject(SysFlowCredential as never, 'test'); + await ql.syncSchemas(); + ql.setCryptoProvider(fakeCrypto()); + return ql; +} + +/** Boot the plugin over `declared`, with a fake provider so the reconcile materializes. */ +async function boot(declared: unknown[], opts: { credentialResolver?: CredentialResolver } = {}) { + const ql = await dataEngine(); + const kernel = new LiteKernel({ logger: { level: 'error' } } as never); + kernels.push(kernel); + const plugin = new AutomationServicePlugin({ suspendedRunStore: 'memory', credentialResolver: opts.credentialResolver ?? (() => undefined) }); + kernel.use(plugin); + kernel.use({ + name: 'test.harness', + type: 'standard' as const, + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: any) { + ctx.registerService('objectql', { registry: { listItems: (t: string) => (t === 'connector' ? declared : []) } }); + ctx.registerService('data', ql); + ctx.getService('automation').registerConnectorProvider('fake', (pctx: any) => ({ + def: { name: pctx.name, label: pctx.label, type: 'api', actions: [{ key: 'request', label: 'Request' }] }, + handlers: { request: async () => ({ status: 200, ok: true, body: {} }) }, + })); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + await flush(); + const engine = kernel.getService('automation') as AutomationEngine; + const source = kernel.getService(CONNECTOR_CREDENTIAL_SOURCE_SERVICE) as IConnectorCredentialSource; + return { plugin, engine, source, ql }; +} + +describe('[ADR-0015 D7] the plugin lends a declared instance\'s credential', () => { + it('registers the source and declares it among the services init() provides', async () => { + const { plugin, source } = await boot([sap()]); + expect(plugin.providesServices).toContain(CONNECTOR_CREDENTIAL_SOURCE_SERVICE); + expect(typeof source.borrow).toBe('function'); + }); + + it('lends a bearer credential set as an environment variable', async () => { + setEnv(REF, DUMMY_ENV_VALUE); + const { source } = await boot([sap()]); + const handle = await source.borrow('sap_s4'); + expect(handle.connector).toBe('sap_s4'); + await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } }); + }); + + it('lends the api-key and basic kinds in the shape the connector sends them', async () => { + setEnv(REF, DUMMY_ENV_VALUE); + const { source } = await boot([ + sap({ name: 'keyed', auth: { type: 'api-key', credentialRef: REF } }), + sap({ name: 'keyed_param', auth: { type: 'api-key', credentialRef: REF, headerName: 'apikey', paramName: 'key' } }), + sap({ name: 'basic', auth: { type: 'basic', username: 'svc_reader', credentialRef: REF } }), + ]); + await expect((await source.borrow('keyed')).get()).resolves.toEqual({ + auth: { type: 'api-key', key: DUMMY_ENV_VALUE, headerName: 'X-API-Key' }, + }); + await expect((await source.borrow('keyed_param')).get()).resolves.toEqual({ + auth: { type: 'api-key', key: DUMMY_ENV_VALUE, headerName: 'apikey', paramName: 'key' }, + }); + await expect((await source.borrow('basic')).get()).resolves.toEqual({ + auth: { type: 'basic', username: 'svc_reader', password: DUMMY_ENV_VALUE }, + }); + }); + + it('lends before the credential is set: get refuses 503 with the remedy, then answers once it is set — and a write is picked up with no refresh', async () => { + const { source, engine } = await boot([sap()]); + const handle = await source.borrow('sap_s4'); + + const missing = await handle.get().catch((e: Error) => e); + expect(missing).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect((missing as Error).message).toContain(`credential '${REF}' is not set`); + expect((missing as Error).message).toContain('Setup → Connector credentials'); + + await engine.setConnectorCredential({ ref: REF, value: DUMMY_VALUE, context: { userId: 'usr_admin' } as never }); + await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_VALUE } }); + + // A second write: the handle re-resolves on its next get, no refresh asked. + await engine.setConnectorCredential({ ref: REF, value: DUMMY_VALUE_2, context: { userId: 'usr_admin' } as never }); + await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_VALUE_2 } }); + }); + + it('picks up a value changed where the plugin cannot see it on refresh — the driver\'s 401 path', async () => { + setEnv(REF, DUMMY_ENV_VALUE); + const { source } = await boot([sap()]); + const handle = await source.borrow('sap_s4'); + await expect(handle.get()).resolves.toEqual({ auth: { type: 'bearer', token: DUMMY_ENV_VALUE } }); + setEnv(REF, DUMMY_VALUE_2); // rotated outside the door + const sent: string[] = []; + const result = await callWithBorrowedCredential(handle, async (auth) => { + const token = auth.type === 'bearer' ? auth.token : ''; + sent.push(token); + return { status: token === DUMMY_VALUE_2 ? 200 : 401 }; + }); + expect(result).toEqual({ status: 200 }); + expect(sent).toEqual([DUMMY_ENV_VALUE, DUMMY_VALUE_2]); + }); + + it.each([ + ['an undeclared instance', [], 'is not declared in this stack\'s connectors'], + ['a catalog descriptor', [{ name: 'sap_s4', label: 'SAP', type: 'api' }], 'declares no provider'], + ['an instance with no credential', [sap({ auth: { type: 'none' } })], 'declares no auth credential'], + ['an instance with no auth at all', [sap({ auth: undefined })], 'declares no auth credential'], + ['a disabled instance', [sap({ enabled: false })], 'is disabled (enabled: false)'], + ])('refuses to lend %s (422, naming the fix)', async (_label, declared, phrase) => { + const { source } = await boot(declared as unknown[]); + const refusal = await source.borrow('sap_s4').catch((e: Error) => e); + expect(refusal).toMatchObject({ code: 'VALIDATION_ERROR', status: 422 }); + expect((refusal as Error).message).toContain("connector instance 'sap_s4'"); + expect((refusal as Error).message).toContain(phrase); + }); +}); + +describe('[ADR-0015 D7] createConnectorCredentialSource — the refusals of a handle', () => { + const entry = sap(); + const deps = (over: Partial[0]> = {}) => createConnectorCredentialSource({ + readDeclared: async () => [entry], + chain: async () => ({ env: () => DUMMY_VALUE }), + vaultWired: () => false, + generation: () => 0, + ...over, + }); + + it('answers 503 when the declaration cannot be read at borrow', async () => { + const source = deps({ readDeclared: async () => { throw new Error('protocol down'); } }); + await expect(source.borrow('sap_s4')).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + }); + + it('answers 503 on get when the instance stopped being declared after the borrow', async () => { + let declared: unknown[] = [entry]; + let generation = 0; + const source = deps({ readDeclared: async () => declared, generation: () => generation }); + const handle = await source.borrow('sap_s4'); + await expect(handle.get()).resolves.toMatchObject({ auth: { token: DUMMY_VALUE } }); + declared = []; + generation += 1; + await expect(handle.get()).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + }); + + it('withholds a host resolver\'s own message, which may carry a value', async () => { + const source = deps({ chain: async () => ({ env: () => undefined, vault: () => { throw new Error(`vault said ${DUMMY_VALUE}`); } }) }); + const handle = await source.borrow('sap_s4'); + const err = await handle.get().catch((e: Error) => e); + expect(err).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect((err as Error).message).toContain("credential 'DUMMY_SAP_TOKEN_D7'"); + expect((err as Error).message).not.toContain(DUMMY_VALUE); + }); + + it('answers 503 when the tenancy posture cannot be read', async () => { + const source = deps({ chain: async () => { throw new Error('tenancy down'); } }); + const handle = await source.borrow('sap_s4'); + await expect(handle.get()).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + }); + + it('shares one resolution between concurrent gets', async () => { + let reads = 0; + const source = deps({ chain: async () => { reads += 1; return { env: () => DUMMY_VALUE }; } }); + const handle = await source.borrow('sap_s4'); + await Promise.all([handle.get(), handle.get(), handle.refresh()]); + expect(reads).toBe(1); + }); +}); diff --git a/packages/services/service-automation/src/connector-credential-source.ts b/packages/services/service-automation/src/connector-credential-source.ts new file mode 100644 index 00000000000..82836b2f091 --- /dev/null +++ b/packages/services/service-automation/src/connector-credential-source.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The connector credential source — lending an instance's credential (ADR-0015 D7) + * + * An API-backed datasource names a declared connector instance in + * `external.connector` and borrows that instance's credential (ADR-0015 + * amendment D7). This is the lending half: the + * {@link IConnectorCredentialSource} the plugin registers under + * `CONNECTOR_CREDENTIAL_SOURCE_SERVICE` (the contract and the call rule a + * driver presents through live in `@objectstack/types`). + * + * A borrow judges the DECLARATION — the same declared items the reconcile + * materializes from — and resolves no value. A handle resolves the + * instance's `auth.credentialRef` through the same chain the connector itself + * resolves it through (`./connector-credentials.ts`: env → the + * `sys_connector_credential` row → the host's resolver; no stored arm under a + * walled tenancy posture), so one credential set under Setup → Connector + * credentials serves the connector's actions and the datasource's records. + * + * Today's instances carry the static kinds (bearer, API key, basic), which + * ADR-0143 D4 leaves resolved once rather than per dispatch. A handle keeps + * the value it resolved until one of two things: a `refresh()` (the + * driver's `401` path, so a rotated credential is picked up by the first + * request it refuses), or a credential write or reconcile in this plugin (the + * {@link ConnectorCredentialSourceDeps.generation} moves). The handle does not + * watch the declaration itself: a change to the instance (disabled, its + * `auth` rewritten) is seen after the next reconcile, which every declaration + * path runs, or on a `refresh()`. The grant-bearing kinds ADR-0143 adds answer + * through the same handle, per dispatch, when they land. + * + * ⛔ A value leaves this module only as the return of a handle's `get` / + * `refresh`. Every refusal names the instance and the reference, never a + * value, and a host resolver's own message is not interpolated. + */ + +import { + connectorCredentialRefusal, + type BorrowedConnectorAuth, + type BorrowedConnectorCredential, + type ConnectorCredentialHandle, + type IConnectorCredentialSource, +} from '@objectstack/types'; +import type { ConnectorInstanceAuth } from '@objectstack/spec/integration'; +import { + missingCredentialReason, + resolveConnectorCredential, + type ConnectorCredentialChain, + type ConnectorCredentialRecord, +} from './connector-credentials.js'; + +/** The slice of a declared `connectors:` entry a borrow reads — raw as authored. */ +export interface LendableConnectorItem { + name?: string; + enabled?: boolean; + provider?: string; + auth?: ConnectorInstanceAuth; +} + +/** What the source reads through — the plugin's own readers, injected so the source holds no plugin state. */ +export interface ConnectorCredentialSourceDeps { + /** + * The declared `connectors:` items, as the reconcile reads them. + * `undefined` when nothing is declared at all (no registry). Throws when + * the declaration cannot be read now. + */ + readDeclared(): Promise; + /** The resolver chain for this deployment's tenancy posture. Throws when the posture cannot be read. */ + chain(): Promise; + /** Whether the host injected a resolver — named in a missing credential's remedy. */ + vaultWired(): boolean; + /** A counter the plugin moves on every credential write and reconcile; a handle re-resolves when it moved. */ + generation(): number; +} + +type Lendable = LendableConnectorItem & { name: string; provider: string; auth: Exclude }; + +function findDeclared(items: readonly unknown[] | undefined, name: string): LendableConnectorItem | undefined { + for (const raw of items ?? []) { + const item = raw as LendableConnectorItem | null; + if (item && typeof item === 'object' && item.name === name) return item; + } + return undefined; +} + +/** + * Why `item` cannot lend a credential, or `undefined` when it can. The three + * authoring findings `collectDatasourceConnectorErrors` (`@objectstack/spec`, + * `stack.zod.ts`) refuses at build, judged again here because a + * runtime-created datasource never passes through `defineStack`, and a + * declaration can change after a build. Plus the one the build accepts: a + * disabled instance turns the vendor off for its records as for its actions. + */ +function lendRefusal(connector: string, item: LendableConnectorItem | undefined): string | undefined { + const subject = `connector instance '${connector}' (named by this datasource's external.connector)`; + if (item === undefined) { + return `${subject} is not declared in this stack's connectors. Declare it — a connectors entry named '${connector}' ` + + 'with a provider and an auth credential — or correct the name.'; + } + if (typeof item.provider !== 'string' || item.provider.length === 0) { + return `${subject} declares no provider: a connector entry with no provider is a catalog descriptor and holds no ` + + 'credential to lend. Give it the provider that serves this vendor and an auth credential.'; + } + const auth = item.auth; + if (!auth || typeof auth !== 'object' || auth.type === 'none') { + return `${subject} declares no auth credential, so there is nothing to borrow. Declare its auth, or remove ` + + 'external.connector from the datasource if the upstream needs no credential.'; + } + if (item.enabled === false) { + return `${subject} is disabled (enabled: false), which turns the vendor off for this datasource's records as for ` + + "the connector's actions. Enable the instance to read through it."; + } + return undefined; +} + +function borrowedAuthOf(auth: Lendable['auth'], value: string): BorrowedConnectorAuth { + switch (auth.type) { + case 'bearer': + return { type: 'bearer', token: value }; + case 'api-key': + return { + type: 'api-key', + key: value, + headerName: auth.headerName ?? 'X-API-Key', + ...(auth.paramName !== undefined ? { paramName: auth.paramName } : {}), + }; + case 'basic': + return { type: 'basic', username: auth.username, password: value }; + } +} + +const unavailable = (message: string): Error => connectorCredentialRefusal(message, 'SERVICE_UNAVAILABLE', 503); + +/** + * The source. One per automation plugin; it holds nothing but the handles' + * caches, and each handle holds only its own instance's last value. + */ +export function createConnectorCredentialSource(deps: ConnectorCredentialSourceDeps): IConnectorCredentialSource { + const readDeclared = async (connector: string): Promise => { + try { + return await deps.readDeclared(); + } catch { + throw unavailable( + `the connector declarations could not be read, so connector instance '${connector}' cannot be lent to a ` + + 'datasource now; it is retried on the next connect.', + ); + } + }; + + /** The value `entry`'s reference resolves to now, through the chain. */ + const resolve = async (connector: string, entry: Lendable): Promise => { + const ref = entry.auth.credentialRef; + let chain: ConnectorCredentialChain; + try { + chain = await deps.chain(); + } catch { + throw unavailable( + `the tenancy posture could not be read, so where credential '${ref}' of connector instance '${connector}' ` + + 'is kept cannot be told now; the next request retries.', + ); + } + // One row read for this reference, never the store's listing: `resolve` + // reads the row's value only when it is the one that answers. + let rows: Map | undefined = new Map(); + if (chain.store) { + try { + const row = await chain.store.read(ref); + if (row) rows.set(ref, row); + } catch { + rows = undefined; + } + } + let resolution: Awaited>; + try { + resolution = await resolveConnectorCredential(chain, ref, rows); + } catch { + // The host's resolver threw. Its message is not ours to vouch for. + throw unavailable( + `the host's credential resolver failed on credential '${ref}' of connector instance '${connector}'; ` + + 'the next request retries.', + ); + } + if (resolution.kind === 'resolved') return { auth: borrowedAuthOf(entry.auth, resolution.value) }; + if (resolution.kind === 'missing') { + throw unavailable(`connector instance '${connector}': ${missingCredentialReason(ref, deps.vaultWired())}.`); + } + throw unavailable(`connector instance '${connector}': ${resolution.reason}`); + }; + + /** The instance's current declaration, judged; a refusal here is a condition the request cannot change, so `503`. */ + const currentEntry = async (connector: string): Promise => { + const item = findDeclared(await readDeclared(connector), connector); + const refusal = lendRefusal(connector, item); + if (refusal) throw unavailable(refusal); + return item as Lendable; + }; + + return { + async borrow(connector: string): Promise { + const refusal = lendRefusal(connector, findDeclared(await readDeclared(connector), connector)); + if (refusal) throw connectorCredentialRefusal(refusal, 'VALIDATION_ERROR', 422); + + let held: { generation: number; credential: BorrowedConnectorCredential } | undefined; + let inflight: Promise | undefined; + // Single flight: concurrent requests that find no held value, or + // that all saw a `401`, share one resolution. + const fill = (): Promise => { + inflight ??= (async () => { + const generation = deps.generation(); + const credential = await resolve(connector, await currentEntry(connector)); + held = { generation, credential }; + return credential; + })().finally(() => { inflight = undefined; }); + return inflight; + }; + return { + connector, + async get() { + if (held && held.generation === deps.generation()) return held.credential; + return fill(); + }, + async refresh() { + held = undefined; + return fill(); + }, + }; + }, + }; +} diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index a827d780254..8744db630ec 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -54,6 +54,8 @@ import { type ConnectorCredentialRecord, type ConnectorCredentialResolution, } from './connector-credentials.js'; +import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE } from '@objectstack/types'; +import { createConnectorCredentialSource } from './connector-credential-source.js'; import { FlowCredentialChannel, storedFlowStates, @@ -673,7 +675,7 @@ export class AutomationServicePlugin implements Plugin { * Services init() registers on every path (ADR-0116, #4131) — lets the * kernel name this plugin when a consumer requires one before it inits. */ - providesServices = ['automation']; + providesServices = ['automation', CONNECTOR_CREDENTIAL_SOURCE_SERVICE]; version = '1.0.0'; type = 'standard' as const; // Soft dependency on metadata: we look it up at start() and tolerate absence. @@ -801,6 +803,13 @@ export class AutomationServicePlugin implements Plugin { * credential door writes. See `./connector-credentials.ts`. */ private connectorCredentialStore?: ConnectorCredentialStore; + /** + * [ADR-0015 D7] Moved by every reconcile — which every credential write + * runs — so a datasource's borrowed credential is resolved again after a + * write or a declaration change instead of serving the value it held + * (`./connector-credential-source.ts`). + */ + private credentialGeneration = 0; /** [#20790] Serializes the one-time credential move — see {@link scheduleCredentialMigration}. */ private credentialMigration: Promise = Promise.resolve(); /** [#20790] The crypto-provider subscription that re-runs the move; dropped at destroy. */ @@ -1074,6 +1083,18 @@ export class AutomationServicePlugin implements Plugin { clear: (request) => this.clearConnectorCredential(ctx, request), }); + // [ADR-0015 D7] The connector credential source: a datasource that + // names a declared instance in `external.connector` borrows that + // instance's credential through it, resolved by the same chain the + // connector uses. Registered in `init()`, so every `start()` — the + // datasource connection path's included — finds it. + ctx.registerService(CONNECTOR_CREDENTIAL_SOURCE_SERVICE, createConnectorCredentialSource({ + readDeclared: () => this.readDeclaredConnectorItems(ctx, { post: true }), + chain: () => this.connectorCredentialChain(ctx), + vaultWired: () => this.options.credentialResolver !== undefined, + generation: () => this.credentialGeneration, + })); + // [#20281 stage ③] The connector sync executor, served on the // `automation` service's contract (`pullConnectorSource`) — the door a // job's `pull` run form binds through. Attached before the service is @@ -2030,6 +2051,8 @@ export class AutomationServicePlugin implements Plugin { private async reconcileDeclaredConnectors(ctx: PluginContext, opts: { fatal: boolean }): Promise { const engine = this.engine; if (!engine || this.destroyed) return; + // [ADR-0015 D7] A borrowed credential is resolved again after this run. + this.credentialGeneration += 1; // This run supersedes any pending degraded-instance retry; it is // rescheduled at the end if instances remain degraded. this.clearDeclarativeRetryTimer(); diff --git a/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts index f1ee36fa115..0f62a71db26 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts @@ -1379,7 +1379,7 @@ describe('[#27727] reconnect requires `previous`, the record the live pool was b expect(live).toBeDefined(); const defsBefore = engine!.defs.length; - // @ts-expect-error — `previous` is required: without its `driver` a kept pool would be restored unfloored + // @ts-expect-error — `previous` is required: without its `driver`, when a pool is live, a failed rebuild would restore it unfloored await expect(service.reconnect(pluginDs, { context: ctx })).rejects.toThrow(/reconnect needs `previous`/); await expect( service.reconnect(pluginDs, { context: ctx, previous: { driver: '' } }), diff --git a/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts b/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts new file mode 100644 index 00000000000..89d58035126 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts @@ -0,0 +1,471 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { + CONNECTOR_CREDENTIAL_SOURCE_SERVICE, + callWithBorrowedCredential, + connectorCredentialRefusal, + type BorrowedConnectorAuth, + type BorrowedConnectorCredential, + type ConnectorCredentialHandle, + type IConnectorCredentialSource, +} from '@objectstack/types'; +import { + DatasourceConnectionService, + type ConnectableDatasource, + type ConnectionEngineLike, +} from '../datasource-connection-service.js'; +import type { DatasourceConnectionSpec, IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js'; +import { + DatasourceAdminService, + type DatasourceAdminServiceConfig, + type StoredDatasource, +} from '../datasource-admin-service.js'; +import { borrowConnectorCredential } from '../datasource-connector-credential.js'; +import { DatasourceAdminServicePlugin } from '../datasource-admin-plugin.js'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +/** + * [ADR-0015 amendment D7] A datasource that names a declared connector + * instance in `external.connector` borrows that instance's credential at + * connect, and its driver presents it per request. + * + * The lender here is a double of the automation service's source (its own + * suite, `service-automation/src/connector-credential-source.test.ts`, runs + * the real one), and the driver is a test double over a vendor API that + * presents through `callWithBorrowedCredential`, the way the OData driver + * will. The pins: + * + * - a borrowed static credential reaches the upstream; + * - an `oauth2`-shaped credential is refreshed after it expires, and once on + * a `401` (the grant-bearing kinds answer through the same handle); + * - an undeclared instance — and a host with no lender at all — is refused + * at connect, fail-closed: no driver built, nothing registered; + * - the same verdict for a PLUGIN driver (driver-odata), which the engine + * adopts from its `driver.` service before any connect runs, so the + * connect reaches only the already-registered guard; + * - the admin door refuses to save (and to probe) a draft naming an + * instance that cannot lend, and probes a lendable one with its credential. + */ + +/** The engine double: records registrations, as the connection service's own suite does. */ +function fakeEngine() { + const drivers = new Map(); + const engine: ConnectionEngineLike & { drivers: typeof drivers } = { + drivers, + registerDriver: (driver: IDataDriver) => { + if (!drivers.has(driver.name)) drivers.set(driver.name, driver); + }, + registerDatasourceDef: () => {}, + getDriverByName: (name) => drivers.get(name), + syncObjectSchema: async () => {}, + markDatasourceUnavailable: () => {}, + clearDatasourceUnavailable: () => {}, + unregisterDriver: (name) => drivers.delete(name), + }; + return engine; +} + +/** A vendor API that accepts only the tokens in `valid`. */ +function upstream() { + const valid = new Set(); + const seen: string[] = []; + const presented = (auth: BorrowedConnectorAuth): string => + auth.type === 'bearer' ? auth.token : auth.type === 'api-key' ? auth.key : `${auth.username}:${auth.password}`; + return { + valid, + seen, + async get(auth: BorrowedConnectorAuth): Promise<{ status: number; rows?: unknown[] }> { + const token = presented(auth); + seen.push(token); + return valid.has(token) ? { status: 200, rows: [{ id: 'order_1' }] } : { status: 401 }; + }, + }; +} + +/** The driver double: one `find`, presented through the borrowed handle. */ +function apiDriverFactory(api: ReturnType) { + const specs: DatasourceConnectionSpec[] = []; + const factory: IDatasourceDriverFactory = { + supports: (id) => id === 'odata', + create: vi.fn(async (spec: DatasourceConnectionSpec) => { + specs.push(spec); + const handle = spec.credential; + const driver = { + name: 'odata', + async find() { + if (!handle) throw Object.assign(new Error('no credential'), { status: 401 }); + const res = await callWithBorrowedCredential(handle, (auth) => api.get(auth)); + return res.rows ?? []; + }, + }; + return { driver, connect: async () => {} }; + }), + }; + return { factory, specs }; +} + +/** A lender double: `static` answers one token; `oauth2` mints `tok-N`, each valid for `ttlMs`. */ +function lender(opts: { + declared?: string[]; + kind?: 'static' | 'oauth2'; + now?: () => number; + ttlMs?: number; + onMint?: (token: string) => void; +}) { + const declared = new Set(opts.declared ?? ['sap_s4']); + const borrowed: string[] = []; + let issued = 0; + const mint = (): BorrowedConnectorCredential => { + issued += 1; + const token = `tok-${issued}`; + opts.onMint?.(token); + return { auth: { type: 'bearer', token }, expiresAt: (opts.now ?? Date.now)() + (opts.ttlMs ?? 3_600_000) }; + }; + const source: IConnectorCredentialSource = { + async borrow(connector) { + borrowed.push(connector); + if (!declared.has(connector)) { + throw connectorCredentialRefusal( + `connector instance '${connector}' (named by this datasource's external.connector) is not declared in this stack's connectors.`, + 'VALIDATION_ERROR', + 422, + ); + } + if (opts.kind !== 'oauth2') { + const fixed: BorrowedConnectorCredential = { auth: { type: 'bearer', token: 'static-key' } }; + return { connector, get: async () => fixed, refresh: async () => fixed }; + } + let current = mint(); + const handle: ConnectorCredentialHandle = { + connector, + get: async () => current, + refresh: async () => (current = mint()), + }; + return handle; + }, + }; + return { source, borrowed, issued: () => issued }; +} + +const sapOrders: ConnectableDatasource = { + name: 'sap_orders', + driver: 'odata', + schemaMode: 'external', + config: { serviceUrl: 'https://sap.example.test/odata/v4/' }, + external: { connector: 'sap_s4' }, +}; + +function service(factory: IDatasourceDriverFactory, source: IConnectorCredentialSource | undefined) { + const engine = fakeEngine(); + const svc = new DatasourceConnectionService({ + factory: () => factory, + engine: () => engine, + connectorCredentials: () => source, + }); + return { svc, engine }; +} + +describe('[ADR-0015 D7] a datasource borrows its connector instance\'s credential at connect', () => { + it('hands the driver the borrowed handle, and a static credential reaches the upstream', async () => { + const api = upstream(); + api.valid.add('static-key'); + const { factory, specs } = apiDriverFactory(api); + const { source, borrowed } = lender({}); + const { svc, engine } = service(factory, source); + + const result = await svc.connect(sapOrders, { context: { trigger: 'runtime-admin' } }); + expect(result.status).toBe('connected'); + expect(borrowed).toEqual(['sap_s4']); + expect(specs[0].credential?.connector).toBe('sap_s4'); + expect(specs[0].secret).toBeUndefined(); + + const rows = await (engine.drivers.get('sap_orders') as unknown as { find(): Promise }).find(); + expect(rows).toEqual([{ id: 'order_1' }]); + expect(api.seen).toEqual(['static-key']); + }); + + it('refreshes an oauth2 credential after it expires — no reconnect', async () => { + let clock = 0; + const api = upstream(); + const { factory } = apiDriverFactory(api); + const { source, issued } = lender({ kind: 'oauth2', now: () => clock, ttlMs: 60_000, onMint: (t) => api.valid.add(t) }); + const { svc, engine } = service(factory, source); + await svc.connect(sapOrders, { context: { trigger: 'runtime-admin' } }); + const driver = engine.drivers.get('sap_orders') as unknown as { find(): Promise }; + + const realNow = Date.now; + Date.now = () => clock; + try { + await driver.find(); + expect(api.seen).toEqual(['tok-1']); + clock = 120_000; // tok-1 has expired + api.valid.delete('tok-1'); + await driver.find(); + expect(api.seen).toEqual(['tok-1', 'tok-2']); + expect(issued()).toBe(2); + } finally { + Date.now = realNow; + } + }); + + it('refreshes once on a 401 and retries; a second 401 is a 503 naming the instance', async () => { + const api = upstream(); + const { factory } = apiDriverFactory(api); + let revoke = false; + const { source } = lender({ kind: 'oauth2', onMint: (t) => { if (t !== 'tok-1' && !revoke) api.valid.add(t); } }); + const { svc, engine } = service(factory, source); + await svc.connect(sapOrders, { context: { trigger: 'runtime-admin' } }); + const driver = engine.drivers.get('sap_orders') as unknown as { find(): Promise }; + + // tok-1 was revoked upstream: refreshed once, retried with tok-2. + await expect(driver.find()).resolves.toEqual([{ id: 'order_1' }]); + expect(api.seen).toEqual(['tok-1', 'tok-2']); + + // The grant is gone: tok-2 refused, tok-3 refused too. + revoke = true; + api.valid.clear(); + await expect(driver.find()).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(api.seen).toEqual(['tok-1', 'tok-2', 'tok-2', 'tok-3']); + }); + + it('refuses an undeclared instance at connect, fail-closed: no driver built, nothing registered', async () => { + const { factory } = apiDriverFactory(upstream()); + const { source } = lender({ declared: [] }); + const { svc, engine } = service(factory, source); + const result = await svc.connect(sapOrders, { context: { trigger: 'runtime-admin' } }); + expect(result.status).toBe('failed-credentials'); + expect(result.reason).toContain("connector instance 'sap_s4'"); + expect(result.reason).toContain('is not declared'); + expect(factory.create).not.toHaveBeenCalled(); + expect(engine.drivers.size).toBe(0); + expect(svc.getConnectionState('sap_orders')?.availability).toBe('failed'); + }); + + it('shows a datasource whose instance is disabled as failed, naming the instance — never a silent 401', async () => { + // The lender's refusal for `enabled: false`, in the words the real source + // gives it (`service-automation/src/connector-credential-source.ts`, whose + // own suite pins that it refuses exactly this). + const disabled: IConnectorCredentialSource = { + borrow: async (connector) => { + throw connectorCredentialRefusal( + `connector instance '${connector}' (named by this datasource's external.connector) is disabled (enabled: false), ` + + "which turns the vendor off for this datasource's records as for the connector's actions. Enable the instance to read through it.", + 'VALIDATION_ERROR', + 422, + ); + }, + }; + const api = upstream(); + const { factory } = apiDriverFactory(api); + const engine = fakeEngine(); + const marked: Array<{ name: string; kind: string }> = []; + engine.markDatasourceUnavailable = (info) => { marked.push(info); }; + const svc = new DatasourceConnectionService({ factory: () => factory, engine: () => engine, connectorCredentials: () => disabled }); + + const result = await svc.connect(sapOrders, { context: { trigger: 'runtime-admin' } }); + expect(result.status).toBe('failed-credentials'); + expect(result.reason).toContain("connector instance 'sap_s4'"); + expect(result.reason).toContain('is disabled (enabled: false)'); + expect(svc.getConnectionState('sap_orders')).toMatchObject({ availability: 'failed', status: 'failed-credentials' }); + expect(marked).toEqual([{ name: 'sap_orders', kind: 'failed' }]); + // Nothing was built, so nothing ever asked the upstream. + expect(factory.create).not.toHaveBeenCalled(); + expect(api.seen).toEqual([]); + }); + + it('refuses at connect when no lender is registered, naming the plugin that lends', async () => { + const { factory } = apiDriverFactory(upstream()); + const { svc, engine } = service(factory, undefined); + const result = await svc.connect(sapOrders, { context: { trigger: 'runtime-admin' } }); + expect(result.status).toBe('failed-credentials'); + expect(result.reason).toContain('no connector credential source is registered'); + expect(result.reason).toContain('@objectstack/service-automation'); + expect(factory.create).not.toHaveBeenCalled(); + expect(engine.drivers.size).toBe(0); + }); + + it('fails the boot for a datasource objects bind to, when its instance cannot lend', async () => { + const { factory } = apiDriverFactory(upstream()); + const { source } = lender({ declared: [] }); + const { svc } = service(factory, source); + await expect(svc.connectDeclared({ + datasources: [sapOrders], + objects: [{ name: 'sap_order', datasource: 'sap_orders' }], + })).rejects.toThrow(/connector instance 'sap_s4'/); + }); + + it('borrows nothing for a datasource that names no connector', async () => { + const { factory, specs } = apiDriverFactory(upstream()); + const { source, borrowed } = lender({}); + const { svc } = service(factory, source); + const result = await svc.connect({ ...sapOrders, external: { allowWrites: false } }, { context: { trigger: 'runtime-admin' } }); + expect(result.status).toBe('connected'); + expect(borrowed).toEqual([]); + expect(specs[0].credential).toBeUndefined(); + }); +}); + +/** + * The shipped shape: a plugin driver publishes `driver.` in `init()`, + * `ObjectQLPlugin.start()` adopts it (`registerDriver`), and only then does + * `AppPlugin.start()` call `connectDeclared`, whose connect meets the + * already-registered guard. The composition is the real one — a real + * `ObjectQL` engine and the real `DatasourceAdminServicePlugin` through + * `init()` and `start()`, with the lender on its kernel slot — and the driver + * is adopted the way the discovery loop adopts it. + */ +async function adoptedPluginDriver(source: IConnectorCredentialSource | undefined) { + const ql = new ObjectQL(); + const services = new Map([['objectql', ql], ['data', ql], ['metadata', { get: async () => undefined, list: async () => [], register: async () => {}, unregister: async () => {} }]]); + if (source) services.set(CONNECTOR_CREDENTIAL_SOURCE_SERVICE, source); + const ctx = { + getService: (n: string) => { + if (!services.has(n)) throw new Error(`no service: ${n}`); + return services.get(n); + }, + registerService: (n: string, value: unknown) => void services.set(n, value), + trigger: async () => {}, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + }; + const driver = { name: 'sap_orders', version: '1.0.0', connect: async () => {}, disconnect: async () => {}, find: vi.fn(async () => []) }; + ctx.registerService('driver.sap_orders', driver); + const plugin = new DatasourceAdminServicePlugin({ driverFactory: { supports: () => false, create: vi.fn() } as unknown as IDatasourceDriverFactory }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // ObjectQLPlugin.start()'s discovery loop, for every `driver.*` service. + for (const [name, service] of services) if (name.startsWith('driver.')) ql.registerDriver(service as IDataDriver); + const connection = services.get('datasource-connection') as DatasourceConnectionService; + return { ql, connection, driver }; +} + +describe('[ADR-0015 D7] a plugin driver adopted from its driver. service is judged at connect too', () => { + it('fails the boot for a datasource objects bind to, when its instance is not declared', async () => { + const { source } = lender({ declared: [] }); + const { connection } = await adoptedPluginDriver(source); + await expect(connection.connectDeclared({ + datasources: [sapOrders], + objects: [{ name: 'sap_order', datasource: 'sap_orders' }], + })).rejects.toThrow(/connector instance 'sap_s4'.*is not declared/s); + }); + + it('records a disabled instance as failed, naming it, and marks the datasource unavailable', async () => { + const disabled: IConnectorCredentialSource = { + borrow: async (connector) => { + throw connectorCredentialRefusal(`connector instance '${connector}' (named by this datasource's external.connector) is disabled (enabled: false).`, 'VALIDATION_ERROR', 422); + }, + }; + const { ql, connection, driver } = await adoptedPluginDriver(disabled); + const result = await connection.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } }); + expect(result).toMatchObject({ status: 'failed-credentials' }); + expect(result.reason).toContain('is disabled (enabled: false)'); + expect(connection.getConnectionState('sap_orders')?.availability).toBe('failed'); + expect(ql.listUnavailableDatasources()).toEqual([expect.objectContaining({ name: 'sap_orders', kind: 'failed' })]); + // Not evicted: the driver is its plugin's, and it refuses each request on its own. + expect(ql.getDriverByName('sap_orders')).toBeDefined(); + expect(driver.find).not.toHaveBeenCalled(); + }); + + it('refuses with no lender registered, naming the plugin that lends', async () => { + const { connection } = await adoptedPluginDriver(undefined); + const result = await connection.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } }); + expect(result.status).toBe('failed-credentials'); + expect(result.reason).toContain('@objectstack/service-automation'); + }); + + it('answers already-registered for an instance that can lend, borrowing once to judge it', async () => { + const { source, borrowed } = lender({}); + const { ql, connection } = await adoptedPluginDriver(source); + const result = await connection.connect(sapOrders, { context: { origin: 'code', trigger: 'declared-auto' } }); + expect(result.status).toBe('already-registered'); + expect(borrowed).toEqual(['sap_s4']); + expect(connection.getConnectionState('sap_orders')?.availability).toBe('available'); + expect(ql.listUnavailableDatasources()).toEqual([]); + }); + + it('judges nothing for an adopted driver whose datasource names no connector', async () => { + const { source, borrowed } = lender({ declared: [] }); + const { connection } = await adoptedPluginDriver(source); + const result = await connection.connect({ ...sapOrders, external: { allowWrites: false } }, { context: { origin: 'code', trigger: 'declared-auto' } }); + expect(result.status).toBe('already-registered'); + expect(borrowed).toEqual([]); + }); +}); + +describe('[ADR-0015 D7] borrowConnectorCredential', () => { + it('never throws: a source that throws a non-Error comes back as a reason', async () => { + const source = { borrow: async () => { throw 'boom'; } } as unknown as IConnectorCredentialSource; + await expect(borrowConnectorCredential(source, 'sap_s4')).resolves.toEqual({ ok: false, reason: 'boom', cause: 'boom' }); + }); +}); + +/** An in-memory admin door, with a lender asked through `connectorRefusal` the way the plugin wires it. */ +function adminDoor(source: IConnectorCredentialSource | undefined, opts: { seed?: StoredDatasource[] } = {}) { + const records = new Map((opts.seed ?? []).map((r) => [r.name, structuredClone(r)])); + const ops: string[] = []; + const probe = vi.fn(async () => ({ ok: true, latencyMs: 1 })); + const config: DatasourceAdminServiceConfig = { + probe, + listDatasourceRecords: async () => [...records.values()], + getDatasourceRecord: async (name) => records.get(name), + putDatasourceRecord: async (record) => { ops.push(`put:${record.name}`); records.set(record.name, structuredClone(record)); }, + deleteDatasourceRecord: async (name) => { records.delete(name); }, + writeSecret: async () => { ops.push('writeSecret'); return 'sys_secret:1'; }, + countBoundObjects: async () => 0, + listObjectsBoundTo: async () => [], + registerPool: () => { ops.push('registerPool'); }, + connectorRefusal: async (connector) => { + const borrow = await borrowConnectorCredential(source, connector); + return borrow.ok ? undefined : borrow.reason; + }, + }; + return { service: new DatasourceAdminService(config), records, ops, probe }; +} + +const draft = { + name: 'sap_orders', + label: 'SAP orders', + driver: 'odata', + schemaMode: 'external' as const, + config: { serviceUrl: 'https://sap.example.test/odata/v4/' }, + external: { connector: 'sap_s4' }, +}; + +describe('[ADR-0015 D7] the admin door judges external.connector at save and probe', () => { + it('saves a draft whose instance can lend', async () => { + const { service, ops } = adminDoor(lender({}).source); + await service.createDatasource(draft); + expect(ops).toEqual(['put:sap_orders', 'registerPool']); + }); + + it('refuses to create a draft naming an undeclared instance, leaving nothing behind', async () => { + const { service, ops, records } = adminDoor(lender({ declared: [] }).source); + await expect(service.createDatasource(draft)).rejects.toThrow(/external\.connector: connector instance 'sap_s4'.*is not declared/s); + expect(ops).toEqual([]); + expect(records.size).toBe(0); + }); + + it('refuses to create one when no lender is registered', async () => { + const { service, ops } = adminDoor(undefined); + await expect(service.createDatasource(draft)).rejects.toThrow(/no connector credential source is registered/); + expect(ops).toEqual([]); + }); + + it('refuses an update that points external at an undeclared instance, and lets a label-only one through', async () => { + const seed: StoredDatasource = { ...draft, origin: 'runtime' } as StoredDatasource; + const { service, ops } = adminDoor(lender({ declared: [] }).source, { seed: [seed] }); + await expect(service.updateDatasource('sap_orders', { external: { connector: 'sap_s4' } })).rejects.toThrow(/is not declared/); + expect(ops).toEqual([]); + await service.updateDatasource('sap_orders', { label: 'SAP orders (renamed)' }); + expect(ops).toContain('put:sap_orders'); + }); + + it('answers a probe of an unlendable draft with the reason, without probing', async () => { + const { service, probe } = adminDoor(lender({ declared: [] }).source); + const result = await service.testConnection(draft); + expect(result.ok).toBe(false); + expect(result.error).toContain("connector instance 'sap_s4'"); + expect(probe).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts index f5c37676f31..a533ecd556a 100644 --- a/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/contracts/datasource-driver-factory.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { IntrospectedSchema } from '@objectstack/spec/contracts'; +import type { ConnectorCredentialHandle } from '@objectstack/types'; /** * IDatasourceDriverFactory — host-provided capability that builds a live driver @@ -44,6 +45,16 @@ export interface DatasourceConnectionSpec { schemaMode?: 'managed' | 'external' | 'validate-only'; /** Cleartext secret (password / DSN) injected for this connection only. */ secret?: string; + /** + * The credential borrowed from the connector instance the datasource names + * in `external.connector` (ADR-0015 amendment D7) — a handle, never a value. + * An API-backed driver keeps it for the life of its connection and runs + * every request through `callWithBorrowedCredential` (`@objectstack/types`), + * which asks it per request, refreshes it on expiry and once on a `401`. + * Present only for such a datasource, and then instead of {@link secret}: + * the two credential channels are exclusive. + */ + credential?: ConnectorCredentialHandle; /** External federation settings (timeouts, allowed schemas, …). */ external?: Record; /** Connection pool settings. */ diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index eb4b01ee78e..9cd815b8ee1 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -25,6 +25,8 @@ import type { DatasourceConnectPolicy } from './contracts/connect-policy.js'; import type { Logger } from './logger.js'; import { resolveTursoUrl } from './turso-driver-config.js'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; +import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, type IConnectorCredentialSource } from '@objectstack/types'; +import { borrowConnectorCredential, borrowedConnectorOf } from './datasource-connector-credential.js'; /** * Minimal metadata-service surface used for datasource persistence + the @@ -312,6 +314,8 @@ export class DatasourceAdminServicePlugin implements Plugin { private config?: DatasourceAdminServiceConfig; /** Shared "definition → live driver" path (ADR-0062 D1); also exposed as the `'datasource-connection'` service. */ private connection?: DatasourceConnectionService; + /** [ADR-0015 D7] The connector credential source, resolved per use — see `init()`. */ + private connectorCredentials?: () => IConnectorCredentialSource | undefined; /** * [#13805] The record each live runtime pool was last built from, by * datasource name. Cluster convergence reads it twice: compared against the @@ -390,6 +394,15 @@ export class DatasourceAdminServicePlugin implements Plugin { const factory = (): IDatasourceDriverFactory | undefined => this.options.driverFactory ?? safeGetService(ctx, 'datasource-driver-factory'); + // [ADR-0015 D7] The automation service's connector credential source, the + // one lender an `external.connector` datasource borrows from. Resolved + // per use like the services above: the automation plugin registers it in + // its own `init()`, and a host without that plugin has none — every door + // below then refuses such a datasource and names the plugin. + const connectorCredentials = (): IConnectorCredentialSource | undefined => + safeGetService(ctx, CONNECTOR_CREDENTIAL_SOURCE_SERVICE); + this.connectorCredentials = connectorCredentials; + // The single "definition → live driver" path (ADR-0062 D1). Built here so // the admin pool registration (runtime origin) and the app-plugin // auto-connect (code origin) share one connect + lifecycle + policy path. @@ -398,6 +411,7 @@ export class DatasourceAdminServicePlugin implements Plugin { factory, engine: () => engineOf() as ConnectionEngineLike | undefined, secrets: { resolve: (ref) => this.options.secrets?.resolve?.(ref) ?? Promise.resolve(undefined) }, + connectorCredentials, policy: this.options.connectPolicy, logger: this.options.logger, }); @@ -426,6 +440,13 @@ export class DatasourceAdminServicePlugin implements Plugin { const config: DatasourceAdminServiceConfig = { probe: (input) => this.probe(factory(), input), + // [ADR-0015 D7] The borrow the connect path makes, asked at save: a + // handle is lent or refused, and a lent one is dropped unused. + connectorRefusal: async (connector) => { + const borrow = await borrowConnectorCredential(connectorCredentials(), connector); + return borrow.ok ? undefined : borrow.reason; + }, + // [#21923] Every record this door serves carries the origin its // PROVENANCE gives — `servedOrigin` — whatever the record says. listDatasourceRecords: async () => { @@ -903,6 +924,15 @@ export class DatasourceAdminServicePlugin implements Plugin { const fileRefusal = await absentDatabaseFileRefusal(input); if (fileRefusal) return { ok: false, error: fileRefusal }; + // [ADR-0015 D7] A draft that borrows a connector instance's credential is + // probed with it, exactly as it would connect. + const connector = borrowedConnectorOf(input.external); + let credential: Awaited> | undefined; + if (connector !== undefined) { + credential = await borrowConnectorCredential(this.connectorCredentials?.(), connector); + if (!credential.ok) return { ok: false, error: credential.reason }; + } + let driver: any; try { driver = await factory.create({ @@ -910,6 +940,7 @@ export class DatasourceAdminServicePlugin implements Plugin { config: input.config, secret: input.secret, external: input.external, + ...(credential?.ok ? { credential: credential.handle } : {}), }); } catch (err) { return { ok: false, error: `Failed to build driver: ${errMsg(err)}` }; diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index d1ce3f7ad72..420e0221f6d 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -41,6 +41,7 @@ import { assertDatasourcePoolSupported } from './datasource-pool-support.js'; import { datasourceConnectivityChanged } from './datasource-connectivity-change.js'; import { redactDatasourceConfig, restoreRedactedConfig } from './datasource-config-redaction.js'; import { planCredentialMigration } from './datasource-credential-migration.js'; +import { borrowedConnectorOf } from './datasource-connector-credential.js'; import type { IDatasourceAdminService, DatasourceDraft, @@ -236,6 +237,20 @@ export interface DatasourceAdminServiceConfig { * runtime is counted the moment it is saved. */ listObjectsBoundTo: (datasource: string) => Promise>; + /** + * [ADR-0015 D7] Why a datasource naming connector instance `connector` in + * `external.connector` cannot borrow its credential — the instance is not + * declared, is a catalog descriptor, lends no credential or is disabled, or + * no connector credential source is registered — or `undefined` when it + * can. The create / update / test-connection judgement for such a + * datasource: a runtime-created datasource never passes `defineStack`'s + * check, so this door asks the one the connect path asks. + * + * Optional only for hosts and fakes with no such datasource: absent, a + * draft that names a connector is refused, since nothing could lend it a + * credential. + */ + connectorRefusal?: (connector: string) => Promise; /** Hot-(re)register a runtime datasource's connection pool after write. */ registerPool?: (record: StoredDatasource) => Promise | void; /** Tear down a runtime datasource's pool on remove. */ @@ -633,6 +648,8 @@ export class DatasourceAdminService implements IDatasourceAdminService { 'This connection draft is not a valid datasource.', ); if (refusal) return { ok: false, error: refusal }; + const lendRefusal = await this.connectorLendRefusal({ name: PROBE_DRAFT_NAME, external: input.external } as StoredDatasource); + if (lendRefusal) return { ok: false, error: lendRefusal }; const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs; try { return await this.config.probe({ @@ -679,6 +696,9 @@ export class DatasourceAdminService implements IDatasourceAdminService { // datasource before it exists (the save door gives no verdict on a // binding whose datasource it cannot see), so a create is judged too. await this.assertNoExternalBindingConflict(record); + // [ADR-0015 D7] Same position: a record that names a connector instance no + // source can lend leaves nothing behind. + await this.assertConnectorLendable(record); if (secret) { const credentialsRef = await this.config.writeSecret(secret, { name: input.name }); @@ -764,6 +784,10 @@ export class DatasourceAdminService implements IDatasourceAdminService { // out of service. The secret does not move the verdict. await this.assertNoExternalBindingConflict(merged); } + // [ADR-0015 D7] Only when this write touches `external`, by the same + // reasoning: an instance removed from the stack must not make the row's + // `active: false` unreachable. + if (patch.external !== undefined) await this.assertConnectorLendable(merged); if (secret) { const prevRef = existing.external?.credentialsRef; @@ -1155,6 +1179,27 @@ export class DatasourceAdminService implements IDatasourceAdminService { if (refusal) throw new Error(refusal); } + /** + * [ADR-0015 D7] Why `record`'s `external.connector` cannot borrow a + * credential, or `undefined` when it names none or can. The reason is the + * one the connect path would record ({@link DatasourceAdminServiceConfig.connectorRefusal}). + */ + private async connectorLendRefusal(record: StoredDatasource): Promise { + const connector = borrowedConnectorOf(record.external); + if (connector === undefined) return undefined; + const reason = this.config.connectorRefusal + ? await this.config.connectorRefusal(connector) + : `this datasource borrows the credential of connector instance '${connector}' (external.connector), but this ` + + 'host lends no connector credentials.'; + return reason === undefined ? undefined : `Invalid datasource '${record.name}'.\nexternal.connector: ${reason}`; + } + + /** {@link connectorLendRefusal} for a write door: refuses by throwing (a `400` at the route). */ + private async assertConnectorLendable(record: StoredDatasource): Promise { + const refusal = await this.connectorLendRefusal(record); + if (refusal) throw new Error(refusal); + } + /** `record` as the record judgement must see it: with the binding `secret` will create, when one is supplied. */ private withSecretBinding(record: StoredDatasource, secret: SecretInput | undefined): StoredDatasource { return secret ? withPendingSecretRef(record) : record; diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index 163483cb7b8..2de3d9fa33b 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -46,6 +46,8 @@ import { import { connectFailureRemedy } from './connect-failure-remedy.js'; import type { Logger } from './logger.js'; import type { IDataDriver, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { ConnectorCredentialHandle, IConnectorCredentialSource } from '@objectstack/types'; +import { borrowConnectorCredential, borrowedConnectorOf } from './datasource-connector-credential.js'; /** A datasource definition this service can connect (code- or runtime-origin). */ export interface ConnectableDatasource { @@ -56,6 +58,8 @@ export interface ConnectableDatasource { config?: Record; external?: (Record & { credentialsRef?: string; + /** The declared connector instance whose credential this datasource borrows (ADR-0015 D7). */ + connector?: string; validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' }; }) | undefined; pool?: Record; @@ -151,6 +155,13 @@ export interface DatasourceConnectionServiceConfig { engine: () => ConnectionEngineLike | undefined; /** Dereference `credentialsRef` → cleartext (Phase 2). Optional in Phase 1. */ secrets?: ConnectionSecretResolver; + /** + * The connector credential source an `external.connector` datasource + * borrows through (ADR-0015 D7) — the automation service's, resolved lazily + * per connect. Absent, or answering `undefined`, refuses such a datasource + * at connect: nothing else can supply its credential. + */ + connectorCredentials?: () => IConnectorCredentialSource | undefined; /** Injectable connect policy. Defaults to {@link allowAllConnectPolicy}. */ policy?: DatasourceConnectPolicy; logger?: Logger; @@ -536,6 +547,24 @@ export class DatasourceConnectionService { return { name, status: 'already-registered' }; } } else if (engine?.getDriverByName?.(name)) { + // [ADR-0015 D7] …except for the credential it borrows. A plugin driver + // (driver-odata) is adopted from its `driver.` service before this + // runs, so this guard is the only connect a datasource naming + // `external.connector` ever reaches: without a verdict here an instance + // that cannot lend (undeclared, disabled, no `auth`, no automation + // service) would boot as available and fail every read with its cause in + // the server log only. The borrow judges the declaration and resolves no + // value. The driver is not evicted — it belongs to the plugin that + // registered it (#13578), and it refuses each request on its own — but + // the datasource is recorded failed (Setup → Datasources names why) and, + // when objects bind to it, the boot fails as for any connect failure. + const connector = borrowedConnectorOf(record.external); + if (connector !== undefined) { + const borrow = await borrowConnectorCredential(this.cfg.connectorCredentials?.(), connector); + if (!borrow.ok) { + return this.handleFailure(record, 'failed-credentials', borrow.reason, opts.context, opts.objects, opts.mappedObjects, borrow.cause); + } + } return { name, status: 'already-registered' }; } @@ -620,6 +649,24 @@ export class DatasourceConnectionService { } } + // [ADR-0015 D7] The other credential channel, FAIL-CLOSED for the same + // reason as the block above: a datasource that names a connector instance + // has no credential but the one it borrows, so a connect that cannot + // borrow is refused here, naming why — never a driver built to fail its + // first read with an upstream 401. The borrow judges the declaration + // only (a runtime-created datasource never passed `defineStack`'s + // check); a credential not set yet is the handle's to refuse per request, + // and clears without a reconnect once it is set. + let credential: ConnectorCredentialHandle | undefined; + const connector = borrowedConnectorOf(record.external); + if (connector !== undefined) { + const borrow = await borrowConnectorCredential(this.cfg.connectorCredentials?.(), connector); + if (!borrow.ok) { + return this.handleFailure(record, 'failed-credentials', borrow.reason, opts.context, opts.objects, opts.mappedObjects, borrow.cause); + } + credential = borrow.handle; + } + // #13578 — set to the registry key ONLY when this attempt is the thing that // put it there, so the rollback below can never evict a driver someone else // owns. `registerDriver` is a no-op on a name it already holds (it keeps the @@ -628,7 +675,11 @@ export class DatasourceConnectionService { // "we called register" is not evidence that "we registered". let registeredByThisAttempt: string | undefined; try { - const handle = await factory.create({ ...toSpec(record), ...(secret ? { secret } : {}) }); + const handle = await factory.create({ + ...toSpec(record), + ...(secret ? { secret } : {}), + ...(credential ? { credential } : {}), + }); if (typeof handle?.connect === 'function') await handle.connect(); // The engine routes a datasource to a driver by `driver.name === `. @@ -846,8 +897,9 @@ export class DatasourceConnectionService { if (typeof opts?.previous?.driver !== 'string' || opts.previous.driver === '') { throw new Error( `datasource '${name}': reconnect needs \`previous\`, the record the live pool was built from, ` + - 'with its `driver` id — without it a kept pool would be restored under a definition that names ' + - 'no driver, and a plugin driver would be served without the engine\'s correctness floor. ' + + 'with its `driver` id — without it, when a pool is live, a failed rebuild would restore that pool ' + + 'under a definition that names no driver, and a plugin driver would be served without the engine\'s ' + + 'correctness floor. ' + 'Pass the stored record as it was before this update.', ); } diff --git a/packages/services/service-datasource/src/datasource-connector-credential.ts b/packages/services/service-datasource/src/datasource-connector-credential.ts new file mode 100644 index 00000000000..cec01b528b5 --- /dev/null +++ b/packages/services/service-datasource/src/datasource-connector-credential.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0015 amendment D7] The borrow every door of this package makes for a + * datasource that names a connector instance in `external.connector`: the + * boot and runtime connect (`DatasourceConnectionService`), the connection + * probe, and the create / update judgement. One copy, so the three cannot + * disagree about whether a datasource can borrow. + * + * The source is the automation service's (`CONNECTOR_CREDENTIAL_SOURCE_SERVICE`, + * `@objectstack/types`), read structurally: this package imports no + * automation package, which is the dependency direction the D7 runtime seam + * chose. + */ + +import type { ConnectorCredentialHandle, IConnectorCredentialSource } from '@objectstack/types'; + +/** The connector instance `external` names, or `undefined` when it names none. */ +export function borrowedConnectorOf(external: unknown): string | undefined { + if (typeof external !== 'object' || external === null) return undefined; + const connector = (external as { connector?: unknown }).connector; + return typeof connector === 'string' && connector.length > 0 ? connector : undefined; +} + +/** A borrow's outcome: the handle, or the operator-facing reason it was refused. */ +export type ConnectorCredentialBorrow = + | { ok: true; handle: ConnectorCredentialHandle } + | { ok: false; reason: string; cause?: unknown }; + +/** + * Borrow `connector`'s credential from `source`. Never throws: a missing + * source and a refused borrow both come back as a reason, which names the + * instance and the fix and never a value — the source's refusals are written + * that way, and nothing else is interpolated. + */ +export async function borrowConnectorCredential( + source: IConnectorCredentialSource | undefined, + connector: string, +): Promise { + if (!source || typeof source.borrow !== 'function') { + return { + ok: false, + reason: `this datasource borrows the credential of connector instance '${connector}' (external.connector), but no ` + + 'connector credential source is registered. The automation service (@objectstack/service-automation) lends ' + + "connector credentials; add it to the stack's plugins.", + }; + } + try { + return { ok: true, handle: await source.borrow(connector) }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, reason: message, cause: err }; + } +} diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json index d21b19305c6..66b0ffff3e5 100644 --- a/packages/spec/liveness/datasource.json +++ b/packages/spec/liveness/datasource.json @@ -145,10 +145,10 @@ "note": "dereferenced through the SecretBinder to cleartext for the duration of one connect; never persisted or logged (ADR-0015 Addendum). 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `:457` had rotted onto a docblock about threading a value through an instance field rather than the ConnectResult, ~95 lines above the read. Re-closed by hand against c459da6bc; DATED for the first time." }, "connector": { - "status": "planned", - "verifiedAt": "2026-10-29", - "evidence": "packages/spec/src/stack.zod.ts#collectDatasourceConnectorErrors (a name the stack does not declare, a catalog descriptor or an instance with no auth credential is REFUSED at build); packages/spec/src/data/datasource.zod.ts#DatasourceSchema (beside a bound `credentialsRef`, on a built-in database driver and on a managed datasource it is REFUSED at parse)", - "note": "PLANNED, not `dead`: ADR-0015 amendment D7 (ruling 6486136301) landed the spec half first, and the runtime half — the datasource connection service handing the named instance's credential, resolved per dispatch through ADR-0143 D4's resolver, to the API-backed driver — is its own server card, blocked by this one. Until it lands no runtime reads the key; every pairing that could never work is already refused at authoring, and the only accepted shape is a plugin-contributed driver, none of which ships in this repository today. Flip to `live` with the connection-service anchor when the server card lands, in the same release." + "status": "live", + "verifiedAt": "2026-10-30", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts#attemptConnect (`const connector = borrowedConnectorOf(record.external)`, borrowed from the connector credential source and handed to the driver factory as `credential` — refused fail-closed as `failed-credentials` when the instance cannot lend or no source is registered; the same verdict at its already-registered guard, which is the connect a plugin driver adopted from its `driver.` service reaches); packages/services/service-automation/src/connector-credential-source.ts#createConnectorCredentialSource (the lender: judges the declared instance at borrow and resolves its `auth.credentialRef` through the connector's own chain per handle)", + "note": "ADR-0015 amendment D7 (ruling 6486136301). The spec half refuses at authoring every pairing that could never work (beside a bound `credentialsRef`, on a built-in database driver, on a managed datasource, and an undeclared, descriptor-only or credential-less instance at build). The runtime half borrows the named instance's credential at connect, probe and admin save as a handle, and the API-backed driver asks that handle on each request through `callWithBorrowedCredential` (@objectstack/types: refreshed on expiry, once on a 401, then 503). Today's instances carry only the static kinds (bearer, api-key, basic), which ADR-0143 D4 resolves once rather than per dispatch: the handle resolves the value on first use and holds it, resolving again only on a refresh (the 401 path) or after a credential write or reconcile in the automation plugin. Per-dispatch resolution is D4's rule for the grant-bearing kinds, which answer through the same handle once the connector contract declares them. The OData driver (@objectstack/driver-odata) is its first consumer: a datasource it serves that names `external.connector` borrows the handle at its first request and presents it on every request through the same rule." }, "queryTimeoutMs": { "status": "live", diff --git a/packages/spec/liveness/state-counts/datasource.md b/packages/spec/liveness/state-counts/datasource.md index 7a6ea194af5..c54738fe984 100644 --- a/packages/spec/liveness/state-counts/datasource.md +++ b/packages/spec/liveness/state-counts/datasource.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `datasource` | 30 | 0 | 0 | 0 | 1 | 31 | +| `datasource` | 31 | 0 | 0 | 0 | 0 | 31 | diff --git a/packages/types/src/connector-credential-source.test.ts b/packages/types/src/connector-credential-source.test.ts new file mode 100644 index 00000000000..e5135fa1465 --- /dev/null +++ b/packages/types/src/connector-credential-source.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `callWithBorrowedCredential` — ADR-0143 D4's per-dispatch rule as a driver + * presents a borrowed credential (ADR-0015 D7): refresh on expiry, once on a + * `401` and retry, then a `503` naming the instance. + * + * The grant-bearing kinds do not exist in the authoring contract yet, so the + * `oauth2` cases run against a source double that mints expiring tokens — + * the shape those kinds answer through this handle when they land. + */ + +import { describe, it, expect } from 'vitest'; +import { + borrowedCredentialForms, + callWithBorrowedCredential, + withoutBorrowedCredential, + type BorrowedConnectorAuth, + type BorrowedConnectorCredential, + type ConnectorCredentialHandle, + type ConnectorCredentialRequest, +} from './connector-credential-source.js'; + +/** An `oauth2`-shaped handle: each refresh mints the next token, valid for `ttlMs` from `now()`. */ +function mintingHandle(opts: { now: () => number; ttlMs: number }) { + let issued = 1; + let current: BorrowedConnectorCredential = { auth: { type: 'bearer', token: 'tok-1' }, expiresAt: opts.now() + opts.ttlMs }; + const requests: Array<{ op: 'get' | 'refresh'; request?: ConnectorCredentialRequest }> = []; + let inflight: Promise | undefined; + const handle: ConnectorCredentialHandle & { refreshes: () => number } = { + connector: 'sap_s4', + async get(request) { + requests.push({ op: 'get', request }); + return current; + }, + refresh(request) { + requests.push({ op: 'refresh', request }); + inflight ??= (async () => { + issued += 1; + current = { auth: { type: 'bearer', token: `tok-${issued}` }, expiresAt: opts.now() + opts.ttlMs }; + return current; + })().finally(() => { inflight = undefined; }); + return inflight; + }, + refreshes: () => issued - 1, + }; + return { handle, requests }; +} + +/** A static handle: `get` and `refresh` both answer `auth`, with no lifetime. */ +function staticHandle(auth: BorrowedConnectorAuth) { + let refreshes = 0; + const handle: ConnectorCredentialHandle = { + connector: 'crm_api', + get: async () => ({ auth }), + refresh: async () => { refreshes += 1; return { auth }; }, + }; + return { handle, refreshes: () => refreshes }; +} + +const tokenOf = (auth: BorrowedConnectorAuth): string => (auth.type === 'bearer' ? auth.token : ''); + +describe('callWithBorrowedCredential', () => { + it('sends a borrowed static credential as it is, with no refresh', async () => { + const { handle, refreshes } = staticHandle({ type: 'api-key', key: 'k-123', headerName: 'X-API-Key' }); + const sent: BorrowedConnectorAuth[] = []; + const result = await callWithBorrowedCredential(handle, async (auth) => { sent.push(auth); return { status: 200, body: 'ok' }; }); + expect(result).toEqual({ status: 200, body: 'ok' }); + expect(sent).toEqual([{ type: 'api-key', key: 'k-123', headerName: 'X-API-Key' }]); + expect(refreshes()).toBe(0); + }); + + it('refreshes an oauth2 credential that expired before it is sent', async () => { + let clock = 1_000_000; + const now = () => clock; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + clock += 3_600_000; // tok-1 is now past its expiry + const sent: string[] = []; + await callWithBorrowedCredential(handle, async (auth) => { sent.push(tokenOf(auth)); return { status: 200 }; }, { now }); + expect(sent).toEqual(['tok-2']); + expect(handle.refreshes()).toBe(1); + }); + + it('refreshes inside the skew margin, not only past expiry', async () => { + let clock = 0; + const now = () => clock; + const { handle } = mintingHandle({ now, ttlMs: 60_000 }); + clock = 40_000; // 20 s left, inside the 30 s default margin + const sent: string[] = []; + await callWithBorrowedCredential(handle, async (auth) => { sent.push(tokenOf(auth)); return { status: 200 }; }, { now }); + expect(sent).toEqual(['tok-2']); + }); + + it('sends an unexpired oauth2 credential without refreshing it', async () => { + const now = () => 0; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + const sent: string[] = []; + await callWithBorrowedCredential(handle, async (auth) => { sent.push(tokenOf(auth)); return { status: 200 }; }, { now }); + expect(sent).toEqual(['tok-1']); + expect(handle.refreshes()).toBe(0); + }); + + it('refreshes once on a 401 result and retries with the new credential', async () => { + const now = () => 0; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + const sent: string[] = []; + const result = await callWithBorrowedCredential( + handle, + async (auth) => { sent.push(tokenOf(auth)); return { status: tokenOf(auth) === 'tok-1' ? 401 : 200 }; }, + { now }, + ); + expect(result).toEqual({ status: 200 }); + expect(sent).toEqual(['tok-1', 'tok-2']); + expect(handle.refreshes()).toBe(1); + }); + + it('reads a thrown error declaring 401 as the same refusal', async () => { + const now = () => 0; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + const sent: string[] = []; + const result = await callWithBorrowedCredential(handle, async (auth) => { + sent.push(tokenOf(auth)); + if (tokenOf(auth) === 'tok-1') throw Object.assign(new Error('unauthorized'), { statusCode: 401 }); + return 'rows'; + }, { now }); + expect(result).toBe('rows'); + expect(sent).toEqual(['tok-1', 'tok-2']); + }); + + it('ends on a 503 naming the instance when the refreshed credential is refused too, without the upstream text', async () => { + const now = () => 0; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + let calls = 0; + const run = callWithBorrowedCredential(handle, async (auth) => { + calls += 1; + throw Object.assign(new Error(`bad token ${tokenOf(auth)}`), { status: 401 }); + }, { now }); + await expect(run).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + const err = await run.catch((e: Error) => e); + expect(err.message).toContain("connector instance 'sap_s4'"); + expect(err.message).not.toContain('tok-'); + expect(calls).toBe(2); + expect(handle.refreshes()).toBe(1); + }); + + it('passes a non-401 result and a non-401 throw through unchanged', async () => { + const { handle, refreshes } = staticHandle({ type: 'bearer', token: 't' }); + await expect(callWithBorrowedCredential(handle, async () => ({ status: 403 }))).resolves.toEqual({ status: 403 }); + const boom = Object.assign(new Error('gateway'), { status: 502 }); + await expect(callWithBorrowedCredential(handle, async () => { throw boom; })).rejects.toBe(boom); + expect(refreshes()).toBe(0); + }); + + it('honours a caller-supplied isUnauthorized', async () => { + const now = () => 0; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + const sent: string[] = []; + await callWithBorrowedCredential( + handle, + async (auth) => { sent.push(tokenOf(auth)); return { ok: tokenOf(auth) !== 'tok-1' }; }, + { now, isUnauthorized: (r) => !r.ok }, + ); + expect(sent).toEqual(['tok-1', 'tok-2']); + }); + + it('asks the handle in the name of the request organization', async () => { + const now = () => 0; + const { handle, requests } = mintingHandle({ now, ttlMs: 3_600_000 }); + await callWithBorrowedCredential(handle, async (auth) => ({ status: tokenOf(auth) === 'tok-1' ? 401 : 200 }), { now, organizationId: 'org_a' }); + expect(requests).toEqual([ + { op: 'get', request: { organizationId: 'org_a' } }, + { op: 'refresh', request: { organizationId: 'org_a' } }, + ]); + }); + + it('lets concurrent refusals share one refresh on a single-flight handle', async () => { + const now = () => 0; + const { handle } = mintingHandle({ now, ttlMs: 3_600_000 }); + const call = async (auth: BorrowedConnectorAuth) => ({ status: tokenOf(auth) === 'tok-1' ? 401 : 200 }); + await Promise.all([ + callWithBorrowedCredential(handle, call, { now }), + callWithBorrowedCredential(handle, call, { now }), + ]); + expect(handle.refreshes()).toBe(1); + }); + + it('propagates a refusal of the handle itself', async () => { + const refusal = Object.assign(new Error("credential 'sap_token' is not set"), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + const handle: ConnectorCredentialHandle = { connector: 'sap_s4', get: async () => { throw refusal; }, refresh: async () => { throw refusal; } }; + await expect(callWithBorrowedCredential(handle, async () => 'never')).rejects.toBe(refusal); + }); +}); + +describe('withoutBorrowedCredential — an upstream\'s echo of the presented credential', () => { + const b64 = (text: string) => Buffer.from(text, 'utf8').toString('base64').replace(/=+$/, ''); + + it('withholds a query-parameter API key in every spelling a request URL carries it', () => { + const auth: BorrowedConnectorAuth = { type: 'api-key', key: 'k-2/x+y==', headerName: 'X-API-Key', paramName: 'apikey' }; + const text = `raw k-2/x+y== · uri ${encodeURIComponent('k-2/x+y==')} · form ${new URLSearchParams({ a: 'k-2/x+y==' }).toString().slice(2)} · b64 ${b64('k-2/x+y==')}`; + const out = withoutBorrowedCredential(text, auth); + expect(out).toBe('raw [credential withheld] · uri [credential withheld] · form [credential withheld] · b64 [credential withheld]'); + }); + + it('withholds a basic credential as its password and as the Authorization header spells it', () => { + const auth: BorrowedConnectorAuth = { type: 'basic', username: 'svc', password: 'pw-secret' }; + expect(borrowedCredentialForms(auth)).toContain(b64('svc:pw-secret')); + expect(withoutBorrowedCredential(`Basic ${b64('svc:pw-secret')} refused for pw-secret`, auth)) + .toBe('Basic [credential withheld] refused for [credential withheld]'); + }); + + it('leaves text that carries no spelling alone, and never blanks a value shorter than four characters', () => { + expect(withoutBorrowedCredential('Resource not found', { type: 'bearer', token: 'tok-1' })).toBe('Resource not found'); + expect(withoutBorrowedCredential('a bad request', { type: 'bearer', token: 'a' })).toBe('a bad request'); + }); +}); diff --git a/packages/types/src/connector-credential-source.ts b/packages/types/src/connector-credential-source.ts new file mode 100644 index 00000000000..45cbefb6952 --- /dev/null +++ b/packages/types/src/connector-credential-source.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # A borrowed connector credential — the D7 runtime seam + * + * ADR-0015 amendment D7 lets an API-backed datasource name a declared + * connector instance in `external.connector` and borrow that instance's + * credential, so one Connect per vendor serves both the connector's actions + * and the datasource's records. This module is the contract the borrow runs + * through, and the one rule a driver presents the credential by. + * + * ## Who sits on each side + * + * - The PRODUCER is `@objectstack/service-automation`, which owns connector + * instances and their credentials (ADR-0097, ADR-0143). It registers a + * {@link IConnectorCredentialSource} under + * {@link CONNECTOR_CREDENTIAL_SOURCE_SERVICE}. + * - The CONSUMER is `@objectstack/service-datasource`'s connection path, which + * borrows a handle when a datasource names a connector and hands it to the + * driver factory beside the connection spec. + * - The PRESENTER is the driver — a plugin-contributed one over a vendor API, + * such as OData — which runs every request through + * {@link callWithBorrowedCredential}. + * + * ## Why it lives in `@objectstack/types` + * + * The dependency direction the D7 ruling left open is answered here: no + * package on the data path imports the automation service, and the + * automation service imports no datasource package. Both services already + * depend on this package, and so can an out-of-repository driver, so the + * shared declaration adds no edge — the same home rule + * `stranded-decision.ts` follows. `@objectstack/objectql` is not involved: + * the engine sees a registered driver, never a credential. + * + * ## Per dispatch, from the first kind + * + * ADR-0143 D4 resolves a grant-bearing credential per dispatch: refreshed on + * expiry (with a clock-skew margin) and once on an upstream `401`, the call + * retried once. The handle has that shape now — {@link ConnectorCredentialHandle.get} + * answers the credential to send, {@link ConnectorCredentialHandle.refresh} + * answers a new one — so the static kinds (bearer, API key, basic) and the + * grant-bearing kinds ADR-0143 adds later meet a driver through one + * interface, and a driver written against it today needs no change then. + * + * ⛔ A value crosses this seam only as the return of `get` / `refresh`, into + * the driver's request. Nothing here logs, stores or echoes it, and the + * refusals below name the connector instance, never a value. An upstream's + * own text, which may echo it, goes through {@link withoutBorrowedCredential} + * before a driver throws it. + */ + +/** The kernel service name the connector credential source is registered under. */ +export const CONNECTOR_CREDENTIAL_SOURCE_SERVICE = 'connector-credential-source'; + +/** + * The credential a driver presents on one request — the resolved form of a + * connector instance's `auth`, never its reference. + */ +export type BorrowedConnectorAuth = + | { type: 'bearer'; token: string } + | { type: 'api-key'; key: string; headerName: string; paramName?: string } + | { type: 'basic'; username: string; password: string }; + +/** One answer of a {@link ConnectorCredentialHandle}. */ +export interface BorrowedConnectorCredential { + /** What to send. */ + readonly auth: BorrowedConnectorAuth; + /** + * When the credential stops being valid, in epoch milliseconds. Absent for + * a credential with no known lifetime (the static kinds). A credential at + * or past `expiresAt` minus the skew margin is refreshed before it is sent. + */ + readonly expiresAt?: number; +} + +/** What a borrow is asked in the name of. */ +export interface ConnectorCredentialRequest { + /** + * The organization of the request the credential is for (ADR-0143 Q1 = A, + * an organization-shared connection). Absent for a read that carries none + * — a boot-time drift check, a schedule — which ADR-0143 D4 answers from + * the instance's connection only when exactly one exists. A static + * credential is environment-level and answers the same either way. + */ + readonly organizationId?: string; +} + +/** + * A borrowed credential, bound to one connector instance. Returned by + * {@link IConnectorCredentialSource.borrow}; the driver keeps it for the life + * of its connection and asks it on every request. + */ +export interface ConnectorCredentialHandle { + /** The connector instance this handle borrows from. */ + readonly connector: string; + /** + * The credential to send now. Throws a refusal carrying `code` and + * `status` when none can be answered (the credential is not set, the + * store cannot be read, the instance was disabled): `503 + * SERVICE_UNAVAILABLE`, since each clears without a change to the + * datasource. + */ + get(request?: ConnectorCredentialRequest): Promise; + /** + * A credential obtained again, for the request that was refused with the + * one {@link get} answered (a `401`) or that would send an expired one. + * Concurrent calls on one handle share one refresh, so a rotating grant is + * never spent twice. Throws as {@link get} does. + */ + refresh(request?: ConnectorCredentialRequest): Promise; +} + +/** The provider side: answers a handle for a declared connector instance. */ +export interface IConnectorCredentialSource { + /** + * A handle on `connector`'s credential. Resolves no value: a borrow checks + * the declaration, so an instance whose credential is not set yet still + * lends a handle whose {@link ConnectorCredentialHandle.get} refuses until + * it is. Throws a refusal carrying `code` and `status` when the stack + * declares no such instance, when the entry has no `provider` (a catalog + * descriptor), when it declares no `auth` credential, or when it is + * disabled — `422 VALIDATION_ERROR`, fixed by the declaration — and + * `503 SERVICE_UNAVAILABLE` when the declaration cannot be read now. + */ + borrow(connector: string): Promise; +} + +/** A refusal of this seam: a standard-catalog `code` and its `status` on the error, the message naming the instance. */ +export function connectorCredentialRefusal(message: string, code: 'VALIDATION_ERROR' | 'SERVICE_UNAVAILABLE', status: 422 | 503): Error { + return Object.assign(new Error(message), { code, status, statusCode: status }); +} + +/** The shortest spelling a scrub withholds: a shorter one would blank ordinary words. */ +const MIN_WITHHELD_LENGTH = 4; + +function base64Unpadded(text: string): string { + let binary = ''; + for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte); + return btoa(binary).replace(/=+$/, ''); +} + +/** + * Every spelling of a borrowed credential an upstream may echo back in its + * own text: the value; URL-encoded both ways (`encodeURIComponent`, and the + * form encoding `URLSearchParams` writes), as an API key presented as a query + * parameter travels in the request URL; base64; and, for `basic`, + * base64(`username:password`), the `Authorization: Basic` header. A base64 + * form is kept without its `=` padding, so an echo that drops it is caught + * too. Longest first, so a longer form is replaced before a shorter one + * inside it. The same spellings `@objectstack/service-automation`'s + * connector call path withholds. + */ +export function borrowedCredentialForms(auth: BorrowedConnectorAuth): string[] { + const value = auth.type === 'bearer' ? auth.token : auth.type === 'api-key' ? auth.key : auth.password; + const forms = new Set([ + value, + encodeURIComponent(value), + new URLSearchParams({ v: value }).toString().slice(2), + base64Unpadded(value), + ]); + if (auth.type === 'basic') forms.add(base64Unpadded(`${auth.username}:${auth.password}`)); + return [...forms].filter((f) => f.length >= MIN_WITHHELD_LENGTH).sort((a, b) => b.length - a.length); +} + +/** + * `text` with every spelling of the presented credential withheld + * ({@link borrowedCredentialForms}). A driver runs an upstream's own text + * through this before it reaches an error it throws: the engine's floor may + * hand that message to the caller (a request-shaped refusal is answered `400` + * with it), and an upstream that echoes the request URI carries a + * query-parameter API key with it. + */ +export function withoutBorrowedCredential(text: string, auth: BorrowedConnectorAuth): string { + let out = text; + for (const form of borrowedCredentialForms(auth)) out = out.split(form).join('[credential withheld]'); + return out; +} + +/** The margin before `expiresAt` inside which a credential is refreshed rather than sent. */ +export const BORROWED_CREDENTIAL_SKEW_MS = 30_000; + +/** Options for {@link callWithBorrowedCredential}. */ +export interface BorrowedCredentialCallOptions extends ConnectorCredentialRequest { + /** + * Whether a call's RESULT is the upstream refusing the credential. Default: + * a result whose `status` is `401` (a fetch `Response`, a `{ status }` + * envelope). A call that THROWS an error whose `status` or `statusCode` is + * `401` is read as the same refusal either way. + */ + readonly isUnauthorized?: (result: T) => boolean; + /** The clock, for tests. Default `Date.now`. */ + readonly now?: () => number; + /** The skew margin. Default {@link BORROWED_CREDENTIAL_SKEW_MS}. */ + readonly skewMs?: number; +} + +function hasStatus401(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const v = value as { status?: unknown; statusCode?: unknown }; + return v.status === 401 || v.statusCode === 401; +} + +/** + * The refusal {@link callWithBorrowedCredential} ends on when the upstream + * refused a freshly obtained credential too. `503`, a condition of the + * connection the caller cannot change — the floor over a plugin driver + * answers it `ERR_DATASOURCE_UNAVAILABLE` with this message in the server log + * only. It names the instance and the remedy; the upstream's own text, which + * may echo the credential, is not carried. + */ +function credentialRejectedRefusal(connector: string): Error { + return connectorCredentialRefusal( + `the upstream refused the credential borrowed from connector instance '${connector}' (HTTP 401), and refused it ` + + 'again after it was obtained afresh. Set that instance\'s credential again (or reconnect it); the datasource ' + + 'reads with the new one on its next request, with no restart.', + 'SERVICE_UNAVAILABLE', + 503, + ); +} + +/** + * Run one upstream call with a borrowed credential — ADR-0143 D4's rule, the + * one copy every driver presents through: + * + * 1. ask the handle; a credential at or past its expiry (less the skew + * margin) is refreshed before it is sent; + * 2. send it; when the upstream answers `401` (a result + * {@link BorrowedCredentialCallOptions.isUnauthorized} recognises, or a + * throw declaring `status` / `statusCode` 401), refresh once and send + * again; + * 3. a second `401` ends the call with a `503 SERVICE_UNAVAILABLE` refusal + * naming the instance, never with the upstream's text. + * + * Any other result is returned and any other throw propagates unchanged, + * refusals of the handle included. + */ +export async function callWithBorrowedCredential( + handle: ConnectorCredentialHandle, + call: (auth: BorrowedConnectorAuth) => Promise, + options: BorrowedCredentialCallOptions = {}, +): Promise { + const request: ConnectorCredentialRequest = options.organizationId === undefined ? {} : { organizationId: options.organizationId }; + const now = options.now ?? Date.now; + const skew = options.skewMs ?? BORROWED_CREDENTIAL_SKEW_MS; + const unauthorized = options.isUnauthorized ?? hasStatus401; + + let credential = await handle.get(request); + if (credential.expiresAt !== undefined && credential.expiresAt - skew <= now()) { + credential = await handle.refresh(request); + } + + const attempt = async (): Promise<{ refused: true } | { refused: false; result: T }> => { + let result: T; + try { + result = await call(credential.auth); + } catch (err) { + if (hasStatus401(err)) return { refused: true }; + throw err; + } + return unauthorized(result) ? { refused: true } : { refused: false, result }; + }; + + const first = await attempt(); + if (!first.refused) return first.result; + credential = await handle.refresh(request); + const second = await attempt(); + if (!second.refused) return second.result; + throw credentialRejectedRefusal(handle.connector); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5ba58375b16..03fccd78cc9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -107,6 +107,12 @@ export * from './share-password-header.js'; // the display projection of exactly the fields that record carries — built once // for the same two mounts, from this home for the same reason. export * from './share-link-resolve-answer.js'; +// [ADR-0015 D7] The borrowed connector credential: the contract the +// automation service lends a connector instance's credential through, the +// datasource connection path borrows it by, and a plugin driver presents it +// with. Producer and consumer are two services neither of which may import +// the other; both already depend on this package, so the seam adds no edge. +export * from './connector-credential-source.js'; // Placeholder for Kernel interface to avoid circular dependency // The actual Kernel implementation will satisfy this interface. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e8147f8f49..bf8ced32ea9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1072,6 +1072,9 @@ importers: '@objectstack/spec': specifier: workspace:* version: link:../../spec + '@objectstack/types': + specifier: workspace:* + version: link:../../types fast-xml-parser: specifier: ^5.10.1 version: 5.10.1