Packages

phoenix_kit

1.7.38
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules sync connection.ex
Raw

lib/modules/sync/connection.ex

defmodule PhoenixKit.Modules.Sync.Connection do
@moduledoc """
Schema for DB Sync persistent connections between PhoenixKit instances.
Connections allow two PhoenixKit sites to establish a permanent relationship
for data synchronization, replacing ephemeral session codes with token-based
authentication.
## Direction
Each connection has a direction:
- `"sender"` - This site will send data (configured on the data-sharing site)
- `"receiver"` - This site will receive data (configured on the data-receiving site)
## Approval Modes (Sender-side)
- `"auto_approve"` - All transfers are automatically approved
- `"require_approval"` - Each transfer needs manual approval
- `"per_table"` - Tables in `auto_approve_tables` don't need approval, others do
## Status Flow
```
pending → active → suspended → revoked
↘
expired (auto-set when limits exceeded or past expires_at)
```
## Security Features
- Token-based authentication (auth_token_hash)
- Optional download password
- IP whitelist
- Time-of-day restrictions
- Download and record limits
- Expiration date
## Usage Examples
# Create a sender connection
{:ok, conn} = Connections.create_connection(%{
name: "Production Receiver",
direction: "sender",
site_url: "https://receiver.example.com",
approval_mode: "auto_approve"
})
# Approve a pending connection
{:ok, conn} = Connections.approve_connection(conn, admin_user_id)
# Suspend a connection
{:ok, conn} = Connections.suspend_connection(conn, admin_user_id, "Security audit")
"""
use Ecto.Schema
import Ecto.Changeset
alias PhoenixKit.Users.Auth.User
@type t :: %__MODULE__{}
@primary_key {:uuid, UUIDv7, autogenerate: true}
@valid_directions ~w(sender receiver)
@valid_statuses ~w(pending active suspended revoked expired)
@valid_approval_modes ~w(auto_approve require_approval per_table)
@valid_conflict_strategies ~w(skip overwrite merge append)
schema "phoenix_kit_sync_connections" do
field :id, :integer, read_after_writes: true
field :name, :string
field :direction, :string
field :site_url, :string
field :auth_token, :string, virtual: true
field :auth_token_hash, :string
field :status, :string, default: "pending"
# Sender-side settings
field :approval_mode, :string, default: "auto_approve"
field :allowed_tables, {:array, :string}, default: []
field :excluded_tables, {:array, :string}, default: []
field :auto_approve_tables, {:array, :string}, default: []
# Expiration & limits
field :expires_at, :utc_datetime_usec
field :max_downloads, :integer
field :downloads_used, :integer, default: 0
field :max_records_total, :integer
field :records_downloaded, :integer, default: 0
# Per-request limits
field :max_records_per_request, :integer, default: 10_000
field :rate_limit_requests_per_minute, :integer, default: 60
# Additional security
field :download_password, :string, virtual: true
field :download_password_hash, :string
field :ip_whitelist, {:array, :string}, default: []
field :allowed_hours_start, :integer
field :allowed_hours_end, :integer
# Receiver-side settings
field :default_conflict_strategy, :string, default: "skip"
field :auto_sync_enabled, :boolean, default: false
field :auto_sync_tables, {:array, :string}, default: []
field :auto_sync_interval_minutes, :integer, default: 60
# Approval & status tracking
field :approved_at, :utc_datetime_usec
field :suspended_at, :utc_datetime_usec
field :suspended_reason, :string
field :revoked_at, :utc_datetime_usec
field :revoked_reason, :string
# Audit & statistics
field :last_connected_at, :utc_datetime_usec
field :last_transfer_at, :utc_datetime_usec
field :total_transfers, :integer, default: 0
field :total_records_transferred, :integer, default: 0
field :total_bytes_transferred, :integer, default: 0
field :metadata, :map, default: %{}
# legacy
field :approved_by, :integer
belongs_to :approved_by_user, User,
foreign_key: :approved_by_uuid,
references: :uuid,
type: UUIDv7
# legacy
field :suspended_by, :integer
belongs_to :suspended_by_user, User,
foreign_key: :suspended_by_uuid,
references: :uuid,
type: UUIDv7
# legacy
field :revoked_by, :integer
belongs_to :revoked_by_user, User,
foreign_key: :revoked_by_uuid,
references: :uuid,
type: UUIDv7
# legacy
field :created_by, :integer
belongs_to :created_by_user, User,
foreign_key: :created_by_uuid,
references: :uuid,
type: UUIDv7
timestamps(type: :utc_datetime_usec)
end
@doc """
Creates a changeset for connection creation.
"""
def changeset(connection, attrs) do
connection
|> cast(attrs, [
:name,
:direction,
:site_url,
:auth_token,
:status,
:approval_mode,
:allowed_tables,
:excluded_tables,
:auto_approve_tables,
:expires_at,
:max_downloads,
:downloads_used,
:max_records_total,
:records_downloaded,
:max_records_per_request,
:rate_limit_requests_per_minute,
:download_password,
:ip_whitelist,
:allowed_hours_start,
:allowed_hours_end,
:default_conflict_strategy,
:auto_sync_enabled,
:auto_sync_tables,
:auto_sync_interval_minutes,
:metadata,
:created_by,
:created_by_uuid
])
|> validate_required([:name, :direction, :site_url])
|> validate_inclusion(:direction, @valid_directions)
|> validate_inclusion(:status, @valid_statuses)
|> validate_inclusion(:approval_mode, @valid_approval_modes)
|> validate_inclusion(:default_conflict_strategy, @valid_conflict_strategies)
|> validate_number(:max_records_per_request, greater_than: 0)
|> validate_number(:rate_limit_requests_per_minute, greater_than: 0)
|> validate_number(:allowed_hours_start,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 23
)
|> validate_number(:allowed_hours_end, greater_than_or_equal_to: 0, less_than_or_equal_to: 23)
|> validate_number(:auto_sync_interval_minutes, greater_than: 0)
|> unique_constraint([:site_url, :direction],
name: :phoenix_kit_sync_connections_site_direction_uidx
)
|> hash_auth_token()
|> hash_download_password()
end
@doc """
Changeset for approving a connection.
"""
def approve_changeset(connection, admin_user_id) do
connection
|> change(%{
status: "active",
approved_at: DateTime.utc_now(),
approved_by: admin_user_id,
approved_by_uuid: resolve_user_uuid(admin_user_id)
})
end
@doc """
Changeset for suspending a connection.
"""
def suspend_changeset(connection, admin_user_id, reason \\ nil) do
connection
|> change(%{
status: "suspended",
suspended_at: DateTime.utc_now(),
suspended_by: admin_user_id,
suspended_by_uuid: resolve_user_uuid(admin_user_id),
suspended_reason: reason
})
end
@doc """
Changeset for revoking a connection.
"""
def revoke_changeset(connection, admin_user_id, reason \\ nil) do
connection
|> change(%{
status: "revoked",
revoked_at: DateTime.utc_now(),
revoked_by: admin_user_id,
revoked_by_uuid: resolve_user_uuid(admin_user_id),
revoked_reason: reason
})
end
@doc """
Changeset for reactivating a suspended connection.
"""
def reactivate_changeset(connection) do
connection
|> change(%{
status: "active",
suspended_at: nil,
suspended_by: nil,
suspended_by_uuid: nil,
suspended_reason: nil
})
end
@doc """
Changeset for updating connection statistics.
"""
def stats_changeset(connection, attrs) do
connection
|> cast(attrs, [
:downloads_used,
:records_downloaded,
:last_connected_at,
:last_transfer_at,
:total_transfers,
:total_records_transferred,
:total_bytes_transferred
])
end
@doc """
Changeset for updating connection settings.
"""
def settings_changeset(connection, attrs) do
connection
|> cast(attrs, [
:name,
:status,
:approval_mode,
:allowed_tables,
:excluded_tables,
:auto_approve_tables,
:expires_at,
:max_downloads,
:downloads_used,
:max_records_total,
:records_downloaded,
:max_records_per_request,
:rate_limit_requests_per_minute,
:download_password,
:ip_whitelist,
:allowed_hours_start,
:allowed_hours_end,
:default_conflict_strategy,
:auto_sync_enabled,
:auto_sync_tables,
:auto_sync_interval_minutes,
:approved_at,
:suspended_at,
:suspended_reason,
:revoked_at,
:revoked_reason,
:last_connected_at,
:last_transfer_at,
:total_transfers,
:total_records_transferred,
:total_bytes_transferred,
:metadata
])
|> validate_inclusion(:status, @valid_statuses)
|> validate_inclusion(:approval_mode, @valid_approval_modes)
|> validate_inclusion(:default_conflict_strategy, @valid_conflict_strategies)
|> validate_number(:max_records_per_request, greater_than: 0)
|> validate_number(:rate_limit_requests_per_minute, greater_than: 0)
|> validate_number(:allowed_hours_start,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 23
)
|> validate_number(:allowed_hours_end, greater_than_or_equal_to: 0, less_than_or_equal_to: 23)
|> validate_number(:auto_sync_interval_minutes, greater_than: 0)
|> hash_download_password()
end
# Hash auth_token if provided
defp hash_auth_token(changeset) do
case get_change(changeset, :auth_token) do
nil ->
changeset
token when is_binary(token) and byte_size(token) > 0 ->
put_change(changeset, :auth_token_hash, hash_token(token))
_ ->
changeset
end
end
# Hash download_password if provided
defp hash_download_password(changeset) do
case get_change(changeset, :download_password) do
nil ->
changeset
"" ->
# Empty string clears the password
put_change(changeset, :download_password_hash, nil)
password when is_binary(password) ->
put_change(changeset, :download_password_hash, hash_token(password))
_ ->
changeset
end
end
# Hash a token/password using SHA256
defp hash_token(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
@doc """
Verifies an auth token against the stored hash.
"""
def verify_auth_token(%__MODULE__{auth_token_hash: hash}, token) when is_binary(token) do
hash_token(token) == hash
end
def verify_auth_token(_, _), do: false
@doc """
Verifies a download password against the stored hash.
"""
def verify_download_password(%__MODULE__{download_password_hash: nil}, _), do: true
def verify_download_password(%__MODULE__{download_password_hash: ""}, _), do: true
def verify_download_password(%__MODULE__{download_password_hash: hash}, password)
when is_binary(password) do
hash_token(password) == hash
end
def verify_download_password(_, _), do: false
@doc """
Generates a secure random token for connection authentication.
"""
def generate_auth_token do
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
end
@doc """
Checks if a connection is currently active and within limits.
"""
def active?(%__MODULE__{status: "active"} = conn) do
not expired?(conn) and within_download_limits?(conn) and within_record_limits?(conn)
end
def active?(_), do: false
@doc """
Checks if a connection has expired.
"""
def expired?(%__MODULE__{expires_at: nil}), do: false
def expired?(%__MODULE__{expires_at: expires_at}) do
DateTime.compare(DateTime.utc_now(), expires_at) == :gt
end
@doc """
Checks if a connection is within download limits.
"""
def within_download_limits?(%__MODULE__{max_downloads: nil}), do: true
def within_download_limits?(%__MODULE__{max_downloads: max, downloads_used: used}) do
used < max
end
@doc """
Checks if a connection is within record limits.
"""
def within_record_limits?(%__MODULE__{max_records_total: nil}), do: true
def within_record_limits?(%__MODULE__{max_records_total: max, records_downloaded: downloaded}) do
downloaded < max
end
@doc """
Checks if current time is within allowed hours.
"""
def within_allowed_hours?(%__MODULE__{allowed_hours_start: nil}), do: true
def within_allowed_hours?(%__MODULE__{allowed_hours_end: nil}), do: true
def within_allowed_hours?(%__MODULE__{
allowed_hours_start: start_hour,
allowed_hours_end: end_hour
}) do
current_hour = DateTime.utc_now().hour
if start_hour <= end_hour do
current_hour >= start_hour and current_hour <= end_hour
else
# Handles overnight ranges (e.g., 22:00 to 06:00)
current_hour >= start_hour or current_hour <= end_hour
end
end
@doc """
Checks if an IP address is in the whitelist.
"""
def ip_allowed?(%__MODULE__{ip_whitelist: []}), do: true
def ip_allowed?(%__MODULE__{ip_whitelist: nil}), do: true
def ip_allowed?(%__MODULE__{ip_whitelist: whitelist}, ip) when is_binary(ip) do
ip in whitelist
end
def ip_allowed?(_, _), do: false
@doc """
Checks if a table requires approval based on the connection's approval mode.
"""
def requires_approval?(%__MODULE__{approval_mode: "auto_approve"}, _table), do: false
def requires_approval?(%__MODULE__{approval_mode: "require_approval"}, _table), do: true
def requires_approval?(
%__MODULE__{approval_mode: "per_table", auto_approve_tables: tables},
table
) do
table not in tables
end
def requires_approval?(_, _), do: true
@doc """
Checks if a table is allowed to be accessed.
"""
def table_allowed?(%__MODULE__{excluded_tables: excluded, allowed_tables: allowed}, table) do
not_excluded = table not in excluded
allowed_or_empty =
case allowed do
[] -> true
_ -> table in allowed
end
not_excluded and allowed_or_empty
end
# Resolves user UUID from integer user_id (dual-write)
defp resolve_user_uuid(user_id) when is_integer(user_id) do
import Ecto.Query, only: [from: 2]
alias PhoenixKit.Users.Auth.User
from(u in User, where: u.id == ^user_id, select: u.uuid) |> PhoenixKit.RepoHelper.repo().one()
end
defp resolve_user_uuid(_), do: nil
end