mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 09:56:02 +03:00
Refactor Sites and Stats API authorization logic (#4297)
* Refactor and unify auth plugs for Stats and Sites APIs * Expose get site Sites API endpoint to all API keys * Test the new plug * Add test for endpoint with modified scope * Fix typos Co-authored-by: hq1 <hq@mtod.org> * Rename plug for consistency (h/t @aerosol) --------- Co-authored-by: hq1 <hq@mtod.org>
This commit is contained in:
parent
839691d29f
commit
790984e1ad
@ -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
|
|
206
lib/plausible_web/plugs/authorize_public_api.ex
Normal file
206
lib/plausible_web/plugs/authorize_public_api.ex
Normal file
@ -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
|
@ -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
|
|
@ -166,8 +166,8 @@ defmodule PlausibleWeb.Router do
|
|||||||
get "/:domain/suggestions/:filter_name", StatsController, :filter_suggestions
|
get "/:domain/suggestions/:filter_name", StatsController, :filter_suggestions
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/api/v1/stats", PlausibleWeb.Api do
|
scope "/api/v1/stats", PlausibleWeb.Api, assigns: %{api_scope: "stats:read:*"} do
|
||||||
pipe_through [:public_api, PlausibleWeb.AuthorizeStatsApiPlug]
|
pipe_through [:public_api, PlausibleWeb.Plugs.AuthorizePublicAPI]
|
||||||
|
|
||||||
get "/realtime/visitors", ExternalStatsController, :realtime_visitors
|
get "/realtime/visitors", ExternalStatsController, :realtime_visitors
|
||||||
get "/aggregate", ExternalStatsController, :aggregate
|
get "/aggregate", ExternalStatsController, :aggregate
|
||||||
@ -175,23 +175,32 @@ defmodule PlausibleWeb.Router do
|
|||||||
get "/timeseries", ExternalStatsController, :timeseries
|
get "/timeseries", ExternalStatsController, :timeseries
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/api/v2", PlausibleWeb.Api do
|
scope "/api/v2", PlausibleWeb.Api, assigns: %{api_scope: "stats:read:*"} do
|
||||||
pipe_through [:public_api, PlausibleWeb.AuthorizeStatsApiPlug]
|
pipe_through [:public_api, PlausibleWeb.Plugs.AuthorizePublicAPI]
|
||||||
|
|
||||||
post "/query", ExternalQueryApiController, :query
|
post "/query", ExternalQueryApiController, :query
|
||||||
end
|
end
|
||||||
|
|
||||||
on_ee do
|
on_ee do
|
||||||
scope "/api/v1/sites", PlausibleWeb.Api do
|
scope "/api/v1/sites", PlausibleWeb.Api do
|
||||||
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
|
pipe_through :public_api
|
||||||
|
|
||||||
post "/", ExternalSitesController, :create_site
|
scope assigns: %{api_scope: "sites:read:*"} do
|
||||||
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
|
pipe_through PlausibleWeb.Plugs.AuthorizePublicAPI
|
||||||
put "/goals", ExternalSitesController, :find_or_create_goal
|
|
||||||
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
|
get "/:site_id", ExternalSitesController, :get_site
|
||||||
get "/:site_id", ExternalSitesController, :get_site
|
end
|
||||||
put "/:site_id", ExternalSitesController, :update_site
|
|
||||||
delete "/:site_id", ExternalSitesController, :delete_site
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -509,6 +509,17 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
|
|||||||
assert json_response(conn, 200) == %{"domain" => new_domain, "timezone" => site.timezone}
|
assert json_response(conn, 200) == %{"domain" => new_domain, "timezone" => site.timezone}
|
||||||
end
|
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
|
test "is 404 when site cannot be found", %{conn: conn} do
|
||||||
conn = get(conn, "/api/v1/sites/foobar.baz")
|
conn = get(conn, "/api/v1/sites/foobar.baz")
|
||||||
|
|
||||||
|
235
test/plausible_web/plugs/authorize_public_api_test.exs
Normal file
235
test/plausible_web/plugs/authorize_public_api_test.exs
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user