feat(objectql): mask password fields on the generic read path (#2036)#3170
Merged
Conversation
A `password`-typed field on a non-auth object round-tripped plaintext through the generic CRUD engine — neither hashed nor masked the way `secret` is. Someone modeling a `password` field on a custom object reasonably expects credential-grade handling; they silently got plaintext storage and plaintext reads. Mask `password` to SECRET_MASK on the generic read path (find/findOne, and $expand which re-enters find), mirroring `secret`. Unlike `secret`, a password value stays plaintext at rest — no encryption, no sys_secret row, no CryptoProvider required. An echoed mask is dropped on write so a form round-trip does not overwrite the stored value. Objects marked `managedBy: 'better-auth'` are exempt so the auth subsystem's own reads (which go through the engine) still see the stored value; a platform-objects pin asserts no shipped identity object even declares a `password` field today. `ObjectSchema.create()` now warns (non-fatally, deduped per object) when a `password` field is declared on a non-auth object, steering authors to `secret` or the auth subsystem. Decision and rationale recorded in ADR-0100; the aggregate() masking gap (pre-existing for `secret` too) is noted there as a follow-up. - objectql: add collectMaskedReadFields; wire mask/echoed-mask paths - spec: author-time warning in ObjectSchema.create(); correct field.zod docs - tests: password-masking units, warning units, platform-objects pin, dogfood field-zoo f_password upgraded present -> masked - examples: field-zoo label 'Password (one-way hash)' -> '(masked on read)' Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…not hashed The field-type gallery said a `password` field is "stored as a one-way hash" — inaccurate for a generic object, where (per ADR-0100 / this PR) the value is plaintext at rest but masked to •••••••• on read. One-way hashing is owned by the auth subsystem and applies only to its identity tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
… password) Reframe ADR-0100 as the single record for both credential-bearing field types: it now documents the pre-existing `secret` channel (encrypted at rest, masked on read, fail-closed) alongside the new `password` masking decision (#2036), and gives the long-dangling "secret field channel" comment references a real home. Renamed the file to reflect the unified scope. All code references use the stable ADR-0100 number, unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
os-zhuang
marked this pull request as ready for review
July 18, 2026 06:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #2036. A
password-typed field declared on a non-auth object (e.g.showcase_field_zoo.f_password) round-tripped plaintext through the generic CRUD engine — it was neither one-way hashed nor masked-on-read the waysecretis. Someone modeling apasswordfield on a custom object reasonably expects credential-grade handling; today they silently got plaintext storage and plaintext reads, a runtime/security trap the static gates don't catch.This implements the issue's leaning decision (option 1 + 3): mask on read like
secret, plus a non-fatal author-time warning. The rationale is recorded in ADR-0100, which this PR also unifies to cover both credential channels (secretandpassword) — giving the previously-dangling "secret field channel" comment references a real home.What changed
passwordfields are masked toSECRET_MASK(••••••••) infind/findOne(and therefore$expand, which re-entersfind). A newcollectMaskedReadFieldshelper returns everysecretfield plus every genericpasswordfield;maskSecretFieldsconsumes it.secretbehavior is unchanged.secret, apasswordvalue is not encrypted and gets nosys_secretrow; it's stored verbatim and needs noCryptoProvider. One-way hashing was rejected (option 2): it only makes sense for credential verification, which a non-auth object never does, and doing it in the engine would stand up a second, unmanaged credential store.SECRET_MASK, so a form round-trip that echoes the mask is a no-op rather than overwriting the stored value.managedBy: 'better-auth'exemption — the auth subsystem reads its identity rows through the engine, so masking a credential column there would break login. Better-auth objects are exempt. Today this is a safety net, not load-bearing: no shipped identity object even declares apassword-typed field (sys_account.passwordis a hashedtextcolumn) — pinned by a platform-objects test so retyping it becomes a deliberate decision.ObjectSchema.create()emits a dedupedconsole.warnwhen apasswordfield is declared on a non-better-authobject, steering authors toField.secretor the auth subsystem. A warning, not a build error —passwordnow has a defined generic-path contract, and the field-zoo example intentionally exercises every field type, so a hard error would be self-inflicted breakage.password"one-way hashed" (flagged by the docs-drift check).Tests
packages/objectql/src/secret-fields.test.ts— new password-masking block: plaintext at rest (no crypto, nosys_secret), masked on find/findOne, unset staysnull, echoed-mask no-op, new value replaces, and the better-auth exemption.packages/spec/src/data/object.test.ts— warns once for a generic password field, deduped, silent for better-auth and forsecret.packages/platform-objects/src/platform-objects.test.ts— pins that no shipped object declares apassword-typed field, and thatsys_account.passwordistext.packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts—f_passwordupgraded frompresenttomasked(verified reading backSECRET_MASKover real HTTP).Verification
All run green from a fresh worktree:
@objectstack/objectql— 12 tests (7 new)@objectstack/spec— object.test.ts, 103 tests@objectstack/platform-objects— 120 tests@objectstack/dogfood— field-zoo HTTP round-trip, 46 tests (real REST → engine path)Follow-up
aggregate()masks neithersecretnorpassword(a pre-existing gap forsecret). Post-hoc masking of aggregate output would corrupt group keys; the correct fix is to reject aggregations that reference a credential field. Noted in ADR-0100 and tracked in #3171.🤖 Generated with Claude Code
Generated by Claude Code