Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/27875-datasource-secret-resolver-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/types': patch
'@objectstack/cli': patch
'@objectstack/driver-odata': patch
---

feat: `os serve` publishes its datasource secret store's `resolve` as the kernel service `datasource-secret-resolver`, and `driver-odata` reads it when it is passed no secret store

Clause-②: yes (widening)
Metadata-protocol: none

A stack served by `os serve` that mounts `createODataDriverPlugin` could not resolve its datasource's `external.credentialsRef`: the server's `sys_secret` store was handed only to the datasource admin, so a `sys_secret:` handle stored through Setup → Datasources did not resolve for the plugin, and the stack had to pass a `resolve` of its own. It now resolves with no code:

```ts
plugins: [createODataDriverPlugin({ name: 'erp', serviceRoot: 'https://erp.example.com/odata/', auth: { scheme: 'bearer' } })],
datasources: [{ name: 'erp', driver: 'erp', config: {}, schemaMode: 'external',
external: { credentialsRef: 'sys_secret:…' } }],
// no `secrets` option: under os serve the handle resolves through the server's own store
```

- **`@objectstack/cli`**: `os serve` registers the read half of the datasource secret binder it builds as the kernel service `datasource-secret-resolver` (plugin `com.objectstack.cli.datasource-secret-resolver`). Only `resolve` is published; storing and removing a credential stays the datasource admin's. It decrypts datasource credentials only: a settings secret or a secret field's row in `sys_secret` does not open through it. When `os serve` builds no binder (no crypto provider), the service is not registered.
- **`@objectstack/driver-odata`**: when `options.secrets` is absent, the plugin reads the `datasource-secret-resolver` service at the first request. An explicit `options.secrets` still wins. With neither, the request is refused as before, sent to nobody, and the message now names the service.
- **`@objectstack/types`**: `DATASOURCE_SECRET_RESOLVER_SERVICE` and `IDatasourceSecretResolver`, the contract a host publishes the resolver under and every plugin driver that dereferences a `credentialsRef` reads it by: an explicit store first, the service looked up at the first request (never at `init`), fail-closed when neither resolves.
31 changes: 16 additions & 15 deletions content/docs/data-modeling/odata-datasources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,14 @@ The plugin builds the driver from its options and publishes it as the service
```typescript
import { defineStack } from '@objectstack/spec';
import { createODataDriverPlugin } from '@objectstack/driver-odata';
import { erpSecretStore } from './secrets'; // your host's secret store; see Credentials

export default defineStack({
// …the datasource and objects of step 2…
plugins: [
createODataDriverPlugin({
name: 'erp', // = the datasource name
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
auth: { scheme: 'bearer' },
secrets: erpSecretStore,
auth: { scheme: 'bearer' }, // under os serve, no `secrets`: see Credentials
}),
],
});
Expand All @@ -64,7 +62,7 @@ export default defineStack({
| `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. |
| `version` | `4.0` | The protocol version. `4.0` is the only value. |
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. |
| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. Required for `bearer` and `basic`. |
| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. When absent, the host's `datasource-secret-resolver` service is used; `os serve` registers one. |
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
| `requestTimeoutMs` | `30000` | The timeout of each request. |

Expand Down Expand Up @@ -167,7 +165,7 @@ What each piece does on this driver:

⛔ Never put a credential in the stack, in the plugin options, or in the `serviceRoot`
URL. The secret is the datasource's `external.credentialsRef`, and the plugin resolves it
through the `secrets` store you pass:
through the host's secret store:

- **`bearer`** sends `Authorization: Bearer <secret>`.
- **`basic`** takes `username` from the options and the password from the secret.
Expand All @@ -180,20 +178,23 @@ old token fails. The driver does not obtain or refresh tokens itself. A service
issues short-lived OAuth2 access tokens needs something that keeps the stored secret
current.

