add integrations schema with encrypted credentials

This commit is contained in:
Graham McIntire 2026-02-12 16:54:14 -06:00
parent 2fd195e6b3
commit 39f0ffe253
No known key found for this signature in database
3 changed files with 181 additions and 0 deletions

View file

@ -0,0 +1,54 @@
defmodule Towerops.Integrations.Integration do
@moduledoc """
Schema for per-organization third-party integrations.
Each organization can have one integration per provider. Credentials
are encrypted at rest using Cloak AES-256-GCM.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Encrypted
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_providers ~w(preseem)
@valid_sync_statuses ~w(never success partial failed)
schema "integrations" do
field :provider, :string
field :enabled, :boolean, default: false
field :credentials, Encrypted.Map
field :sync_interval_minutes, :integer, default: 10
field :last_synced_at, :utc_datetime
field :last_sync_status, :string, default: "never"
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
def changeset(integration, attrs) do
integration
|> cast(attrs, [
:organization_id,
:provider,
:enabled,
:credentials,
:sync_interval_minutes,
:last_synced_at,
:last_sync_status
])
|> validate_required([:organization_id, :provider])
|> validate_inclusion(:provider, @valid_providers)
|> validate_inclusion(:last_sync_status, @valid_sync_statuses)
|> validate_number(:sync_interval_minutes, greater_than: 0)
|> unique_constraint([:organization_id, :provider],
error_key: :provider,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -0,0 +1,24 @@
defmodule Towerops.Repo.Migrations.CreateIntegrations do
use Ecto.Migration
def change do
create table(:integrations, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :provider, :string, null: false
add :enabled, :boolean, default: false, null: false
add :credentials, :binary
add :sync_interval_minutes, :integer, default: 10
add :last_synced_at, :utc_datetime
add :last_sync_status, :string, default: "never"
timestamps(type: :utc_datetime)
end
create unique_index(:integrations, [:organization_id, :provider])
create index(:integrations, [:organization_id])
end
end

View file

@ -0,0 +1,103 @@
defmodule Towerops.Integrations.IntegrationTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations.Integration
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{organization: organization}
end
describe "changeset/2" do
test "valid changeset with required fields", %{organization: org} do
attrs = %{
organization_id: org.id,
provider: "preseem",
enabled: true,
credentials: %{"api_key" => "test-key-123"}
}
changeset = Integration.changeset(%Integration{}, attrs)
assert changeset.valid?
end
test "requires organization_id" do
attrs = %{provider: "preseem"}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "requires provider" do
attrs = %{organization_id: Ecto.UUID.generate()}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider
end
test "validates provider is a known value" do
attrs = %{organization_id: Ecto.UUID.generate(), provider: "unknown_provider"}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).provider
end
test "validates sync_interval_minutes is positive" do
attrs = %{
organization_id: Ecto.UUID.generate(),
provider: "preseem",
sync_interval_minutes: 0
}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "must be greater than 0" in errors_on(changeset).sync_interval_minutes
end
test "validates last_sync_status is a known value" do
attrs = %{
organization_id: Ecto.UUID.generate(),
provider: "preseem",
last_sync_status: "bogus"
}
changeset = Integration.changeset(%Integration{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).last_sync_status
end
test "enforces unique constraint on org + provider", %{organization: org} do
attrs = %{organization_id: org.id, provider: "preseem"}
{:ok, _} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()
assert {:error, changeset} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()
assert "has already been taken" in errors_on(changeset).provider
end
end
describe "credentials encryption" do
test "credentials are encrypted at rest", %{organization: org} do
attrs = %{
organization_id: org.id,
provider: "preseem",
credentials: %{"api_key" => "super-secret-key"}
}
{:ok, integration} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(Integration, integration.id)
assert reloaded.credentials == %{"api_key" => "super-secret-key"}
raw =
Repo.one(
from i in "integrations",
where: i.id == type(^integration.id, :binary_id),
select: i.credentials
)
refute raw == Jason.encode!(attrs.credentials)
end
end
end