Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror by os-warren · Pull Request #13604 · objectstack-ai/objectstack · GitHub
Skip to content

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror - #13604

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction
Aug 31, 2026
Merged

feat(spec): treat nested datasource-config credential positions identically to the top-level keys they mirror#13604
os-warren merged 1 commit into
mainfrom
claude/issue-13405-nested-credential-redaction

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.password on a contract-less driver) — was accepted at publish and served by every datasource read door in cleartext with redactedConfigKeys: []. 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-driver passthroughSecretPaths table 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:

  • One spelling list.CANONICAL_CREDENTIAL_KEYS / FORMER_CREDENTIAL_ALIASES moved to driver/common.zod.ts (bottom of the driver-schema import graph) as CREDENTIAL_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 in metadata-protocol #8300 no-second-copy posture applied to the list itself. The existing sync pin (every builtin z.never() key appears in the canonical list) still holds it to the contracts.
  • Read door, both consumers.redactDatasourceConfig applies 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 dotted redactedKeys (the shape carryForwardRedactedValues already walks) plus a new redactedPaths segments field. Both consumers sit behind this one function: service-datasource (getDatasource() / admin routes) and the kernel per-type redaction hook (BUILTIN_METADATA_TYPE_REDACTORS.datasource behind /meta/datasource); acceptance tests drive each door directly.
  • Publish refusal.credentialFreeMongoOptions refuses a non-empty string under a credential-spelled key at any object depth of the mongodb options passthrough, with a message that does not inherit the auth.password-only "wins over" reassurance (that claim is measured for auth.password alone, A bound external.credentialsRef is silently dropped on the DSN branches of the mysql and mongodb driver arms #8696). The measured auth.password path keeps its own prescription; nothing double-reports.
  • Schema derivation walked at depth.refusedCredentialPaths / refusedCredentialPathsOfSchema extend the z.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.
  • passthroughSecretPaths stays 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's options is 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: mongo options.autoEncryption.kmsProviders secret material (CSFLE: secretAccessKey / privateKey / clientSecret / local.key) is not on passthroughSecretPaths and is served cleartext on datasource reads #13602).
  • Arrays are off the walk, on both doors — the structural line valueAtPath / withoutPath already drew. This is what keeps row-shaped data (memory's initialData seeds) out of the judgment without a per-driver exclusion list: redacting a seed row's own password FIELD would corrupt data the driver serves.
  • The restore inverse is now derived, not restated.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 consumes redactedPaths segments, so a stored key containing a literal dot cannot be mis-split (the dotted redactedKeys wire shape is unchanged; the kernel-level dotted contract keeps its pre-existing dot ambiguity, noted in the docblock trail).
  • ADR-0087: semantic migration entry datasource-config-options-nested-credential-spelling-refused (major 18) + changeset (spec minor / service-datasource patch — the same split mongo config.options.auth.password is 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:

  • before — options.auth.token, options.pool.password (mongodb), tunnel.password and a nested URL userinfo password (contract-less driver): all served verbatim, redactedKeys=[], planted markers present in the served config.
  • after — same inputs: served config carries no marker; redactedKeys names each position (options.auth.token, options.pool.password, tunnel.password, replication.url).
  • write door before/after — MongoConfigSchema.safeParse accepted options.auth.token and options.pool.password on BASE; both refused at their exact paths on the fix, with options.auth.password refusal 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.ts off-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 / 403 PERMISSION_DENIED without manage_platform_settings; pinned by admin-routes-auth-guard.test.ts ("answers 403 PERMISSION_DENIED without manage_platform_settings" per route).
  • GET /api/v1/meta/datasourceYES for any authenticated same-tenant user: registerMetadataEndpoints (rest-server.ts) wraps every /meta/* route in enforceAuth only — 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 _drafts route). 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 (--listFiles names 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)
  • Derived gate families (scripts/pm/dispatch-gates.mjs at 2f02ca6, no hand-fed paths): 25 families derived; 24 run locally, all exit 0 — including check: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").
  • NOT measured locally, declared: check:dual-build-cjs-loads answered "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-wide pnpm lint likewise left to CI.
  • metadata-protocol ships no typecheck script (coverage-ledger package; no source files edited there — tests only ran).

Notes for review

  • Clause-② parking: opened as DRAFT; needs:contract-review re-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.
  • The card's second-order DELETE-eviction rider was NOT confirmed at the MetadataManager layer — counter-evidence in the report on the card (unregister does fan out cluster-wide via notifyWatchers → CLUSTER_CHANNEL → peer invalidateForForeignWrite). Left to PM re-triage; out of scope here either way.
  • Reproduction specifics stay withheld per the 2026-08-18 disclosure ruling; nothing in this PR names the QA recipe.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-datasource, @objectstack/spec, touching 18 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via MongoConfigSchema (symbol), authToken (literal))
  • content/docs/plugins/packages.mdx(via authToken (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via authToken (literal), auth_token (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 83fbc93970b1825577a9406ebde7c732acf4cef4 — the merge of head 2f02ca6ae1f2d11f27d214f1ce9d7cf051971069 into base 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09b0d7b9511b6b4e29a24189ab6c4998b437ddb2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 needs:contract-review park). Not a self-release: the dispatching seat acts on the maintainer's word, citing it here. Pre-release verification: all 48 check runs on head 2f02ca6a completed success/skipped (zero red); PM checklist review of record: ACCEPT on #13405 (comment 5473184091), run at claude-fable-5; dispatch tier claude-fable-5 (not below CONTRACT_REVIEW_TIER); security-fix direction (the safer fix: derived nested judgment) taken; no governed surface in the diff. Landing note stands: this diff carries one migrations/registry.ts row (#8360 text-merge magnet) — if a sibling ADR-0087 entry lands first, regenerate with the repo tooling. Stripping needs:contract-review from both carriers, flipping ready, arming the queue. — session_01PBjwYLS6BciTQW3c9xQiD2


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review August 31, 2026 04:07
@os-warren
os-warren enabled auto-merge August 31, 2026 04:07
@os-warren
os-warren added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 51ecb2fAug 31, 2026
53 checks passed
@os-warren
os-warren deleted the claude/issue-13405-nested-credential-redaction branch August 31, 2026 04:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only

2 participants

@os-warren@claude