Skip to content

Commit

Permalink
Rework 'cloak.migrate'
Browse files Browse the repository at this point in the history
The task has a new strategy and much improved tests to verify that it
actually works. The previous implementation may never have worked: #61.
  • Loading branch information
danielberkompas committed Mar 21, 2018
1 parent 80a3713 commit 7d92a4f
Show file tree
Hide file tree
Showing 19 changed files with 474 additions and 194 deletions.
13 changes: 13 additions & 0 deletions config/test.exs
Expand Up @@ -11,3 +11,16 @@ config :cloak, Cloak.TestVault,
{Cloak.Ciphers.AES.CTR,
tag: "AES.CTR.V1", key: Base.decode64!("o5IzV8xlunc0m0/8HNHzh+3MCBBvYZa0mv4CsZic5qI=")}
]

config :logger, level: :warn

config :cloak, ecto_repos: [Cloak.TestRepo]

config :cloak, Cloak.TestRepo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "cloak_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox,
priv: "test/support/"
14 changes: 10 additions & 4 deletions guides/how_to/rotate_keys.md
Expand Up @@ -28,12 +28,18 @@ key to the `:retired` label.

For each schema that uses your vault, run `mix cloak.migrate`:

mix cloak.migrate -v MyApp.Vault -r MyApp.Repo -s MyApp.Schema -f encryption_version
mix cloak.migrate -r MyApp.Repo -s MyApp.Schema

Alternatively, you can create an alias in your `mix.exs` as shown in
the `Mix.Tasks.Cloak.Migrate` documentation and migrate all schemas at once.
Alternatively, you can migrate multiple schemas at once by configuring
the following values in `config/config.exs`:

mix cloak.migrate_all
config :my_app,
cloak_repo: MyApp.Repo,
cloak_schemas: [...]

With that in place, you can simply run:

mix cloak.migrate

## Remove Retired Key

Expand Down
18 changes: 8 additions & 10 deletions guides/upgrading/0.6.x_to_0.7.x.md
Expand Up @@ -5,6 +5,7 @@ Cloak 0.7 introduced a number of important changes.
- Encryption is now performed through `Cloak.Vault` modules
- Ciphertext no longer contains a `module_tag`
- Ecto types are now local to your project
- You no longer need an `:encryption_version` field

## Install Cloak 0.7

Expand Down Expand Up @@ -101,22 +102,19 @@ And then replace `Cloak.EncryptedBinaryField` in your schema:
field :encryption_version
end

Finally, you'd change the `:encryption_version` to use `MyApp.Vault.version/0`
instead of `Cloak.version/0`.
Finally, you'd remove the `:encryption_version` field as it is no longer
needed.

@doc false
def changeset(struct, attrs \\ %{}) do
struct
|> cast(attrs, [:name])
|> put_change(:encryption_version, MyApp.Vault.version())
end
alter table(:users) do
remove :encryption_version
end

## Migrate Existing Data

To convert ciphertext en masse from the old `v0.6` format to the new `v0.7`
format, you'll need to run `mix cloak.migrate` on each encrypted schema.
format, you'll need to run `mix cloak.migrate` as shown in its documentation.

mix cloak.migrate -v MyApp.Vault -r MyApp.Repo -s MyApp.Schema -f encryption_version
mix cloak.migrate -r MyApp.Repo -s MyApp.Schema

## Remove `:retired` Cipher

Expand Down
11 changes: 10 additions & 1 deletion lib/cloak/field.ex
@@ -1,11 +1,15 @@
defmodule Cloak.Field do
@moduledoc false

@callback __cloak__ :: Keyword.t()

defmacro __using__(opts) do
vault = Keyword.fetch!(opts, :vault)
label = opts[:label]

quote location: :keep do
@behaviour Cloak.Field

@doc false
def type, do: :binary

Expand Down Expand Up @@ -42,7 +46,12 @@ defmodule Cloak.Field do
@doc false
def after_decrypt(value), do: value

defoverridable Module.definitions_in(__MODULE__)
defoverridable type: 0, cast: 1, dump: 1, load: 1, before_encrypt: 1, after_decrypt: 1

@doc false
def __cloak__ do
[vault: unquote(vault), label: unquote(label)]
end

