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
41 changes: 41 additions & 0 deletions .changeset/stack-audit-on-decrypt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@cipherstash/stack': major
---

The typed EQL v3 client's `decryptModel` / `bulkDecryptModels` are now
audit-chainable. They return a chainable operation (a `MappedDecryptOperation`)
instead of a bare `Promise<Result<…>>`, so you can attach audit metadata and a
lock context before awaiting:

```typescript
await client
.decryptModel(item, table)
.audit({ metadata: { requestId } })
.withLockContext({ identityClaim: ["sub"] })
```

Both chaining orders forward the metadata to ZeroKMS, and the `Date`
reconstruction still applies to the successful result. Await-only call sites are
unchanged — the operation is still thenable to the same `Result`. This restores
audited decrypt through the DynamoDB adapter (`encryptedDynamoDB(...).decryptModel`)
for a v3 client, which previously had nowhere to carry the metadata.

Chaining `.withLockContext()` onto a decrypt operation that already took a lock
context positionally (`decryptModel(item, table, lc).withLockContext(other)`) now
throws instead of silently keeping the first. Pass the lock context one way or
the other, not both.

**Breaking:** `EncryptionV3` is now a deprecated, type-identical alias of
`Encryption`: `Encryption` is overloaded so an array of concrete EQL v3 tables
yields the same strongly-typed client. Use `Encryption` for new code. If you were
already passing EQL v3 tables to plain `Encryption`, you now receive the typed
client rather than the nominal one — its `decryptModel` / `bulkDecryptModels`
return type changes, and the two-argument form reconstructs `Date` columns from
`cast_as` instead of leaving them as ISO strings. Code that read those columns as
strings needs updating. As part of this collapse
`EncryptionV3` no longer independently pins the wire format — like `Encryption`,
it now honours an explicit `config.eqlVersion` (the retained migration escape
hatch). The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2
builders remain available (now marked `@deprecated`) for reading and migrating
legacy v2 data; the client authors EQL v3 only. Their full removal is deferred to
a later PR.
17 changes: 17 additions & 0 deletions .changeset/stack-dynamodb-v2-write-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@cipherstash/stack': major
---

**Breaking (DynamoDB adapter):** `encryptedDynamoDB(...).encryptModel` and
`bulkEncryptModels` no longer accept an EQL v2 table — write is EQL v3 only. The
v2 write type overloads have been removed, narrowing encrypt to `AnyV3Table`.

**Decrypt still reads existing v2 items.** `decryptModel` / `bulkDecryptModels`
continue to accept an EQL v2 table (`encryptedColumn` / `encryptedField` from
`@cipherstash/stack/schema`), so previously stored v2 DynamoDB items remain
readable — the adapter keeps its v2 envelope reconstruction. Only the v2 write
surface is gone.

Migrate v2 write call sites to an EQL v3 table (`encryptedTable` + `types.*` from
`@cipherstash/stack/eql/v3`). To keep reading old data, pass the v2 table to the
decrypt methods.
13 changes: 13 additions & 0 deletions .changeset/stack-skills-eql-v3-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'stash': patch
---

Skills refresh for the EQL v3 collapse (ships in the `stash` tarball):

- `stash-dynamodb`: audited decrypt now works on the typed client —
`client.decryptModel(item, table).audit({ … })` — so the old "use
`Encryption({ config: { eqlVersion: 3 } })` for audited decrypts" caveat is
removed. Encrypt/write is EQL v3 only; decrypt still reads existing v2 items.
- `stash-encryption`: canonical examples use `Encryption` (with `EncryptionV3`
noted as a deprecated alias); the DynamoDB notes state encrypt is v3-only while
decrypt still reads v2.
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ Three rules to remember when editing CI or pnpm config:

## Key Concepts and APIs

