defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do @moduledoc """ Plug for authorizing access to Stats and Sites APIs. The plug expects `:api_scope` to be provided in the assigns. The scope will then be used to check for API key validity. The assign can be provided in the router configuration in a following way: scope "/api/v1/stats", PlausibleWeb.Api, assigns: %{api_scope: "some:scope:*"} do pipe_through [:public_api, #{inspect(__MODULE__)}] # route definitions follow # ... end The scope from `:api_scope` is checked for match against all scopes from API key's `scopes` field. If the scope is among `@implicit_scopes`, it's considered to be present for any valid API key. Scopes are checked for match by prefix, so if we have `some:scope:*` in matching route `:api_scope` and the API key has `some:*` in its `scopes` field, they will match. After a match is found, additional verification can be conducted, like in case of `stats:read:*`, where valid site ID is expected among parameters too. All API requests are rate limited per API key, enforcing a given hourly request limit. """ use Plausible.Repo import Plug.Conn alias Plausible.Auth alias Plausible.RateLimit alias Plausible.Teams alias PlausibleWeb.Api.Helpers, as: H require Logger # Scopes permitted implicitly for every API key. Existing API keys # have _either_ `["stats:read:*"]` (the default) or `["sites:provision:*"]` # set as their valid scopes. We always consider implicit scopes as # present in addition to whatever else is provided for a particular # API key. @implicit_scopes ["stats:read:*", "sites:read:*"] def init(opts) do opts end def call(conn, _opts) do requested_scope = Map.fetch!(conn.assigns, :api_scope) context = conn.assigns[:api_context] with {:ok, token} <- get_bearer_token(conn), {:ok, api_key, limit_key, hourly_limit} <- find_api_key(conn, token, context), :ok <- check_api_key_rate_limit(limit_key, hourly_limit), :ok <- check_api_key_burst_limit(limit_key), {:ok, conn} <- verify_by_scope(conn, api_key, requested_scope) do conn |> assign(:current_user, api_key.user) |> assign(:current_team, api_key.team) else error -> send_error(conn, requested_scope, error) end end ### Verification dispatched by scope defp find_api_key(conn, token, :site) do case Auth.find_api_key_for_team_of_site(token, conn.params["site_id"]) do {:ok, %{api_key: api_key, team: nil}} -> {:ok, api_key, Auth.ApiKey.legacy_limit_key(api_key.user), Auth.ApiKey.legacy_hourly_request_limit()} {:ok, %{api_key: api_key, team: team}} -> team_role_result = Plausible.Teams.Memberships.team_role(team, api_key.user) cond do Auth.super_admin?(api_key.user) -> :pass team_role_result == {:ok, :guest} -> Logger.warning( "[#{inspect(__MODULE__)}] API key #{api_key.id} user accessing #{conn.params["site_id"]} as a guest" ) team_role_result == {:error, :not_a_member} -> Logger.warning( "[#{inspect(__MODULE__)}] API key #{api_key.id} user trying to access #{conn.params["site_id"]} as a non-member" ) true -> :pass end {:ok, api_key, Auth.ApiKey.limit_key(team), team.hourly_api_request_limit} {:error, _} = error -> error end end defp find_api_key(_conn, token, _) do case Auth.find_api_key(token) do {:ok, %{api_key: api_key, team: nil}} -> {:ok, api_key, Auth.ApiKey.legacy_limit_key(api_key.user), Auth.ApiKey.legacy_hourly_request_limit()} {:ok, %{api_key: api_key, team: team}} -> {:ok, api_key, Auth.ApiKey.limit_key(team), team.hourly_api_request_limit} {:error, _} = error -> error end end defp verify_by_scope(conn, api_key, "stats:read:" <> _ = scope) do with :ok <- check_scope(api_key, scope), {:ok, site} <- find_site(conn.params["site_id"]), :ok <- verify_site_access( site: site, api_key: api_key, feature: Plausible.Billing.Feature.StatsAPI, allow_consolidated_views: conn.private[:allow_consolidated_views] ) do Plausible.OpenTelemetry.add_site_attributes(site) site = Plausible.Repo.preload(site, :completed_imports) {:ok, assign(conn, :site, site)} end end defp verify_by_scope(conn, api_key, "sites:" <> scope_suffix = scope) do feature = case scope_suffix do "read:" <> _ -> Plausible.Billing.Feature.StatsAPI "provision:" <> _ -> Plausible.Billing.Feature.SitesAPI end with :ok <- check_scope(api_key, scope), :ok <- maybe_verify_site_access(conn, api_key, feature), :ok <- maybe_verify_team_access(conn, api_key, feature) do {:ok, conn} end end defp verify_by_scope(conn, api_key, scope) do with :ok <- check_scope(api_key, scope) do {:ok, conn} end end defp check_scope(_api_key, required_scope) when required_scope in @implicit_scopes do :ok end defp check_scope(api_key, required_scope) do found? = Enum.any?(api_key.scopes, fn scope -> scope = String.trim_trailing(scope, "*") String.starts_with?(required_scope, scope) end) if found? do :ok else {:error, :invalid_api_key} end end defp get_bearer_token(conn) do authorization_header = conn |> Plug.Conn.get_req_header("authorization") |> List.first() case authorization_header do "Bearer " <> token -> {:ok, String.trim(token)} _ -> {:error, :missing_api_key} end end defp check_api_key_rate_limit(limit_key, hourly_limit) do case RateLimit.check_rate(limit_key, to_timeout(hour: 1), hourly_limit) do {:allow, _} -> :ok {:deny, _} -> {:error, :rate_limit, "Too many API requests. The limit is #{hourly_limit} per hour. Please contact us to request more capacity."} end end defp check_api_key_burst_limit(limit_key) do burst_period_seconds = Auth.ApiKey.burst_period_seconds() burst_request_limit = Auth.ApiKey.burst_request_limit() case RateLimit.check_rate( limit_key, to_timeout(second: burst_period_seconds), burst_request_limit ) do {:allow, _} -> :ok {:deny, _} -> {:error, :rate_limit, "Too many API requests in a short period of time. The limit is #{burst_request_limit} per #{burst_period_seconds} seconds. Please throttle your requests."} end end defp maybe_verify_site_access(conn, api_key, feature) do case find_site(conn.params["site_id"]) do {:ok, site} -> verify_site_access( site: site, api_key: api_key, feature: feature, allow_consolidated_views: conn.private[:allow_consolidated_views] ) _ -> :ok end end defp maybe_verify_team_access(conn, api_key, feature) do team = api_key.team || Teams.get(conn.params["team_id"]) if team do verify_team_access(api_key, team, feature) else :ok end end defp find_site(nil), do: {:error, :missing_site_id} defp find_site(site_id) do domain_based_search = from s in Plausible.Site, where: s.domain == ^site_id or s.domain_changed_from == ^site_id case Repo.one(domain_based_search) do %Plausible.Site{} = site -> {:ok, site} nil -> {:error, :invalid_api_key} end end defp verify_site_access(opts) do site = Keyword.fetch!(opts, :site) api_key = Keyword.fetch!(opts, :api_key) feature = Keyword.fetch!(opts, :feature) allow_consolidated_views = Keyword.fetch!(opts, :allow_consolidated_views) team = Repo.preload(site, :team).team is_member? = Plausible.Teams.Memberships.site_member?(site, api_key.user) is_super_admin? = Auth.super_admin?(api_key.user_id) cond do Plausible.Sites.consolidated?(site) && !allow_consolidated_views -> {:error, :unavailable_for_consolidated_view} is_super_admin? -> :ok api_key.team_id && api_key.team_id != site.team_id -> {:error, :invalid_api_key} Teams.locked?(team) -> {:error, :site_locked} feature.check_availability(team) !== :ok -> {:error, :upgrade_required} is_member? -> :ok true -> {:error, :invalid_api_key} end end defp verify_team_access(api_key, team, feature) do is_member? = Plausible.Teams.Memberships.team_member?(team, api_key.user) is_super_admin? = Auth.super_admin?(api_key.user_id) cond do is_super_admin? -> :ok api_key.team_id && api_key.team_id != team.id -> {:error, :invalid_api_key} Teams.locked?(team) -> {:error, :site_locked} feature.check_availability(team) !== :ok -> {:error, :upgrade_required} is_member? -> :ok true -> {:error, :invalid_api_key} end end defp send_error(conn, _, {:error, :unavailable_for_consolidated_view}) do H.bad_request( conn, "This operation is unavailable for a consolidated view" ) end defp send_error(conn, _, {:error, :missing_api_key}) do H.unauthorized( conn, "Missing API key. Please use a valid Plausible API key as a Bearer Token." ) end defp send_error(conn, "stats:read:" <> _, {:error, :invalid_api_key}) do H.unauthorized( conn, "Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested." ) end defp send_error(conn, _, {:error, :invalid_api_key}) do H.unauthorized( conn, "Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested." ) end defp send_error(conn, _, {:error, :rate_limit, message}) do H.too_many_requests( conn, message ) end defp send_error(conn, _, {:error, :missing_site_id}) do H.bad_request( conn, "Missing site ID. Please provide the required site_id parameter with your request." ) end defp send_error(conn, scope, {:error, :upgrade_required}) do feature = case scope do "sites:provision:" <> _ -> Plausible.Billing.Feature.SitesAPI _ -> Plausible.Billing.Feature.StatsAPI end feature_payment_error(conn, feature) end defp send_error(conn, _, {:error, :site_locked}) do H.payment_required( conn, "This Plausible site is locked due to missing active subscription. In order to access it, the site owner should subscribe to a suitable plan" ) end defp feature_payment_error(conn, feature) do feature_name = feature.display_name() H.payment_required( conn, "The account that owns this API key does not have access to #{feature_name}. Please make sure you're using the API key of a subscriber account and that the subscription plan includes #{feature_name}" ) end end