From 2fd195e6b3581774508c4330567b1e4be49914bd Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 16:52:05 -0600 Subject: [PATCH] add encrypted map ecto type for integration credentials --- lib/towerops/ecto_types/encrypted_map.ex | 21 ++++++++++ .../ecto_types/encrypted_map_test.exs | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 lib/towerops/ecto_types/encrypted_map.ex create mode 100644 test/towerops/ecto_types/encrypted_map_test.exs diff --git a/lib/towerops/ecto_types/encrypted_map.ex b/lib/towerops/ecto_types/encrypted_map.ex new file mode 100644 index 00000000..db2edb0b --- /dev/null +++ b/lib/towerops/ecto_types/encrypted_map.ex @@ -0,0 +1,21 @@ +defmodule Towerops.Encrypted.Map do + @moduledoc """ + Encrypted map field using Cloak vault. + + Used for storing sensitive configuration like API credentials as encrypted JSON. + Data is encrypted at rest using AES-256-GCM. + + ## Usage + + field :credentials, Towerops.Encrypted.Map + + ## Database Schema + + The database column must be `:binary`: + + add :credentials, :binary + + Note: Atom keys become string keys after decryption. + """ + use Cloak.Ecto.Map, vault: Towerops.Vault +end diff --git a/test/towerops/ecto_types/encrypted_map_test.exs b/test/towerops/ecto_types/encrypted_map_test.exs new file mode 100644 index 00000000..6595ed8e --- /dev/null +++ b/test/towerops/ecto_types/encrypted_map_test.exs @@ -0,0 +1,42 @@ +defmodule Towerops.Encrypted.MapTest do + use Towerops.DataCase, async: true + + alias Towerops.Encrypted.Map, as: EncryptedMap + + describe "cast/1" do + test "casts a valid map" do + assert {:ok, %{"key" => "value"}} = EncryptedMap.cast(%{"key" => "value"}) + end + + test "casts atom-keyed maps" do + assert {:ok, %{key: "value"}} = EncryptedMap.cast(%{key: "value"}) + end + + test "rejects non-map values" do + assert :error = EncryptedMap.cast("not a map") + assert :error = EncryptedMap.cast(123) + end + + test "casts nil" do + assert {:ok, nil} = EncryptedMap.cast(nil) + end + end + + describe "dump/1 and load/1 round-trip" do + test "encrypts and decrypts a map" do + original = %{"api_key" => "secret123", "base_url" => "https://api.preseem.com"} + + assert {:ok, encrypted} = EncryptedMap.dump(original) + assert is_binary(encrypted) + refute encrypted == Jason.encode!(original) + + assert {:ok, decrypted} = EncryptedMap.load(encrypted) + assert decrypted == original + end + + test "handles nil values" do + assert {:ok, nil} = EncryptedMap.dump(nil) + assert {:ok, nil} = EncryptedMap.load(nil) + end + end +end