Porting `cloak`-managed fields from Ecto schema to Ash
One of the Ecto schemas in my project uses Cloak to encrypt a value to a column on write, then decrypt the column on read and expose that on a virtual field on the schema.
For example, the schema has these two fields:
GF.Encrypted.Map
is a very small module tells cloak
about the details of encrypting and decrypting the data. cloak
will then perform its magic in the background. On database write, encrypted_meetup
is expected to be an unencrypted value, and cloak
will encrypt that value to the underlying column as a Postgres bytea
type. On database read, cloak
will decrypt the value, and put it in encrypted_meetup
, and it will also cast that value to a struct, and put it in meetup
.
How should I approach porting this to an Ash resource? Should I also set up two attributes in the Ash resource, just like in the Ecto schema? Should I define a custom Ash type for transparently encrypting and decrypting the value?GitHub
GitHub - danielberkompas/cloak: Elixir encryption library designed ...
Elixir encryption library designed for Ecto. Contribute to danielberkompas/cloak development by creating an account on GitHub.
32 Replies
You can see how I do it in ash_hq here: https://github.com/ash-project/ash_hq/blob/main/lib/ash_hq/accounts/resources/user/user.ex
GitHub
ash_hq/user.ex at main · ash-project/ash_hq
The Ash Framework homepage and documentation site. - ash_hq/user.ex at main · ash-project/ash_hq
The general pattern is a
change
that encrypts incoming values, and a calculation for the decrypted valueGitHub
ash_hq/user.ex at main · ash-project/ash_hq
The Ash Framework homepage and documentation site. - ash_hq/user.ex at main · ash-project/ash_hq
That is the change
GitHub
ash_hq/user.ex at main · ash-project/ash_hq
The Ash Framework homepage and documentation site. - ash_hq/user.ex at main · ash-project/ash_hq
and those are the calculations
👏 Nice, I will check these out!
The benefit of that pattern is that fields are never decrypted unless explicitly asked for, and in calculations you have access to things like the user and tenant
so you could pretty easily audit log access to encrypted data, for example
@Zach Daniel I'm still trying to get this to work. I created a test for exercising the database read and decrypt. The test setup uses the Ecto schema to write the record to the database with the encrypted column. Then the test queries the record using Ash:
The resulting decrypted field value is
#Ash.NotLoaded<:calculation>
, and my Decrypt
Calculation module isn't being called. What needs to happen to trigger the calculation?
The documentation for calculate/3
says:
I'm pretty sure I'm doing #2. I don't understand #1 or #3.You need to load those values
Org |> Ash.Query.filter(id == ^org.id) |> Ash.Query.load([:encrypted_meetup]) |> GF.Ash.read_one!()
they aren't loaded by default
You load calculations/aggregates/relationships all using load
load
! Okay, today I learn about load
.And you can do
Api.load
to load on things that you already have
i.e Org |> .... |> GF.Ash.read_one!() |> GF.Ash.load(:calc)
Hmm, I'm not following that. Things I already have?
Like records you've already retrieved I mean
you can load calculations on the query, or after the fact on records
Ok, got it. Already-loaded records, versus something specified inside the query.
yep!
Okay, I think I'm out of the woods with regards to reading and decrypting. Now focusing on encrypting and writing...
The relevant parts of the Org resource:
The test:
Running the test gives this error:
Unlike your example in
lib/ash_hq/accounts/resources/user/user.ex
, which is a string both for the encrypted and decrypted values, I have a map for the decrypted value, and a binary for the encrypted value.
The most important parts of my Decrypt module are not getting executed.Yeah, okay that makes sense
so you're going to need something a bit more robust since you can't just set the attribute and then encrypt it
and TBH I should probably do something similar
The anon function in the
Ash.Changeset.before_action()
call isn't getting called.Yeah, its that
change set_attribute/1
because its trying to set a map to a string field, and then expecting your change to encrypt it later
but that first part doesn't work
So if you do a key value of source -> destination (unencrypted input -> encrypted attribute)
you can do
then you remove the change set_attribute/1
partI notice you're not calling
Ash.Changeset.before_action()
, like you do in your user.ex example. What's going on there?you mean in the example I just gave above?
Just leaving it out for brevity
Should I use
Ash.Changeset.before_action()
, like you do in user.ex?Yes, you most likely should
before_action
says "do this when the action is actually run", and ensures that it only happens once, for example
Whereas if you just do it in the change callback, a changeset might be validated many times (like in the case of an AshPhoenix.Form
)If I wrap it in
Ash.Changeset.before_action(changeset, fn changeset -> ... end)
, the anonymous function doesn't get called. The Invalid value provided for encrypted_meetup: is invalid.
error happens first.
If I don't wrap it, eventually Ash.Changeset.force_change_attribute(changeset, destination, new_value)
gets called with the correct values, but the error still happens.before_action
hooks don't get called on invalid changesets
Did you remove the change set_attribute
?
Something in your action is setting a value for encrypted_meetup
And its setting it to an invalid valueNo, I didn't remove it.
Sorry, I missed that part!
👍
Changes get called (by default) on valid and invalid changes
but
before_action
hooks are saved until just before the valid action is being executedIt's working!
so thats why you saw your stuff happening
when it wasn't in a
before_action
hook I meanOkay. That's starting to make sense.
The action lifecycle can be a lot to absorb 🙂
but it is laid out here: https://ash-hq.org/docs/guides/ash/latest/topics/actions
specifically here: https://ash-hq.org/docs/guides/ash/latest/topics/actions#changesets-for-actions