Skip to content

Commit a6d21c4

Browse files
committed
docs(adr): unify ADR-0100 to cover both credential channels (secret + 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
1 parent e376713 commit a6d21c4

2 files changed

Lines changed: 125 additions & 96 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# ADR-0100: Credential Field Channels — `secret` (encrypted) and `password` (masked)
2+
3+
- **Status**: Accepted
4+
- **Date**: 2026-07-18
5+
- **Issue**: #2036 (found via #2033 / #2025 / #2028 field-type round-trip work)
6+
- **Relates to**: ADR-0077 (authoring-surface boundary), ADR-0078 (no silently
7+
inert metadata), ADR-0069 (enterprise authentication hardening)
8+
9+
This ADR unifies the two credential-bearing field types under one record. The
10+
`secret` channel had shipped without its own ADR — code comments referenced a
11+
"secret field channel" ADR that never existed; this document is that home. The
12+
`password` masking decision (#2036) is the new material and is documented
13+
alongside it, because the two types share the read mask and only make sense when
14+
contrasted.
15+
16+
## Context
17+
18+
ObjectStack has three places a credential can live, and they must not be
19+
confused:
20+
21+
1. **`secret`** — a reversible machine credential (DB password, API key, token)
22+
authored on any object. Already implemented: encrypted at rest, masked on
23+
read, decryptable only through a privileged path.
24+
2. **`password`** — a field type authors reach for on custom objects. Its
25+
*intended* association (one-way hashing) belongs to the auth subsystem, but a
26+
`password` field on a **non-auth** object never touched that subsystem.
27+
3. **Auth-subsystem credentials** — better-auth's identity tables
28+
(`sys_account.password`, a hashed `text` column), one-way hashed off the
29+
generic CRUD path entirely.
30+
31+
The bug (#2036): a `password`-typed field on a non-auth object (e.g.
32+
`showcase_field_zoo.f_password`) round-tripped **plaintext** through the generic
33+
CRUD engine — neither hashed nor masked. This is a low-code platform where field
34+
types are author-driven (often by an AI); someone modeling a `password` field
35+
reasonably expects credential-grade handling and silently got plaintext storage
36+
and plaintext reads, a runtime/security trap the static gates do not catch.
37+
38+
The issue framed four options: (1) mask on read like `secret`; (2) hash on write
39+
in the generic path; (3) an author-time guard; (4) document as auth-only. Option
40+
2 is the wrong fit — one-way hashing only makes sense for credential
41+
*verification*, which a non-auth object never does, and doing it in the engine
42+
would stand up a second, unmanaged credential store that violates the
43+
"auth subsystem owns credentials" boundary. Option 4 leaves the silent trap in
44+
place. This ADR adopts **1 + 3** for `password`, and records the pre-existing
45+
`secret` channel it now sits beside.
46+
47+
## Decision
48+
49+
### A. The `secret` channel (records existing behavior)
50+
51+
A `secret` field is **reversible and encrypted at rest**:
52+
53+
- **Write** — the plaintext is wrapped by the registered `ICryptoProvider`,
54+
persisted as a `sys_secret` row, and replaced on the business row by an opaque
55+
`secret:<id>` ref. Cleartext never reaches the business table.
56+
- **Read** — the ref is masked to `SECRET_MASK` (`••••••••`) on the generic path
57+
(`find`/`findOne`/`$expand`); an unset secret reads back `null`.
58+
- **Fail-closed** — writing a secret value with no `CryptoProvider` registered,
59+
or no reachable `sys_secret` store, THROWS rather than persist cleartext.
60+
- **Privileged read**`resolveSecret(ref)` is the only sanctioned way back to
61+
plaintext (e.g. a datasource connection binder); it is never on the generic
62+
read path.
63+
64+
### B. The `password` channel (new, #2036)
65+
66+
A `password` field on a generic (non-`better-auth`) object is **plaintext at
67+
rest but masked on read**:
68+
69+
1. **Masked on read** — masked to `SECRET_MASK` in `find`/`findOne` (and
70+
`$expand`, which re-enters `find`), exactly like `secret`.
71+
2. **Plaintext at rest, by design****not** encrypted, **no** `sys_secret`
72+
row, **no** `CryptoProvider` required. Masking is a read-path transform only.
73+
This keeps the change minimal and avoids a second credential store. Authors
74+
who need reversible encryption-at-rest should use `secret`.
75+
3. **Echoed-mask write guard** — because a read now returns `SECRET_MASK`, a
76+
client that reads a record and PATCHes it back would otherwise overwrite the
77+
stored value with the literal mask. The write path drops any masked field
78+
(secret or password) whose incoming value equals `SECRET_MASK`, so an
79+
unchanged round-trip is a no-op. Accepted cost: the literal string
80+
`••••••••` cannot itself be stored as a password via an echoing client.
81+
4. **`managedBy: 'better-auth'` exemption** — the auth subsystem reads its
82+
identity rows *through* the engine's `find`/`findOne`, so masking a credential
83+
column there would break login. Objects marked `managedBy: 'better-auth'` are
84+
exempt from password masking. Today this is a safety net, not load-bearing:
85+
no shipped identity object even declares a `password`-typed field
86+
(`sys_account.password` is a hashed `text` column), pinned by a
87+
platform-objects test so retyping it becomes a deliberate decision.
88+
5. **Non-fatal author-time warning (ADR-0077/0078)**`ObjectSchema.create()`
89+
emits a `console.warn` (deduped per object name) when a `password` field is
90+
declared on a non-`better-auth` object, steering authors to `Field.secret`
91+
for reversible machine credentials or to the auth subsystem for login
92+
credentials. It is a *warning*, not a build error: `password` now has a
93+
defined generic-path contract (so ADR-0078 does not compel an error), and the
94+
field-zoo example intentionally exercises every field type — a hard error
95+
would be self-inflicted breakage. Raw `.parse()` stays silent, since it also
96+
loads persisted metadata and `create()` is the authoring surface (ADR-0077).
97+
98+
### C. Shared mechanism
99+
100+
Both channels share one read-mask collector — `collectMaskedReadFields`
101+
(`packages/objectql/src/secret-fields.ts`): every `secret` field, plus every
102+
`password` field on a non-`better-auth` object. `maskSecretFields` (read) and the
103+
echoed-mask drop (write) in `engine.ts` both consume it, so the better-auth
104+
exemption lives in exactly one place. `SECRET_MASK` is the single mask constant
105+
for both.
106+
107+
## Consequences
108+
109+
- Edit forms that prefill from a read now show the mask for `password` fields —
110+
identical to the existing `secret` UX. Unchanged-value saves are protected by
111+
the echoed-mask guard (B3).
112+
- The generic read path no longer leaks credential plaintext for `password`
113+
fields; the field-zoo HTTP round-trip pins this (`f_password` upgraded from
114+
`present` to `masked`).
115+
- The dangling "secret field channel" references in `field.zod.ts` and
116+
`secret-fields.ts` now resolve to this ADR.
117+
118+
## Non-goals / follow-ups
119+
120+
- **`aggregate()` masking gap.** `aggregate()` masks neither `secret` nor
121+
`password` — a pre-existing gap for `secret`. Post-hoc masking of aggregate
122+
output would corrupt group keys; the correct fix is to *reject* aggregations
123+
that reference a credential field. Tracked in #3171, not addressed here.
124+
- **Hashing / verification for authored `password` fields** — explicitly out of
125+
scope (B2). Credential verification belongs to the auth subsystem.

docs/adr/0100-password-field-masking-in-generic-read-path.md

Lines changed: 0 additions & 96 deletions
This file was deleted.

0 commit comments

Comments
 (0)