`secrets` is any object with `resolve(credentialsRef)`, answering the secret or
`undefined`:
Which store resolves it:

- **A host that composes its own kernel** passes the same binder it gives
`DatasourceAdminServicePlugin`. A `sys_secret:<id>` handle that the Add Datasource
wizard minted then resolves, as on any federated datasource
- **Under `os serve`**, pass no `secrets`. The server publishes its own datasource secret
store as the kernel service `datasource-secret-resolver`, and the plugin reads it at the
first request. A `sys_secret:<id>` handle that the Add Datasource wizard minted
resolves, as on any federated datasource
([Credentials](/docs/data-modeling/external-datasources#4-credentials)).
- **`os serve`** builds its secret store internally and does not hand it to a stack's
plugins. Pass a `resolve` backed by your host's own secret handling instead, and
declare `credentialsRef` as a handle that it resolves.
- **A host that composes its own kernel** passes `secrets`: the same binder it gives
`DatasourceAdminServicePlugin`, or any object with `resolve(credentialsRef)` answering
the secret or `undefined`. It can instead register that `resolve` as the
`datasource-secret-resolver` service (`DATASOURCE_SECRET_RESOLVER_SERVICE` in
`@objectstack/types`), which every plugin driver reading a `credentialsRef` uses the
same way. A `secrets` option passed to the plugin always wins over the service.

Resolution fails closed. Each of these refuses the request rather than send it without
the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with no
`secrets`, and a handle that does not resolve. The caller is answered
the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with neither
`secrets` nor the service, and a handle that does not resolve. The caller is answered
`503 ERR_DATASOURCE_UNAVAILABLE`, and the cause goes to the server log.

## 4. What the service is asked
Expand Down
154 changes: 154 additions & 0 deletions packages/cli/src/commands/serve-datasource-secret-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os serve` publishes its datasource secret binder's read half as the kernel
* service `datasource-secret-resolver`, so a plugin driver the stack composes
* (`@objectstack/driver-odata`) resolves a datasource's
* `external.credentialsRef` through the host's own `sys_secret` store.
*
* ## The gap
*
* `serve.ts` built the binder inline and handed it only to
* `DatasourceAdminServicePlugin`. No service exposed it, so under `os serve` a
* stack's OData plugin had no way to dereference a `sys_secret:` handle the
* datasource admin had stored, and the stack author had to write a `resolve`
* of their own.
*
* ## What this file pins
*
* The plugin runs inside a REAL `ObjectKernel` over a REAL binder
* (`createDatasourceSecretBinder` with `LocalCryptoProvider`):
*
* 1. a credential bound through the binder resolves through the service, to
* its cleartext;
* 2. the service is the read half only — no `bind`, no `unbind`;
* 3. a `sys_secret` row another producer sealed (a settings secret) does not
* open through it, and neither does a handle that is not the binder's;
* 4. the wiring inside `run()`: the plugin is mounted only when a binder was
* built, with that binder, and the plugin is defined once.
*
* The driver's side (reading the service at its first request, an explicit
* `options.secrets` winning, the fail-closed refusal with no service) is
* pinned in `@objectstack/driver-odata`'s own plugin tests.
*/

import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { ObjectKernel } from '@objectstack/core';
import { assertEngineDeleteDispatch } from '@objectstack/metadata-core';
import { createDatasourceSecretBinder } from '@objectstack/service-datasource';
import { LocalCryptoProvider } from '@objectstack/service-settings';
import { DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types';

// The repo's ONE code/prose separator: a comment naming the factory must not
// answer for code that does not call it.
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';

import { createDatasourceSecretResolverPlugin } from './serve.js';

/** …/packages/cli/src/commands — seeded from `import.meta.url`. */
const HERE = resolve(fileURLToPath(import.meta.url), '..');
/** `serve.ts`'s CODE, with every comment span blanked. */
const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8'));

const KEY = Buffer.from('404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f', 'hex');

/** The one service a kernel refuses to start without. Nothing here reads data. */
const DATA_STUB = {
name: 'com.example.datasource-secret-resolver.data-stub',
version: '1.0.0',
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
start: async () => {},
};

/** An in-memory `sys_secret` table with the engine surface the binder calls. */
function secretStore() {
const rows = new Map<string, Record<string, unknown>>();
return {
rows,
async insert(_object: string, data: Record<string, unknown>) { rows.set(String(data.id), data); return data; },
async delete(_object: string, options: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(options);
rows.delete(String(options.where.id));
},
async find(_object: string, query: Record<string, unknown>) {
const where = (query.where ?? {}) as Record<string, unknown>;
const hits = [...rows.values()].filter((row) => Object.entries(where).every(([k, v]) => {
if (k.startsWith('$') || (v !== null && typeof v === 'object')) {
throw new Error('only plain equality filters are implemented by this double');
}
return row[k] === v;
}));
return typeof query.limit === 'number' ? hits.slice(0, query.limit) : hits;
},
};
}

/** Boot a real kernel with the resolver plugin over a real binder; hand back the service and the binder. */
async function boot() {
const engine = secretStore();
const cryptoProvider = new LocalCryptoProvider({ key: KEY });
const binder = createDatasourceSecretBinder({ engine, cryptoProvider });
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
await kernel.use(DATA_STUB);
await kernel.use(createDatasourceSecretResolverPlugin(binder));
await kernel.bootstrap();
const resolver = kernel.getService<IDatasourceSecretResolver>(DATASOURCE_SECRET_RESOLVER_SERVICE);
return { kernel, engine, cryptoProvider, binder, resolver };
}

describe('createDatasourceSecretResolverPlugin — the binder\'s read half as a kernel service', () => {
it('a credential bound through the binder resolves through the service', async () => {
const { kernel, binder, resolver } = await boot();
try {
const ref = await binder.bind({ value: 'token-from-sys-secret' }, { name: 'trippin' });
expect(ref).toMatch(/^sys_secret:/);
expect(await resolver.resolve(ref)).toBe('token-from-sys-secret');
} finally {
await kernel.shutdown();
}
});

it('publishes resolve and nothing else of the binder', async () => {
const { kernel, resolver } = await boot();
try {
expect(Object.keys(resolver)).toEqual(['resolve']);
} finally {
await kernel.shutdown();
}
});

it('opens no row another producer sealed, and no handle that is not the binder\'s', async () => {
const { kernel, engine, cryptoProvider, resolver } = await boot();
try {
// A settings secret in the same table, sealed under its own scope.
const handle = await cryptoProvider.encrypt('settings-cleartext', { scope: 'settings', namespace: 'mail', key: 'smtp_password' });
await engine.insert('sys_secret', {
id: handle.id, namespace: 'mail', key: 'smtp_password',
kms_key_id: handle.kmsKeyId, alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext,
});
expect(await resolver.resolve(`sys_secret:${handle.id}`)).toBeUndefined();
expect(await resolver.resolve('sys_secret:missing')).toBeUndefined();
expect(await resolver.resolve('vault:somewhere-else')).toBeUndefined();
} finally {
await kernel.shutdown();
}
});

it('declares the service it registers (ADR-0116), so a consumer that requires it can name the provider', () => {
expect(createDatasourceSecretResolverPlugin({ resolve: async () => undefined }).providesServices)
.toEqual([DATASOURCE_SECRET_RESOLVER_SERVICE]);
});
});

describe('the wiring inside `run()`', () => {
it('mounts the plugin only when a binder was built, handing it that binder', () => {
expect(SERVE).toMatch(
/if \(secrets\) \{\s*await kernel\.use\(createDatasourceSecretResolverPlugin\(secrets\)\);/,
);
expect(SERVE.match(/'com\.objectstack\.cli\.datasource-secret-resolver'/g) ?? [], 'a second inline copy of the plugin').toHaveLength(1);
});
});
43 changes: 42 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
// posture is the authoritative knob and `resolveTenancyPosture()` already folds
// the legacy boolean in as its unset-fallback. serve's last direct reader of the
// boolean was the banner, and that was exactly the drift #4801 fixed.
import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError, DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types';
import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel';
// [#22301] The `requires` token → provider table and its exact identity match,
// shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS`
Expand Down Expand Up @@ -5333,6 +5333,16 @@ export default class Serve extends Command {
);
trackPlugin('DatasourceAdminServicePlugin');

// The binder's read half, published as a kernel service so a plugin
// driver the stack composes (`@objectstack/driver-odata`) resolves
// its datasource's `external.credentialsRef` through this same
// store. Only when a binder was built: without one the service is
// absent, and such a driver refuses the request, naming it.
if (secrets) {
await kernel.use(createDatasourceSecretResolverPlugin(secrets));
trackPlugin('DatasourceSecretResolver');
}

// REST routes under /api/v1/datasources, mounted by a tiny plugin
// during its init (same pattern as the hostname guard above). What
// it reads, and which warning a boot without a server gets, lives
Expand Down Expand Up @@ -6057,6 +6067,37 @@ export function createDatasourceAdminRoutesPlugin(
};
}

/**
* The plugin `os serve` publishes its datasource secret binder's read half
* with: the kernel service {@link DATASOURCE_SECRET_RESOLVER_SERVICE}, whose
* one method is the binder's `resolve`. A plugin driver the stack composes
* reads it to dereference a datasource's `external.credentialsRef`, so a
* `sys_secret:` handle stored through the datasource admin resolves for that
* driver with no code from the stack author.
*
* Only `resolve` is published — `bind` and `unbind` stay with
* `DatasourceAdminServicePlugin`, which holds the whole binder. The binder
* decrypts under the `datasource_credential` scope alone, so the service
* cannot open a settings secret or a secret field's row. Registered in
* `init`, so it is in place before any request; a driver reads it at its
* first request, never at its own `init`.
*/
export function createDatasourceSecretResolverPlugin(
secrets: IDatasourceSecretResolver,
) {
const resolver: IDatasourceSecretResolver = {
resolve: (credentialsRef: string) => secrets.resolve(credentialsRef),
};
return {
name: 'com.objectstack.cli.datasource-secret-resolver',
version: '1.0.0',
providesServices: [DATASOURCE_SECRET_RESOLVER_SERVICE],
init: async (ctx: { registerService(name: string, service: unknown): void }): Promise<void> => {
ctx.registerService(DATASOURCE_SECRET_RESOLVER_SERVICE, resolver);
},
};
}

/**
* What {@link createUnknownHostnameGuardPlugin} is constructed with.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/drivers/driver-odata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const plugins = [
name: 'erp', // the datasource it serves
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
auth: { scheme: 'bearer' }, // or { scheme: 'basic', username: 'svc_reader' }
secrets: datasourceSecretBinder, // the host's datasource secret store
// no `secrets`: credentialsRef resolves through the host's `datasource-secret-resolver` service
}),
];

Expand Down Expand Up @@ -68,6 +68,7 @@ export default defineStack({
| `serviceRoot` | — | The service root URL (http or https, no credentials, no query). |
| `version` | `4.0` | The protocol version. Only the v4 dialect ships. |
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. Leave it `none` when the datasource borrows a connector instance's credential (below). |
| `secrets` | (none) | An object with `resolve(credentialsRef)` that the secret is resolved through. When absent, the kernel service `datasource-secret-resolver` is read at the first request; `os serve` registers it over its own `sys_secret` store. A host composing its own kernel passes the binder it gives `DatasourceAdminServicePlugin`, or registers that service. |
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
| `requestTimeoutMs` | `30000` | Per-request timeout. |

Expand Down
Loading
Loading