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 b55796e3d67d..000000000000 --- 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_public_api.ex b/lib/plausible_web/plugs/authorize_public_api.ex new file mode 100644 index 000000000000..45956d1dbfcb --- /dev/null +++ b/lib/plausible_web/plugs/authorize_public_api.ex @@ -0,0 +1,206 @@ +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.Sites + alias PlausibleWeb.Api.Helpers, as: H + + # 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) + + 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 = + 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(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? = 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 4dc68b5bd355..000000000000 --- 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 bd7179c13f16..7c281e06c54a 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.Plugs.AuthorizePublicAPI] get "/realtime/visitors", ExternalStatsController, :realtime_visitors get "/aggregate", ExternalStatsController, :aggregate @@ -175,23 +175,32 @@ 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.Plugs.AuthorizePublicAPI] 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:read:*"} do + pipe_through PlausibleWeb.Plugs.AuthorizePublicAPI + + get "/:site_id", ExternalSitesController, :get_site + end + + scope assigns: %{api_scope: "sites:provision:*"} do + pipe_through PlausibleWeb.Plugs.AuthorizePublicAPI + + 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 + put "/:site_id", ExternalSitesController, :update_site + delete "/:site_id", ExternalSitesController, :delete_site + end end end diff --git a/test/plausible_web/controllers/api/external_sites_controller_test.exs b/test/plausible_web/controllers/api/external_sites_controller_test.exs index e454a6bd463f..0a079648ac8b 100644 --- a/test/plausible_web/controllers/api/external_sites_controller_test.exs +++ b/test/plausible_web/controllers/api/external_sites_controller_test.exs @@ -509,6 +509,17 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do assert json_response(conn, 200) == %{"domain" => new_domain, "timezone" => site.timezone} end + test "get a site with basic scope config", %{conn: conn, user: user, site: site} do + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites/" <> site.domain) + + assert json_response(conn, 200) == %{"domain" => site.domain, "timezone" => site.timezone} + end + test "is 404 when site cannot be found", %{conn: conn} do conn = get(conn, "/api/v1/sites/foobar.baz") diff --git a/test/plausible_web/plugs/authorize_public_api_test.exs b/test/plausible_web/plugs/authorize_public_api_test.exs new file mode 100644 index 000000000000..a7501e89c5ad --- /dev/null +++ b/test/plausible_web/plugs/authorize_public_api_test.exs @@ -0,0 +1,235 @@ +defmodule PlausibleWeb.Plugs.AuthorizePublicAPITest do + use PlausibleWeb.ConnCase, async: false + + alias PlausibleWeb.Plugs.AuthorizePublicAPI + + setup %{conn: conn} do + conn = + conn + |> put_private(PlausibleWeb.FirstLaunchPlug, :skip) + |> bypass_through(PlausibleWeb.Router) + + {:ok, conn: conn} + end + + test "halts with error when bearer token is missing", %{conn: conn} do + conn = + conn + |> get("/") + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Missing API key." + end + + test "halts with error when bearer token is invalid against read-only Stats API", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer invalid") + |> get("/") + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key or site ID." + end + + test "halts with error when bearer token is invalid", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer invalid") + |> get("/") + |> assign(:api_scope, "sites:provision:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key." + end + + test "halts with error on missing site ID when request made to Stats API", %{conn: conn} do + api_key = insert(:api_key, user: build(:user)) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/") + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 400)["error"] =~ "Missing site ID." + end + + @tag :ee_only + test "halts with error when upgrade is required", %{conn: conn} do + user = insert(:user, trial_expiry_date: nil) + site = insert(:site, members: [user]) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", %{"site_id" => site.domain}) + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + + assert json_response(conn, 402)["error"] =~ + "The account that owns this API key does not have access" + end + + test "halts with error when site is locked", %{conn: conn} do + user = insert(:user) + site = insert(:site, members: [user], locked: true) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", %{"site_id" => site.domain}) + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 402)["error"] =~ "This Plausible site is locked" + end + + test "halts with error when site ID is invalid", %{conn: conn} do + user = insert(:user) + _site = insert(:site, members: [user]) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", %{"site_id" => "invalid.domain"}) + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key or site ID." + end + + test "halts with error when API key owner does not have access to the requested site", %{ + conn: conn + } do + user = insert(:user) + site = insert(:site) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", %{"site_id" => site.domain}) + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key or site ID." + end + + test "halts with error when API lacks required scope", %{conn: conn} do + user = insert(:user) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/") + |> assign(:api_scope, "sites:provision:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key." + end + + test "halts with error when API rate limit hit", %{conn: conn} do + user = insert(:user) + api_key = insert(:api_key, user: user, hourly_request_limit: 1) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/") + |> assign(:api_scope, "sites:read:*") + + first_resp = AuthorizePublicAPI.call(conn, nil) + second_resp = AuthorizePublicAPI.call(conn, nil) + + refute first_resp.halted + assert second_resp.halted + assert json_response(second_resp, 429)["error"] =~ "Too many API requests." + end + + test "passes and sets current user when valid API key with required scope provided", %{ + conn: conn + } do + user = insert(:user) + api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"]) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/") + |> assign(:api_scope, "sites:provision:*") + |> AuthorizePublicAPI.call(nil) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + end + + test "passes and sets current user and site when valid API key and site ID provided", %{ + conn: conn + } do + user = insert(:user) + site = insert(:site, members: [user]) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", %{"site_id" => site.domain}) + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + assert conn.assigns.site.id == site.id + end + + @tag :ee_only + test "passes for super admin user even if not a member of the requested site", %{conn: conn} do + user = insert(:user) + patch_env(:super_admin_user_ids, [user.id]) + site = insert(:site, locked: true) + api_key = insert(:api_key, user: user) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", %{"site_id" => site.domain}) + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + assert conn.assigns.site.id == site.id + end + + test "passes for subscope match", %{conn: conn} do + user = insert(:user) + api_key = insert(:api_key, user: user, scopes: ["funnels:*"]) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/") + |> assign(:api_scope, "funnels:read:*") + |> AuthorizePublicAPI.call(nil) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + end +end