Uh oh!
There was an error while loading. Please reload this page.
feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604
Conversation
…ically to the top-level keys they mirror A credential under the very spelling the top level refuses and redacts - one object level down (options.auth.token, options.pool.password, tunnel.password on a contract-less driver) - was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. The nested judgment was a hand-enumerated per-driver path table on the read side and absent on the write side, while the top level was derived from the driver contract. Both sides now consume one derivation: the canonical spellings and former aliases move to driver/common.zod.ts (CREDENTIAL_KEY_SPELLINGS, the bottom of the import graph); the read scrub applies the name judgment and the URL composite at every object depth for every driver; the write door's passthrough walk refuses the same spellings at any depth; refusedCredentialPaths walks nested object shapes for z.never leaves; arrays stay off the walk on both doors (row-shaped data is not config). passthroughSecretPaths remains only as the client-measured residue. restoreRedactedConfig is now derived from the redactor's own redactedPaths, so every current and future redaction source is mirrored on the untouched-Save round trip by construction. Semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) carries the authored-artifact upgrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 126 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 83fbc93970b1825577a9406ebde7c732acf4cef4 && git checkout 83fbc93970b1825577a9406ebde7c732acf4cef4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 && git checkout -B drift-repro 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 && git merge --no-ff 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069
node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2
|
os-warren
commented
Aug 31, 2026
Released by maintainer instruction. Provenance: maintainer, live PM session chat, 2026-08-31 ~04:0xZ, verbatim 「pr 绿了为什么不合并」 — read as a personal release of the green parked contract-face PRs, per the recorded #12606 precedent (「12606 绿了」= personal release lifting the Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13405
The class, and the shape of the fix
A datasource credential in a nested config position — under the very spelling the top level refuses at publish and redacts on read, one object level down (
options.auth.token,options.pool.password,tunnel.passwordon a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext withredactedConfigKeys: []. Mechanism (per the triage grading on the card): the top-level judgment is DERIVED from the driver contract (z.never()keys), but the nested side was only the hand-enumerated per-driverpassthroughSecretPathstable on the read side, and absent on the write side. Any nested position off the table defaulted to cleartext.Per triage's shape (supersedes the card author's blanket-recursion suggestion), the nested side is now derived from the same single source as the top level, on both doors and at publish:
CANONICAL_CREDENTIAL_KEYS/FORMER_CREDENTIAL_ALIASESmoved todriver/common.zod.ts(bottom of the driver-schema import graph) asCREDENTIAL_KEY_SPELLINGS, so the write door's passthrough walk and the read redactor consume ONE list — the A per-type metadata redaction seam belongs in@objectstack/spec/kernel— no service package can reach a registry inmetadata-protocol#8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtinz.never()key appears in the canonical list) still holds it to the contracts.redactDatasourceConfigapplies the name judgment AND the URL composite (userinfo + query params) at every object depth, for every driver, contract-less included. Nested removals are reported as dottedredactedKeys(the shapecarryForwardRedactedValuesalready walks) plus a newredactedPathssegments field. Both consumers sit behind this one function:service-datasource(getDatasource()/ admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasourcebehind/meta/datasource); acceptance tests drive each door directly.credentialFreeMongoOptionsrefuses a non-empty string under a credential-spelled key at any object depth of the mongodboptionspassthrough, with a message that does not inherit theauth.password-only "wins over" reassurance (that claim is measured forauth.passwordalone, A boundexternal.credentialsRefis silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measuredauth.passwordpath keeps its own prescription; nothing double-reports.refusedCredentialPaths/refusedCredentialPathsOfSchemaextend thez.never()derivation below the top level; no builtin driver declares a nested refusal today (pinned per driver, measured not assumed), so the nested branch is proved against a constructed schema.passthroughSecretPathsstays as the residue it should have been: client-MEASURED secret spellings that mirror no top-level key (proxyPassword,key,passphrase,tlsCertificateKeyFilePassword,AWS_SESSION_TOKEN). The schema genuinely cannot see any of them — mongo'soptionsis a record-of-unknown, so there is no shape to read; names measured against the client are the one thing neither derivation can produce. A position missing from the table is no longer cleartext by default; it leaks only if it ALSO mirrors no credential spelling, which is exactly the class a client measurement must decide (filed as the follow-up, out of scope here: mongooptions.autoEncryption.kmsProviderssecret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not onpassthroughSecretPathsand is served cleartext on datasource reads #13602).valueAtPath/withoutPathalready drew. This is what keeps row-shaped data (memory'sinitialDataseeds) out of the judgment without a per-driver exclusion list: redacting a seed row's ownpasswordFIELD would corrupt data the driver serves.restoreRedactedConfig(service-datasource) computes what the read path serves for the stored row and grafts stored material back wherever the patch is indistinguishable from that projection — so every current and future redaction source is mirrored on the untouched-Save round trip by construction, and an author's edit always wins. It consumesredactedPathssegments, so a stored key containing a literal dot cannot be mis-split (the dottedredactedKeyswire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).datasource-config-options-nested-credential-spelling-refused(major 18) + changeset (spec minor / service-datasource patch — the same split mongoconfig.options.auth.passwordis a fourth spelling of an inline credential — authorable, persisted cleartext, unredacted, and read by the client #9040 landed with in 17.1.0).Mandatory non-empty control (triage rule 3)
Positions deliberately OFF
passthroughSecretPaths, measured on the BASE build (098a08f dist) and the fixed build (2f02ca6 dist) with the same script:options.auth.token,options.pool.password(mongodb),tunnel.passwordand a nested URL userinfo password (contract-less driver): all served verbatim,redactedKeys=[], planted markers present in the served config.redactedKeysnames each position (options.auth.token,options.pool.password,tunnel.password,replication.url).MongoConfigSchema.safeParseacceptedoptions.auth.tokenandoptions.pool.passwordon BASE; both refused at their exact paths on the fix, withoptions.auth.passwordrefusal unchanged as the positive control.Pinned as tests at every layer: spec redaction suite (off-table describe), write-door suite (nested-spelling describe), kernel hook (
metadata-type-redaction.test.ts), service door (datasource-config-redaction.test.tsoff-table describe — read, untouched round-trip, typed-in refusal, nested-URL restore).Must-answer: are the read doors reachable by NON-admin same-tenant users?
Measured from source, split answer:
GET /api/v1/datasources/:name— NO for plain users:requireDatasourceAdmin(admin-routes.ts) refuses 401 anonymous / 403PERMISSION_DENIEDwithoutmanage_platform_settings; pinned byadmin-routes-auth-guard.test.ts("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).GET /api/v1/meta/datasource— YES for any authenticated same-tenant user:registerMetadataEndpoints(rest-server.ts) wraps every/meta/*route inenforceAuthonly — the anonymous-deny umbrella; "an authenticated user passes exactly as on /data". No capability gate exists on the per-type list read (the only authoring-capability gate in that family is the_draftsroute). So "same tenant, admin-only" does NOT hold: an admin-written nested credential was readable by every authenticated tenant user through the meta door — which is why the kernel-hook half of this fix is load-bearing, not belt-and-suspenders.Verification (all quoted from runs at head
2f02ca6a, tree clean)pnpm --filter @objectstack/spec test— "Test Files 444 passed (444) / Tests 11845 passed (11845)"pnpm --filter @objectstack/service-datasource test— "Test Files 28 passed (28) / Tests 595 passed (595)" (re-run at head against the final dist)pnpm --filter @objectstack/metadata-protocol test— "Test Files 145 passed | 2 skipped / Tests 2017 passed | 10 skipped" (pre-existing skips)pnpm --filter @objectstack/spec typecheck— tsc, scripts, and test layers all OK ("check:test-typecheck: OK — @objectstack/spec's test layer compiles")pnpm --filter @objectstack/service-datasource typecheck— exit 0; the edited test file is IN the tsc program (--listFilesnames it once)pnpm --filter @objectstack/spec check:generated— "All 14 generated artifacts are up to date" (api-surface, export-origins, docs regenerated after rebuild; authorable-surface unchanged-green)scripts/pm/dispatch-gates.mjsat 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — includingcheck:authorable-surface,check:docs,check:liveness,check:empty-state,check:cross-package-test-inputs,check:merge-driver,check:changeset-gate-self-tests,check:adr-0087-registration,check:nul-bytes("OK, scanned 7533 text files").check:dual-build-cjs-loadsanswered "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist" (38 unbuilt packages; it is an all-or-nothing full-repo gate). CI's Build Core owns that run. Repo-widepnpm lintlikewise left to CI.typecheckscript (coverage-ledger package; no source files edited there — tests only ran).Notes for review
needs:contract-reviewre-hung on both carriers in the same stroke as this PR (per the director's 00:42Z note on the card). Never to be enqueued by an agent seat.Generated by Claude Code
Generated by Claude Code