Skip to content

Commit 5f5762d

Browse files
authored
feat(objectql): mask password fields on the generic read path (#2036) (#3170)
Mask `password`-typed fields to SECRET_MASK on the generic read path (find/findOne/$expand), mirroring `secret` but keeping the value plaintext at rest (no encryption, no CryptoProvider). Echoed masks are dropped on write; `managedBy: 'better-auth'` objects are exempt so login reads still see the stored value. ObjectSchema.create() warns (non-fatally) when a `password` field is declared on a non-auth object. Decision recorded in the unified ADR-0100 (secret + password credential channels). Aggregate-path gap tracked in #3171. Closes #2036.
1 parent deb7e7e commit 5f5762d

13 files changed

Lines changed: 437 additions & 41 deletions

File tree

content/docs/data-modeling/field-types.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ Phone number field.
7676
```
7777

7878
### `password`
79-
Masked password input (stored as a one-way hash, owned by the auth subsystem). For reversible encrypted-at-rest secrets (API keys, tokens), use the `secret` type instead.
79+
Masked password input. On a generic object the value is **plaintext at rest** but **masked to `••••••••` on read** (ADR-0100) — it is *not* one-way hashed. One-way hashing is owned by the auth subsystem and applies only to its identity tables, never to an authored `password` field. For reversible encrypted-at-rest secrets (API keys, tokens, DB passwords), use the `secret` type instead; for login credentials, model them on the auth user object.
8080

8181
```typescript
8282
{ name: 'password', label: 'Password', type: 'password', required: true }
8383
```
8484

8585
### `secret`
86-
Reversible, encrypted-at-rest value (DB password, API key, token). Unlike `password` (a one-way hash), a `secret` is encrypted on write via the registered crypto provider, stored as an opaque ref on the row, and masked on read. Fail-closed: with no provider configured, writes throw rather than persist cleartext.
86+
Reversible, encrypted-at-rest value (DB password, API key, token). Unlike `password` (masked on read but plaintext at rest, or one-way hashed inside the auth subsystem), a `secret` is encrypted on write via the registered crypto provider, stored as an opaque ref on the row, and masked on read. Fail-closed: with no provider configured, writes throw rather than persist cleartext.
8787

8888
```typescript
8989
{ name: 'api_key', label: 'API Key', type: 'secret' }
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.

examples/app-showcase/src/data/objects/field-zoo.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const FieldZoo = ObjectSchema.create({
3535
f_email: Field.email({ label: 'Email', searchable: true }),
3636
f_url: Field.url({ label: 'URL' }),
3737
f_phone: Field.phone({ label: 'Phone' }),
38-
f_password: Field.password({ label: 'Password (one-way hash)' }),
38+
f_password: Field.password({ label: 'Password (masked on read)' }),
3939
f_secret: Field.secret({ label: 'Secret (encrypted at rest)' }),
4040

4141
// ── Rich content ─────────────────────────────────────────────────────

packages/objectql/src/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export {
7575
isSecretRef,
7676
parseSecretRef,
7777
collectSecretFields,
78+
collectMaskedReadFields,
7879
} from './secret-fields.js';
7980

8081
// Utilities

packages/objectql/src/engine.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contra
1919
import type { ICryptoProvider, CryptoHandle } from '@objectstack/spec/contracts';
2020
import {
2121
collectSecretFields,
22+
collectMaskedReadFields,
2223
makeSecretRef,
2324
parseSecretRef,
2425
isSecretRef,
@@ -1363,19 +1364,28 @@ export class ObjectQL implements IDataEngine {
13631364
}
13641365

13651366
/**
1366-
* Encrypt any `secret`-typed fields on `row` in place before it reaches the
1367-
* driver. Each plaintext is wrapped by the ICryptoProvider, persisted as a
1368-
* `sys_secret` row, and replaced on `row` by an opaque ref. Cleartext never
1369-
* reaches the business table.
1367+
* Normalize credential fields on `row` in place before it reaches the driver.
1368+
*
1369+
* Two channels share the read mask ({@link SECRET_MASK}) but differ on write
1370+
* (see ADR-0100):
1371+
*
1372+
* - **`secret`** — encrypted: each plaintext is wrapped by the ICryptoProvider,
1373+
* persisted as a `sys_secret` row, and replaced on `row` by an opaque ref.
1374+
* Cleartext never reaches the business table.
1375+
* - **`password`** (generic, non-`better-auth`) — plaintext at rest: stored
1376+
* verbatim, no encryption and no `sys_secret` row. Only the echoed-mask drop
1377+
* below applies to it.
13701378
*
13711379
* Rules:
1372-
* - No secret fields on the object ⇒ no-op (fast path, no crypto cost).
1373-
* - `null`/`undefined` value ⇒ left as-is (clears the secret).
1374-
* - Value already a ref (re-save of an unchanged ref) ⇒ left as-is.
1375-
* - Value equal to the read mask ⇒ dropped, so a form round-trip that
1376-
* echoes the mask does not overwrite the stored secret.
1377-
* - **Fail-closed:** any other value with no CryptoProvider registered, or
1378-
* no reachable `sys_secret` store, THROWS — never persists cleartext.
1380+
* - Any masked field (secret or password) whose value equals the read mask ⇒
1381+
* the key is dropped, so a form round-trip that echoes the mask does not
1382+
* overwrite the stored value.
1383+
* - No secret fields on the object ⇒ no further work (fast path, no crypto).
1384+
* - `null`/`undefined` secret value ⇒ left as-is (clears the secret).
1385+
* - Secret value already a ref (re-save of an unchanged ref) ⇒ left as-is.
1386+
* - **Fail-closed:** any other secret value with no CryptoProvider registered,
1387+
* or no reachable `sys_secret` store, THROWS — never persists cleartext.
1388+
* (A `password` field needs no CryptoProvider — it is stored as-is.)
13791389
*/
13801390
private async encryptSecretFields(
13811391
object: string,
@@ -1385,6 +1395,17 @@ export class ObjectQL implements IDataEngine {
13851395
): Promise<void> {
13861396
if (!row || typeof row !== 'object') return;
13871397
const schema = this._registry.getObject(object);
1398+
1399+
// Echoed-mask drop for every field the read path masks (secret + generic
1400+
// password). The read path returns SECRET_MASK; a client that PATCHes it
1401+
// back means "unchanged", so drop the key rather than persist the literal
1402+
// mask. Doing this up front keeps the better-auth exemption in one place
1403+
// (collectMaskedReadFields) and covers objects that have a password field
1404+
// but no secret field. (#2036, ADR-0100)
1405+
for (const field of collectMaskedReadFields(schema)) {
1406+
if (field in row && row[field] === SECRET_MASK) delete row[field];
1407+
}
1408+
13881409
const secretFields = collectSecretFields(schema);
13891410
if (secretFields.length === 0) return;
13901411

@@ -1394,12 +1415,6 @@ export class ObjectQL implements IDataEngine {
13941415

13951416
if (value === null || typeof value === 'undefined') continue; // clear
13961417
if (isSecretRef(value)) continue; // already encrypted ref
1397-
if (value === SECRET_MASK) {
1398-
// The read path masks secrets to SECRET_MASK; a form that echoes it
1399-
// back means "unchanged". Drop the key so the stored secret survives.
1400-
delete row[field];
1401-
continue;
1402-
}
14031418

14041419
if (!this.cryptoProvider) {
14051420
throw new Error(
@@ -1446,20 +1461,23 @@ export class ObjectQL implements IDataEngine {
14461461
}
14471462

14481463
/**
1449-
* Mask `secret`-typed fields on read so plaintext never leaves the engine
1450-
* through the normal query path. A set secret becomes {@link SECRET_MASK};
1451-
* an unset one stays `null`. Privileged callers that genuinely need the
1452-
* plaintext use {@link resolveSecret} against the stored ref.
1464+
* Mask credential fields on read so plaintext never leaves the engine through
1465+
* the normal query path. Covers `secret` fields (always) and `password` fields
1466+
* on generic, non-`better-auth` objects (see {@link collectMaskedReadFields}
1467+
* and ADR-0100). A set value becomes {@link SECRET_MASK}; an unset one stays
1468+
* `null`. Privileged callers that genuinely need a secret's plaintext use
1469+
* {@link resolveSecret} against the stored ref; a `password` field is stored
1470+
* as plaintext at rest, so its cleartext is only ever reachable off this path.
14531471
*/
14541472
private maskSecretFields(object: string, rows: any): void {
14551473
if (!rows) return;
14561474
const schema = this._registry.getObject(object);
1457-
const secretFields = collectSecretFields(schema);
1458-
if (secretFields.length === 0) return;
1475+
const maskedFields = collectMaskedReadFields(schema);
1476+
if (maskedFields.length === 0) return;
14591477
const list = Array.isArray(rows) ? rows : [rows];
14601478
for (const row of list) {
14611479
if (!row || typeof row !== 'object') continue;
1462-
for (const field of secretFields) {
1480+
for (const field of maskedFields) {
14631481
if (!(field in row)) continue;
14641482
row[field] = row[field] == null ? null : SECRET_MASK;
14651483
}

packages/objectql/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export {
102102
isSecretRef,
103103
parseSecretRef,
104104
collectSecretFields,
105+
collectMaskedReadFields,
105106
} from './secret-fields.js';
106107

107108
// Export Utilities

packages/objectql/src/secret-fields.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,91 @@ describe('objectql secret-field channel', () => {
232232
}
233233
});
234234
});
235+
236+
// A generic (non-better-auth) object with a `password` field. Unlike `secret`,
237+
// it is plaintext at rest but masked on read — no crypto involved (ADR-0100).
238+
const deviceObject = {
239+
name: 'device', label: 'Device',
240+
fields: {
241+
id: { name: 'id', label: 'ID', type: 'text' as const },
242+
name: { name: 'name', label: 'Name', type: 'text' as const },
243+
admin_password: { name: 'admin_password', label: 'Admin Password', type: 'password' as const },
244+
},
245+
};
246+
247+
// An identity table the auth subsystem owns: `managedBy: 'better-auth'` exempts
248+
// its `password` field from masking so better-auth's own reads see the value.
249+
const authUserObject = {
250+
name: 'authy_user', label: 'Auth User', managedBy: 'better-auth' as const,
251+
fields: {
252+
id: { name: 'id', label: 'ID', type: 'text' as const },
253+
password: { name: 'password', label: 'Password', type: 'password' as const },
254+
},
255+
};
256+
257+
async function buildPasswordEngine() {
258+
// Deliberately NO CryptoProvider — a password field must not require one.
259+
const engine = new ObjectQL();
260+
const { driver, stores } = makeMemoryDriver();
261+
engine.registerDriver(driver, true);
262+
await engine.init();
263+
engine.registry.registerObject(deviceObject as any);
264+
engine.registry.registerObject(authUserObject as any);
265+
return { engine, stores };
266+
}
267+
268+
describe('objectql password-field masking (ADR-0100)', () => {
269+
let ctx: Awaited<ReturnType<typeof buildPasswordEngine>>;
270+
beforeEach(async () => { ctx = await buildPasswordEngine(); });
271+
272+
it('stores plaintext at rest (no crypto), but masks on find / findOne', async () => {
273+
const created = await ctx.engine.insert('device', { name: 'router', admin_password: 'hunter2' });
274+
275+
// At rest: the driver row holds the verbatim plaintext, and nothing was
276+
// written to sys_secret (no encryption channel for password).
277+
const stored = ctx.stores.get('device')!.get(created.id) as any;
278+
expect(stored.admin_password).toBe('hunter2');
279+
expect(ctx.stores.get('sys_secret')?.size ?? 0).toBe(0);
280+
281+
// On read: the generic path never echoes the plaintext.
282+
const viaFind = (await ctx.engine.find('device', { where: { id: created.id } }))[0] as any;
283+
expect(viaFind.admin_password).toBe(SECRET_MASK);
284+
const viaOne = await ctx.engine.findOne('device', { where: { id: created.id } }) as any;
285+
expect(viaOne.admin_password).toBe(SECRET_MASK);
286+
});
287+
288+
it('an unset password reads back as null, not the mask', async () => {
289+
const created = await ctx.engine.insert('device', { name: 'router', admin_password: null });
290+
const viaOne = await ctx.engine.findOne('device', { where: { id: created.id } }) as any;
291+
expect(viaOne.admin_password).toBeNull();
292+
});
293+
294+
it('echoing the read mask back does NOT overwrite the stored password', async () => {
295+
const created = await ctx.engine.insert('device', { name: 'router', admin_password: 'keep-me' });
296+
297+
// Form round-trip: user renames the device, the masked field echoes back.
298+
await ctx.engine.update('device', { id: created.id, name: 'gateway', admin_password: SECRET_MASK });
299+
300+
const stored = ctx.stores.get('device')!.get(created.id) as any;
301+
expect(stored.name).toBe('gateway');
302+
expect(stored.admin_password).toBe('keep-me'); // unchanged plaintext
303+
});
304+
305+
it('updating with a real new value replaces the stored plaintext', async () => {
306+
const created = await ctx.engine.insert('device', { name: 'router', admin_password: 'old-pw' });
307+
await ctx.engine.update('device', { id: created.id, admin_password: 'new-pw' });
308+
309+
const stored = ctx.stores.get('device')!.get(created.id) as any;
310+
expect(stored.admin_password).toBe('new-pw');
311+
// And a read still masks it.
312+
const viaOne = await ctx.engine.findOne('device', { where: { id: created.id } }) as any;
313+
expect(viaOne.admin_password).toBe(SECRET_MASK);
314+
});
315+
316+
it('better-auth identity tables are exempt: their password field is NOT masked on read', async () => {
317+
const created = await ctx.engine.insert('authy_user', { password: 'hashed-by-auth' });
318+
const viaOne = await ctx.engine.findOne('authy_user', { where: { id: created.id } }) as any;
319+
// Masking here would break login — the auth subsystem must read its own value.
320+
expect(viaOne.password).toBe('hashed-by-auth');
321+
});
322+
});

0 commit comments

Comments
 (0)