A supported way to transform model fields on write and read (encrypt / tokenize / redact) #15868
sashyo
started this conversation in
Feature Requests
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
Add a small, opt-in registration point that lets a plugin transform selected model attributes on the way into Postgres and on the way back out, without editing the model classes. Declare which attributes a transformer owns, and Chatwoot applies it in the write path and the read path, passing the current request's agent so the transform can be role-aware.
This is the seam for field-level encryption, tokenization, and role-based PII redaction of contacts and messages. It is deliberately vendor-neutral: the framework ships the registration point, not any particular crypto or provider.
Motivation
A support desk holds the data a breach is about: contact names, phone numbers, emails, and the full text of conversations. A growing number of self-hosted operators want some of those columns to be ciphertext at rest, or tokenized for compliance scope reduction, or redacted for agents without a role, while Chatwoot keeps working with plaintext in-request. Today there is no supported way to do that. You either fork and edit
ContactandMessage, or ship a gem that reopens those classes and monkeypatches callbacks in, which is brittle across upgrades and easy to get wrong.What is already there, and what is missing
Chatwoot already has every primitive this needs:
before_saveandafter_findare exactly the two points a transform belongs.Current.useralready carries the signed-in agent, set per request inApplicationController. A read transform that needs to know who is asking already has its answer.What is missing is a supported place to register a transform against attributes, so an integration does not have to edit
Contact/Messageor monkeypatch them. That is the whole ask.Proposal
A registry, consulted by a thin concern that Chatwoot includes in models that opt in (or applied through an initializer-time registration). A transformer names the attributes it owns and implements up to two hooks:
before_save, each registered attribute that changed is passed throughon_writebefore the row is written.after_find, each registered attribute is passed throughon_readwithCurrent.user, and the returned value is set without marking the attribute dirty (so it is not re-persisted).A batched form (
on_read_many/on_write_many) is worth supporting so a transformer that talks to a network opens a page of messages in one round trip rather than one call per row.Why register rather than "just add a concern"
You can add a concern in a fork, or reopen the classes from a gem, and that is how this proof of concept works today. But a supported registry is the difference between a first-class extension and a monkeypatch: it survives upgrades, it does not depend on load order or on internal model structure, and it gives every integration (encryption, tokenization, redaction) one documented place instead of each one patching the same two classes. It is the same move Chatwoot already makes elsewhere with hooks and listeners.
Example plugin (one implementation, not part of core)
A field-sealing plugin registers
Contact#name,Contact#phone_number, andMessage#content, seals them on write against an external backend, and opens them on read only whenCurrent.userholds a role a quorum granted. The backend is the plugin's business (a KMS, Vault, an HSM, a threshold-crypto network, in-process AES with envelope keys). Core stays vendor-neutral.Non-goals
WHERE/ORDER BY, the same as for any field-level-encryption scheme. Which attributes to register is the operator's choice, and lookup keys (a contact's email/identifier) stay in the clear by that choice.Backward compatibility
Fully additive and opt-in. With no transformers registered,
before_save/after_findbehave exactly as today, behind a single guard. No schema changes.Prior art / reference
We built this end to end against a fork:
Contact.name/phone_numberandMessage.contentare stored asms1:ciphertext in Postgres, a role-granted agent reads plaintext, an ungranted agent reads ciphertext, and revoking the role turns reads off with no change to the app. The whole change is a smallbefore_save/after_findconcern plus a sidecar client, and the only reason it is a fork rather than a gem is that it editsContactandMessageto include the concern. A registration point would remove that last edit. Happy to open a draft PR that adds just the registry. There is a longer write-up of the working integration in a separate Show and tell post: https://github.com/orgs/chatwoot/discussions/15866 .All reactions