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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@

All notable changes to this project will be documented in this file.

## [2.1.0] - 2026-07-31

### yes-core

#### Added
- `Middlewares::WriteEncryptor` — encrypts on `#serialize` exactly like `Middlewares::Encryptor`
(it subclasses it), but its `#deserialize` is a no-op.
- `Middlewares.register_encryptor(key_repository, config:)` — registers `:encryptor` and
`:write_encryptor` together. Host applications should call this instead of assigning
`config.middlewares[:encryptor]` by hand; registering the decrypting encryptor on its own doubles
the encryptor round trips of every encrypted append.
- `Middlewares.for_write` — the middleware keys to pass to `#append_to_stream`: every configured
middleware, with `:encryptor` swapped for `:write_encryptor`. Derived from the live config rather
than hard-coded, because `PgEventstore::Client` resolves a passed list with
`config.middlewares.slice(*list)`, which silently drops unregistered names — a literal list could
therefore resolve to one with no encryptor at all and write plaintext at rest. Falls back to the
full list when `:write_encryptor` is missing, and the railtie warns about that at boot.
- `Middlewares::ENCRYPTOR` / `Middlewares::WRITE_ENCRYPTOR` config-key constants, and
`DataEncryptor::CIPHERTEXT_KEY` for the `es_encrypted` data key.

#### Changed
- Every write site now appends with `middlewares: Middlewares.for_write`, so an append no longer
decrypts the event it returns: `CommandHandling::EventPublisher`,
`CommandHandling::CommandGroupExecutor`, `Commands::Stateless::Handler` and
`TestSupport::EventHelpers#append_event`. This covers both `PgEventstore#multiple` paths, whose
sub-events publish through those same call sites.

pg_eventstore 3.0 runs every registered middleware's `#deserialize` on the events returned by
`#append_to_stream`, not only on reads. Nothing in yes-core reads `data` off that returned event —
`otl_record_response` records only type, revision, stream and positions, and `ReadModelUpdater`
always receives the command payload on the write path — so each encrypted append was paying an
uncached key lookup plus a decrypt against the encryptor service for a payload it discarded. Those
calls also ran inside the SERIALIZABLE transaction opened by `#multiple`, widening the window for
`PG::TRSerializationFailure` and its retries.

⚠️ Consumers that relied on the appended event coming back decrypted must read the event instead.

#### Fixed
- `Middlewares::Encryptor#serialize` is now idempotent: it returns the event untouched when the data
is already encrypted. Required because the default middleware list holds two serialize-capable
encryptors once `:write_encryptor` is registered, so an append that omits `middlewares:` would
otherwise encrypt twice — the second pass encrypting the first pass's sentinels and overwriting the
real ciphertext irrecoverably. It also makes re-appending an event that was read at rest
(`middlewares: Middlewares.without(:encryptor)`) safe, which it was not before.

## [2.0.0] - 2026-07-28

Major bump because `yes-core` now requires `pg_eventstore` v3, whose schema is
Expand Down
16 changes: 8 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
PATH
remote: yes-auth
specs:
yes-auth (2.0.0)
yes-auth (2.1.0)
rails (>= 7.1)
yes-core (~> 2.0)

PATH
remote: yes-command-api
specs:
yes-command-api (2.0.0)
yes-command-api (2.1.0)
message_bus (~> 4.0)
rails (>= 7.1)
yes-core

PATH
remote: yes-core
specs:
yes-core (2.0.0)
yes-core (2.1.0)
cerbos (~> 0.8)
dry-inflector (~> 1.2)
dry-schema (~> 1.13)
Expand All @@ -32,7 +32,7 @@ PATH
PATH
remote: yes-read-api
specs:
yes-read-api (2.0.0)
yes-read-api (2.1.0)
api-pagination (~> 5.0)
pagy (~> 6.0)
rails (>= 7.1)
Expand Down Expand Up @@ -571,10 +571,10 @@ CHECKSUMS
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
yes-auth (2.0.0)
yes-command-api (2.0.0)
yes-core (2.0.0)
yes-read-api (2.0.0)
yes-auth (2.1.0)
yes-command-api (2.1.0)
yes-core (2.1.0)
yes-read-api (2.1.0)
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd

RUBY VERSION
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,14 +365,18 @@ command :change, :ssn, :string, encrypt: true

Encryption is performed by a PgEventstore middleware that delegates the actual key management and cryptography to a `key_repository` object you provide. *Yes* does not ship a concrete implementation — you plug in any object that satisfies the interface below.

Register the middleware:
Register the middlewares:

```ruby
PgEventstore.configure do |config|
config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
Yes::Core::Middlewares.register_encryptor(key_repository, config:)
end
```

