add integrations schema with encrypted credentials
This commit is contained in:
parent
2fd195e6b3
commit
39f0ffe253
3 changed files with 181 additions and 0 deletions
54
lib/towerops/integrations/integration.ex
Normal file
54
lib/towerops/integrations/integration.ex
Normal 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
|
||||
24
priv/repo/migrations/20260212225249_create_integrations.exs
Normal file
24
priv/repo/migrations/20260212225249_create_integrations.exs
Normal 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
|
||||
103
test/towerops/integrations/integration_test.exs
Normal file
103
test/towerops/integrations/integration_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue