Skip to content

DynamoDB: a stored EQL v2 grouped date column decrypts but is never reconstructed #833

Description

@tobyhede

Found while expanding v2 read coverage for #829. Characterised by a passing test on that branch, not fixed.

Symptom

Reading a stored EQL v2 item whose date-like column was declared under a v2 grouped field returns the decrypted value as a string rather than a Date. The declared column type is types.Date(...) / types.Timestamp(...), so the TypeScript type says Date and the runtime hands back a string.

Nothing throws and no data is lost — the value decrypts correctly. It is simply not reconstructed.

Mechanism

Two halves of the read path disagree about which path a grouped column lives at.

  1. A v2 grouped column registered its build key on the bare leaf, so a field inside a group was stored as <group>.<leaf>__source while the schema knew it only as <leaf>. makeColumnMatcher's bare-leaf fallback (packages/stack/src/dynamodb/helpers.ts, v2 reads only) exists precisely so those attributes still rebuild:

    if (paths.has(dotted)) return dotted
    if (allowBareLeaf && prefix && paths.has(leaf)) return leaf

    The envelope is therefore rebuilt at the nested position (details.birthday).

  2. rowReconstructor (packages/stack/src/encryption/client-v3.ts) derives its date paths from the declared property names:

    const dateProperties = Object.entries(propToDb).filter(...).map(([property]) => property)
    return (row) => reconstructDatePaths(row, dateProperties)

    So it asks reconstructDatePaths for a top-level birthday, the value sits at details.birthday, Object.hasOwn(source, leaf) is false, and the path is skipped.

reconstructDatePaths is genuinely path-aware — a column declared at a dotted path reconstructs correctly. The gap is only that the v2 bare-leaf fallback resolves to a path that the reconstruction step is not told about.

Scope

Narrow. Requires all of: DynamoDB adapter, { storedEqlVersion: 2 }, a v2 grouped field, and a date-like cast_as. v3 reads are unaffected (v3 registers full dotted paths and never uses the fallback). Non-date grouped columns are unaffected, since only date-like casts need per-row reconstruction.

Workaround available today

Declare the dotted path, which makes the exact-path match fire and reconstruction follow it:

const orders = encryptedTable('orders', {
  'details.birthday': types.Date('details_birthday'),
})

This is covered by a passing test and is the recommended fix for callers in the meantime.

Where it is pinned

packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts, in the block:

a v2 grouped date column decrypts but is not reconstructed

It is labelled CHARACTERISATION, not endorsement. It asserts the current (wrong) behaviour so the gap is visible rather than silent.

When this is fixed, that test must be inverted — it currently encodes the defect as expected behaviour, and nothing in CI will flag that a green test is describing a bug.

Suggested fix direction

Have the read path reconstruct at the position the envelope was actually rebuilt at, rather than at the declared path — i.e. thread the matched path from makeColumnMatcher through to the reconstruction step for the v2 bare-leaf case, instead of recomputing date paths independently from the table's declared keys.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions