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
45 changes: 45 additions & 0 deletions .changeset/crypto-context-producer-vocabularies.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/spec": patch
"@objectstack/service-datasource": patch
---

docs(spec,service-datasource): `CryptoContext` documents the three producer vocabularies, and the AAD sentence narrows to the guarantee that holds (#12599)

Documentation-only correction to the `ICryptoProvider` contract. No behavior
changes and no schema shape changes: `gen:schema`, `gen:openapi` and `gen:docs`
all regenerate byte-identically over this diff. It ships because the corrected
TSDoc is emitted into the published `.d.ts`, so it is what every consumer of
`@objectstack/spec` reads at the call site.

`CryptoContext.namespace` / `.key` documented themselves as a settings
coordinate ("Settings namespace the value belongs to" / "Specifier key within
the namespace"), one layer below the three producers that actually construct
the type:

| producer | `ctx.namespace` | `ctx.key` | where `handle.id` is recorded |
|---|---|---|---|
| `SettingsService` | settings namespace | specifier key | `sys_setting.value_enc` |
| the ObjectQL engine's secret-field path | **object name** | **field name** | a `secret:` ref on the business row |
| the datasource secret binder | caller-supplied, default `datasource` | datasource name | a `sys_secret:` credentialsRef |

The prose now names all three, and `CryptoHandle.id`, `encrypt()` and
`rotateKey()` no longer describe `sys_setting.value_enc` as the general
destination of a handle.

The load-bearing half is the AAD sentence. It read "Helps reject ciphertexts
that were copied across namespaces", which overstates what a binding over this
pair can provide: `(namespace, key)` is **one flat space shared by all three
vocabularies**, `sys_secret` declares the pair non-unique by design, and nothing
reserves a name in one vocabulary against another. The sentence is replaced by
the guarantee that actually holds — such a binding rejects a ciphertext swapped
between two coordinates *within one producer's vocabulary*, and does **not**
exclude a cross-vocabulary pair. The docblock records this as the contract's
present state and names the intended end state (a producer-discriminated AAD)
so the weak guard is not read as the designed one.

The same settings-only prose on `DatasourceSecretBinderDeps.namespace`
("Settings namespace recorded on the secret row") is corrected in the same
change.

Recorded under the maintainer ruling of 2026-08-27 on #12599 (Option A now,
with the producer-discriminated AAD recorded as direction in its own ADR).
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,14 @@ export interface DatasourceSecretBinderDeps {
engine: SecretStoreEngineLike;
/** Crypto provider that wraps cleartext into a {@link CryptoHandle}. */
cryptoProvider: ICryptoProvider;
/** Settings namespace recorded on the secret row (default `'datasource'`). */
/**
* Namespace recorded on the secret row and used as the `CryptoContext`
* namespace (default `'datasource'`). This is the datasource producer's
* own vocabulary, **not** a settings namespace — the three producers of
* `sys_secret` rows share one flat `(namespace, key)` space and the pair
* does not attribute a row to a producer. See `CryptoContext` in
* `@objectstack/spec`.
*/
namespace?: string;
}

Expand Down
97 changes: 78 additions & 19 deletions packages/spec/src/contracts/crypto-provider.ts
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ICryptoProvider — pluggable encryption hook for the Settings subsystem.
* ICryptoProvider — pluggable encryption hook shared by every platform
* surface that seals a secret at rest.
*
* The provider's only job is to round-trip plaintext to a *handle*
* (a string the caller persists; opaque to everyone else). The handle
* doubles as the value stored in `sys_setting.value_enc` — it usually
* points to a row in `sys_secret`, but the contract intentionally
* leaves the format up to the implementation.
* usually points to a row in `sys_secret`, but the contract intentionally
* leaves the format up to the implementation. Where the caller *records*
* the handle differs per producer — see "Producers" below.
*
* Producers — three independent call sites construct a
* {@link CryptoContext}, and only the first of them means "settings":
*
* 1. **Settings** (`SettingsService`) — `ctx.namespace` is the settings
* namespace, `ctx.key` the specifier key; `handle.id` is recorded in
* `sys_setting.value_enc`.
* 2. **Object secret fields** (the ObjectQL engine's secret-field path)
* — `ctx.namespace` is the **object name**, `ctx.key` the **field
* name**; `handle.id` is recorded as a `secret:` ref on the business
* row itself.
* 3. **Datasource credentials** (the datasource secret binder) —
* `ctx.namespace` is caller-supplied (default `'datasource'`),
* `ctx.key` the datasource name; `handle.id` is recorded as the
* artefact's `sys_secret:` credentialsRef.
*
* All three persist a `sys_secret` row keyed by `handle.id`, and all three
* share one flat `(namespace, key)` space: `sys_secret` declares that pair
* **non-unique** precisely because it does not attribute a row to a
* producer. See {@link CryptoContext} for what that costs an AAD binding.
*
* Why an interface (not a concrete class):
*
Expand All@@ -24,15 +45,16 @@
*
* Lifecycle:
*
* 1. `encrypt(plain)` — called once per `set()` for an encrypted
* specifier. Returns a `CryptoHandle` describing both the storage
* 1. `encrypt(plain, ctx)` — called once per write of an encrypted
* value (a settings `set()`, an object secret field, a datasource
* credential). Returns a `CryptoHandle` describing both the storage
* handle and the KMS metadata. The caller persists a `sys_secret`
* row keyed by `handle.id` and stores `handle.id` in
* `sys_setting.value_enc`.
* 2. `decrypt(handle)` — called on every `get()` of an encrypted
* specifier to reveal the plaintext to the consumer (e.g.
* EmailService building a transport). Implementations may cache
* decrypted plaintext in-process for the duration of a request.
* row keyed by `handle.id` and records `handle.id` wherever its
* producer keeps it (see "Producers" above).
* 2. `decrypt(handle, ctx)` — called on every read of an encrypted
* value to reveal the plaintext to the consumer (e.g. EmailService
* building a transport). Implementations may cache decrypted
* plaintext in-process for the duration of a request.
* 3. `rotateKey(handle)` — re-wraps the same plaintext under a new
* KMS key. Returns a new handle (typically `version + 1`). Audit
* trail records the rotation as `action='rotate'`.
Expand All@@ -41,7 +63,12 @@
* multiple async tasks. They should *not* assume sequential access.
*/
export interface CryptoHandle {
/** Stable opaque id stored in `sys_setting.value_enc`. */
/**
* Stable opaque id — the key of the `sys_secret` row, recorded by the
* producer wherever that producer keeps its reference (`sys_setting
* .value_enc`, a `secret:` ref on a business row, or a `sys_secret:`
* credentialsRef). Not a settings-only coordinate.
*/
readonly id: string;
/** Identifier of the KMS key that wrapped the cipher. */
readonly kmsKeyId: string;
Expand All@@ -60,21 +87,52 @@ export interface CryptoHandle {
/**
* Optional context passed to encrypt/decrypt so providers can implement
* Additional Authenticated Data (AAD) bindings — e.g. AWS KMS encryption
* context. Helps reject ciphertexts that were copied across namespaces.
* context.
*
* ⚠️ **What an AAD over this pair does and does not guarantee.**
* `(namespace, key)` is one flat space shared by the three producer
* vocabularies listed under {@link ICryptoProvider} — settings
* namespace/specifier key, object name/field name, datasource binder —
* and nothing reserves a name in one vocabulary against another. So a
* provider binding its ciphertext to this pair **rejects a ciphertext
* swapped between two coordinates within one producer's vocabulary** (a
* settings value moved to another specifier; a secret field moved to
* another field). It does **NOT** exclude a cross-vocabulary pair: an
* object named `mail` carrying a secret field named `api_key` yields the
* same coordinate as the `mail` settings namespace's `api_key` specifier,
* under the same provider and key, in a `sys_secret` table that permits
* both rows — and a ciphertext swapped between those two rows decrypts
* cleanly. Implementations MUST NOT treat this pair as attributing a
* ciphertext to a producer.
*
* This describes the contract as it stands today, not the shape it is
* meant to keep: the intended end state is a producer-discriminated AAD
* (a scope discriminant on this type, delimiter-safe encoding), deferred
* because it is a breaking `ICryptoProvider` change plus an at-rest
* rewrap of every existing ciphertext. Until that lands, the paragraph
* above is the guarantee — do not read a stronger one into it.
*/
export interface CryptoContext {
/** Settings namespace the value belongs to. */
/**
* Producer-scoped namespace: a settings namespace, an **object name**
* (secret fields), or a caller-supplied datasource namespace (default
* `'datasource'`). Not a settings namespace in general.
*/
namespace: string;
/** Specifier key within the namespace. */
/**
* Producer-scoped key within {@link CryptoContext.namespace}: a settings
* specifier key, a **field name** (secret fields), or a datasource name.
*/
key: string;
/** Optional tenant id for multi-tenant key segregation. */
tenantId?: string;
}

export interface ICryptoProvider {
/**
* Encrypt plaintext and return a handle. The handle is stored in
* `sys_secret` and referenced by `sys_setting.value_enc`.
* Encrypt plaintext and return a handle. The caller persists it as a
* `sys_secret` row and references it from wherever its producer keeps
* the reference (see "Producers" on {@link ICryptoProvider}).
*/
encrypt(plain: string, ctx: CryptoContext): Promise<CryptoHandle>;

Expand All@@ -89,7 +147,8 @@ export interface ICryptoProvider {
* Re-wrap the plaintext under the provider's current KMS key.
* The returned handle replaces the input handle in `sys_secret`.
* Implementations SHOULD bump `version` and update `kmsKeyId` while
* leaving `id` stable (so `sys_setting.value_enc` need not be rewritten).
* leaving `id` stable, so no producer's stored reference to the handle
* has to be rewritten.
*/
rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise<CryptoHandle>;

Expand Down
Loading