Skip to content

feat(protect): add support for deeply nested protect schemas - #155

Merged
calvinbrewer merged 6 commits into
mainfrom
nested-encryption
Jun 11, 2025
Merged

feat(protect): add support for deeply nested protect schemas#155
calvinbrewer merged 6 commits into
mainfrom
nested-encryption

Conversation

@calvinbrewer

@calvinbrewer calvinbrewer commented Jun 9, 2025

Copy link
Copy Markdown
Contributor

Added support for nested objects in Protect schemas, allowing you to encrypt and search on nested properties through the model interfaces. E.g.

import { csTable, csColumn, csValue } from "@cipherstash/protect";

export const protectedUsers = csTable("users", {
  email: csColumn("email").freeTextSearch().equality().orderAndRange(),
  profile: {
    name: csValue("profile.name"),
    address: {
      street: csValue("profile.address.street"),
      location: {
        coordinates: csValue("profile.address.location.coordinates"),
      },
    },
  },
});

When working with nested objects:

  • Each level can have its own encrypted fields
  • The maximum nesting depth is 3 levels
  • Null and undefined values are supported at any level
  • Optional nested objects are supported

@calvinbrewer

Copy link
Copy Markdown
Contributor Author

The encrypt config will look like the following and the i -> column field in the EQL payload will have the dot notation as the column name to continue protection against confused deputy attacks. @CDThomas Is this the correct way to handle this?

{
  v: 2,
  tables: {
    users: {
      email: [Object],
      address: [Object],
      'example.field': [Object],
      'example.nested.deeper': [Object]
    }
  }
}

@calvinbrewer
calvinbrewer requested a review from CDThomas June 9, 2025 22:35

@CDThomas CDThomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few minor comments, but LGTM otherwise.

Comment thread packages/protect/src/ffi/index.ts Outdated
Comment thread packages/protect/__tests__/nested-models.test.ts
Comment thread packages/protect/src/ffi/model-helpers.ts
Comment thread packages/protect/src/ffi/model-helpers.ts
Comment thread docs/reference/schema.md
Protect.js supports nested objects in your schema, allowing you to encrypt **but not search on** nested properties. You can define nested objects up to 3 levels deep.
This is useful for data stores that have less structured data, like NoSQL databases.

You can define nested objects by using the `csValue` function to define a value in a nested object. The value naming convention of the `csValue` function is a dot-separated string of the nested object path, e.g. `profile.name` or `profile.address.street`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The column/attribute name in the DB table can't contain a . since we're using it a delimiter, right? Definitely an edge case, but thought it was worth mentioning.

@CDThomas

Copy link
Copy Markdown
Contributor

The encrypt config will look like the following and the i -> column field in the EQL payload will have the dot notation as the column name to continue protection against confused deputy attacks. @CDThomas Is this the correct way to handle this?

{
  v: 2,
  tables: {
    users: {
      email: [Object],
      address: [Object],
      'example.field': [Object],
      'example.nested.deeper': [Object]
    }
  }
}

This makes sense to me @calvinbrewer. I think the main thing we want to do on the client at the moment is to only decrypt the specific fields that have been configured as encrypted (as opposed to blindly decrypting anything that looks like a ciphertext).

@calvinbrewer
calvinbrewer merged commit c0cf180 into main Jun 11, 2025
@calvinbrewer
calvinbrewer deleted the nested-encryption branch June 11, 2025 19:10
tobyhede added a commit that referenced this pull request Aug 21, 2026
Six open Dependabot alerts, all reachable because the overrides written to
close earlier ones had gone stale in two distinct ways.

OVERTAKEN SELECTORS go quiet. `postcss@<8.5.10` stopped matching once the
tree resolved 8.5.14, so the entry sat here looking like a fix while a
vulnerable version installed underneath it (#167 GHSA-r28c-9q8g-f849 HIGH,
path traversal reading arbitrary .map files via attacker-controlled
sourceMappingURL; #176 its incomplete-fix follow-up). `js-yaml@<3.15.0` was
overtaken by its own fix — the tree resolved exactly 3.15.0, so nothing
matched and nothing lifted it to 3.15.1 (#189).

EXACT PINS INSIDE A STILL-MATCHING RANGE are worse: they cannot self-heal
and Dependabot cannot fix them. `js-yaml@>=4.0.0 <5` kept matching, so a
pin written to FIX an advisory was holding the tree at 4.2.0 and blocking
4.3.1 (#155, #188 — quadratic-CPU DoS via merge-key chains, then `!!omap`
duplicate-key detection). `fast-uri@<3.1.3` did the same at 3.1.3 while
#158 needs 3.1.4 and #175 needs 3.1.5.

Both shapes are now written into the comments, because the lesson is
reusable: keep the ceiling AHEAD of the patched floor rather than at it.

THE js-yaml CAUTION IS DISCHARGED, checked rather than assumed. The
comment warned that a past bump broke changesets. @changesets/[email protected]
declares `js-yaml: ^4.1.1`, which 4.3.1 satisfies; 4.2.0 and 4.3.1 have
identical `dependencies` and `exports`, no `engines`, no `main`; 4.3.0 and
4.3.1 are security backports only, with no API removal on the 4.x line —
the breaking churn (grouped constant exports, MERGE_KEY removal, mandatory
identify) is all on 5.x, which the `<5` ceiling already excludes. Verified
by resolving js-yaml from @changesets/parse's own path (4.3.1, not a nested
copy) and running `changeset status`, which parsed all 14 changesets and
produced the correct per-package bumps.

No cooldown bypass was needed: every target is 13-19 days old against the
7-day minimumReleaseAge. The comment claiming "4.3.0 is still inside the
cooldown" was 54 days stale.

None of the four is a runtime dependency of any published package, so
nothing ships them and this needs no changeset.

Also corrects dependabot.yml's "the only Rust in the repo" — there are two
cargo entries, and the /packages/eql one landed with the subtree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants