fix(metadata-protocol): redact stored credentials on the metadata read path, with the write-path carry-forward (#8154) - #8673
Conversation
…d path (#8154) The /meta read exits served the whole stored body, so a legacy datasource row came back with config.password in cleartext on getMetaItems, getMetaItem and getMetaItemLayered (both overlay and effective layers). Consume the per-type redactor registry landed by #8300 (@objectstack/spec/kernel) rather than patching the datasource shape: datasource is the registry's first consumer, not this code's subject. _diagnostics stay computed on the RAW stored body BEFORE redaction — the redacted body is exactly what the post-#8078 schema accepts, so the inverse ordering flips valid:false to valid:true and destroys the #8081 item-3 migration inventory. Composed inside decorateMetadataItem so no call site can invert an ordering it cannot see. Ships with the write-path inverse, which is not optional: saveMetaItem accepts a redacted body and persists the credential away, so the read scrub alone converts today's loud 422 into silent credential deletion on an ordinary GET-edit-PUT round trip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…a-read-path-redaction
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #8154
decorateMetadataItemreturned the whole stored body, so adatasourcerow written before #8078 closed the write door came back withconfig.passwordin cleartext — and the password embedded inconfig.urlbeside it — fromGET /api/v1/meta/datasources, from the single-item read, and from the layered read in both itsoverlayandeffectivelayers. PR #8126 closed the datasource-admin door (GET /api/v1/datasources/:name); this closes the platform door one over.This carries all five things the PM review settled in comment
5275779695, in one PR: the hook consumption, the datasource redactor as its first consumer, the read-exit application, thegetMetaItemLayeredexit, and thesaveMetaItemwrite-path carry-forward.Why the read scrub could not ship alone
Measured on
origin/mainbefore this change, and the reason this is one PR rather than two:saveMetaItemaccepts a redacted datasource body and persists the credential away. So a read-path redaction with no write-path inverse converts today's loud422into silent credential deletion on an ordinary/metaGET → edit → PUT round trip.config.urlmakes the inverse unavoidable rather than a masking choice — a URL-embedded password is schema-accepted (#8078 pinned that as fact), so dropping it round-trips to deletion and masking it round-trips to storing the mask as the literal password.The carry-forward is the generic form of what PR #8126 added to
updateDatasource, and sits where that one sits: after every gate, immediately before the put. The gates judge what the author wrote; this restores material the author never saw and is not asking to change. Stored material is re-applied only where the incoming body is indistinguishable from what the read served — anything the author actually wrote wins and is still judged by #8078's write gate on its own merits, so a typed-inpasswordis refused exactly as before.It also restores the #4326 byte-identical round-trip invariant that read-redaction alone would have broken: an untouched GET → PUT of a legacy datasource now persists a body identical to the one at rest.
The generic hook, not a datasource patch
The redactor is resolved through
getMetadataTypeRedactorfrom@objectstack/spec/kernel— the seam #8300 landed.datasourceis that registry's first consumer and is never named in this diff's logic; a plugin whose metadata type stores secrets (the SSO seat next) gets the same protection by callingregisterMetadataTypeRedactor, with no change here.packages/specis consumed read-only — nothing in it is touched.Two fail-open traps closed deliberately:
_diagnosticsareundefined. A type with a redactor and no registered Zod schema is exactly the shape a plugin's secret-bearing type arrives in; an early return on the diagnostics miss would serve its credentials in cleartext, keyed on an unrelated registration.catchhere produces the outcome A per-type metadata redaction seam belongs in@objectstack/spec/kernel— no service package can reach a registry inmetadata-protocol#8300's own header calls the worst available one — a redaction that looks installed but is not applied._diagnosticsordering — load-bearing, and made structuralDiagnostics are still computed on the raw stored body, before redaction. The redacted body is precisely the shape the post-#8078 schema accepts, so computing them afterwards flips
valid:falsetovalid:trueon exactly the rows holding a stored credential — deleting the operator's only inventory of what still needs migrating (#8081 item 3).Rather than leave that as an ordering four call sites must remember, the two steps are composed inside
decorateMetadataItem, so no call site can invert an ordering it cannot see. Reverse-verified in the predicted direction: inverting the two lines turns the badge assertions red withexpected true to be false, while the redaction assertions stay green.codeandoverlaytoo — please contest this if it is wronggetMetaItemLayerednever callsdecorateMetadataItem; it computes_diagnosticsitself and serves three raw layers. All three are redacted here, and that is a reading of another lane's ruling, stated rather than inherited, fordomain:engine-core/domain:specreviewers to check:⛔ It is not a licence to fold, govern or inject on those layers. Redaction subtracts, and only a credential.
Scope boundaries held
packages/specuntouched — consumed read-only.sys_metadatathrough the data engine (loadDatasourceRows) and the code bundle, never through these exits. The stored record is never mutated; redaction is a serving act.redactedKeyswould be a read decoration, and that list lives inspec/kernel/metadata-read-decorations.ts— a key not on it rides the GET → edit → PUT round trip into a closed schema (未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001) and fails every legacy save withunrecognized_keysnaming a key the author never wrote. That file ispackages/spec's to change; noted in the module header.sys_metadatais [security]sys_metadata.metadatais a general cleartext sink: any authored artefact whose schema permits an inline credential lands it there (datasourceconfig.password, connectorauthentication) #7990's and is not re-filed.The two measurements #8154 owed (A3)
SysMetadataRepository.getByHash— an ADR-0009 contract method implemented by four repositories with no production caller anywhere: every reference outside the implementations is the contract suite, unit tests, or a docblock. It serves nothing today.SysMetadataRepository.restoreVersion— a write. Both callers (rollbackMetaItem,revertCommit) return receipts with no body; the restored body reaches onlyapplyRegistryWriteThrough, which must stay raw. No leak, and redacting there would corrupt the restored row.A third exit does leak and is filed rather than folded in:
GET /api/v1/meta/:type/:name/diffreturns stored body values fromsys_metadata_history, a plane no item-level redactor reaches — #8671. Redacting it is a genuinely different decision (redacting both bodies makes a credential rotation an invisible no-diff), so it needs its own evidence.Verification — all readings taken at
f0221b7c9, the head this PR pushesPer-package, not an aggregate:
@objectstack/metadata-protocol@objectstack/rest@objectstack/runtime@objectstack/service-datasourcetypecheckgreen on all four. Gate union at the same head —check:nul-bytes,check:cross-package-test-inputs,check:durability-log-level,check:error-code-casing,check:filter-alias-parity,check:changeset-gate-self-tests,check:objectui-changeset,check:engine-double-contract,check:query-options-erasure,check:type-check-coverage— all pass, pluspnpm --filter @objectstack/spec check:generated(13/13 up to date) after mergingmain.The last four gates were derived from the actual changed paths with
scripts/pm/dispatch-gates.mjs; the changeset family and the test-file convention family were not on the dispatch list and are named here for that reason.17 new assertions in
protocol.metadata-redaction.test.ts, with both anti-vacuity arms wired:datasourcewith an identity redactor through the publicregisterMetadataTypeRedactoroverlay and asserts the cleartext comes back on all three exits — so the green above is a statement about the redactor running, not about a credential-free fixture;Every fixture row is seeded directly into the stub engine rather than through
saveMetaItem— that is the population under test, since #8078 means such a row can no longer be authored through any door.Generated by Claude Code