diff --git a/assets/js/dashboard/stats/behaviours/props.js b/assets/js/dashboard/stats/behaviours/props.js index 4986b215df77b..8ba2d7074fa7e 100644 --- a/assets/js/dashboard/stats/behaviours/props.js +++ b/assets/js/dashboard/stats/behaviours/props.js @@ -98,7 +98,7 @@ export default function Properties(props) { BUILD_EXTRA && { name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true }, BUILD_EXTRA && { name: 'average_revenue', label: 'Average', hiddenOnMobile: true } ]} - detailsLink={`/custom-prop-values/${propKey}`} + detailsLink={url.sitePath(`custom-prop-values/${propKey}`)} maybeHideDetails={true} query={query} color="bg-red-50" diff --git a/assets/js/dashboard/util/url.js b/assets/js/dashboard/util/url.js index 609f116123747..f201c6fe0b22b 100644 --- a/assets/js/dashboard/util/url.js +++ b/assets/js/dashboard/util/url.js @@ -5,7 +5,7 @@ export function apiPath(site, path = '') { } export function sitePath(path = '') { - return `/${path}` + window.location.search + return (path.startsWith('/') ? path : '/' + path) + window.location.search } export function setQuery(key, value) { 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/stats/sql/expression.ex b/lib/plausible/stats/sql/expression.ex index 2a0afe9ac9f46..b3b8a8dab1b68 100644 --- a/lib/plausible/stats/sql/expression.ex +++ b/lib/plausible/stats/sql/expression.ex @@ -19,7 +19,7 @@ defmodule Plausible.Stats.SQL.Expression do defmacrop field_or_blank_value(key, expr, empty_value) do quote do - wrap_expression([t], %{ + wrap_alias([t], %{ unquote(key) => fragment("if(empty(?), ?, ?)", unquote(expr), unquote(empty_value), unquote(expr)) }) @@ -40,13 +40,13 @@ defmodule Plausible.Stats.SQL.Expression do end def dimension(key, "time:month", _table, query) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => fragment("toStartOfMonth(toTimeZone(?, ?))", t.timestamp, ^query.timezone) }) end def dimension(key, "time:week", _table, query) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => weekstart_not_before( to_timezone(t.timestamp, ^query.timezone), @@ -56,19 +56,19 @@ defmodule Plausible.Stats.SQL.Expression do end def dimension(key, "time:day", _table, query) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => fragment("toDate(toTimeZone(?, ?))", t.timestamp, ^query.timezone) }) end def dimension(key, "time:hour", :sessions, query) do - wrap_expression([s], %{ + wrap_alias([s], %{ key => regular_time_slots(query, 3600) }) end def dimension(key, "time:hour", _table, query) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => fragment("toStartOfHour(toTimeZone(?, ?))", t.timestamp, ^query.timezone) }) end @@ -77,7 +77,7 @@ defmodule Plausible.Stats.SQL.Expression do def dimension(key, "time:minute", :sessions, %Query{ period: "30m" }) do - wrap_expression([s], %{ + wrap_alias([s], %{ key => fragment( "arrayJoin(range(dateDiff('minute', now(), ?), dateDiff('minute', now(), ?) + 1))", @@ -89,36 +89,36 @@ defmodule Plausible.Stats.SQL.Expression do # :NOTE: This is not exposed in Query APIv2 def dimension(key, "time:minute", _table, %Query{period: "30m"}) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => fragment("dateDiff('minute', now(), ?)", t.timestamp) }) end # :NOTE: This is not exposed in Query APIv2 def dimension(key, "time:minute", :sessions, query) do - wrap_expression([s], %{ + wrap_alias([s], %{ key => regular_time_slots(query, 60) }) end # :NOTE: This is not exposed in Query APIv2 def dimension(key, "time:minute", _table, query) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => fragment("toStartOfMinute(toTimeZone(?, ?))", t.timestamp, ^query.timezone) }) end def dimension(key, "event:name", _table, _query), - do: wrap_expression([t], %{key => t.name}) + do: wrap_alias([t], %{key => t.name}) def dimension(key, "event:page", _table, _query), - do: wrap_expression([t], %{key => t.pathname}) + do: wrap_alias([t], %{key => t.pathname}) def dimension(key, "event:hostname", _table, _query), - do: wrap_expression([t], %{key => t.hostname}) + do: wrap_alias([t], %{key => t.hostname}) def dimension(key, "event:props:" <> property_name, _table, _query) do - wrap_expression([t], %{ + wrap_alias([t], %{ key => fragment( "if(not empty(?), ?, '(none)')", @@ -129,10 +129,10 @@ defmodule Plausible.Stats.SQL.Expression do end def dimension(key, "visit:entry_page", _table, _query), - do: wrap_expression([t], %{key => t.entry_page}) + do: wrap_alias([t], %{key => t.entry_page}) def dimension(key, "visit:exit_page", _table, _query), - do: wrap_expression([t], %{key => t.exit_page}) + do: wrap_alias([t], %{key => t.exit_page}) def dimension(key, "visit:utm_medium", _table, _query), do: field_or_blank_value(key, t.utm_medium, @not_set) @@ -171,42 +171,42 @@ defmodule Plausible.Stats.SQL.Expression do do: field_or_blank_value(key, t.browser_version, @not_set) def dimension(key, "visit:country", _table, _query), - do: wrap_expression([t], %{key => t.country}) + do: wrap_alias([t], %{key => t.country}) def dimension(key, "visit:region", _table, _query), - do: wrap_expression([t], %{key => t.region}) + do: wrap_alias([t], %{key => t.region}) def dimension(key, "visit:city", _table, _query), - do: wrap_expression([t], %{key => t.city}) + do: wrap_alias([t], %{key => t.city}) def event_metric(:pageviews) do - wrap_expression([e], %{ + wrap_alias([e], %{ pageviews: fragment("toUInt64(round(countIf(? = 'pageview') * any(_sample_factor)))", e.name) }) end def event_metric(:events) do - wrap_expression([], %{ + wrap_alias([], %{ events: fragment("toUInt64(round(count(*) * any(_sample_factor)))") }) end def event_metric(:visitors) do - wrap_expression([e], %{ + wrap_alias([e], %{ visitors: fragment("toUInt64(round(uniq(?) * any(_sample_factor)))", e.user_id) }) end def event_metric(:visits) do - wrap_expression([e], %{ + wrap_alias([e], %{ visits: fragment("toUInt64(round(uniq(?) * any(_sample_factor)))", e.session_id) }) end on_ee do def event_metric(:total_revenue) do - wrap_expression( + wrap_alias( [e], %{ total_revenue: @@ -216,7 +216,7 @@ defmodule Plausible.Stats.SQL.Expression do end def event_metric(:average_revenue) do - wrap_expression( + wrap_alias( [e], %{ average_revenue: @@ -227,7 +227,7 @@ defmodule Plausible.Stats.SQL.Expression do end def event_metric(:sample_percent) do - wrap_expression([], %{ + wrap_alias([], %{ sample_percent: fragment("if(any(_sample_factor) > 1, round(100 / any(_sample_factor)), 100)") }) @@ -245,7 +245,7 @@ defmodule Plausible.Stats.SQL.Expression do event_page_filter = Query.get_filter(query, "event:page") condition = SQL.WhereBuilder.build_condition(:entry_page, event_page_filter) - wrap_expression([], %{ + wrap_alias([], %{ bounce_rate: fragment( "toUInt32(ifNotFinite(round(sumIf(is_bounce * sign, ?) / sumIf(sign, ?) * 100), 0))", @@ -257,32 +257,32 @@ defmodule Plausible.Stats.SQL.Expression do end def session_metric(:visits, _query) do - wrap_expression([s], %{ + wrap_alias([s], %{ visits: fragment("toUInt64(round(sum(?) * any(_sample_factor)))", s.sign) }) end def session_metric(:pageviews, _query) do - wrap_expression([s], %{ + wrap_alias([s], %{ pageviews: fragment("toUInt64(round(sum(? * ?) * any(_sample_factor)))", s.sign, s.pageviews) }) end def session_metric(:events, _query) do - wrap_expression([s], %{ + wrap_alias([s], %{ events: fragment("toUInt64(round(sum(? * ?) * any(_sample_factor)))", s.sign, s.events) }) end def session_metric(:visitors, _query) do - wrap_expression([s], %{ + wrap_alias([s], %{ visitors: fragment("toUInt64(round(uniq(?) * any(_sample_factor)))", s.user_id) }) end def session_metric(:visit_duration, _query) do - wrap_expression([], %{ + wrap_alias([], %{ visit_duration: fragment("toUInt32(ifNotFinite(round(sum(duration * sign) / sum(sign)), 0))"), __internal_visits: fragment("toUInt32(sum(sign))") @@ -290,7 +290,7 @@ defmodule Plausible.Stats.SQL.Expression do end def session_metric(:views_per_visit, _query) do - wrap_expression([s], %{ + wrap_alias([s], %{ views_per_visit: fragment( "ifNotFinite(round(sum(? * ?) / sum(?), 2), 0)", @@ -303,7 +303,7 @@ defmodule Plausible.Stats.SQL.Expression do end def session_metric(:sample_percent, _query) do - wrap_expression([], %{ + wrap_alias([], %{ sample_percent: fragment("if(any(_sample_factor) > 1, round(100 / any(_sample_factor)), 100)") }) diff --git a/lib/plausible/stats/sql/fragments.ex b/lib/plausible/stats/sql/fragments.ex index 92cfcc510000f..560f005b8f678 100644 --- a/lib/plausible/stats/sql/fragments.ex +++ b/lib/plausible/stats/sql/fragments.ex @@ -149,10 +149,10 @@ defmodule Plausible.Stats.SQL.Fragments do ### Examples - iex> wrap_expression([t], %{ foo: t.column }) |> expand_macro_once + iex> wrap_alias([t], %{ foo: t.column }) |> expand_macro_once "%{foo: dynamic([t], selected_as(t.column, :foo))}" """ - defmacro wrap_expression(binding, map_literal) do + defmacro wrap_alias(binding, map_literal) do update_literal_map_values(map_literal, fn {key, expr} -> key_expr = if Macro.quoted_literal?(key) do @@ -171,11 +171,11 @@ defmodule Plausible.Stats.SQL.Fragments do ### Examples iex> select_merge_as(q, [t], %{ foo: t.column }) |> expand_macro_once - "select_merge(q, [], ^wrap_expression([t], %{foo: t.column}))" + "select_merge(q, [], ^wrap_alias([t], %{foo: t.column}))" """ defmacro select_merge_as(q, binding, map_literal) do quote do - select_merge(unquote(q), [], ^wrap_expression(unquote(binding), unquote(map_literal))) + select_merge(unquote(q), [], ^wrap_alias(unquote(binding), unquote(map_literal))) end end @@ -205,4 +205,16 @@ defmodule Plausible.Stats.SQL.Fragments do end) end end + + defp update_literal_map_values({:%{}, ctx, keyword_list}, mapper_fn) do + { + :%{}, + ctx, + Enum.map(keyword_list, fn {key, expr} -> + {key, mapper_fn.({key, expr})} + end) + } + end + + defp update_literal_map_values(ast, _), do: ast end diff --git a/lib/plausible/stats/sql/special_metrics.ex b/lib/plausible/stats/sql/special_metrics.ex index 29eaec275d364..74dc121ac3491 100644 --- a/lib/plausible/stats/sql/special_metrics.ex +++ b/lib/plausible/stats/sql/special_metrics.ex @@ -137,7 +137,7 @@ defmodule Plausible.Stats.SQL.SpecialMetrics do defp total_visitors_subquery(site, query, include_imported) defp total_visitors_subquery(site, query, true = _include_imported) do - wrap_expression([], %{ + wrap_alias([], %{ total_visitors: subquery(total_visitors(site, query)) + subquery(Plausible.Stats.Imported.total_imported_visitors(site, query)) @@ -145,7 +145,7 @@ defmodule Plausible.Stats.SQL.SpecialMetrics do end defp total_visitors_subquery(site, query, false = _include_imported) do - wrap_expression([], %{ + wrap_alias([], %{ total_visitors: subquery(total_visitors(site, query)) }) 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 0000000000000..45956d1dbfcb1 --- /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 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..7c281e06c54a4 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/priv/repo/seeds.exs b/priv/repo/seeds.exs index 4d2dc4c99f20d..44e1db967060e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -168,6 +168,7 @@ native_stats_range screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]), operating_system: Enum.random(["Windows", "Mac", "Linux"]), operating_system_version: to_string(Enum.random(0..15)), + utm_campaign: Enum.random(["", "Referral", "Advertisement", "Email"]), pathname: Enum.random([ "/", 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 e454a6bd463ff..0a079648ac8bd 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 0000000000000..a7501e89c5ad1 --- /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