├── .tool-versions ├── mix.lock.license ├── .tool-versions.license ├── documentation ├── dsls │ ├── DSL-AshCloak.md.license │ └── DSL-AshCloak.md ├── topics │ └── how-does-ash-cloak-work.md └── tutorials │ └── getting-started-with-ash-cloak.md ├── test ├── test_helper.exs ├── support │ ├── domain.ex │ ├── vault.ex │ ├── change.ex │ └── resource.ex ├── ash_cloak │ └── set_up_encryption_test.exs └── ash_cloak_test.exs ├── lib ├── ash_cloak │ ├── info.ex │ ├── errors │ │ └── no_such_encrypted_attribute.ex │ ├── changes │ │ └── encrypt.ex │ ├── calculations │ │ └── decrypt.ex │ └── transformers │ │ └── set_up_encryption.ex └── ash_cloak.ex ├── .formatter.exs ├── .github ├── workflows │ └── elixir.yml └── dependabot.yml ├── priv └── bad_fixtures │ └── bad_resource.ex ├── .gitignore ├── config └── config.exs ├── .check.exs ├── LICENSES └── MIT.txt ├── README.md ├── CHANGELOG.md ├── mix.exs ├── .credo.exs └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.1.2 2 | elixir 1.18.4 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /documentation/dsls/DSL-AshCloak.md.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /test/support/domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Test.Domain do 6 | @moduledoc false 7 | use Ash.Domain 8 | 9 | resources do 10 | resource(AshCloak.Test.Resource) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/ash_cloak/info.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Info do 6 | @moduledoc "Introspection functions for the `AshCloak` extension." 7 | use Spark.InfoGenerator, extension: AshCloak, sections: [:cloak] 8 | end 9 | -------------------------------------------------------------------------------- /test/support/vault.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Test.Vault do 6 | @moduledoc false 7 | def encrypt!(value) when is_binary(value), do: "encrypted #{value}" 8 | 9 | def decrypt!("encrypted " <> value), do: value 10 | end 11 | -------------------------------------------------------------------------------- /test/support/change.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Test.Change do 6 | @moduledoc false 7 | use Ash.Resource.Change 8 | 9 | def change(changeset, _opts, _) do 10 | changeset |> Ash.Changeset.set_argument(:encrypted, 13) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | spark_locals_without_parens = [attributes: 1, decrypt_by_default: 1, on_decrypt: 1, vault: 1] 6 | 7 | [ 8 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 9 | locals_without_parens: spark_locals_without_parens, 10 | export: [ 11 | locals_without_parens: spark_locals_without_parens 12 | ] 13 | ] 14 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | workflow_call: 14 | jobs: 15 | ash-ci: 16 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 17 | secrets: 18 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 19 | with: 20 | reuse: true 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | --- 6 | updates: 7 | - directory: / 8 | groups: 9 | dev-dependencies: 10 | dependency-type: development 11 | production-dependencies: 12 | dependency-type: production 13 | package-ecosystem: mix 14 | schedule: 15 | day: thursday 16 | interval: monthly 17 | versioning-strategy: lockfile-only 18 | version: 2 19 | -------------------------------------------------------------------------------- /priv/bad_fixtures/bad_resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Test.BadResource do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshCloak.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshCloak] 12 | 13 | actions do 14 | defaults([:read, :destroy, create: :*, update: :*]) 15 | end 16 | 17 | attributes do 18 | uuid_primary_key(:id) 19 | 20 | attribute :some_secret, :string do 21 | allow_nil?(false) 22 | public?(false) 23 | sensitive?(true) 24 | end 25 | end 26 | 27 | cloak do 28 | vault AshCloak.Test.Vault 29 | 30 | attributes [:huuge_typo_in_some_secret_lol] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/ash_cloak/set_up_encryption_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.SetUpEncryptionTest do 6 | @moduledoc false 7 | 8 | @bad_fixtures_dir Application.app_dir(:ash_cloak, "priv/bad_fixtures") 9 | @bad_resource_file Path.join([@bad_fixtures_dir, "bad_resource.ex"]) 10 | 11 | @external_resource @bad_resource_file 12 | 13 | use ExUnit.Case 14 | 15 | test "outputs the invalid attribute in the error message" do 16 | %Spark.Error.DslError{message: message} = 17 | assert_raise Spark.Error.DslError, 18 | fn -> 19 | Code.eval_file(@bad_resource_file) 20 | end 21 | 22 | assert message =~ "No attribute called :huuge_typo_in_some_secret_lol" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/ash_cloak/errors/no_such_encrypted_attribute.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Errors.NoSuchEncryptedAttribute do 6 | @moduledoc """ 7 | An error raised when attempting to decrypt an attribute that is not encrypted. 8 | """ 9 | 10 | use Splode.Error, fields: [:key, :resource], class: :invalid 11 | 12 | def message(error) do 13 | """ 14 | Attempted to encrypt and set attribute#{for_key(error)}#{for_resource(error)}, but it is not configured for encryption.} 15 | """ 16 | end 17 | 18 | defp for_key(%{key: key}) when not is_nil(key), do: " for #{key}" 19 | defp for_key(_), do: "" 20 | 21 | defp for_resource(%{resource: resource}) when not is_nil(resource), do: " for #{resource}" 22 | defp for_resource(_), do: "" 23 | end 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | ash_cloak-*.tar 28 | 29 | # Temporary files, for example, from tests. 30 | /tmp/ 31 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | config :ash, :disable_async?, true 8 | config :ash, :validate_domain_resource_inclusion?, false 9 | config :ash, :validate_domain_config_inclusion?, false 10 | config :ash, :keep_read_action_loads_when_loading?, false 11 | 12 | if Mix.env() == :dev do 13 | config :git_ops, 14 | mix_project: AshCloak.MixProject, 15 | changelog_file: "CHANGELOG.md", 16 | repository_url: "https://github.com/ash-project/ash_cloak", 17 | # Instructs the tool to manage your mix version in your `mix.exs` file 18 | # See below for more information 19 | manage_mix_version?: true, 20 | # Instructs the tool to manage the version in your README.md 21 | # Pass in `true` to use `"README.md"` or a string to customize 22 | manage_readme_version: [ 23 | "README.md", 24 | "documentation/tutorials/getting-started-with-ash-cloak.md" 25 | ], 26 | version_tag_prefix: "v" 27 | end 28 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | ## all available options with default values (see `mix check` docs for description) 7 | # parallel: true, 8 | # skipped: true, 9 | 10 | ## list of tools (see `mix check` docs for defaults) 11 | tools: [ 12 | ## curated tools may be disabled (e.g. the check for compilation warnings) 13 | # {:compiler, false}, 14 | 15 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 16 | # {:credo, "mix credo --format oneline"}, 17 | 18 | {:check_formatter, command: "mix spark.formatter --check"}, 19 | {:doctor, false}, 20 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 21 | 22 | ## custom new tools may be added (mix tasks or arbitrary commands) 23 | # {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}}, 24 | # {:my_arbitrary_tool, command: "npm test", cd: "assets"}, 25 | # {:my_arbitrary_script, command: ["my_script", "argument with spaces"], cd: "scripts"} 26 | ] 27 | ] 28 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /lib/ash_cloak/changes/encrypt.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Changes.Encrypt do 6 | @moduledoc "Takes an argument, and encrypts it into an attribute called `encrypted_{attribute}`" 7 | use Ash.Resource.Change 8 | 9 | def change(changeset, opts, _) do 10 | Ash.Changeset.before_action(changeset, fn changeset -> 11 | attribute = opts[:field] 12 | 13 | case Ash.Changeset.fetch_argument(changeset, attribute) do 14 | {:ok, value} -> 15 | AshCloak.encrypt_and_set(changeset, attribute, value) 16 | 17 | :error -> 18 | changeset 19 | end 20 | end) 21 | end 22 | 23 | def atomic(changeset, opts, _) do 24 | attribute = opts[:field] 25 | 26 | case Ash.Changeset.fetch_argument(changeset, attribute) do 27 | {:ok, value} -> 28 | encryption_target = String.to_existing_atom("encrypted_#{attribute}") 29 | {:atomic, %{encryption_target => AshCloak.do_encrypt(changeset.resource, value)}} 30 | 31 | :error -> 32 | {:ok, changeset} 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /documentation/topics/how-does-ash-cloak-work.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # How does AshCloak work? 8 | 9 | ## Rewrite attributes to calculations 10 | 11 | First, AshCloak changes the name of each cloaked attribute to `encrypted_`, and sets `public?: false` and `sensitive?: true`. 12 | 13 | Then it adds a _calculation_ matching the original attribute that, when loaded, will decrypt the given attribute and call any configured `on_decrypt` callbacks. 14 | 15 | ## Modify Actions 16 | 17 | AshCloak then goes through each action that accepts the attribute and removes the attribute from the accept list. 18 | 19 | Then it adds an argument by the same name, and a `change` that encrypts the attribute value. 20 | 21 | This `change` also deletes the argument from the arguments list and from the params. This is a small extra layer of security to prevent accidental leakage of the value. 22 | 23 | ## Add `preparation` and `change` 24 | 25 | Finally, it add a `preparation` and a `change` that will automatically load the corresponding calculations for any attribute in the `decrypt_by_default` list. 26 | 27 | ## The result 28 | 29 | The cloaked attribute will now seamlessly encrypt when writing and decrypt on request. 30 | -------------------------------------------------------------------------------- /documentation/dsls/DSL-AshCloak.md: -------------------------------------------------------------------------------- 1 | 4 | # AshCloak 5 | 6 | An extension for encrypting attributes of a resource. 7 | 8 | See the getting started guide for more information. 9 | 10 | 11 | ## cloak 12 | Encrypt attributes of a resource 13 | 14 | 15 | 16 | 17 | 18 | 19 | ### Options 20 | 21 | | Name | Type | Default | Docs | 22 | |------|------|---------|------| 23 | | [`vault`](#cloak-vault){: #cloak-vault .spark-required} | `module` | | The vault to use to encrypt & decrypt the value | 24 | | [`attributes`](#cloak-attributes){: #cloak-attributes } | `atom \| list(atom)` | `[]` | The attribute or attributes to encrypt. The attribute will be renamed to `encrypted_{attribute}`, and a calculation with the same name will be added. | 25 | | [`decrypt_by_default`](#cloak-decrypt_by_default){: #cloak-decrypt_by_default } | `atom \| list(atom)` | `[]` | A list of attributes that should be decrypted (their calculation should be loaded) by default. | 26 | | [`on_decrypt`](#cloak-on_decrypt){: #cloak-on_decrypt } | `(any, any, any, any -> any) \| mfa` | | A function to call when decrypting any value. Takes the resource, field, records, and calculation context. Must return `:ok` or `{:error, error}` | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/ash_cloak/calculations/decrypt.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Calculations.Decrypt do 6 | @moduledoc false 7 | use Ash.Resource.Calculation 8 | 9 | def load(_, opts, _), do: [opts[:field]] 10 | 11 | def calculate([%resource{} | _] = records, opts, context) do 12 | vault = AshCloak.Info.cloak_vault!(resource) 13 | plain_field = opts[:plain_field] 14 | 15 | case approve_decrypt(resource, records, plain_field, context) do 16 | :ok -> 17 | Enum.map(records, fn record -> 18 | record 19 | |> Map.get(opts[:field]) 20 | |> case do 21 | nil -> 22 | nil 23 | 24 | value -> 25 | value 26 | |> Base.decode64!() 27 | |> vault.decrypt!() 28 | |> Ash.Helpers.non_executable_binary_to_term() 29 | end 30 | end) 31 | 32 | {:error, error} -> 33 | {:error, error} 34 | end 35 | end 36 | 37 | def calculate([], _, _), do: [] 38 | 39 | defp approve_decrypt(resource, records, field, context) do 40 | case AshCloak.Info.cloak_on_decrypt(resource) do 41 | {:ok, {m, f, a}} -> 42 | apply(m, f, [resource, records, field, context] ++ List.wrap(a)) 43 | 44 | {:ok, function} when is_function(function, 4) -> 45 | function.(resource, records, field, context) 46 | 47 | :error -> 48 | :ok 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ![Logo](https://github.com/ash-project/ash/blob/main/logos/cropped-for-header-black-text.png?raw=true#gh-light-mode-only) 8 | ![Logo](https://github.com/ash-project/ash/blob/main/logos/cropped-for-header-white-text.png?raw=true#gh-dark-mode-only) 9 | 10 | ![Elixir CI](https://github.com/ash-project/ash_cloak/workflows/CI/badge.svg) 11 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 12 | [![Hex version badge](https://img.shields.io/hexpm/v/ash_cloak.svg)](https://hex.pm/packages/ash_cloak) 13 | [![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ash_cloak) 14 | [![REUSE status](https://api.reuse.software/badge/github.com/ash-project/ash_cloak)](https://api.reuse.software/info/github.com/ash-project/ash_cloak) 15 | 16 | # AshCloak 17 | 18 | AshCloak is an [Ash](https://hexdocs.pm/ash) extension to seamlessly encrypt and decrypt attributes of your resources. 19 | 20 | Encrypting attributes ensures that sensitive fields are not stored in the clear in your data layer. 21 | 22 | AshCloak can be used with any Ash data layer. 23 | 24 | It's recommended to use [Cloak](https://github.com/danielberkompas/cloak) as a vault implementation but you could also build your own. 25 | 26 | ## Tutorials 27 | 28 | - [Get Started with AshCloak](documentation/tutorials/getting-started-with-ash-cloak.md) 29 | 30 | ## Topics 31 | 32 | - [How does AshCloak work?](documentation/topics/how-does-ash-cloak-work.md) 33 | 34 | ## Reference 35 | 36 | - [AshCloak DSL](documentation/dsls/DSL-AshCloak.md) 37 | -------------------------------------------------------------------------------- /test/support/resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Test.Resource do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshCloak.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshCloak] 12 | 13 | ets do 14 | private?(true) 15 | end 16 | 17 | @attributes [:encrypted, :encrypted_always_loaded, :encrypted_with_default, :not_encrypted] 18 | actions do 19 | defaults([:read, :destroy, create: @attributes, update: @attributes]) 20 | 21 | create :change_before_encrypt do 22 | accept(@attributes) 23 | change(AshCloak.Test.Change) 24 | end 25 | 26 | create :change_without_accept do 27 | change(AshCloak.Test.Change) 28 | end 29 | 30 | update :update_not_encrypted do 31 | accept([:not_encrypted]) 32 | end 33 | end 34 | 35 | cloak do 36 | vault(AshCloak.Test.Vault) 37 | attributes([:encrypted, :encrypted_always_loaded, :encrypted_with_default]) 38 | decrypt_by_default([:encrypted_always_loaded]) 39 | 40 | on_decrypt(fn resource, records, field, context -> 41 | send(self(), {:decrypting, resource, records, field, context}) 42 | 43 | if Enum.any?(records, &(&1.not_encrypted == "dont allow decryption")) do 44 | {:error, "can't do it dude"} 45 | else 46 | :ok 47 | end 48 | end) 49 | end 50 | 51 | attributes do 52 | uuid_primary_key(:id) 53 | attribute(:not_encrypted, :string) 54 | attribute(:encrypted, :integer, public?: true) 55 | attribute(:encrypted_always_loaded, :map, public?: true) 56 | attribute(:encrypted_with_default, :integer, default: 42, allow_nil?: false, public?: true) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /documentation/tutorials/getting-started-with-ash-cloak.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Get Started with AshCloak 8 | 9 | ## Installation 10 | 11 | Add `ash_cloak` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | {:ash_cloak, "~> 0.1.7"} 15 | ``` 16 | 17 | Follow [the cloak getting started guide](https://hexdocs.pm/cloak/readme.html) to add `cloak` as a dependency, as AshCloak does not add a vault implementation for you. Note that you do not need `cloak_ecto` because your Ash data layer will take care of this. 18 | 19 | Alternatively you could use your own vault module that implements `encrypt!` and `decrypt!`, but we recommend using `Cloak` to achieve that goal. See the [cloak vault guide](https://hexdocs.pm/cloak/install.html#create-a-vault) 20 | 21 | ### Add the `AshCloak` extension to your resource 22 | 23 | ```elixir 24 | defmodule User do 25 | use Ash.Resource, extensions: [AshCloak] 26 | 27 | cloak do 28 | # the vault to use to encrypt them 29 | vault MyApp.Vault 30 | 31 | # the attributes to encrypt 32 | attributes [:address, :phone_number] 33 | 34 | # This is just equivalent to always providing `load: fields` on all calls 35 | decrypt_by_default [:address] 36 | 37 | # An MFA or function to be invoked beforce any decryption 38 | on_decrypt fn records, field, context -> 39 | # Ash has policies that allow forbidding certain users to load data. 40 | # You should generally use those for authorization rules, and 41 | # only use this callback for auditing/logging. 42 | Audit.user_accessed_encrypted_field(records, field, context) 43 | 44 | if context.user.name == "marty" do 45 | {:error, "No martys at the party!"} 46 | else 47 | :ok 48 | end 49 | end 50 | end 51 | end 52 | ``` 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Change Log 8 | 9 | All notable changes to this project will be documented in this file. 10 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 11 | 12 | 13 | 14 | ## [v0.1.7](https://github.com/ash-project/ash_cloak/compare/v0.1.6...v0.1.7) (2025-08-31) 15 | 16 | 17 | 18 | 19 | ### Bug Fixes: 20 | 21 | * fix the missing attribute name when the passed attribute does not exist (#116) by Simon Bergström 22 | 23 | ## [v0.1.6](https://github.com/ash-project/ash_cloak/compare/v0.1.5...v0.1.6) (2025-03-14) 24 | 25 | 26 | 27 | 28 | ### Bug Fixes: 29 | 30 | * Apply default of attrs only for create actions (#84) 31 | 32 | ## [v0.1.5](https://github.com/ash-project/ash_cloak/compare/v0.1.4...v0.1.5) (2025-03-11) 33 | 34 | 35 | 36 | 37 | ### Bug Fixes: 38 | 39 | * Apply default of attribute to argument (#80) 40 | 41 | ## [v0.1.4](https://github.com/ash-project/ash_cloak/compare/v0.1.3...v0.1.4) (2025-02-13) 42 | 43 | 44 | 45 | 46 | ### Bug Fixes: 47 | 48 | * return `{:ok, changeset}` to do nothing on encryption change 49 | 50 | ## [v0.1.3](https://github.com/ash-project/ash_cloak/compare/v0.1.2...v0.1.3) (2025-01-27) 51 | 52 | 53 | 54 | 55 | ### Bug Fixes: 56 | 57 | * encrypt attribute when not listed on `accept` list (#46) 58 | 59 | ## [v0.1.2](https://github.com/ash-project/ash_cloak/compare/v0.1.1...v0.1.2) (2024-08-19) 60 | 61 | 62 | 63 | 64 | ### Improvements: 65 | 66 | * add atomic implementation for `AshCloack.Changes.Encrypt` 67 | 68 | ## [v0.1.1](https://github.com/ash-project/ash_cloak/compare/v0.1.0...v0.1.1) (2024-08-02) 69 | 70 | ### Bug Fixes: 71 | 72 | - [`AshCloak.Changes.Encrypt`] run encrypt on before_action (#14) 73 | 74 | ### Improvements: 75 | 76 | - [`AshCloak`] add `AshCloak.encrypt_and_set/3` 77 | 78 | ## [v0.1.0](https://github.com/ash-project/ash_cloak/compare/v0.1.0-rc.0...v0.1.0) (2024-05-11) 79 | 80 | ## [v0.1.0-rc.0](https://github.com/ash-project/ash_cloak/compare/v0.1.0...v0.1.0) (2024-04-25) 81 | 82 | Initial feature set 83 | -------------------------------------------------------------------------------- /lib/ash_cloak.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak do 6 | @moduledoc """ 7 | An extension for encrypting attributes of a resource. 8 | 9 | See the getting started guide for more information. 10 | """ 11 | 12 | @transformers [ 13 | AshCloak.Transformers.SetupEncryption 14 | ] 15 | 16 | @cloak %Spark.Dsl.Section{ 17 | name: :cloak, 18 | describe: "Encrypt attributes of a resource", 19 | schema: [ 20 | vault: [ 21 | type: {:behaviour, Cloak.Vault}, 22 | doc: "The vault to use to encrypt & decrypt the value", 23 | required: true 24 | ], 25 | attributes: [ 26 | type: {:wrap_list, :atom}, 27 | default: [], 28 | doc: 29 | "The attribute or attributes to encrypt. The attribute will be renamed to `encrypted_{attribute}`, and a calculation with the same name will be added." 30 | ], 31 | decrypt_by_default: [ 32 | type: {:wrap_list, :atom}, 33 | default: [], 34 | doc: 35 | "A list of attributes that should be decrypted (their calculation should be loaded) by default." 36 | ], 37 | on_decrypt: [ 38 | type: {:or, [{:fun, 4}, :mfa]}, 39 | doc: 40 | "A function to call when decrypting any value. Takes the resource, field, records, and calculation context. Must return `:ok` or `{:error, error}`" 41 | ] 42 | ] 43 | } 44 | 45 | use Spark.Dsl.Extension, sections: [@cloak], transformers: @transformers 46 | 47 | @doc """ 48 | Encrypts and writes to an encrypted attribute. 49 | 50 | If the changeset is pending (i.e not currently running the action), then it is added as a before_action hook. 51 | Otherwise, it is run immediately 52 | 53 | Raises AshCloak.Errors.NoSuchEncryptedAttribute if the attribute is not configured for encryption. 54 | """ 55 | @spec encrypt_and_set(Ash.Changeset.t(), attr :: atom, term :: term) :: Ash.Changeset.t() 56 | def encrypt_and_set(changeset, key, value) do 57 | if key in AshCloak.Info.cloak_attributes!(changeset.resource) do 58 | if changeset.phase == :pending do 59 | Ash.Changeset.before_action(changeset, &do_encrypt_and_set(&1, key, value)) 60 | else 61 | do_encrypt_and_set(changeset, key, value) 62 | end 63 | else 64 | raise AshCloak.Errors.NoSuchEncryptedAttribute, key: key, resource: changeset.resource 65 | end 66 | end 67 | 68 | @doc false 69 | def do_encrypt(resource, value) do 70 | vault = AshCloak.Info.cloak_vault!(resource) 71 | 72 | value 73 | |> :erlang.term_to_binary() 74 | |> vault.encrypt!() 75 | |> Base.encode64() 76 | end 77 | 78 | defp do_encrypt_and_set(changeset, key, value) do 79 | encrypted_value = do_encrypt(changeset.resource, value) 80 | encryption_target = String.to_existing_atom("encrypted_#{key}") 81 | 82 | changeset 83 | |> Ash.Changeset.force_change_attribute(encryption_target, encrypted_value) 84 | |> Map.update!(:arguments, &Map.delete(&1, key)) 85 | |> Map.update!(:params, fn params -> 86 | Map.drop(params, [key, to_string(key)]) 87 | end) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/ash_cloak/transformers/set_up_encryption.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.Transformers.SetupEncryption do 6 | @moduledoc false 7 | use Spark.Dsl.Transformer 8 | 9 | # sobelow_skip ["DOS.BinToAtom", "DOS.StringToAtom"] 10 | def transform(dsl) do 11 | module = Spark.Dsl.Transformer.get_persisted(dsl, :module) 12 | cloaked_attrs = AshCloak.Info.cloak_attributes!(dsl) 13 | 14 | Enum.reduce_while(cloaked_attrs, {:ok, dsl}, fn attr, {:ok, dsl} -> 15 | attribute = Ash.Resource.Info.attribute(dsl, attr) 16 | 17 | if !attribute do 18 | raise Spark.Error.DslError, 19 | module: module, 20 | message: "No attribute called #{inspect(attr)} found", 21 | path: [:cloak, :attributes] 22 | end 23 | 24 | if attribute.primary_key? do 25 | raise Spark.Error.DslError, 26 | module: module, 27 | message: "cannot encrypt primary key attribute", 28 | path: [:cloak, :attributes] 29 | end 30 | 31 | name = attribute.name 32 | 33 | dsl 34 | |> Spark.Dsl.Transformer.remove_entity([:attributes], &(&1.name == attribute.name)) 35 | |> Ash.Resource.Builder.add_attribute(:"encrypted_#{name}", :binary, 36 | allow_nil?: attribute.allow_nil?, 37 | sensitive?: true, 38 | public?: false, 39 | description: "Encrypted #{attribute.name}" 40 | ) 41 | |> Ash.Resource.Builder.add_calculation( 42 | attribute.name, 43 | attribute.type, 44 | {AshCloak.Calculations.Decrypt, [field: :"encrypted_#{name}", plain_field: name]}, 45 | [ 46 | public?: attribute.public?, 47 | constraints: attribute.constraints, 48 | allow_nil?: attribute.allow_nil?, 49 | sensitive?: true, 50 | filterable?: false, 51 | sortable?: false 52 | ] 53 | |> add_description(attribute) 54 | ) 55 | |> rewrite_actions(attribute) 56 | |> case do 57 | {:ok, dsl} -> {:cont, {:ok, dsl}} 58 | {:error, error} -> {:halt, {:error, error}} 59 | end 60 | end) 61 | |> add_automatic_decrypt_preparation_and_change() 62 | end 63 | 64 | defp add_description(opts, %{description: description}) when is_binary(description) do 65 | Keyword.put(opts, :description, description) 66 | end 67 | 68 | defp add_description(opts, _), do: opts 69 | 70 | defp add_automatic_decrypt_preparation_and_change({:ok, dsl}) do 71 | case AshCloak.Info.cloak_decrypt_by_default!(dsl) do 72 | [] -> 73 | {:ok, dsl} 74 | 75 | decrypt_by_default -> 76 | dsl 77 | |> Ash.Resource.Builder.add_change({Ash.Resource.Change.Load, target: decrypt_by_default}) 78 | |> Ash.Resource.Builder.add_preparation( 79 | {Ash.Resource.Preparation.Build, options: [load: decrypt_by_default]} 80 | ) 81 | end 82 | end 83 | 84 | defp add_automatic_decrypt_preparation_and_change({:error, error}) do 85 | {:error, error} 86 | end 87 | 88 | defp rewrite_actions({:ok, dsl}, attr) do 89 | dsl 90 | |> Ash.Resource.Info.actions() 91 | |> Enum.filter(&(&1.type in [:create, :update, :destroy])) 92 | |> Enum.reduce_while({:ok, dsl}, fn action, {:ok, dsl} -> 93 | new_accept = action.accept -- [attr.name] 94 | 95 | opts = 96 | case action.type do 97 | :create -> [constraints: attr.constraints, default: attr.default] 98 | _ -> [constraints: attr.constraints] 99 | end 100 | 101 | with {:ok, argument} <- 102 | Ash.Resource.Builder.build_action_argument(attr.name, attr.type, opts), 103 | {:ok, change} <- 104 | Ash.Resource.Builder.build_action_change( 105 | {AshCloak.Changes.Encrypt, field: attr.name} 106 | ) do 107 | {:cont, 108 | {:ok, 109 | Spark.Dsl.Transformer.replace_entity( 110 | dsl, 111 | [:actions], 112 | %{ 113 | action 114 | | arguments: [argument | Enum.reject(action.arguments, &(&1.name == attr.name))], 115 | changes: [change | action.changes], 116 | accept: new_accept 117 | }, 118 | &(&1.name == action.name) 119 | )}} 120 | else 121 | other -> 122 | {:halt, other} 123 | end 124 | end) 125 | end 126 | 127 | defp rewrite_actions({:error, error}, _), do: {:error, error} 128 | 129 | def after?(Ash.Resource.Transformers.DefaultAccept), do: true 130 | def after?(_), do: false 131 | end 132 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloak.MixProject do 6 | use Mix.Project 7 | 8 | @description """ 9 | An Ash extension to seamlessly encrypt and decrypt resource attributes. 10 | """ 11 | 12 | @version "0.1.7" 13 | 14 | def project do 15 | [ 16 | app: :ash_cloak, 17 | version: @version, 18 | package: package(), 19 | elixir: "~> 1.14", 20 | start_permanent: Mix.env() == :prod, 21 | elixirc_paths: elixirc_paths(Mix.env()), 22 | deps: deps(), 23 | docs: &docs/0, 24 | dialyzer: [plt_add_apps: [:mix]], 25 | aliases: aliases(), 26 | description: @description, 27 | source_url: "https://github.com/ash-project/ash_cloak", 28 | homepage_url: "https://github.com/ash-project/ash_cloak" 29 | ] 30 | end 31 | 32 | defp docs do 33 | [ 34 | main: "readme", 35 | source_ref: "v#{@version}", 36 | extra_section: "GUIDES", 37 | extras: [ 38 | {"README.md", title: "Home"}, 39 | "documentation/tutorials/getting-started-with-ash-cloak.md", 40 | "documentation/topics/how-does-ash-cloak-work.md", 41 | {"documentation/dsls/DSL-AshCloak.md", search_data: Spark.Docs.search_data_for(AshCloak)}, 42 | "CHANGELOG.md" 43 | ], 44 | groups_for_extras: [ 45 | Tutorials: ~r"documentation/tutorials", 46 | Topics: ~r"documentation/topics", 47 | Reference: ~r"documentation/dsls", 48 | "About AshCloak": [ 49 | "CHANGELOG.md" 50 | ] 51 | ], 52 | before_closing_head_tag: fn type -> 53 | if type == :html do 54 | """ 55 | 64 | """ 65 | end 66 | end 67 | ] 68 | end 69 | 70 | # Run "mix help compile.app" to learn about applications. 71 | def application do 72 | [ 73 | extra_applications: [:logger] 74 | ] 75 | end 76 | 77 | defp package do 78 | [ 79 | maintainers: [ 80 | "Zach Daniel " 81 | ], 82 | licenses: ["MIT"], 83 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* 84 | CHANGELOG* documentation), 85 | links: %{ 86 | "GitHub" => "https://github.com/ash-project/ash_cloak", 87 | "Changelog" => "https://github.com/ash-project/ash_cloak/blob/main/CHANGELOG.md", 88 | "Discord" => "https://discord.gg/HTHRaaVPUc", 89 | "Website" => "https://ash-hq.org", 90 | "Forum" => "https://elixirforum.com/c/elixir-framework-forums/ash-framework-forum", 91 | "REUSE Compliance" => "https://api.reuse.software/info/github.com/ash-project/ash_cloak" 92 | } 93 | ] 94 | end 95 | 96 | defp elixirc_paths(:test), do: ["lib", "test/support"] 97 | defp elixirc_paths(_), do: ["lib"] 98 | 99 | # Run "mix help deps" to learn about dependencies. 100 | defp deps do 101 | [ 102 | {:ash, ash_version("~> 3.0")}, 103 | {:igniter, "~> 0.5", only: [:dev, :test]}, 104 | {:ex_doc, "~> 0.37-rc", only: [:dev, :test], runtime: false}, 105 | {:ex_check, "~> 0.12", only: [:dev, :test]}, 106 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 107 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 108 | {:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false}, 109 | {:git_ops, "~> 2.5", only: [:dev, :test]}, 110 | {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, 111 | {:simple_sat, ">= 0.0.0", only: :test}, 112 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false} 113 | ] 114 | end 115 | 116 | defp ash_version(default_version) do 117 | case System.get_env("ASH_VERSION") do 118 | nil -> default_version 119 | "local" -> [path: "../ash"] 120 | "main" -> [git: "https://github.com/ash-project/ash.git"] 121 | version -> "~> #{version}" 122 | end 123 | end 124 | 125 | defp aliases do 126 | [ 127 | sobelow: "sobelow --skip", 128 | credo: "credo --strict", 129 | docs: [ 130 | "spark.cheat_sheets", 131 | "docs", 132 | "spark.replace_doc_links" 133 | ], 134 | "spark.formatter": "spark.formatter --extensions AshCloak", 135 | "spark.cheat_sheets_in_search": "spark.cheat_sheets_in_search --extensions AshCloak", 136 | "spark.cheat_sheets": "spark.cheat_sheets --extensions AshCloak" 137 | ] 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file contains the configuration for Credo and you are probably reading 6 | # this after creating it with `mix credo.gen.config`. 7 | # 8 | # If you find anything wrong or unclear in this file, please report an 9 | # issue on GitHub: https://github.com/rrrene/credo/issues 10 | # 11 | %{ 12 | # 13 | # You can have as many configs as you like in the `configs:` field. 14 | configs: [ 15 | %{ 16 | # 17 | # Run any config using `mix credo -C `. If no config name is given 18 | # "default" is used. 19 | # 20 | name: "default", 21 | # 22 | # These are the files included in the analysis: 23 | files: %{ 24 | # 25 | # You can give explicit globs or simply directories. 26 | # In the latter case `**/*.{ex,exs}` will be used. 27 | # 28 | included: [ 29 | "lib/", 30 | "src/", 31 | "test/", 32 | "web/", 33 | "apps/*/lib/", 34 | "apps/*/src/", 35 | "apps/*/test/", 36 | "apps/*/web/" 37 | ], 38 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 39 | }, 40 | # 41 | # Load and configure plugins here: 42 | # 43 | plugins: [], 44 | # 45 | # If you create your own checks, you must specify the source files for 46 | # them here, so they can be loaded by Credo before running the analysis. 47 | # 48 | requires: [], 49 | # 50 | # If you want to enforce a style guide and need a more traditional linting 51 | # experience, you can change `strict` to `true` below: 52 | # 53 | strict: false, 54 | # 55 | # To modify the timeout for parsing files, change this value: 56 | # 57 | parse_timeout: 5000, 58 | # 59 | # If you want to use uncolored output by default, you can change `color` 60 | # to `false` below: 61 | # 62 | color: true, 63 | # 64 | # You can customize the parameters of any check by adding a second element 65 | # to the tuple. 66 | # 67 | # To disable a check put `false` as second element: 68 | # 69 | # {Credo.Check.Design.DuplicatedCode, false} 70 | # 71 | checks: [ 72 | # 73 | ## Consistency Checks 74 | # 75 | {Credo.Check.Consistency.ExceptionNames, []}, 76 | {Credo.Check.Consistency.LineEndings, []}, 77 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 78 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 79 | {Credo.Check.Consistency.SpaceInParentheses, []}, 80 | {Credo.Check.Consistency.TabsOrSpaces, []}, 81 | 82 | # 83 | ## Design Checks 84 | # 85 | # You can customize the priority of any check 86 | # Priority values are: `low, normal, high, higher` 87 | # 88 | {Credo.Check.Design.AliasUsage, false}, 89 | # You can also customize the exit_status of each check. 90 | # If you don't want TODO comments to cause `mix credo` to fail, just 91 | # set this value to 0 (zero). 92 | # 93 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 94 | {Credo.Check.Design.TagFIXME, []}, 95 | 96 | # 97 | ## Readability Checks 98 | # 99 | {Credo.Check.Readability.AliasOrder, []}, 100 | {Credo.Check.Readability.FunctionNames, []}, 101 | {Credo.Check.Readability.LargeNumbers, []}, 102 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 103 | {Credo.Check.Readability.ModuleAttributeNames, []}, 104 | {Credo.Check.Readability.ModuleDoc, []}, 105 | {Credo.Check.Readability.ModuleNames, []}, 106 | {Credo.Check.Readability.ParenthesesInCondition, []}, 107 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 108 | {Credo.Check.Readability.PredicateFunctionNames, []}, 109 | {Credo.Check.Readability.PreferImplicitTry, []}, 110 | {Credo.Check.Readability.RedundantBlankLines, []}, 111 | {Credo.Check.Readability.Semicolons, []}, 112 | {Credo.Check.Readability.SpaceAfterCommas, []}, 113 | {Credo.Check.Readability.StringSigils, []}, 114 | {Credo.Check.Readability.TrailingBlankLine, []}, 115 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 116 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 117 | {Credo.Check.Readability.VariableNames, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.CondStatements, []}, 123 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 124 | {Credo.Check.Refactor.FunctionArity, [max_arity: 13]}, 125 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 126 | {Credo.Check.Refactor.MapInto, false}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 129 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 130 | {Credo.Check.Refactor.Nesting, [max_nesting: 6]}, 131 | {Credo.Check.Refactor.UnlessWithElse, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | 134 | # 135 | ## Warnings 136 | # 137 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 138 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 139 | {Credo.Check.Warning.IExPry, []}, 140 | {Credo.Check.Warning.IoInspect, []}, 141 | {Credo.Check.Warning.LazyLogging, false}, 142 | {Credo.Check.Warning.MixEnv, false}, 143 | {Credo.Check.Warning.OperationOnSameValues, []}, 144 | {Credo.Check.Warning.OperationWithConstantResult, []}, 145 | {Credo.Check.Warning.RaiseInsideRescue, []}, 146 | {Credo.Check.Warning.UnusedEnumOperation, []}, 147 | {Credo.Check.Warning.UnusedFileOperation, []}, 148 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 149 | {Credo.Check.Warning.UnusedListOperation, []}, 150 | {Credo.Check.Warning.UnusedPathOperation, []}, 151 | {Credo.Check.Warning.UnusedRegexOperation, []}, 152 | {Credo.Check.Warning.UnusedStringOperation, []}, 153 | {Credo.Check.Warning.UnusedTupleOperation, []}, 154 | {Credo.Check.Warning.UnsafeExec, []}, 155 | 156 | # 157 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 158 | 159 | # 160 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 161 | # 162 | {Credo.Check.Readability.StrictModuleLayout, false}, 163 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 164 | {Credo.Check.Consistency.UnusedVariableNames, false}, 165 | {Credo.Check.Design.DuplicatedCode, false}, 166 | {Credo.Check.Readability.AliasAs, false}, 167 | {Credo.Check.Readability.MultiAlias, false}, 168 | {Credo.Check.Readability.Specs, false}, 169 | {Credo.Check.Readability.SinglePipe, false}, 170 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 171 | {Credo.Check.Refactor.ABCSize, false}, 172 | {Credo.Check.Refactor.AppendSingleItem, false}, 173 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 174 | {Credo.Check.Refactor.ModuleDependencies, false}, 175 | {Credo.Check.Refactor.NegatedIsNil, false}, 176 | {Credo.Check.Refactor.PipeChainStart, false}, 177 | {Credo.Check.Refactor.VariableRebinding, false}, 178 | {Credo.Check.Warning.LeakyEnvironment, false}, 179 | {Credo.Check.Warning.MapGetUnsafePass, false}, 180 | {Credo.Check.Warning.UnsafeToAtom, false} 181 | 182 | # 183 | # Custom checks can be created using `mix credo.gen.check`. 184 | # 185 | ] 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /test/ash_cloak_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 ash_cloak contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshCloakTest do 6 | use ExUnit.Case 7 | doctest AshCloak 8 | 9 | require Ash.Query 10 | 11 | defp decode(value) do 12 | "encrypted " <> value = Base.decode64!(value) 13 | :erlang.binary_to_term(value) 14 | end 15 | 16 | test "it encrypts the input values" do 17 | encrypted = 18 | AshCloak.Test.Resource 19 | |> Ash.Changeset.for_create(:create, %{ 20 | not_encrypted: "plain", 21 | encrypted: 12, 22 | encrypted_always_loaded: %{hello: :world} 23 | }) 24 | |> Ash.Changeset.set_context(%{foo: :bar}) 25 | |> Ash.create!() 26 | 27 | # encrypted value is stored 28 | assert decode(encrypted.encrypted_encrypted) == 12 29 | 30 | # complex values are encrypted 31 | assert decode(encrypted.encrypted_encrypted_always_loaded) == %{hello: :world} 32 | 33 | # values are not loaded unless you request them 34 | assert %Ash.NotLoaded{} = encrypted.encrypted 35 | 36 | # values that are requested are loaded by default 37 | assert encrypted.encrypted_always_loaded == %{hello: :world} 38 | 39 | # plain attribtues are not affected 40 | assert encrypted.not_encrypted == "plain" 41 | 42 | # on_decrypt is notified 43 | assert_received {:decrypting, AshCloak.Test.Resource, [_], :encrypted_always_loaded, %{}} 44 | 45 | # only for fields that are being decrypted 46 | refute_received {:decrypting, _, _, _, _} 47 | end 48 | 49 | test "it encrypts input values on update" do 50 | encrypted = 51 | AshCloak.Test.Resource 52 | |> Ash.Changeset.for_create(:create, %{ 53 | not_encrypted: "plain", 54 | encrypted: 12, 55 | encrypted_always_loaded: %{hello: :world} 56 | }) 57 | |> Ash.Changeset.set_context(%{foo: :bar}) 58 | |> Ash.create!() 59 | |> Ash.Changeset.for_update(:update, %{ 60 | not_encrypted: "plain2", 61 | encrypted: 13, 62 | encrypted_always_loaded: %{hello: :world2} 63 | }) 64 | |> Ash.update!() 65 | 66 | assert_received {:decrypting, AshCloak.Test.Resource, [_], :encrypted_always_loaded, %{}} 67 | 68 | # encrypted value is stored 69 | assert decode(encrypted.encrypted_encrypted) == 13 70 | 71 | # complex values are encrypted 72 | assert decode(encrypted.encrypted_encrypted_always_loaded) == %{hello: :world2} 73 | 74 | # values are not loaded unless you request them 75 | assert %Ash.NotLoaded{} = encrypted.encrypted 76 | 77 | # values that are requested are loaded by default 78 | assert encrypted.encrypted_always_loaded == %{hello: :world2} 79 | 80 | # plain attribtues are not affected 81 | assert encrypted.not_encrypted == "plain2" 82 | 83 | # on_decrypt is notified 84 | assert_received {:decrypting, AshCloak.Test.Resource, [_], :encrypted_always_loaded, %{}} 85 | 86 | # only for fields that are being decrypted 87 | refute_received {:decrypting, _, _, _, _} 88 | end 89 | 90 | test "it encrypts input values on atomic update" do 91 | encrypted = 92 | AshCloak.Test.Resource 93 | |> Ash.Changeset.for_create(:create, %{ 94 | not_encrypted: "plain", 95 | encrypted: 12, 96 | encrypted_always_loaded: %{hello: :world} 97 | }) 98 | |> Ash.Changeset.set_context(%{foo: :bar}) 99 | |> Ash.create!() 100 | 101 | assert_received {:decrypting, AshCloak.Test.Resource, [_], :encrypted_always_loaded, %{}} 102 | 103 | assert %Ash.BulkResult{records: [encrypted]} = 104 | AshCloak.Test.Resource 105 | |> Ash.Query.filter(id == ^encrypted.id) 106 | |> Ash.bulk_update!( 107 | :update, 108 | %{ 109 | not_encrypted: "plain2", 110 | encrypted: 13, 111 | encrypted_always_loaded: %{hello: :world2} 112 | }, 113 | return_records?: true 114 | ) 115 | 116 | assert_received {:decrypting, AshCloak.Test.Resource, [_], :encrypted_always_loaded, %{}} 117 | 118 | # encrypted value is stored 119 | assert decode(encrypted.encrypted_encrypted) == 13 120 | 121 | # complex values are encrypted 122 | assert decode(encrypted.encrypted_encrypted_always_loaded) == %{hello: :world2} 123 | 124 | # values are not loaded unless you request them 125 | assert %Ash.NotLoaded{} = encrypted.encrypted 126 | 127 | # values that are requested are loaded by default 128 | assert encrypted.encrypted_always_loaded == %{hello: :world2} 129 | 130 | # plain attribtues are not affected 131 | assert encrypted.not_encrypted == "plain2" 132 | 133 | # only for fields that are being decrypted 134 | refute_received {:decrypting, _, _, _, _} 135 | end 136 | 137 | test "encrypt after action change" do 138 | encrypted = 139 | AshCloak.Test.Resource 140 | |> Ash.Changeset.for_create(:change_before_encrypt, %{ 141 | not_encrypted: "plain", 142 | encrypted_always_loaded: %{hello: :world} 143 | }) 144 | |> Ash.Changeset.set_context(%{foo: :bar}) 145 | |> Ash.create!() 146 | 147 | assert decode(encrypted.encrypted_encrypted) == 13 148 | end 149 | 150 | test "it encrypt by set_argument directly" do 151 | encrypted = 152 | AshCloak.Test.Resource 153 | |> Ash.Changeset.new() 154 | |> Ash.Changeset.set_argument(:encrypted, 14) 155 | |> Ash.Changeset.for_create(:create, %{ 156 | not_encrypted: "plain", 157 | encrypted_always_loaded: %{hello: :world} 158 | }) 159 | |> Ash.Changeset.set_context(%{foo: :bar}) 160 | |> Ash.create!() 161 | 162 | assert decode(encrypted.encrypted_encrypted) == 14 163 | end 164 | 165 | test "it encrypts even when the attribute is not in the accept list" do 166 | encrypted = 167 | AshCloak.Test.Resource 168 | |> Ash.Changeset.for_create(:change_without_accept) 169 | |> Ash.create!() 170 | 171 | assert decode(encrypted.encrypted_encrypted) == 13 172 | end 173 | 174 | test "it encrypts with default value" do 175 | encrypted = 176 | AshCloak.Test.Resource 177 | |> Ash.Changeset.for_create(:create, %{}) 178 | |> Ash.create!() 179 | 180 | assert decode(encrypted.encrypted_encrypted_with_default) == 42 181 | end 182 | 183 | test "it doesn't update not accepted encrypted fields with default value" do 184 | encrypted = 185 | AshCloak.Test.Resource 186 | |> Ash.Changeset.for_create(:create, %{encrypted_with_default: 1}) 187 | |> Ash.create!() 188 | 189 | assert decode(encrypted.encrypted_encrypted_with_default) == 1 190 | 191 | updated_encrypted = 192 | encrypted 193 | |> Ash.Changeset.for_update(:update_not_encrypted, %{not_encrypted: "plain"}) 194 | |> Ash.update!() 195 | 196 | assert updated_encrypted.not_encrypted == "plain" 197 | assert decode(updated_encrypted.encrypted_encrypted_with_default) == 1 198 | end 199 | 200 | test "encrypt_and_set encrypts and sets values correctly" do 201 | # Test with pending changeset 202 | pending_changeset = 203 | AshCloak.Test.Resource 204 | |> Ash.Changeset.for_create(:create, %{not_encrypted: "plain"}) 205 | |> AshCloak.encrypt_and_set(:encrypted, 15) 206 | |> Ash.create!() 207 | 208 | assert decode(pending_changeset.encrypted_encrypted) == 15 209 | 210 | # Test with non-pending changeset (during action) 211 | changeset = 212 | AshCloak.Test.Resource 213 | |> Ash.Changeset.for_create(:create, %{not_encrypted: "plain"}) 214 | |> Map.put(:phase, :action) 215 | |> AshCloak.encrypt_and_set(:encrypted, 16) 216 | |> Ash.create!() 217 | 218 | assert decode(changeset.encrypted_encrypted) == 16 219 | 220 | # Test with invalid attribute 221 | assert_raise AshCloak.Errors.NoSuchEncryptedAttribute, fn -> 222 | AshCloak.Test.Resource 223 | |> Ash.Changeset.for_create(:create, %{}) 224 | |> AshCloak.encrypt_and_set(:non_existent, "value") 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "ash": {:hex, :ash, "3.10.0", "839d696ef8a4d1f5b980a469fb19ef1383f21ddfb0e602ef91fc9811b2be529a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04b722edb6f8674fbe6ee7833e7e7ca43c404635e748bc4d17a6a1dba288dfc7"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, 5 | "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 9 | "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, 10 | "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 11 | "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, 12 | "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, 13 | "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, 14 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 15 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 16 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 17 | "git_ops": {:hex, :git_ops, "2.9.0", "b74f6040084f523055b720cc7ef718da47f2cbe726a5f30c2871118635cb91c1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "7fdf84be3490e5692c5dc1f8a1084eed47a221c1063e41938c73312f0bfea259"}, 18 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 19 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 20 | "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, 21 | "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, 22 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 23 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 24 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 25 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 27 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 28 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 29 | "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, 30 | "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, 31 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 32 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 33 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 34 | "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, 35 | "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, 36 | "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, 37 | "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, 38 | "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, 39 | "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, 40 | "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, 41 | "spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"}, 42 | "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, 43 | "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, 44 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 45 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 46 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 47 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 48 | "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, 49 | "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"}, 50 | } 51 | --------------------------------------------------------------------------------