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:
Adrian Gruntkowski 2024-07-02 15:09:23 +02:00 committed by GitHub
parent 839691d29f
commit 790984e1ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 473 additions and 184 deletions

View File

@ -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

View 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

View File

@ -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

View File

@ -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]
pipe_through :public_api
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
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

View File

@ -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")

View 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