- **Initialization**: `EncryptionV3({ schemas })` returns the typed EQL v3 client. (`Encryption({ schemas })` is the legacy v2 client.) Provide at least one `encryptedTable`.
- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `EncryptionV3` from `@cipherstash/stack/v3`. (Legacy EQL v2 — `Encryption` + `encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()` from `@cipherstash/stack/schema` — remains for existing deployments. DynamoDB accepts both v3 and v2 tables; nested fields work in both — v2 via `encryptedField` groups, v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups.)
- **Initialization**: `Encryption({ schemas })` is the single client factory. It is overloaded: an array of concrete EQL v3 tables yields the strongly-typed EQL v3 client; a v2/loose schema set yields the nominal client. `EncryptionV3` (from `@cipherstash/stack/v3`) is a deprecated, type-identical alias of `Encryption`. Provide at least one `encryptedTable`.
- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `Encryption` (import `encryptedTable`/`types` from `@cipherstash/stack/v3`). The client authors EQL v3 only; the `config.eqlVersion` field and the `@cipherstash/stack/schema` EQL v2 builders (`encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()`) remain (now `@deprecated`) for reading/migrating legacy v2 data. `decrypt`/`decryptModel` read both v2 and v3 payloads. DynamoDB **writes EQL v3 only; decrypt still reads existing v2 items**; nested fields work in both — v2 via `encryptedField` groups (read only), v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups.
- **Operations** (all return Result-like objects and support chaining `.withLockContext(lockContext)` and `.audit()` when applicable):
- `encrypt(plaintext, { table, column })`
- `decrypt(encryptedPayload)`
Expand Down
10 changes: 5 additions & 5 deletions packages/stack-supabase/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,11 @@ export async function encryptedSupabaseV3(
}

// 5. Build the raw (eqlVersion 3) encryption client from the merged tables.
// NB: `Encryption`, not `EncryptionV3` — the query builder consumes the raw
// chainable `EncryptionClient`, whereas `EncryptionV3` returns the typed
// wrapper whose `decryptModel` returns a plain Promise<Result>. Pass only
// tables that carry at least one encrypted column (`Encryption` requires a
// non-empty schema list).
// NB: the query builder consumes the raw chainable `EncryptionClient`, and
// calls `decryptModel(row)` with no table — the typed client degrades to
// nominal (passthrough) behaviour for that arity, so either shape works.
// Pass only tables that carry at least one encrypted column (`Encryption`
// requires a non-empty schema list).
const encryptionSchemas = [...synth.tables.values()].filter(
(t) => Object.keys(t.columnBuilders).length > 0,
)
Expand Down
174 changes: 174 additions & 0 deletions packages/stack/__tests__/decrypt-audit-forwarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Runtime proof that audit metadata attached to the typed EQL v3 client's
* `decryptModel` / `bulkDecryptModels` reaches ZeroKMS — the core half of
* acceptance #2b. Before PR 3 the typed client `await`ed the underlying decrypt
* and mapped the value, which collapsed the chain and dropped `.audit()`; the
* `MappedDecryptOperation` wrapper restores it.
*
* The metadata surfaces as `unverifiedContext` on the mocked protect-ffi
* `decryptBulk` call. Both chaining orders are covered (Risk R3):
* `.audit().withLockContext()` and `.withLockContext().audit()` must each
* forward the metadata (and the lock context's identity claim).
*
* Credential-free: protect-ffi is mocked, so there is no ZeroKMS round-trip.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Encryption } from '@/index'

// A protect-ffi-shaped encrypted payload so the SDK's `isEncryptedPayload`
// check detects the model field as encrypted and routes it to `decryptBulk`.
const enc = () => ({ v: 2, i: { t: 'users', c: 'email' }, c: 'ciphertext' })

vi.mock('@cipherstash/protect-ffi', () => ({
newClient: vi.fn(async () => ({ __mock: 'client' })),
encrypt: vi.fn(async () => enc()),
decrypt: vi.fn(async () => 'decrypted'),
encryptBulk: vi.fn(async (_c: unknown, opts: { plaintexts: unknown[] }) =>
opts.plaintexts.map(enc),
),
decryptBulk: vi.fn(async (_c: unknown, opts: { ciphertexts: unknown[] }) =>
opts.ciphertexts.map(() => 'decrypted'),
),
decryptBulkFallible: vi.fn(
async (_c: unknown, opts: { ciphertexts: unknown[] }) =>
opts.ciphertexts.map(() => ({ data: 'decrypted' })),
),
encryptQuery: vi.fn(async () => enc()),
encryptQueryBulk: vi.fn(async (_c: unknown, opts: { queries: unknown[] }) =>
opts.queries.map(enc),
),
}))

import * as ffi from '@cipherstash/protect-ffi'
// Imported after the mock so the v3 table builder is available; `Encryption`
// returns the typed client for an all-v3 schema set.
import { encryptedTable, types } from '@/encryption/v3'

const users = encryptedTable('users', {
email: types.TextEq('email'),
})

const IDENTITY_CLAIM = { identityClaim: ['sub'] }

// biome-ignore lint/suspicious/noExplicitAny: test helper unwraps Result
function unwrap(result: any) {
if (result.failure) {
throw new Error(`operation failed: ${result.failure.message}`)
}
return result.data
}

/** Options the FFI decrypt was last called with (second arg). */
// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args
const lastDecryptOpts = () => (ffi.decryptBulk as any).mock.calls.at(-1)[1]

/** The lock context is carried per-ciphertext, not on the top-level opts. */
const lastCiphertextLockContext = () =>
lastDecryptOpts().ciphertexts[0]?.lockContext

let client: Awaited<ReturnType<typeof Encryption<readonly [typeof users]>>>
let prevWorkspaceCrn: string | undefined

beforeEach(async () => {
vi.clearAllMocks()
prevWorkspaceCrn = process.env.CS_WORKSPACE_CRN
process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace'
client = await Encryption({ schemas: [users] })
})

afterEach(() => {
// Restore the prior value so this suite doesn't leak env state into other
// Vitest suites sharing the worker.
if (prevWorkspaceCrn === undefined) {
delete process.env.CS_WORKSPACE_CRN
} else {
process.env.CS_WORKSPACE_CRN = prevWorkspaceCrn
}
})

describe('typed v3 client: audit metadata forwards through decryptModel', () => {
it('forwards .audit({ metadata }) as unverifiedContext (no lock context)', async () => {
unwrap(
await client
.decryptModel({ email: enc() }, users)
.audit({ metadata: { sub: 'u1' } }),
)

expect(ffi.decryptBulk).toHaveBeenCalledTimes(1)
expect(lastDecryptOpts().unverifiedContext).toEqual({ sub: 'u1' })
})

it('forwards metadata AND identity claim with .audit().withLockContext()', async () => {
unwrap(
await client
.decryptModel({ email: enc() }, users)
.audit({ metadata: { m: 1 } })
.withLockContext(IDENTITY_CLAIM),
)

expect(ffi.decryptBulk).toHaveBeenCalledTimes(1)
const opts = lastDecryptOpts()
expect(opts.unverifiedContext).toEqual({ m: 1 })
expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM)
})

it('forwards metadata AND identity claim with .withLockContext().audit()', async () => {
unwrap(
await client
.decryptModel({ email: enc() }, users)
.withLockContext(IDENTITY_CLAIM)
.audit({ metadata: { m: 2 } }),
)

expect(ffi.decryptBulk).toHaveBeenCalledTimes(1)
const opts = lastDecryptOpts()
expect(opts.unverifiedContext).toEqual({ m: 2 })
expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM)
})

it('forwards metadata via the lockContext argument (no chaining)', async () => {
unwrap(
await client
.decryptModel({ email: enc() }, users, IDENTITY_CLAIM)
.audit({ metadata: { m: 3 } }),
)

expect(ffi.decryptBulk).toHaveBeenCalledTimes(1)
const opts = lastDecryptOpts()
expect(opts.unverifiedContext).toEqual({ m: 3 })
expect(lastCiphertextLockContext()).toEqual(IDENTITY_CLAIM)
})

it('throws when a second lock context is chained onto an already-bound op', () => {
// The wrapper always exposes `withLockContext`, so a positional bind
// followed by a chained one type-checks. Silently keeping the first would
// drop the caller's intent (and fail later at ZeroKMS with an opaque
// rejection); reject the re-bind at the call site instead.
const op = client.decryptModel({ email: enc() }, users, IDENTITY_CLAIM)

expect(() => op.withLockContext({ identityClaim: ['other'] })).toThrow(
/already bound to a lock context/i,
)
})

it('throws on a re-bind for bulkDecryptModels too', () => {
const op = client.bulkDecryptModels([{ email: enc() }], users, {
identityClaim: ['sub'],
})

expect(() => op.withLockContext(IDENTITY_CLAIM)).toThrow(
/already bound to a lock context/i,
)
})

