From f00317346aec1c5b20549696db7318d6f51cc5ec Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jul 2024 15:41:17 +0200 Subject: [PATCH] Refactor and unify auth plugs for Stats and Sites APIs --- .../lib/plausible_web/authorize_sites_api.ex | 57 ------ lib/plausible_web/plugs/authorize_api.ex | 179 ++++++++++++++++++ .../plugs/authorize_stats_api.ex | 115 ----------- lib/plausible_web/router.ex | 30 +-- 4 files changed, 196 insertions(+), 185 deletions(-) delete mode 100644 extra/lib/plausible_web/authorize_sites_api.ex create mode 100644 lib/plausible_web/plugs/authorize_api.ex delete mode 100644 lib/plausible_web/plugs/authorize_stats_api.ex diff --git a/extra/lib/plausible_web/authorize_sites_api.ex b/extra/lib/plausible_web/authorize_sites_api.ex deleted file mode 100644 index b55796e3d67de..0000000000000 --- a/extra/lib/plausible_web/authorize_sites_api.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule PlausibleWeb.AuthorizeSitesApiPlug do - import Plug.Conn - use Plausible.Repo - alias Plausible.Auth.ApiKey - alias PlausibleWeb.Api.Helpers, as: H - - def init(options) do - options - end - - def call(conn, _opts) do - with {:ok, raw_api_key} <- get_bearer_token(conn), - {:ok, api_key} <- verify_access(raw_api_key) do - user = Repo.get_by(Plausible.Auth.User, id: api_key.user_id) - assign(conn, :current_user, user) - else - {:error, :missing_api_key} -> - H.unauthorized( - conn, - "Missing API key. Please use a valid Plausible API key as a Bearer Token." - ) - - {:error, :invalid_api_key} -> - 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 - end - - defp verify_access(api_key) do - hashed_key = ApiKey.do_hash(api_key) - - found_key = - Repo.one( - from a in ApiKey, - where: a.key_hash == ^hashed_key, - where: fragment("? @> ?", a.scopes, ["sites:provision:*"]) - ) - - cond do - found_key -> {:ok, found_key} - true -> {:error, :invalid_api_key} - end - end - - defp get_bearer_token(conn) do - authorization_header = - Plug.Conn.get_req_header(conn, "authorization") - |> List.first() - - case authorization_header do - "Bearer " <> token -> {:ok, String.trim(token)} - _ -> {:error, :missing_api_key} - end - end -end diff --git a/lib/plausible_web/plugs/authorize_api.ex b/lib/plausible_web/plugs/authorize_api.ex new file mode 100644 index 0000000000000..48611f2b92d99 --- /dev/null +++ b/lib/plausible_web/plugs/authorize_api.ex @@ -0,0 +1,179 @@ +defmodule PlausibleWeb.AuthorizeApiPlug do + @moduledoc """ + Plug for authorizing access to Stats and Sites APIs. + """ + + use Plausible.Repo + + import Plug.Conn + + alias Plausible.Auth + alias Plausible.RateLimit + alias Plausible.Sites + alias PlausibleWeb.Api.Helpers, as: H + + # Scopes permitted implicitly for every API key + @implicit_scopes ["stats:read:*"] + + def init(opts) do + opts + end + + def call(conn, _opts) do + requested_scope = Map.fetch!(conn.assigns, :api_scope) + + with {:ok, token} <- get_bearer_token(conn), + {:ok, api_key} <- Auth.find_api_key(token), + :ok <- check_api_key_rate_limit(api_key), + {:ok, conn} <- verify_by_scope(conn, api_key, requested_scope) do + assign(conn, :current_user, api_key.user) + else + error -> send_error(conn, requested_scope, error) + end + end + + ### Verification dispatched by scope + + 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(api_key, site) do + Plausible.OpenTelemetry.add_site_attributes(site) + site = Plausible.Imported.load_import_data(site) + {:ok, assign(conn, :site, site)} + 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 = + Plug.Conn.get_req_header(conn, "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(api_key) do + case RateLimit.check_rate( + "api_request:#{api_key.id}", + to_timeout(hour: 1), + api_key.hourly_request_limit + ) do + {:allow, _} -> :ok + {:deny, _} -> {:error, :rate_limit, api_key.hourly_request_limit} + 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(api_key, site) do + is_member? = Sites.is_member?(api_key.user_id, site) + is_super_admin? = Plausible.Auth.is_super_admin?(api_key.user_id) + + cond do + is_super_admin? -> + :ok + + Sites.locked?(site) -> + {:error, :site_locked} + + Plausible.Billing.Feature.StatsAPI.check_availability(api_key.user) !== :ok -> + {:error, :upgrade_required} + + is_member? -> + :ok + + true -> + {:error, :invalid_api_key} + end + 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, limit}) do + H.too_many_requests( + conn, + "Too many API requests. Your API key is limited to #{limit} requests per hour. Please contact us to request more capacity." + ) + 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, _, {:error, :upgrade_required}) do + H.payment_required( + conn, + "The account that owns this API key does not have access to Stats API. Please make sure you're using the API key of a subscriber account and that the subscription plan includes Stats API" + ) + 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 +end diff --git a/lib/plausible_web/plugs/authorize_stats_api.ex b/lib/plausible_web/plugs/authorize_stats_api.ex deleted file mode 100644 index 4dc68b5bd3558..0000000000000 --- a/lib/plausible_web/plugs/authorize_stats_api.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule PlausibleWeb.AuthorizeStatsApiPlug do - import Plug.Conn - use Plausible.Repo - alias Plausible.Auth - alias Plausible.Sites - alias Plausible.RateLimit - alias PlausibleWeb.Api.Helpers, as: H - - def init(options) do - options - end - - def call(conn, _opts) do - with {:ok, token} <- get_bearer_token(conn), - {:ok, api_key} <- Auth.find_api_key(token), - :ok <- check_api_key_rate_limit(api_key), - {:ok, site} <- verify_access(api_key, conn.params["site_id"]) do - Plausible.OpenTelemetry.add_site_attributes(site) - site = Plausible.Imported.load_import_data(site) - assign(conn, :site, site) - else - {:error, :missing_api_key} -> - H.unauthorized( - conn, - "Missing API key. Please use a valid Plausible API key as a Bearer Token." - ) - - {:error, :missing_site_id} -> - H.bad_request( - conn, - "Missing site ID. Please provide the required site_id parameter with your request." - ) - - {:error, :rate_limit, limit} -> - H.too_many_requests( - conn, - "Too many API requests. Your API key is limited to #{limit} requests per hour. Please contact us to request more capacity." - ) - - {:error, :invalid_api_key} -> - 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." - ) - - {:error, :upgrade_required} -> - H.payment_required( - conn, - "The account that owns this API key does not have access to Stats API. Please make sure you're using the API key of a subscriber account and that the subscription plan includes Stats API" - ) - - {:error, :site_locked} -> - 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 - end - - defp verify_access(_api_key, nil), do: {:error, :missing_site_id} - - defp verify_access(api_key, 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 -> - is_member? = Sites.is_member?(api_key.user_id, site) - is_super_admin? = Plausible.Auth.is_super_admin?(api_key.user_id) - - cond do - is_super_admin? -> - {:ok, site} - - Sites.locked?(site) -> - {:error, :site_locked} - - Plausible.Billing.Feature.StatsAPI.check_availability(api_key.user) !== :ok -> - {:error, :upgrade_required} - - is_member? -> - {:ok, site} - - true -> - {:error, :invalid_api_key} - end - - nil -> - {:error, :invalid_api_key} - end - end - - defp get_bearer_token(conn) do - authorization_header = - Plug.Conn.get_req_header(conn, "authorization") - |> List.first() - - case authorization_header do - "Bearer " <> token -> {:ok, String.trim(token)} - _ -> {:error, :missing_api_key} - end - end - - @one_hour 60 * 60 * 1000 - defp check_api_key_rate_limit(api_key) do - case RateLimit.check_rate( - "api_request:#{api_key.id}", - @one_hour, - api_key.hourly_request_limit - ) do - {:allow, _} -> :ok - {:deny, _} -> {:error, :rate_limit, api_key.hourly_request_limit} - end - end -end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index bd7179c13f168..e241ee6657c37 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -166,8 +166,8 @@ defmodule PlausibleWeb.Router do get "/:domain/suggestions/:filter_name", StatsController, :filter_suggestions end - scope "/api/v1/stats", PlausibleWeb.Api do - pipe_through [:public_api, PlausibleWeb.AuthorizeStatsApiPlug] + scope "/api/v1/stats", PlausibleWeb.Api, assigns: %{api_scope: "stats:read:*"} do + pipe_through [:public_api, PlausibleWeb.AuthorizeApiPlug] get "/realtime/visitors", ExternalStatsController, :realtime_visitors get "/aggregate", ExternalStatsController, :aggregate @@ -175,23 +175,27 @@ defmodule PlausibleWeb.Router do get "/timeseries", ExternalStatsController, :timeseries end - scope "/api/v2", PlausibleWeb.Api do - pipe_through [:public_api, PlausibleWeb.AuthorizeStatsApiPlug] + scope "/api/v2", PlausibleWeb.Api, assigns: %{api_scope: "stats:read:*"} do + pipe_through [:public_api, PlausibleWeb.AuthorizeApiPlug] post "/query", ExternalQueryApiController, :query end on_ee do scope "/api/v1/sites", PlausibleWeb.Api do - pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug] - - post "/", ExternalSitesController, :create_site - put "/shared-links", ExternalSitesController, :find_or_create_shared_link - put "/goals", ExternalSitesController, :find_or_create_goal - delete "/goals/:goal_id", ExternalSitesController, :delete_goal - get "/:site_id", ExternalSitesController, :get_site - put "/:site_id", ExternalSitesController, :update_site - delete "/:site_id", ExternalSitesController, :delete_site + pipe_through :public_api + + scope assigns: %{api_scope: "sites:provision:*"} do + pipe_through PlausibleWeb.AuthorizeApiPlug + + post "/", ExternalSitesController, :create_site + put "/shared-links", ExternalSitesController, :find_or_create_shared_link + put "/goals", ExternalSitesController, :find_or_create_goal + delete "/goals/:goal_id", ExternalSitesController, :delete_goal + get "/:site_id", ExternalSitesController, :get_site + put "/:site_id", ExternalSitesController, :update_site + delete "/:site_id", ExternalSitesController, :delete_site + end end end