Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ Phone number field.
```

### `password`
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.
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.

```typescript
{ name: 'password', label: 'Password', type: 'password', required: true }
```

### `secret`
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.
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.

```typescript
{ name: 'api_key', label: 'API Key', type: 'secret' }
Expand Down
125 changes: 125 additions & 0 deletions docs/adr/0100-credential-field-channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# ADR-0100: Credential Field Channels — `secret` (encrypted) and `password` (masked)

- **Status**: Accepted
- **Date**: 2026-07-18
- **Issue**: #2036 (found via #2033 / #2025 / #2028 field-type round-trip work)
- **Relates to**: ADR-0077 (authoring-surface boundary), ADR-0078 (no silently
inert metadata), ADR-0069 (enterprise authentication hardening)

This ADR unifies the two credential-bearing field types under one record. The
`secret` channel had shipped without its own ADR — code comments referenced a
"secret field channel" ADR that never existed; this document is that home. The
`password` masking decision (#2036) is the new material and is documented
alongside it, because the two types share the read mask and only make sense when
contrasted.

## Context

ObjectStack has three places a credential can live, and they must not be
confused:

1. **`secret`** — a reversible machine credential (DB password, API key, token)
authored on any object. Already implemented: encrypted at rest, masked on
read, decryptable only through a privileged path.
2. **`password`** — a field type authors reach for on custom objects. Its
*intended* association (one-way hashing) belongs to the auth subsystem, but a
`password` field on a **non-auth** object never touched that subsystem.
3. **Auth-subsystem credentials** — better-auth's identity tables
(`sys_account.password`, a hashed `text` column), one-way hashed off the
generic CRUD path entirely.

The bug (#2036): a `password`-typed field on a non-auth object (e.g.
`showcase_field_zoo.f_password`) round-tripped **plaintext** through the generic
CRUD engine — neither hashed nor masked. This is a low-code platform where field
types are author-driven (often by an AI); someone modeling a `password` field
reasonably expects credential-grade handling and silently got plaintext storage
and plaintext reads, a runtime/security trap the static gates do not catch.

The issue framed four options: (1) mask on read like `secret`; (2) hash on write
in the generic path; (3) an author-time guard; (4) document as auth-only. Option
2 is the wrong fit — one-way hashing 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 that violates the
"auth subsystem owns credentials" boundary. Option 4 leaves the silent trap in
place. This ADR adopts **1 + 3** for `password`, and records the pre-existing
`secret` channel it now sits beside.

## Decision

### A. The `secret` channel (records existing behavior)

A `secret` field is **reversible and encrypted at rest**:

- **Write** — the plaintext is wrapped by the registered `ICryptoProvider`,
persisted as a `sys_secret` row, and replaced on the business row by an opaque
`secret:<id>` ref. Cleartext never reaches the business table.
- **Read** — the ref is masked to `SECRET_MASK` (`••••••••`) on the generic path
(`find`/`findOne`/`$expand`); an unset secret reads back `null`.
- **Fail-closed** — writing a secret value with no `CryptoProvider` registered,
or no reachable `sys_secret` store, THROWS rather than persist cleartext.
- **Privileged read** — `resolveSecret(ref)` is the only sanctioned way back to
plaintext (e.g. a datasource connection binder); it is never on the generic
read path.

### B. The `password` channel (new, #2036)

A `password` field on a generic (non-`better-auth`) object is **plaintext at
rest but masked on read**:

1. **Masked on read** — masked to `SECRET_MASK` in `find`/`findOne` (and
`$expand`, which re-enters `find`), exactly like `secret`.
2. **Plaintext at rest, by design** — **not** encrypted, **no** `sys_secret`
row, **no** `CryptoProvider` required. Masking is a read-path transform only.
This keeps the change minimal and avoids a second credential store. Authors
who need reversible encryption-at-rest should use `secret`.
3. **Echoed-mask write guard** — because a read now returns `SECRET_MASK`, a
client that reads a record and PATCHes it back would otherwise overwrite the
stored value with the literal mask. The write path drops any masked field
(secret or password) whose incoming value equals `SECRET_MASK`, so an
unchanged round-trip is a no-op. Accepted cost: the literal string
`••••••••` cannot itself be stored as a password via an echoing client.
4. **`managedBy: 'better-auth'` exemption** — the auth subsystem reads its
identity rows *through* the engine's `find`/`findOne`, so masking a credential
column there would break login. Objects marked `managedBy: 'better-auth'` are
exempt from password masking. Today this is a safety net, not load-bearing:
no shipped identity object even declares a `password`-typed field
(`sys_account.password` is a hashed `text` column), pinned by a
platform-objects test so retyping it becomes a deliberate decision.
5. **Non-fatal author-time warning (ADR-0077/0078)** — `ObjectSchema.create()`
emits a `console.warn` (deduped per object name) when a `password` field is
declared on a non-`better-auth` object, steering authors to `Field.secret`
for reversible machine credentials or to the auth subsystem for login
credentials. It is a *warning*, not a build error: `password` now has a
defined generic-path contract (so ADR-0078 does not compel an error), and the
field-zoo example intentionally exercises every field type — a hard error
would be self-inflicted breakage. Raw `.parse()` stays silent, since it also
loads persisted metadata and `create()` is the authoring surface (ADR-0077).

### C. Shared mechanism

Both channels share one read-mask collector — `collectMaskedReadFields`
(`packages/objectql/src/secret-fields.ts`): every `secret` field, plus every
`password` field on a non-`better-auth` object. `maskSecretFields` (read) and the
echoed-mask drop (write) in `engine.ts` both consume it, so the better-auth
exemption lives in exactly one place. `SECRET_MASK` is the single mask constant
for both.

## Consequences

- Edit forms that prefill from a read now show the mask for `password` fields —
identical to the existing `secret` UX. Unchanged-value saves are protected by
the echoed-mask guard (B3).
- The generic read path no longer leaks credential plaintext for `password`
fields; the field-zoo HTTP round-trip pins this (`f_password` upgraded from
`present` to `masked`).
- The dangling "secret field channel" references in `field.zod.ts` and
`secret-fields.ts` now resolve to this ADR.

## Non-goals / follow-ups

- **`aggregate()` masking gap.** `aggregate()` masks neither `secret` nor
`password` — a pre-existing gap for `secret`. Post-hoc masking of aggregate
output would corrupt group keys; the correct fix is to *reject* aggregations
that reference a credential field. Tracked in #3171, not addressed here.
- **Hashing / verification for authored `password` fields** — explicitly out of
scope (B2). Credential verification belongs to the auth subsystem.
2 changes: 1 addition & 1 deletion examples/app-showcase/src/data/objects/field-zoo.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const FieldZoo = ObjectSchema.create({
f_email: Field.email({ label: 'Email', searchable: true }),
f_url: Field.url({ label: 'URL' }),
f_phone: Field.phone({ label: 'Phone' }),
f_password: Field.password({ label: 'Password (one-way hash)' }),
f_password: Field.password({ label: 'Password (masked on read)' }),
f_secret: Field.secret({ label: 'Secret (encrypted at rest)' }),

// ── Rich content ─────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions packages/objectql/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export {
isSecretRef,
parseSecretRef,
collectSecretFields,
collectMaskedReadFields,
} from './secret-fields.js';

// Utilities
Expand Down
66 changes: 42 additions & 24 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contra
import type { ICryptoProvider, CryptoHandle } from '@objectstack/spec/contracts';
import {
collectSecretFields,
collectMaskedReadFields,
makeSecretRef,
parseSecretRef,
isSecretRef,
Expand Down Expand Up @@ -1363,19 +1364,28 @@ export class ObjectQL implements IDataEngine {
}

/**
* Encrypt any `secret`-typed fields on `row` in place before it reaches the
* driver. Each plaintext is wrapped by the ICryptoProvider, persisted as a
* `sys_secret` row, and replaced on `row` by an opaque ref. Cleartext never
* reaches the business table.
* Normalize credential fields on `row` in place before it reaches the driver.
*
* Two channels share the read mask ({@link SECRET_MASK}) but differ on write
* (see ADR-0100):
*
* - **`secret`** — encrypted: each plaintext is wrapped by the ICryptoProvider,
* persisted as a `sys_secret` row, and replaced on `row` by an opaque ref.
* Cleartext never reaches the business table.
* - **`password`** (generic, non-`better-auth`) — plaintext at rest: stored
* verbatim, no encryption and no `sys_secret` row. Only the echoed-mask drop
* below applies to it.
*
* Rules:
* - No secret fields on the object ⇒ no-op (fast path, no crypto cost).
* - `null`/`undefined` value ⇒ left as-is (clears the secret).
* - Value already a ref (re-save of an unchanged ref) ⇒ left as-is.
* - Value equal to the read mask ⇒ dropped, so a form round-trip that
* echoes the mask does not overwrite the stored secret.
* - **Fail-closed:** any other value with no CryptoProvider registered, or
* no reachable `sys_secret` store, THROWS — never persists cleartext.
* - Any masked field (secret or password) whose value equals the read mask ⇒
* the key is dropped, so a form round-trip that echoes the mask does not
* overwrite the stored value.
* - No secret fields on the object ⇒ no further work (fast path, no crypto).
* - `null`/`undefined` secret value ⇒ left as-is (clears the secret).
* - Secret value already a ref (re-save of an unchanged ref) ⇒ left as-is.
* - **Fail-closed:** any other secret value with no CryptoProvider registered,
* or no reachable `sys_secret` store, THROWS — never persists cleartext.
* (A `password` field needs no CryptoProvider — it is stored as-is.)
*/
private async encryptSecretFields(
object: string,
Expand All @@ -1385,6 +1395,17 @@ export class ObjectQL implements IDataEngine {
): Promise<void> {
if (!row || typeof row !== 'object') return;
const schema = this._registry.getObject(object);

// Echoed-mask drop for every field the read path masks (secret + generic
// password). The read path returns SECRET_MASK; a client that PATCHes it
// back means "unchanged", so drop the key rather than persist the literal
// mask. Doing this up front keeps the better-auth exemption in one place
// (collectMaskedReadFields) and covers objects that have a password field
// but no secret field. (#2036, ADR-0100)
for (const field of collectMaskedReadFields(schema)) {
if (field in row && row[field] === SECRET_MASK) delete row[field];
}

const secretFields = collectSecretFields(schema);
if (secretFields.length === 0) return;

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

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

if (!this.cryptoProvider) {
throw new Error(
Expand Down Expand Up @@ -1446,20 +1461,23 @@ export class ObjectQL implements IDataEngine {
}

/**
* Mask `secret`-typed fields on read so plaintext never leaves the engine
* through the normal query path. A set secret becomes {@link SECRET_MASK};
* an unset one stays `null`. Privileged callers that genuinely need the
* plaintext use {@link resolveSecret} against the stored ref.
* Mask credential fields on read so plaintext never leaves the engine through
* the normal query path. Covers `secret` fields (always) and `password` fields
* on generic, non-`better-auth` objects (see {@link collectMaskedReadFields}
* and ADR-0100). A set value becomes {@link SECRET_MASK}; an unset one stays
* `null`. Privileged callers that genuinely need a secret's plaintext use
* {@link resolveSecret} against the stored ref; a `password` field is stored
* as plaintext at rest, so its cleartext is only ever reachable off this path.
*/
private maskSecretFields(object: string, rows: any): void {
if (!rows) return;
const schema = this._registry.getObject(object);
const secretFields = collectSecretFields(schema);
if (secretFields.length === 0) return;
const maskedFields = collectMaskedReadFields(schema);
if (maskedFields.length === 0) return;
const list = Array.isArray(rows) ? rows : [rows];
for (const row of list) {
if (!row || typeof row !== 'object') continue;
for (const field of secretFields) {
for (const field of maskedFields) {
if (!(field in row)) continue;
row[field] = row[field] == null ? null : SECRET_MASK;
}
Expand Down
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export {
isSecretRef,
parseSecretRef,
collectSecretFields,
collectMaskedReadFields,
} from './secret-fields.js';

// Export Utilities
Expand Down
88 changes: 88 additions & 0 deletions packages/objectql/src/secret-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,91 @@ describe('objectql secret-field channel', () => {
}
});
});

// A generic (non-better-auth) object with a `password` field. Unlike `secret`,
// it is plaintext at rest but masked on read — no crypto involved (ADR-0100).
const deviceObject = {
name: 'device', label: 'Device',
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const },
name: { name: 'name', label: 'Name', type: 'text' as const },
admin_password: { name: 'admin_password', label: 'Admin Password', type: 'password' as const },
},
};

// An identity table the auth subsystem owns: `managedBy: 'better-auth'` exempts
// its `password` field from masking so better-auth's own reads see the value.
const authUserObject = {
name: 'authy_user', label: 'Auth User', managedBy: 'better-auth' as const,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const },
password: { name: 'password', label: 'Password', type: 'password' as const },
},
};

async function buildPasswordEngine() {
// Deliberately NO CryptoProvider — a password field must not require one.
const engine = new ObjectQL();
const { driver, stores } = makeMemoryDriver();
engine.registerDriver(driver, true);
await engine.init();
engine.registry.registerObject(deviceObject as any);
engine.registry.registerObject(authUserObject as any);
return { engine, stores };
}

describe('objectql password-field masking (ADR-0100)', () => {
let ctx: Awaited<ReturnType<typeof buildPasswordEngine>>;
beforeEach(async () => { ctx = await buildPasswordEngine(); });

it('stores plaintext at rest (no crypto), but masks on find / findOne', async () => {
const created = await ctx.engine.insert('device', { name: 'router', admin_password: 'hunter2' });

// At rest: the driver row holds the verbatim plaintext, and nothing was
// written to sys_secret (no encryption channel for password).
const stored = ctx.stores.get('device')!.get(created.id) as any;
expect(stored.admin_password).toBe('hunter2');
expect(ctx.stores.get('sys_secret')?.size ?? 0).toBe(0);

// On read: the generic path never echoes the plaintext.
const viaFind = (await ctx.engine.find('device', { where: { id: created.id } }))[0] as any;
expect(viaFind.admin_password).toBe(SECRET_MASK);
const viaOne = await ctx.engine.findOne('device', { where: { id: created.id } }) as any;
expect(viaOne.admin_password).toBe(SECRET_MASK);
});

it('an unset password reads back as null, not the mask', async () => {
const created = await ctx.engine.insert('device', { name: 'router', admin_password: null });
const viaOne = await ctx.engine.findOne('device', { where: { id: created.id } }) as any;
expect(viaOne.admin_password).toBeNull();
});

it('echoing the read mask back does NOT overwrite the stored password', async () => {
const created = await ctx.engine.insert('device', { name: 'router', admin_password: 'keep-me' });

// Form round-trip: user renames the device, the masked field echoes back.
await ctx.engine.update('device', { id: created.id, name: 'gateway', admin_password: SECRET_MASK });

const stored = ctx.stores.get('device')!.get(created.id) as any;
expect(stored.name).toBe('gateway');
expect(stored.admin_password).toBe('keep-me'); // unchanged plaintext
});

it('updating with a real new value replaces the stored plaintext', async () => {
const created = await ctx.engine.insert('device', { name: 'router', admin_password: 'old-pw' });
await ctx.engine.update('device', { id: created.id, admin_password: 'new-pw' });

const stored = ctx.stores.get('device')!.get(created.id) as any;
expect(stored.admin_password).toBe('new-pw');
// And a read still masks it.
const viaOne = await ctx.engine.findOne('device', { where: { id: created.id } }) as any;
expect(viaOne.admin_password).toBe(SECRET_MASK);
});

it('better-auth identity tables are exempt: their password field is NOT masked on read', async () => {
const created = await ctx.engine.insert('authy_user', { password: 'hashed-by-auth' });
const viaOne = await ctx.engine.findOne('authy_user', { where: { id: created.id } }) as any;
// Masking here would break login — the auth subsystem must read its own value.
expect(viaOne.password).toBe('hashed-by-auth');
});
});
Loading