it('forwards .audit({ metadata }) on bulkDecryptModels', async () => {
unwrap(
await client
.bulkDecryptModels([{ email: enc() }], users)
.audit({ metadata: { b: 4 } }),
)

expect(ffi.decryptBulk).toHaveBeenCalledTimes(1)
expect(lastDecryptOpts().unverifiedContext).toEqual({ b: 4 })
})
})
58 changes: 35 additions & 23 deletions packages/stack/__tests__/dynamodb/client-compat.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,27 +79,25 @@ describe('encryptedDynamoDB accepts both client shapes without a cast', () => {

const dynamo = encryptedDynamoDB({ encryptionClient: typedClient })

describe('all four methods accept both a v3 and a v2 table', () => {
it('encryptModel', () => {
describe('write is EQL v3 only; read accepts both a v3 and a v2 table', () => {
it('encryptModel accepts a v3 table and rejects a v2 table', () => {
expectTypeOf(dynamo.encryptModel).toBeCallableWith(
{ pk: 'a', email: 'a@b.com' },
usersV3,
)
expectTypeOf(dynamo.encryptModel).toBeCallableWith(
{ pk: 'a', email: 'a@b.com' },
usersV2,
)
// Write is EQL v3 only — the v2 write overload was removed, so a v2 table is
// rejected on encrypt (decrypt below still accepts it).
// @ts-expect-error - encryptModel no longer accepts an EQL v2 table
dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV2)
})

it('bulkEncryptModels', () => {
it('bulkEncryptModels accepts a v3 table and rejects a v2 table', () => {
expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith(
[{ pk: 'a', email: 'a@b.com' }],
usersV3,
)
expectTypeOf(dynamo.bulkEncryptModels).toBeCallableWith(
[{ pk: 'a', email: 'a@b.com' }],
usersV2,
)
// @ts-expect-error - bulkEncryptModels no longer accepts an EQL v2 table
dynamo.bulkEncryptModels([{ pk: 'a', email: 'a@b.com' }], usersV2)
})

it('decryptModel', () => {
Expand Down Expand Up @@ -332,18 +330,6 @@ describe('a required-nullable v3 column keeps __source required', () => {
})
})

describe('the v2 overload still returns the input model', () => {
it('keeps an existing v2 caller compiling unchanged', async () => {
const result = await dynamo.encryptModel<{ pk: string; email?: string }>(
{ pk: 'a', email: 'a@b.com' },
usersV2,
)
if (result.failure) throw new Error(result.failure.message)

expectTypeOf(result.data).toEqualTypeOf<{ pk: string; email?: string }>()
})
})

describe('operations chain and resolve', () => {
it('.audit() returns the operation so it stays chainable', () => {
const op = dynamo.encryptModel({ pk: 'a', email: 'a@b.com' }, usersV3)
Expand All @@ -352,6 +338,32 @@ describe('operations chain and resolve', () => {
>()
})

it('.audit() is chainable on decryptModel and returns the operation', () => {
// The DynamoDB decrypt op is chainable; audit metadata now forwards to the
// underlying client decrypt regardless of client shape (see
// resolve-decrypt.test.ts / decrypt-audit-forwarding.test.ts for the runtime
// proof). Here we lock the type-level surface.
const op = dynamo.decryptModel({ pk: 'a', email__source: 'ct' }, usersV3)
expectTypeOf(op).toHaveProperty('audit')
expectTypeOf(op.audit({ metadata: { sub: 'u1' } })).toEqualTypeOf<
typeof op
>()
})

it('awaiting decryptModel yields a discriminated Result', async () => {
const result = await dynamo.decryptModel(
{ pk: 'a', email__source: 'ct' },
usersV3,
)

if (result.failure) {
expectTypeOf(result.failure.message).toEqualTypeOf<string>()
return
}

expectTypeOf(result.data.email).toEqualTypeOf<string>()
})

it('awaiting yields a discriminated Result', async () => {
const result = await dynamo.encryptModel(
{ pk: 'a', email: 'a@b.com' },
Expand Down
4 changes: 3 additions & 1 deletion packages/stack/__tests__/dynamodb/construct-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ describe('encryptedDynamoDB client/table version guard', () => {
message = (e as Error).message
}
expect(message).toContain('users_v3')
expect(message).toMatch(/EncryptionV3|eqlVersion/)
// Post-collapse the fix guidance points at `Encryption` (which auto-selects
// the v3 wire format for a v3 schema set), not `EncryptionV3`/`eqlVersion`.
expect(message).toMatch(/build it with Encryption\(/)
})

it('guards every operation method, not just encryptModel', () => {
Expand Down
Loading