This registers two middlewares against your repository: `:encryptor`, which decrypts events as they are read, and `:write_encryptor`, which encrypts identically but does not decrypt. pg_eventstore runs `#deserialize` on the event returned by `#append_to_stream` too, and nothing on the write path reads that event's data — so *Yes* appends with `middlewares: Yes::Core::Middlewares.for_write`, and your `key_repository` is never asked to decrypt a payload that is about to be discarded. Always register through `register_encryptor`: registering `:encryptor` alone silently costs a key lookup and a decrypt on every encrypted write.

One consequence worth knowing: **the event returned by a command is encrypted**. Read the event back when you need its plaintext.

The `key_repository` must respond to the following methods, each returning a [`Dry::Monads::Result`](https://dry-rb.org/gems/dry-monads/) (or any object responding to `success?`, `failure?`, and `value!`):

| Method | Purpose | Returns (on success) |
Expand Down
2 changes: 1 addition & 1 deletion lib/yes/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Yes
VERSION = '2.0.0'
VERSION = '2.1.0'
end
8 changes: 4 additions & 4 deletions yes-auth/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ../yes-core
specs:
yes-core (2.0.0)
yes-core (2.1.0)
cerbos (~> 0.8)
dry-inflector (~> 1.2)
dry-schema (~> 1.13)
Expand All @@ -17,7 +17,7 @@ PATH
PATH
remote: .
specs:
yes-auth (2.0.0)
yes-auth (2.1.0)
rails (>= 7.1)
yes-core (~> 2.0)

Expand Down Expand Up @@ -542,8 +542,8 @@ CHECKSUMS
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
yes-auth (2.0.0)
yes-core (2.0.0)
yes-auth (2.1.0)
yes-core (2.1.0)
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd

RUBY VERSION
Expand Down
2 changes: 1 addition & 1 deletion yes-auth/lib/yes/auth/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Yes
module Auth
VERSION = '2.0.0'
VERSION = '2.1.0'
end
end
8 changes: 4 additions & 4 deletions yes-command-api/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GIT
PATH
remote: ../yes-core
specs:
yes-core (2.0.0)
yes-core (2.1.0)
cerbos (~> 0.8)
dry-inflector (~> 1.2)
dry-schema (~> 1.13)
Expand All @@ -24,7 +24,7 @@ PATH
PATH
remote: .
specs:
yes-command-api (2.0.0)
yes-command-api (2.1.0)
message_bus (~> 4.0)
rails (>= 7.1)
yes-core
Expand Down Expand Up @@ -588,8 +588,8 @@ CHECKSUMS
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
yes-command-api (2.0.0)
yes-core (2.0.0)
yes-command-api (2.1.0)
yes-core (2.1.0)
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd

BUNDLED WITH
Expand Down
2 changes: 1 addition & 1 deletion yes-command-api/lib/yes/command/api/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module Yes
module Command
module Api
VERSION = '2.0.0'
VERSION = '2.1.0'
end
end
end
12 changes: 6 additions & 6 deletions yes-core/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
PATH
remote: ../yes-command-api
specs:
yes-command-api (2.0.0)
yes-command-api (2.1.0)
message_bus (~> 4.0)
rails (>= 7.1)
yes-core

PATH
remote: ../yes-read-api
specs:
yes-read-api (2.0.0)
yes-read-api (2.1.0)
api-pagination (~> 5.0)
pagy (~> 6.0)
rails (>= 7.1)
Expand All @@ -19,7 +19,7 @@ PATH
PATH
remote: .
specs:
yes-core (2.0.0)
yes-core (2.1.0)
cerbos (~> 0.8)
dry-inflector (~> 1.2)
dry-schema (~> 1.13)
Expand Down Expand Up @@ -631,9 +631,9 @@ CHECKSUMS
webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
yes-command-api (2.0.0)
yes-core (2.0.0)
yes-read-api (2.0.0)
yes-command-api (2.1.0)
yes-core (2.1.0)
yes-read-api (2.1.0)
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd

RUBY VERSION
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ def publish_subsequent_sub_event(sub_cmd)
PgEventstore.client.append_to_stream(
utils.build_stream(metadata: sub_cmd.metadata || {}),
event,
options: { expected_revision: :any }
options: { expected_revision: :any },
middlewares: Middlewares.for_write
)
end

Expand Down
3 changes: 2 additions & 1 deletion yes-core/lib/yes/core/command_handling/event_publisher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ def publish_event
PgEventstore.client.append_to_stream(
command_utilities.build_stream(metadata:),
event,
options: { expected_revision: }
options: { expected_revision: },
middlewares: Middlewares.for_write
).tap { otl_record_response(_1) }
end

Expand Down
3 changes: 2 additions & 1 deletion yes-core/lib/yes/core/commands/stateless/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ def publish_event(event_name)
PgEventstore.client.append_to_stream(
stream,
event,
options: { expected_revision: subject_stream_revision }
options: { expected_revision: subject_stream_revision },
middlewares: Middlewares.for_write
).tap { otl_record_response(_1) }
end
otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ def last_event(subject, event_name, options = {}, skip_decryption: true)
# @return [Enumerator]
def load_events(stream, options: {}, skip_decryption: true)
options = { direction: 'Backwards' }.merge(options)
middlewares = Middlewares.without(:encryptor) if skip_decryption
middlewares = Middlewares.without(Middlewares::ENCRYPTOR) if skip_decryption
PgEventstore.client.read_paginated(stream, options:, middlewares:)
end

Expand Down
4 changes: 2 additions & 2 deletions yes-core/lib/yes/core/data_decryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ def initialize(data:, schema:, repository:)
def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
return data unless key

res = key_repository.decrypt(key:, message: data['es_encrypted'])
res = key_repository.decrypt(key:, message: data[DataEncryptor::CIPHERTEXT_KEY])
return data if res.failure?

decrypted_text = res.value!
decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s)
decrypted.each { |k, value| data[k] = value if data.key?(k) }
data.delete('es_encrypted')
data.delete(DataEncryptor::CIPHERTEXT_KEY)
data
end

