defmodule DaProductApp.Partners do @moduledoc """ The Partners context for managing partner authentication and operations. """ import Ecto.Query, warn: false alias DaProductApp.Repo alias DaProductApp.Partners.Partner @doc """ Returns the list of partners. """ def list_partners do Repo.all(Partner) end @doc """ Gets a single partner. """ def get_partner!(id), do: Repo.get!(Partner, id) @doc """ Gets a partner by ID, returns {:ok, partner} or {:error, :not_found} """ def get_partner(id) do case Repo.get(Partner, id) do nil -> {:error, :not_found} partner -> {:ok, partner} end end @doc """ Gets a partner by partner_code. """ def get_partner_by_code(partner_code) do Repo.get_by(Partner, partner_code: partner_code) end @doc """ Gets a partner by API key for authentication. """ def get_partner_by_api_key(api_key) when is_binary(api_key) do Partner |> where([p], p.api_key == ^api_key) |> where([p], p.status == "ACTIVE") |> where([p], is_nil(p.api_key_expires_at) or p.api_key_expires_at > ^DateTime.utc_now()) |> Repo.one() end def get_partner_by_api_key(_), do: nil @doc """ Validates API key and updates last_used_at timestamp. """ def authenticate_partner(api_key) when is_binary(api_key) do case get_partner_by_api_key(api_key) do %Partner{} = partner -> # Update last used timestamp partner |> Partner.changeset(%{last_used_at: DateTime.utc_now()}) |> Repo.update() {:ok, partner} nil -> {:error, :invalid_api_key} end end def authenticate_partner(_), do: {:error, :invalid_api_key} @doc """ Creates a partner. """ def create_partner(attrs \\ %{}) do %Partner{} |> Partner.changeset(attrs) |> Repo.insert() end @doc """ Updates a partner. """ def update_partner(%Partner{} = partner, attrs) do partner |> Partner.changeset(attrs) |> Repo.update() end @doc """ Generates a new API key for a partner. """ def generate_api_key(%Partner{} = partner, expires_in_days \\ 365) do api_key = generate_secure_key(32) api_secret = generate_secure_key(64) expires_at = DateTime.utc_now() |> DateTime.add(expires_in_days, :day) partner |> Partner.api_key_changeset(%{ api_key: api_key, api_secret: api_secret, api_key_expires_at: expires_at }) |> Repo.update() end @doc """ Rotates the API key for a partner. """ def rotate_api_key(%Partner{} = partner, expires_in_days \\ 365) do generate_api_key(partner, expires_in_days) end @doc """ Suspends a partner (sets status to SUSPENDED). """ def suspend_partner(%Partner{} = partner) do update_partner(partner, %{status: "SUSPENDED"}) end @doc """ Activates a partner (sets status to ACTIVE). """ def activate_partner(%Partner{} = partner) do update_partner(partner, %{status: "ACTIVE"}) end @doc """ Checks if a partner is active and not expired. """ def partner_active?(%Partner{status: "ACTIVE", api_key_expires_at: nil}), do: true def partner_active?(%Partner{status: "ACTIVE", api_key_expires_at: expires_at}) do DateTime.compare(expires_at, DateTime.utc_now()) == :gt end def partner_active?(_), do: false @doc """ Checks if an IP address is whitelisted for a partner. """ def ip_whitelisted?(%Partner{ip_whitelist: nil}, _ip), do: true def ip_whitelisted?(%Partner{ip_whitelist: ""}, _ip), do: true def ip_whitelisted?(%Partner{ip_whitelist: whitelist}, ip) when is_binary(whitelist) do case Jason.decode(whitelist) do {:ok, ip_list} when is_list(ip_list) -> ip in ip_list _ -> false end end @doc """ Updates IP whitelist for a partner. """ def update_ip_whitelist(%Partner{} = partner, ip_list) when is_list(ip_list) do case Jason.encode(ip_list) do {:ok, json_ips} -> update_partner(partner, %{ip_whitelist: json_ips}) {:error, _} -> {:error, :invalid_ip_list} end end @doc """ Gets partner rate limit (requests per minute). """ def get_rate_limit(%Partner{rate_limit_per_minute: nil}), do: 100 def get_rate_limit(%Partner{rate_limit_per_minute: limit}), do: limit # Private helper functions defp generate_secure_key(length) do length |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) |> binary_part(0, length) end # ================================ # ADDITIONAL AUTHENTICATION FUNCTIONS # ================================ @doc """ Checks if partner is within rate limit using Cachex. """ def check_rate_limit(partner_id, requests_per_minute \\ 100) do cache_key = "rate_limit:#{partner_id}" current_minute = div(System.system_time(:second), 60) window_key = "#{cache_key}:#{current_minute}" case Cachex.get(:partner_rate_limit, window_key) do {:ok, nil} -> # First request in this minute window Cachex.put(:partner_rate_limit, window_key, 1, ttl: :timer.minutes(1)) :ok {:ok, count} when count < requests_per_minute -> # Within limit, increment counter Cachex.incr(:partner_rate_limit, window_key) :ok {:ok, _count} -> # Rate limit exceeded {:error, :rate_limit_exceeded} {:error, _} -> # Cache error, allow request but log require Logger Logger.warning("Rate limit cache error for partner #{partner_id}") :ok end end @doc """ Checks if IP address is whitelisted for partner. """ def check_ip_whitelist(%Partner{ip_whitelist: nil}, _ip), do: :ok def check_ip_whitelist(%Partner{ip_whitelist: ""}, _ip), do: :ok def check_ip_whitelist(%Partner{ip_whitelist: whitelist_json}, ip) when is_binary(ip) do case Jason.decode(whitelist_json) do {:ok, ip_list} when is_list(ip_list) -> if ip in ip_list do :ok else {:error, :ip_not_whitelisted} end _ -> # Invalid JSON, default to allow :ok end end def check_ip_whitelist(_, _), do: {:error, :invalid_ip} @doc """ Validates API secret for critical operations (HMAC verification). """ def validate_api_secret(%Partner{api_secret: stored_secret}, provided_secret) when is_binary(stored_secret) and is_binary(provided_secret) do if secure_compare(stored_secret, provided_secret) do :ok else {:error, :invalid_secret} end end def validate_api_secret(_, _), do: {:error, :invalid_secret} # Secure string comparison to prevent timing attacks defp secure_compare(a, b) when byte_size(a) == byte_size(b) do :crypto.hash_equals(a, b) end defp secure_compare(_, _), do: false end