defmodule TarakanWeb.Plugs.ApiRateLimit do @moduledoc """ Applies independent IP, account, token, and mutation limits to API traffic. Every request counts against the per-IP bucket, including Bearer traffic: a request carrying a bogus `Authorization: Bearer …` token must not dodge throttling by failing authentication before the actor buckets run. Valid Bearer requests additionally count against the actor buckets after auth (the double count is deliberate; both buckets share the request limit). IPv6 clients bucket by their /64 prefix. Platform admins and moderators are not rate-limited (mass registration / operational tooling). Authenticated actors get `@actor_multiplier` × the configured limits. The IP bucket stays at the base limit, so the actor ceiling rises without raising the network one. """ import Plug.Conn import Phoenix.Controller, only: [json: 2] alias Tarakan.RateLimiter alias TarakanWeb.Plugs.ClientIp @defaults [request_limit: 120, mutation_limit: 20, window_seconds: 60] @actor_multiplier 4 @unlimited_roles ~w(admin moderator) def init(opts) do configured = Application.get_env(:tarakan, __MODULE__, []) @defaults |> Keyword.merge(configured) |> Keyword.merge(opts) end def call(conn, opts) do mode = Keyword.fetch!(opts, :mode) # Admins/moderators (after auth) are unlimited for operational mass import. if unlimited_actor?(conn) do conn else request_limit = Keyword.fetch!(opts, :request_limit) mutation_limit = Keyword.fetch!(opts, :mutation_limit) window_seconds = Keyword.fetch!(opts, :window_seconds) # Pipelines that serve different traffic get separate namespaces, so a # hotlinked public asset cannot spend the budget a contributor's API # client needs from the same network. bucket = Keyword.get(opts, :bucket, :api_ip) checks = case mode do :ip -> [{{bucket, remote_ip_bucket(conn)}, request_limit}] :actor -> actor_checks(conn, request_limit, mutation_limit) end case Enum.find_value(checks, &limited?(&1, window_seconds)) do nil -> conn retry_after -> reject(conn, retry_after) end end end defp unlimited_actor?(conn) do case conn.assigns[:current_scope] do %{platform_role: role} when role in @unlimited_roles -> true %{account: %{platform_role: role}} when role in @unlimited_roles -> true _ -> false end end defp actor_checks(conn, request_limit, mutation_limit) do scope = conn.assigns[:current_scope] account_id = scope && scope.account_id token_id = scope && scope.token_id {request_limit, mutation_limit} = {request_limit * @actor_multiplier, mutation_limit * @actor_multiplier} base = [ {{:api_account, account_id || :anonymous}, request_limit}, {{:api_token, token_id || :session}, request_limit} ] if conn.method in ~w(POST PUT PATCH DELETE) do [ {{:api_account_mutation, account_id || :anonymous}, mutation_limit}, {{:api_token_mutation, token_id || :session}, mutation_limit} | base ] else base end end defp limited?({key, limit}, window_seconds) do case RateLimiter.check(key, limit, window_seconds) do :ok -> nil {:error, _reason, retry_after} -> retry_after end end defp reject(conn, retry_after) do conn |> put_resp_header("retry-after", Integer.to_string(retry_after)) |> put_status(:too_many_requests) |> json(%{error: "rate limit exceeded", retry_after: retry_after}) |> halt() end defp remote_ip_bucket(conn), do: ClientIp.remote_ip_bucket(conn) end