Expand Down
8 changes: 6 additions & 2 deletions yes-core/lib/yes/core/data_encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ module Core
# encryptor.encrypted_data
# encryptor.encryption_metadata
class DataEncryptor
# Data key holding the ciphertext of all encrypted attributes. Its presence means the data is
# currently encrypted: it is written here and removed by {DataDecryptor}.
CIPHERTEXT_KEY = 'es_encrypted'

# @return [Hash] the encrypted data
attr_reader :encrypted_data

Expand Down Expand Up @@ -51,8 +55,8 @@ def initialize(data:, schema:, repository:)
def encrypt_attributes(key:, data:, attributes:)
text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) })
encrypted = key_repository.encrypt(key:, message: text).value!
attributes.each { |att| data[att.to_s] = 'es_encrypted' if data.key?(att.to_s) }
data['es_encrypted'] = encrypted.attributes[:message]
attributes.each { |att| data[att.to_s] = CIPHERTEXT_KEY if data.key?(att.to_s) }
data[CIPHERTEXT_KEY] = encrypted.attributes[:message]
data
end
end
Expand Down
45 changes: 45 additions & 0 deletions yes-core/lib/yes/core/middlewares.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,54 @@
module Yes
module Core
module Middlewares
# Config key of the encryptor used on read paths: encrypts on #serialize, decrypts on #deserialize.
ENCRYPTOR = :encryptor
# Config key of the encryptor used on write paths: encrypts on #serialize, no-op on #deserialize.
WRITE_ENCRYPTOR = :write_encryptor

class << self
# Registers both encryptor middlewares against the same key repository.
#
# Always use this instead of assigning config.middlewares[:encryptor] by hand: registering the
# decrypting encryptor without its write-only twin silently doubles the encryptor round trips every
# encrypted append performs (see {WriteEncryptor}).
#
# Mutates the given config in place rather than opening its own PgEventstore.configure block, because
# PgEventstore.configure takes a non-reentrant mutex - nesting one inside another deadlocks.
#
# @param key_repository [#find, #create, #encrypt, #decrypt]
# @param config [PgEventstore::Config] the config yielded by PgEventstore.configure
# @return [void]
def register_encryptor(key_repository, config: PgEventstore.config)
config.middlewares[ENCRYPTOR] = Encryptor.new(key_repository)
config.middlewares[WRITE_ENCRYPTOR] = WriteEncryptor.new(key_repository)
end

# Middleware keys to pass to #append_to_stream: every configured middleware, with the decrypting
# encryptor swapped for the write-only one.
#
# Derived from the live config, never hard-coded. PgEventstore::Client resolves a passed list with
# `config.middlewares.slice(*list)`, which silently drops names that are not registered - so a literal
# list would resolve to one with NO encryptor at all against a config that registered it under a
# different key, and would write plaintext at rest undetectably. Deriving the list makes that
# impossible, and picks up any middleware added later for free.
#
# Falls back to the full list when {WRITE_ENCRYPTOR} is not registered. That is correct, just as slow
# as before - unlike a hard-coded list, which would drop encryption altogether.
#
# @return [Array<Symbol>]
def for_write
keys = PgEventstore.config.middlewares.keys
return keys unless keys.include?(WRITE_ENCRYPTOR)

keys - [ENCRYPTOR]
end

# Returns middleware keys excluding the specified one.
#
# Note that excluding {ENCRYPTOR} still yields a list containing {WRITE_ENCRYPTOR}, whose #deserialize
# is a no-op - so `without(:encryptor)` keeps meaning "read the data as it is stored".
#
# @param middleware_name [Symbol] the middleware key to exclude
# @return [Array<Symbol>] remaining middleware keys
def without(middleware_name)
Expand Down
Loading
Loading