Repository navigation
Commit 0a52b7d
feat(cli): os serve publishes its datasource secret store's resolve as the datasource-secret-resolver service, so a stack's OData plugin resolves a sys_secret credentialsRef with no code (#27952)
epic:#27261#1 · marchtian · session_017ihL2fy9PSGEZJdJpoPvzx
<!-- claude-projects-attribution: {"github_login":"marchtian"} -->
_Requested by **田騏** · [project
thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi7GiMpxi6VR7nKYEhHVxChB)_
**Fable review owed.** Developed on Opus while the Fable quota is out.
`Clause-②: yes (widening)` (a new published kernel service), so it lands
after a contract review PASS on the current head.
Fixes #27875
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- **改了什么:** `os serve` 把它为数据源建的密钥解析器的「读」那一半(`resolve`)注册成内核服务
`datasource-secret-resolver`。OData 插件没拿到 `options.secrets`
时,在第一个请求时去读这个服务,于是 `credentialsRef: 'sys_secret:…'` 在 `os serve`
上不用写代码就能解析。显式传的 `options.secrets` 仍然优先;两者都没有时照旧拒绝请求,什么都不发出去,报错里点名这个服务。
- **为什么改:** 声明式 OData 的卖点是只写元数据。凭据这一步要作者自己写 `resolve`,卖点就破了(#27871
写指南时发现)。
- **风险与回滚:** 只发布 `resolve`,`bind`/`unbind` 仍只在数据源管理插件手里;它只解得开
`datasource_credential` 作用域封的行,设置项密钥和密钥字段的行解不开(有测试)。没有加密提供者时 `os serve`
不建解析器,也就不注册服务。进程内任何插件都能调这个 `resolve`;为什么可以接受(信任边界是进程,引擎的 `resolveSecret`
已对进程内调用方解密密钥字段),见 What changes 一节。回滚即 revert。
- **文档:** OData 指南(`odata-datasources.mdx`,#27871 已落地)去掉了「`os serve`
下要自己传 `resolve`」的说明,改为:`os serve` 下不传 `secrets`,凭据走服务端自己的密钥存储。
- **先看:**
`packages/types/src/datasource-secret-resolver.ts`(服务名、接口、插件驱动该怎么读);`packages/cli/src/commands/serve.ts`
的 `createDatasourceSecretResolverPlugin`
和挂载处;`packages/drivers/driver-odata/src/plugin.ts` 的
`hostSecretResolver`。
- **两分钟验证:** `pnpm --filter @objectstack/cli exec vitest run
src/commands/serve-datasource-secret-resolver.test.ts`,预期 5 个用例全绿:真内核加真
binder,绑定的凭据经服务解出原文,服务上只有 `resolve`,别的生产者封的行解不开。
- **需要您做的:** 无。契约复审 PASS 后落地。
## What changes
**The service and its contract**
(`packages/types/src/datasource-secret-resolver.ts`):
`DATASOURCE_SECRET_RESOLVER_SERVICE` (`'datasource-secret-resolver'`)
and `IDatasourceSecretResolver` (`resolve(credentialsRef)`, answering
`undefined` for a handle that is not the binder's, a missing row or a
decrypt failure). The module states how every plugin driver that
dereferences an `external.credentialsRef` reads it: an explicit secret
store handed to the plugin first; the service looked up at the first
request, never at `init()` (AGENTS.md, "Startup registry reads");
fail-closed when neither resolves. It lives in `@objectstack/types`
because the producer (`os serve`) and the consumers (plugin drivers, in
this repository or out of it) never import each other, and the package
depends only on `@objectstack/spec`. The card asked whether other plugin
drivers should read it the same way: yes, and the module is where that
is written down.
**The producer** (`packages/cli/src/commands/serve.ts`):
`createDatasourceSecretResolverPlugin(secrets)` (plugin
`com.objectstack.cli.datasource-secret-resolver`, `providesServices:
['datasource-secret-resolver']`) registers a resolver whose one method
calls the binder's `resolve`. `run()` mounts it right after
`DatasourceAdminServicePlugin`, with the same binder, and only when a
binder was built. No crypto provider means no binder, so no service, and
a driver's refusal names the service.
**The consumer** (`packages/drivers/driver-odata/src/plugin.ts`, the
`secrets` fallback only): `const store = secrets ??
hostSecretResolver(ctxRef)`, evaluated inside the per-request
`credential()` provider. `hostSecretResolver` catches the kernel's throw
on an unregistered name, so the miss reaches the plugin's own refusal,
not the kernel's "not found" text. The refusal message gains the service
name and the two remedies (run under `os serve`, or pass
`options.secrets`). It reads the service through `@objectstack/types`,
which the package already depends on since #27819.
**The guide** (`content/docs/data-modeling/odata-datasources.mdx`,
landed with #27871 while this was in progress): the mount example drops
its `secrets: erpSecretStore` line and import; the option table says an
absent `secrets` falls back to the host's `datasource-secret-resolver`
service, which `os serve` registers; the Credentials section replaces
the "pass your own resolve on os serve" caveat with the two cases (under
`os serve`, pass nothing; a host composing its own kernel passes
`secrets` or registers the service) and the fail-closed list says
"neither `secrets` nor the service".
**Who can call `resolve`, and why that is acceptable.** Any plugin in
the process can read the service and call `resolve()` on any
`sys_secret:` handle. It is not narrowed to the calling datasource's own
`credentialsRef`, because the service cannot tell who calls it, and the
measured alternatives narrow nothing real:
- **The trust boundary is the process, not the plugin.** A plugin is
code the operator installed and runs with the kernel's own reach: it can
read `sys_secret` rows under the system context through the data engine,
and the engine's public `resolveSecret` already decrypts a secret
field's `secret:` reference for any in-process caller
(`packages/objectql/src/engine.ts`, `resolveSecret`). This service adds
no reach of a new kind; it adds the datasource credentials, through the
binder the datasource admin already holds.
- **What it can open is bounded by scope.** The binder decrypts under
the `datasource_credential` crypto scope alone, so a settings secret or
a secret field's row fails authentication and answers `undefined`
(pinned in the new cli test). `bind` and `unbind` are not on the
service.
- **"Only a handle some declared datasource names" would not narrow
it.** Every datasource definition, with its `credentialsRef`, is
readable by any plugin through the engine's `listDatasourceDefs()`, so
such a check excludes only orphaned handles while adding a second read
on every request. A per-caller restriction needs a caller identity the
kernel's service registry does not carry.
- **The alternative a stricter host has** is to not publish it: `os
serve` mounts it only when it mounts the datasource admin itself, and a
host composing its own kernel registers the service or passes `secrets`
explicitly.
**Ledger:** `scripts/engine-double-contract.pinned.json` gains the new
test's `sys_secret` double (its `delete` routes through
`assertEngineDeleteDispatch`), written by
`check-engine-double-contract.mjs --write` as the gate asks.
**Changeset:** `patch` on `@objectstack/types`, `@objectstack/cli` and
`@objectstack/driver-odata` (`Metadata-protocol: none` takes patch).
## Why
Epic #27261 P2 (ruling 6486145005, Tier B = declarative OData). Found by
#27747's carrier (#27871, os-dev-report 6513440511): on `origin/main`,
`serve.ts` builds `createDatasourceSecretBinder` and hands it only to
`DatasourceAdminServicePlugin`, so a stack's OData plugin cannot resolve
a `sys_secret:` handle under `os serve`.
## Verification
On `cf00041b` (`origin/main` merged in three times, last after #27819
landed at `ed8b2375`; #27871 and the driver-odata conformance kit came
in the merge before), after `pnpm install --frozen-lockfile` and a full
build of `./packages/**` (exit 0):
- **New tests.**
`packages/cli/src/commands/serve-datasource-secret-resolver.test.ts`
(5): inside a real `ObjectKernel` over a real binder
(`createDatasourceSecretBinder` with `LocalCryptoProvider`), a
credential bound through the binder resolves through the service to its
cleartext; the service's only key is `resolve`; a settings-scoped row in
the same `sys_secret` table, a missing row and a foreign handle all
answer `undefined`; `providesServices` names the service; and a source
pin (comments masked) that `run()` mounts the plugin inside `if
(secrets)` with that binder and defines it once.
`packages/drivers/driver-odata/src/plugin.test.ts` (5 new): the handle
resolves through the host's service with no `options.secrets` and the
service is asked exactly once; an explicit `options.secrets` wins and
the service is never asked; a handle the service does not resolve is
refused with nothing sent; a service registered after the plugin's
`init` is seen at the first request; and on a real kernel with neither,
the refusal is the plugin's own message naming the service, never the
kernel's text, with nothing sent. The existing "no secret store" refusal
row is unchanged.
- **Ablations, each red then restored** (`git status --porcelain` empty
after each): the fallback removed (`const store = secrets`): 3 failed;
precedence flipped (service before `options.secrets`): 1 failed; the
`getService` try/catch removed: 1 failed (the real-kernel case); the
whole binder registered instead of the read half: 1 failed.
- **Suites:** `driver-odata` 732 passed (the connector-credential cases
included); `types` 832 passed; `cli` the two `serve-datasource-*` files,
11 passed. `typecheck` exit 0 for `types`, `driver-odata` and `cli` (the
cli run includes `check:test-typecheck`).
- **Local gates:** `node scripts/pm/dispatch-gates.mjs --commands`
derived 101 families from the changed files on that head; every one was
run with its exit code recorded before any pipe, and `dispatch-gates
--ran` reads "101 derived, 101 run, 0 NOT-MEASURED (a DERIVED zero)"
(112 of 112 on the pre-#27819 head `33bc41fb`). PR CI on `cf00041b`:
every required check `success`. An earlier pass on the pre-guide head
was red on three gates about the new test's double, all fixed in the
diff: `check:engine-double-contract` (the double's `delete` now routes
through `assertEngineDeleteDispatch`, ledger row written),
`check:where-matcher` (the operator refusal moved inside the matcher
callback) and `check:cross-package-test-inputs` (a doc comment named
another package's test path). The full battery (`pnpm lint`, Test Core
shards, UI jobs) is CI's. No dogfood run: no `!` in the title and no
`(narrowing)` arm.
## Acceptance notes
- **#27819 (#27746) landed first** (`ed8b2375`), so this PR merged
`main` and resolved the one conflict in `plugin.ts`: the header keeps
both sections (the `credentialsRef` channel with its service fallback,
and the borrowed connector credential), one `@objectstack/types` import
carries both service names, and `credential()` keeps `secrets ??
hostSecretResolver(ctxRef)` beside `borrowed()`. In `plugin.test.ts` the
new cases take `main`'s `boot(options, defs, { services })` signature.
The `@objectstack/types` dependency and its lockfile lines are now
`main`'s, so neither is in this diff.
- **The README** (`packages/drivers/driver-odata/README.md`, added at
the epic PM's request): the mount example no longer passes `secrets` and
says the handle resolves through the host's service, and the Config
table gains the `secrets` row. This edit rides in the merge commit
`cf00041b`.
- **One file outside the claim's list:**
`scripts/engine-double-contract.pinned.json` (the ledger row the gate
requires for the new test double; it only grows coverage).
- **How this meets #27819 (#27746).** #27819 reshapes `plugin.ts` around
a `definition()` helper and a `borrowed()` connector path; this PR edits
only the `if (!secrets)` block inside `credential()` plus one helper and
one import, so the two should meet as a small textual conflict in
`credential()`. Whichever lands second takes the other's lines; the
package.json and lockfile lines for `@objectstack/types` are identical
in both. The `types/src/index.ts` export sits away from #27819's hunk.
- **A stack that registers its own `datasource-secret-resolver`**
collides with `os serve`'s at boot (`Service
'datasource-secret-resolver' already registered`), loudly. `os serve`
mounts its plugin only when it mounts the datasource admin itself, so a
stack that brings its own admin plugin gets no second one.
- **Worktree name:** the claim names `objectstack-issue-27875`; the
worktree is `objectstack-27875` on the claimed branch.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(Not touched.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` is CI's.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block is the
repo's own Chinese reader section).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d5951c8 commit 0a52b7d
10 files changed
Lines changed: 423 additions & 32 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages
- cli/src/commands
- drivers/driver-odata
- src
- types/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
47 | 46 | | |
48 | 47 | | |
49 | 48 | | |
50 | 49 | | |
51 | 50 | | |
52 | 51 | | |
53 | 52 | | |
54 | | - | |
55 | | - | |
| 53 | + | |
56 | 54 | | |
57 | 55 | | |
58 | 56 | | |
| |||
64 | 62 | | |
65 | 63 | | |
66 | 64 | | |
67 | | - | |
| 65 | + | |
68 | 66 | | |
69 | 67 | | |
70 | 68 | | |
| |||
167 | 165 | | |
168 | 166 | | |
169 | 167 | | |
170 | | - | |
| 168 | + | |
171 | 169 | | |
172 | 170 | | |
173 | 171 | | |
| |||
180 | 178 | | |
181 | 179 | | |
182 | 180 | | |
183 | | - | |
184 | | - | |
| 181 | + | |
185 | 182 | | |
186 | | - | |
187 | | - | |
188 | | - | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
189 | 187 | | |
190 | | - | |
191 | | - | |
192 | | - | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
193 | 194 | | |
194 | 195 | | |
195 | | - | |
196 | | - | |
| 196 | + | |
| 197 | + | |
197 | 198 | | |
198 | 199 | | |
199 | 200 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| |||
5333 | 5333 | | |
5334 | 5334 | | |
5335 | 5335 | | |
| 5336 | + | |
| 5337 | + | |
| 5338 | + | |
| 5339 | + | |
| 5340 | + | |
| 5341 | + | |
| 5342 | + | |
| 5343 | + | |
| 5344 | + | |
| 5345 | + | |
5336 | 5346 | | |
5337 | 5347 | | |
5338 | 5348 | | |
| |||
6057 | 6067 | | |
6058 | 6068 | | |
6059 | 6069 | | |
| 6070 | + | |
| 6071 | + | |
| 6072 | + | |
| 6073 | + | |
| 6074 | + | |
| 6075 | + | |
| 6076 | + | |
| 6077 | + | |
| 6078 | + | |
| 6079 | + | |
| 6080 | + | |
| 6081 | + | |
| 6082 | + | |
| 6083 | + | |
| 6084 | + | |
| 6085 | + | |
| 6086 | + | |
| 6087 | + | |
| 6088 | + | |
| 6089 | + | |
| 6090 | + | |
| 6091 | + | |
| 6092 | + | |
| 6093 | + | |
| 6094 | + | |
| 6095 | + | |
| 6096 | + | |
| 6097 | + | |
| 6098 | + | |
| 6099 | + | |
| 6100 | + | |
6060 | 6101 | | |
6061 | 6102 | | |
6062 | 6103 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
| 27 | + | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
| 71 | + | |
71 | 72 | | |
72 | 73 | | |
73 | 74 | | |
| |||
0 commit comments