defp encrypt(plaintext) do
if unquote(label) do
Expand Down
77 changes: 77 additions & 0 deletions lib/cloak/migrator.ex
@@ -0,0 +1,77 @@
defmodule Cloak.Migrator do
@moduledoc false

import Ecto.Query

alias Ecto.Changeset

def migrate(repo, schema) when is_atom(repo) and is_atom(schema) do
validate(repo, schema)

min_id = repo.aggregate(schema, :min, :id)
max_id = repo.aggregate(schema, :max, :id)
fields = cloak_fields(schema)

min_id..max_id
|> Flow.from_enumerable(stages: System.schedulers_online())
|> Flow.map(&migrate_row(&1, repo, schema, fields))
|> Flow.run()
end

defp migrate_row(id, repo, schema, fields) do
repo.transaction(fn ->
query =
schema
|> where(id: ^id)
|> lock("FOR UPDATE")

case repo.one(query) do
nil ->
:noop

row ->
row
|> force_changes(fields)
|> repo.update()
end
end)
end

defp force_changes(row, fields) do
Enum.reduce(fields, Changeset.change(row), fn field, changeset ->
Changeset.force_change(changeset, field, Map.get(row, field))
end)
end

defp cloak_fields(schema) do
:fields
|> schema.__schema__()
|> Enum.map(fn field ->
{field, schema.__schema__(:type, field)}
end)
|> Enum.filter(fn {_field, type} ->
Code.ensure_loaded?(type) && function_exported?(type, :__cloak__, 0)
end)
|> Enum.map(fn {field, _type} ->
field
end)
end

defp validate(repo, schema) do
unless ecto_repo?(repo) do
raise ArgumentError, "#{inspect(repo)} is not an Ecto.Repo"
end

unless ecto_schema?(schema) do
raise ArgumentError, "#{inspect(schema)} is not an Ecto.Schema"
end
end

defp ecto_repo?(repo) do
Code.ensure_loaded?(repo) && function_exported?(repo, :__adapter__, 0)
end

defp ecto_schema?(schema) do
Code.ensure_loaded?(schema) && function_exported?(schema, :__schema__, 1)
end
end
77 changes: 77 additions & 0 deletions lib/mix/cloak.ex
@@ -0,0 +1,77 @@
defmodule Mix.Cloak do
@moduledoc false
# Helpers for building Mix tasks for Cloak

# %{ app => %{repo: repo, schemas: schemas}}
def parse_config(args) do
{opts, _, _} = OptionParser.parse(args, aliases: [s: :schema, r: :repo])

opts
|> Enum.into(%{})
|> do_parse_config()
end

defp do_parse_config(%{repo: repo, schema: schema}) do
%{current_app() => %{repo: to_module(repo), schemas: [to_module(schema)]}}
end

defp do_parse_config(_argv) do
get_apps()
|> Enum.map(&get_app_config/1)
|> Enum.into(%{})
|> validate_config!()
end

defp get_apps do
apps = Mix.Project.apps_paths()

if apps do
Map.keys(apps)
else
[current_app()]
end
end

defp get_app_config(app) do
{app,
%{
repo: Application.get_env(app, :cloak_repo),
schemas: Application.get_env(app, :cloak_schemas)
}}
end

defp current_app do
Mix.Project.config()[:app]
end

defp validate_config!(config) do
invalid_configs = Enum.filter(config, &(!valid?(&1)))

unless length(invalid_configs) == 0 do
apps = Keyword.keys(invalid_configs)

raise Mix.Error, """
warning: no configured Ecto repos or schemas found in any of the apps: #{inspect(apps)}
You can avoid this by passing the -r and -s flags or by setting the repo and schemas
in your config/config.exs:
config #{inspect(hd(apps))},
cloak_repo: ...,
cloak_schemas: [...]
"""
end

config
end

defp valid?({_app, %{repo: repo, schemas: [schema | _]}})
when is_atom(repo) and is_atom(schema),
do: true

defp valid?(_config), do: false

defp to_module(name) do
String.to_existing_atom("Elixir." <> name)
end
end

0 comments on commit 7d92a4f

Please sign in to comment.