Repository navigation
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
Conversation
…as the datasource-secret-resolver service; driver-odata reads it when passed no secret store Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…d refuses operators Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…patch, refuses operators inside its matcher, and names no other package's path Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…Ref resolves through the server's own secret store, with no secrets option Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx
…-vfnybu # Conflicts: # packages/drivers/driver-odata/src/plugin.test.ts # packages/drivers/driver-odata/src/plugin.ts
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 40 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5713bbb2296a7ebaae7ae31463f44248267481b6 && git checkout 5713bbb2296a7ebaae7ae31463f44248267481b6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed0e71db94a73acc02512ff2ee0655106d02e585 cf00041b89839fdb60bc1b9c73b5fefa976b8be6 && git checkout -B drift-repro ed0e71db94a73acc02512ff2ee0655106d02e585 && git merge --no-ff cf00041b89839fdb60bc1b9c73b5fefa976b8be6
node scripts/docs-audit/affected-docs.mjs --json ed0e71db94a73acc02512ff2ee0655106d02e585
|
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV 维护者速读
席位意见:ACCEPT。契约复核 PASS 在案(6515948918, 席位核对
|
epic:#27261#1 · marchtian · session_017ihL2fy9PSGEZJdJpoPvzx
Requested by 田騏 · project thread
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仍然优先;两者都没有时照旧拒绝请求,什么都不发出去,报错里点名这个服务。resolve,卖点就破了(docs(data-modeling): an OData datasources guide, and the three-way choice between declarative OData, a plugin driver and a synced real object #27871 写指南时发现)。resolve,bind/unbind仍只在数据源管理插件手里;它只解得开datasource_credential作用域封的行,设置项密钥和密钥字段的行解不开(有测试)。没有加密提供者时os serve不建解析器,也就不注册服务。进程内任何插件都能调这个resolve;为什么可以接受(信任边界是进程,引擎的resolveSecret已对进程内调用方解密密钥字段),见 What changes 一节。回滚即 revert。odata-datasources.mdx,docs(data-modeling): an OData datasources guide, and the three-way choice between declarative OData, a plugin driver and a synced real object #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,别的生产者封的行解不开。What changes
The service and its contract (
packages/types/src/datasource-secret-resolver.ts):DATASOURCE_SECRET_RESOLVER_SERVICE('datasource-secret-resolver') andIDatasourceSecretResolver(resolve(credentialsRef), answeringundefinedfor a handle that is not the binder's, a missing row or a decrypt failure). The module states how every plugin driver that dereferences anexternal.credentialsRefreads it: an explicit secret store handed to the plugin first; the service looked up at the first request, never atinit()(AGENTS.md, "Startup registry reads"); fail-closed when neither resolves. It lives in@objectstack/typesbecause 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)(plugincom.objectstack.cli.datasource-secret-resolver,providesServices: ['datasource-secret-resolver']) registers a resolver whose one method calls the binder'sresolve.run()mounts it right afterDatasourceAdminServicePlugin, 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, thesecretsfallback only):const store = secrets ?? hostSecretResolver(ctxRef), evaluated inside the per-requestcredential()provider.hostSecretResolvercatches 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 underos serve, or passoptions.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 itssecrets: erpSecretStoreline and import; the option table says an absentsecretsfalls back to the host'sdatasource-secret-resolverservice, whichos serveregisters; the Credentials section replaces the "pass your own resolve on os serve" caveat with the two cases (underos serve, pass nothing; a host composing its own kernel passessecretsor registers the service) and the fail-closed list says "neithersecretsnor the service".Who can call
resolve, and why that is acceptable. Any plugin in the process can read the service and callresolve()on anysys_secret:handle. It is not narrowed to the calling datasource's owncredentialsRef, because the service cannot tell who calls it, and the measured alternatives narrow nothing real:sys_secretrows under the system context through the data engine, and the engine's publicresolveSecretalready decrypts a secret field'ssecret: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.datasource_credentialcrypto scope alone, so a settings secret or a secret field's row fails authentication and answersundefined(pinned in the new cli test).bindandunbindare not on the service.credentialsRef, is readable by any plugin through the engine'slistDatasourceDefs(), 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.os servemounts it only when it mounts the datasource admin itself, and a host composing its own kernel registers the service or passessecretsexplicitly.Ledger:
scripts/engine-double-contract.pinned.jsongains the new test'ssys_secretdouble (itsdeleteroutes throughassertEngineDeleteDispatch), written bycheck-engine-double-contract.mjs --writeas the gate asks.Changeset:
patchon@objectstack/types,@objectstack/cliand@objectstack/driver-odata(Metadata-protocol: nonetakes 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.tsbuildscreateDatasourceSecretBinderand hands it only toDatasourceAdminServicePlugin, so a stack's OData plugin cannot resolve asys_secret:handle underos serve.Verification
On
cf00041b(origin/mainmerged in three times, last after #27819 landed ated8b2375; #27871 and the driver-odata conformance kit came in the merge before), afterpnpm install --frozen-lockfileand a full build of./packages/**(exit 0):packages/cli/src/commands/serve-datasource-secret-resolver.test.ts(5): inside a realObjectKernelover a real binder (createDatasourceSecretBinderwithLocalCryptoProvider), a credential bound through the binder resolves through the service to its cleartext; the service's only key isresolve; a settings-scoped row in the samesys_secrettable, a missing row and a foreign handle all answerundefined;providesServicesnames the service; and a source pin (comments masked) thatrun()mounts the plugin insideif (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 nooptions.secretsand the service is asked exactly once; an explicitoptions.secretswins and the service is never asked; a handle the service does not resolve is refused with nothing sent; a service registered after the plugin'sinitis 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.git status --porcelainempty after each): the fallback removed (const store = secrets): 3 failed; precedence flipped (service beforeoptions.secrets): 1 failed; thegetServicetry/catch removed: 1 failed (the real-kernel case); the whole binder registered instead of the read half: 1 failed.driver-odata732 passed (the connector-credential cases included);types832 passed;clithe twoserve-datasource-*files, 11 passed.typecheckexit 0 fortypes,driver-odataandcli(the cli run includescheck:test-typecheck).node scripts/pm/dispatch-gates.mjs --commandsderived 101 families from the changed files on that head; every one was run with its exit code recorded before any pipe, anddispatch-gates --ranreads "101 derived, 101 run, 0 NOT-MEASURED (a DERIVED zero)" (112 of 112 on the pre-feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) #27819 head33bc41fb). PR CI oncf00041b: every required checksuccess. 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'sdeletenow routes throughassertEngineDeleteDispatch, ledger row written),check:where-matcher(the operator refusal moved inside the matcher callback) andcheck: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
ed8b2375), so this PR mergedmainand resolved the one conflict inplugin.ts: the header keeps both sections (thecredentialsRefchannel with its service fallback, and the borrowed connector credential), one@objectstack/typesimport carries both service names, andcredential()keepssecrets ?? hostSecretResolver(ctxRef)besideborrowed(). Inplugin.test.tsthe new cases takemain'sboot(options, defs, { services })signature. The@objectstack/typesdependency and its lockfile lines are nowmain's, so neither is in this diff.packages/drivers/driver-odata/README.md, added at the epic PM's request): the mount example no longer passessecretsand says the handle resolves through the host's service, and the Config table gains thesecretsrow. This edit rides in the merge commitcf00041b.scripts/engine-double-contract.pinned.json(the ledger row the gate requires for the new test double; it only grows coverage).plugin.tsaround adefinition()helper and aborrowed()connector path; this PR edits only theif (!secrets)block insidecredential()plus one helper and one import, so the two should meet as a small textual conflict incredential(). Whichever lands second takes the other's lines; the package.json and lockfile lines for@objectstack/typesare identical in both. Thetypes/src/index.tsexport sits away from feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) #27819's hunk.datasource-secret-resolvercollides withos serve's at boot (Service 'datasource-secret-resolver' already registered), loudly.os servemounts its plugin only when it mounts the datasource admin itself, so a stack that brings its own admin plugin gets no second one.objectstack-issue-27875; the worktree isobjectstack-27875on the claimed branch.Human Minimum
packages/specholds no runtime logic. (Not touched.)pnpm lintis CI's.main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx