Populate `team_id` when provisioning API key (#5234)

* Set team when creating API key

* Create API key with team ID and adjust API key CRM

* Make CRM work with team-less API keys still

* Scope owner's API keys by team on setup

* Rate limit team scoped API keys by their team

* Enforce team scoping for API key with a team

* Prevent using legacy API keys against setup teams

* Enforce team scoping in Sites API too

* Scope API keys list in settings by team

* Do not prevent legacy API keys from accessing setup teams

* List legacy API keys across all teams

* Display "API Keys" under "Team Settings" when team is setup

* Scope teams index in Sites API as well

* Test Sites API actions

* Revert "Scope owner's API keys by team on setup"

This reverts commit 08fd5b4e801417a28ebb9937457cf3e59f7386a0.

* Test and slightly simplify API key CRM logic

* Test API key provisioning from Account Settings

* Test `AuthorizePublicApi` plug adjustments

* Simplify conditionals (h/t @aerosol)

* Change back to using `schema` in CRM logic

* Don't run tests triggering Kaffy warning locally

* Run quirky Kaffy tests only on CI in EE env
This commit is contained in:
Adrian Gruntkowski 2025-03-31 13:35:09 +02:00 committed by GitHub
parent 8d98a75cd5
commit 94e9a20038
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 754 additions and 85 deletions

View File

@ -102,7 +102,7 @@ jobs:
- run: make minio - run: make minio
if: env.MIX_ENV == 'test' if: env.MIX_ENV == 'test'
- run: mix test --include slow --include minio --include migrations --max-failures 1 --warnings-as-errors - run: mix test --include slow --include minio --include migrations --include kaffy_quirks --max-failures 1 --warnings-as-errors
if: env.MIX_ENV == 'test' if: env.MIX_ENV == 'test'
env: env:
MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1" MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1"

View File

@ -14,8 +14,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
@pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000] @pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000]
def index(conn, params) do def index(conn, params) do
team = Teams.get(params["team_id"])
user = conn.assigns.current_user user = conn.assigns.current_user
team = conn.assigns.current_team || Teams.get(params["team_id"])
page = page =
user user
@ -30,9 +30,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def guests_index(conn, params) do def guests_index(conn, params) do
user = conn.assigns.current_user user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, site} <- get_site(user, site_id, [:owner, :admin, :editor, :viewer]) do {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do
opts = [cursor_fields: [inserted_at: :desc, id: :desc], limit: 100, maximum_limit: 1000] opts = [cursor_fields: [inserted_at: :desc, id: :desc], limit: 100, maximum_limit: 1000]
page = site |> Sites.list_guests_query() |> paginate(params, opts) page = site |> Sites.list_guests_query() |> paginate(params, opts)
@ -54,10 +55,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def teams_index(conn, params) do def teams_index(conn, params) do
user = conn.assigns.current_user user = conn.assigns.current_user
team = conn.assigns.current_team
page = page =
user user
|> Teams.Users.teams_query(order_by: :id_desc) |> Teams.Users.teams_query(identifier: team && team.identifier, order_by: :id_desc)
|> paginate(params, @pagination_opts) |> paginate(params, @pagination_opts)
json(conn, %{ json(conn, %{
@ -78,9 +80,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def goals_index(conn, params) do def goals_index(conn, params) do
user = conn.assigns.current_user user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, site} <- get_site(user, site_id, [:owner, :admin, :editor, :viewer]) do {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do
page = page =
site site
|> Plausible.Goals.for_site_query() |> Plausible.Goals.for_site_query()
@ -110,7 +113,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def create_site(conn, params) do def create_site(conn, params) do
user = conn.assigns.current_user user = conn.assigns.current_user
team = Plausible.Teams.get(params["team_id"]) team = conn.assigns.current_team || Teams.get(params["team_id"])
case Sites.create(user, params, team) do case Sites.create(user, params, team) do
{:ok, %{site: site}} -> {:ok, %{site: site}} ->
@ -146,7 +149,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def get_site(conn, %{"site_id" => site_id}) do def get_site(conn, %{"site_id" => site_id}) do
case get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor, :viewer]) do user = conn.assigns.current_user
team = conn.assigns.current_team
case get_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do
{:ok, site} -> {:ok, site} ->
json(conn, %{ json(conn, %{
domain: site.domain, domain: site.domain,
@ -160,7 +166,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def delete_site(conn, %{"site_id" => site_id}) do def delete_site(conn, %{"site_id" => site_id}) do
case get_site(conn.assigns.current_user, site_id, [:owner]) do user = conn.assigns.current_user
team = conn.assigns.current_team
case get_site(user, team, site_id, [:owner]) do
{:ok, site} -> {:ok, site} ->
{:ok, _} = Plausible.Site.Removal.run(site) {:ok, _} = Plausible.Site.Removal.run(site)
json(conn, %{"deleted" => true}) json(conn, %{"deleted" => true})
@ -171,8 +180,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def update_site(conn, %{"site_id" => site_id} = params) do def update_site(conn, %{"site_id" => site_id} = params) do
user = conn.assigns.current_user
team = conn.assigns.current_team
# for now this only allows to change the domain # for now this only allows to change the domain
with {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]), with {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]),
{:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do {:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do
json(conn, site) json(conn, site)
else else
@ -187,10 +199,13 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def find_or_create_guest(conn, params) do def find_or_create_guest(conn, params) do
user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, email} <- expect_param_key(params, "email"), {:ok, email} <- expect_param_key(params, "email"),
{:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]), {:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do {:ok, site} <- get_site(user, team, site_id, [:owner, :admin]) do
existing = Repo.one(Sites.list_guests_query(site, email: email)) existing = Repo.one(Sites.list_guests_query(site, email: email))
if existing do if existing do
@ -230,9 +245,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def delete_guest(conn, params) do def delete_guest(conn, params) do
user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, email} <- expect_param_key(params, "email"), {:ok, email} <- expect_param_key(params, "email"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do {:ok, site} <- get_site(user, team, site_id, [:owner, :admin]) do
existing = Repo.one(Sites.list_guests_query(site, email: email)) existing = Repo.one(Sites.list_guests_query(site, email: email))
case existing do case existing do
@ -262,9 +280,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def find_or_create_shared_link(conn, params) do def find_or_create_shared_link(conn, params) do
user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, link_name} <- expect_param_key(params, "name"), {:ok, link_name} <- expect_param_key(params, "name"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]) do {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]) do
shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name)
shared_link = shared_link =
@ -296,9 +317,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def find_or_create_goal(conn, params) do def find_or_create_goal(conn, params) do
user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, _} <- expect_param_key(params, "goal_type"), {:ok, _} <- expect_param_key(params, "goal_type"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]), {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]),
{:ok, goal} <- Goals.find_or_create(site, params) do {:ok, goal} <- Goals.find_or_create(site, params) do
json(conn, goal) json(conn, goal)
else else
@ -314,9 +338,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end end
def delete_goal(conn, params) do def delete_goal(conn, params) do
user = conn.assigns.current_user
team = conn.assigns.current_team
with {:ok, site_id} <- expect_param_key(params, "site_id"), with {:ok, site_id} <- expect_param_key(params, "site_id"),
{:ok, goal_id} <- expect_param_key(params, "goal_id"), {:ok, goal_id} <- expect_param_key(params, "goal_id"),
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin, :editor]), {:ok, site} <- get_site(user, team, site_id, [:owner, :admin, :editor]),
:ok <- Goals.delete(goal_id, site) do :ok <- Goals.delete(goal_id, site) do
json(conn, %{"deleted" => true}) json(conn, %{"deleted" => true})
else else
@ -345,10 +372,19 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
} }
end end
defp get_site(user, site_id, roles) do defp get_site(user, team, site_id, roles) do
case Plausible.Sites.get_for_user(user, site_id, roles) do case Plausible.Sites.get_for_user(user, site_id, roles) do
nil -> {:error, :site_not_found} nil ->
site -> {:ok, site} {:error, :site_not_found}
site ->
site = Repo.preload(site, :team)
if team && team.id != site.team_id do
{:error, :site_not_found}
else
{:ok, site}
end
end end
end end

View File

@ -18,6 +18,7 @@ defmodule Plausible.Auth.ApiKey do
field :key_hash, :string field :key_hash, :string
field :key_prefix, :string field :key_prefix, :string
belongs_to :team, Plausible.Teams.Team
belongs_to :user, Plausible.Auth.User belongs_to :user, Plausible.Auth.User
timestamps() timestamps()
@ -25,17 +26,24 @@ defmodule Plausible.Auth.ApiKey do
def hourly_request_limit(), do: @hourly_request_limit def hourly_request_limit(), do: @hourly_request_limit
def changeset(schema, attrs \\ %{}) do def changeset(struct, team, attrs) do
schema struct
|> cast(attrs, @required ++ @optional) |> cast(attrs, @required ++ @optional)
|> validate_required(@required) |> validate_required(@required)
|> maybe_put_key() |> maybe_put_key()
|> process_key() |> process_key()
|> maybe_put_team(team)
|> unique_constraint(:key_hash, error_key: :key) |> unique_constraint(:key_hash, error_key: :key)
|> unique_constraint([:team_id, :user_id], error_key: :team)
end end
def update(schema, attrs \\ %{}) do # NOTE: needed only because of lacking introspection in Kaffy
schema def changeset(struct, attrs) do
changeset(struct, nil, attrs)
end
def update(struct, attrs \\ %{}) do
struct
|> cast(attrs, [:name, :user_id, :scopes]) |> cast(attrs, [:name, :user_id, :scopes])
|> validate_required([:user_id, :name]) |> validate_required([:user_id, :name])
end end
@ -57,6 +65,12 @@ defmodule Plausible.Auth.ApiKey do
def process_key(changeset), do: changeset def process_key(changeset), do: changeset
defp maybe_put_team(changeset, nil), do: changeset
defp maybe_put_team(changeset, team) do
put_assoc(changeset, :team, team)
end
defp maybe_put_key(changeset) do defp maybe_put_key(changeset) do
if get_change(changeset, :key) do if get_change(changeset, :key) do
changeset changeset

View File

@ -1,35 +1,76 @@
defmodule Plausible.Auth.ApiKeyAdmin do defmodule Plausible.Auth.ApiKeyAdmin do
@moduledoc """
Stats and Sites API key logic for CRM.
"""
use Plausible.Repo use Plausible.Repo
alias Plausible.Auth
alias Plausible.Teams
def search_fields(_schema) do def search_fields(_schema) do
[ [
:name, :name,
user: [:name, :email] user: [:name, :email],
team: [:name, :identifier]
] ]
end end
def custom_index_query(_conn, _schema, query) do def custom_index_query(_conn, _schema, query) do
from(r in query, preload: [:user]) from(r in query, preload: [:user, team: :owners])
end end
def create_changeset(schema, attrs) do def create_changeset(schema, attrs) do
scopes = [attrs["scope"]] team = Teams.get(attrs["team_identifier"])
Plausible.Auth.ApiKey.changeset(struct(schema, %{}), Map.merge(%{"scopes" => scopes}, attrs))
user_id =
case Integer.parse(Map.get(attrs, "user_id", "")) do
{user_id, ""} -> user_id
_ -> nil
end
user = user_id && Auth.find_user_by(id: user_id)
team =
case {team, user} do
{%{} = team, _} ->
team
{nil, %{} = user} ->
{:ok, team} = Teams.get_or_create(user)
team
_ ->
nil
end
Auth.ApiKey.changeset(schema, team, attrs)
end end
def update_changeset(schema, attrs) do def update_changeset(entry, attrs) do
Plausible.Auth.ApiKey.update(schema, attrs) Auth.ApiKey.update(entry, attrs)
end end
@plaintext_key_help """ @plaintext_key_help """
The value of the API key is sensitive data like a password. Once created, the value of they will never be revealed again. Make sure to copy/paste this into a secure place before hitting 'save'. When sending the key to a customer, use a secure E2EE system that destructs the message after a certain period like https://bitwarden.com/products/send The value of the API key is sensitive data like a password. Once created, the value of they will never be revealed again. Make sure to copy/paste this into a secure place before hitting 'save'. When sending the key to a customer, use a secure E2EE system that destructs the message after a certain period like https://bitwarden.com/products/send
""" """
@team_identifier_help """
Team under which the key is to be created. Defaults to user's personal team when left empty.
"""
def form_fields(_) do def form_fields(_) do
[ [
name: nil, name: nil,
key: %{create: :readonly, update: :hidden, help_text: @plaintext_key_help}, key: %{create: :readonly, update: :hidden, help_text: @plaintext_key_help},
key_prefix: %{create: :hidden, update: :readonly}, key_prefix: %{create: :hidden, update: :readonly},
scope: %{choices: [{"Stats API", ["stats:read:*"]}, {"Sites API", ["sites:provision:*"]}]}, scopes: %{
choices: [
{"Stats API", Jason.encode!(["stats:read:*"])},
{"Sites API", Jason.encode!(["sites:provision:*"])}
]
},
team_identifier: %{update: :hidden, help_text: @team_identifier_help},
user_id: nil user_id: nil
] ]
end end
@ -39,7 +80,55 @@ defmodule Plausible.Auth.ApiKeyAdmin do
key_prefix: nil, key_prefix: nil,
name: nil, name: nil,
scopes: nil, scopes: nil,
owner: %{value: & &1.user.email} owner: %{value: &get_owner/1},
team: %{value: &get_team/1}
] ]
end end
defp get_team(api_key) do
team_name =
case api_key.team && api_key.team.owners do
[owner] ->
if api_key.team.setup_complete do
api_key.team.name
else
owner.name
end
[_ | _] ->
api_key.team.name
nil ->
"(none)"
end
|> html_escape()
if api_key.team do
Phoenix.HTML.raw("""
<a href="/crm/teams/team/#{api_key.team.id}">#{team_name}</a>
""")
else
team_name
end
end
defp get_owner(api_key) do
escaped_name = html_escape(api_key.user.name)
escaped_email = html_escape(api_key.user.email)
owner_html =
"""
<a href="/crm/auth/user/#{api_key.user.id}">#{escaped_name}</a>
<br/>
#{escaped_email}
"""
{:safe, owner_html}
end
defp html_escape(string) do
string
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end end

View File

@ -5,7 +5,11 @@ defmodule Plausible.Auth do
use Plausible use Plausible
use Plausible.Repo use Plausible.Repo
import Ecto.Query
alias Plausible.Auth alias Plausible.Auth
alias Plausible.Billing
alias Plausible.RateLimit alias Plausible.RateLimit
alias Plausible.Teams alias Plausible.Teams
@ -154,13 +158,25 @@ defmodule Plausible.Auth do
def is_super_admin?(_), do: false def is_super_admin?(_), do: false
end end
@spec create_api_key(Auth.User.t(), String.t(), String.t()) :: @spec list_api_keys(Auth.User.t(), Teams.Team.t() | nil) :: [Auth.ApiKey.t()]
{:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required} def list_api_keys(user, team) do
def create_api_key(user, name, key) do query =
params = %{name: name, user_id: user.id, key: key} from(a in Auth.ApiKey,
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params) where: a.user_id == ^user.id,
order_by: [desc: a.id]
)
|> scope_api_keys_by_team(team)
with :ok <- check_stats_api_available(user) do Repo.all(query)
end
@spec create_api_key(Auth.User.t(), Teams.Team.t(), String.t(), String.t()) ::
{:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required}
def create_api_key(user, team, name, key) do
params = %{name: name, user_id: user.id, key: key}
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, team, params)
with :ok <- Billing.Feature.StatsAPI.check_availability(team) do
Repo.insert(changeset) Repo.insert(changeset)
end end
end end
@ -176,7 +192,7 @@ defmodule Plausible.Auth do
end end
@spec find_api_key(String.t(), Keyword.t()) :: @spec find_api_key(String.t(), Keyword.t()) ::
{:ok, Auth.ApiKey.t() | %{api_key: Auth.ApiKey.t(), team: Teams.Team.t() | nil}} {:ok, %{api_key: Auth.ApiKey.t(), team: Teams.Team.t() | nil}}
| {:error, :invalid_api_key | :missing_site_id} | {:error, :invalid_api_key | :missing_site_id}
def find_api_key(raw_key, opts \\ []) do def find_api_key(raw_key, opts \\ []) do
{team_scope, id} = Keyword.get(opts, :team_by, {nil, nil}) {team_scope, id} = Keyword.get(opts, :team_by, {nil, nil})
@ -184,33 +200,52 @@ defmodule Plausible.Auth do
find_api_key(raw_key, team_scope, id) find_api_key(raw_key, team_scope, id)
end end
defp scope_api_keys_by_team(query, nil) do
query
end
defp scope_api_keys_by_team(query, team) do
where(query, [a], is_nil(a.team_id) or a.team_id == ^team.id)
end
defp find_api_key(raw_key, nil, _) do defp find_api_key(raw_key, nil, _) do
hashed_key = Auth.ApiKey.do_hash(raw_key) hashed_key = Auth.ApiKey.do_hash(raw_key)
query = query =
from(api_key in Auth.ApiKey, from(api_key in Auth.ApiKey,
join: user in assoc(api_key, :user), join: user in assoc(api_key, :user),
left_join: team in assoc(api_key, :team),
where: api_key.key_hash == ^hashed_key, where: api_key.key_hash == ^hashed_key,
preload: [user: user] preload: [user: user, team: team]
) )
if found = Repo.one(query) do case Repo.one(query) do
{:ok, found} nil ->
else {:error, :invalid_api_key}
{:error, :invalid_api_key}
%{team: %{} = team} = api_key ->
{:ok, %{api_key: api_key, team: team}}
api_key ->
{:ok, %{api_key: api_key, team: nil}}
end end
end end
defp find_api_key(raw_key, :site, nil) do defp find_api_key(raw_key, :site, nil) do
with {:ok, api_key} <- find_api_key(raw_key, nil, nil) do find_api_key(raw_key, nil, nil)
{:ok, %{api_key: api_key, team: nil}}
end
end end
defp find_api_key(raw_key, :site, domain) do defp find_api_key(raw_key, :site, domain) do
with {:ok, api_key} <- find_api_key(raw_key, nil, nil) do case find_api_key(raw_key, nil, nil) do
team = find_team_by_site(domain) {:ok, %{api_key: api_key, team: nil}} ->
{:ok, %{api_key: api_key, team: team}} team = find_team_by_site(domain)
{:ok, %{api_key: api_key, team: team}}
{:ok, %{api_key: api_key, team: team}} ->
{:ok, %{api_key: api_key, team: team}}
{:error, _} = error ->
error
end end
end end
@ -223,21 +258,6 @@ defmodule Plausible.Auth do
) )
end end
defp check_stats_api_available(user) do
case Plausible.Teams.get_by_owner(user) do
{:ok, team} ->
Plausible.Billing.Feature.StatsAPI.check_availability(team)
{:error, :no_team} ->
Plausible.Billing.Feature.StatsAPI.check_availability(nil)
{:error, :multiple_teams} ->
# NOTE: Loophole to allow creating API keys when user is a member
# on multiple teams.
:ok
end
end
defp rate_limit_key(%Auth.User{id: id}), do: id defp rate_limit_key(%Auth.User{id: id}), do: id
defp rate_limit_key(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn) defp rate_limit_key(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn)
end end

View File

@ -42,7 +42,13 @@ defmodule Plausible.Teams do
end end
def get(team_identifier) when is_binary(team_identifier) do def get(team_identifier) when is_binary(team_identifier) do
Repo.get_by(Teams.Team, identifier: team_identifier) case Ecto.UUID.cast(team_identifier) do
{:ok, uuid} ->
Repo.get_by(Teams.Team, identifier: uuid)
:error ->
nil
end
end end
@spec get!(pos_integer() | binary()) :: Teams.Team.t() @spec get!(pos_integer() | binary()) :: Teams.Team.t()
@ -291,12 +297,14 @@ defmodule Plausible.Teams do
end end
end end
@spec last_subscription_join_query() :: Ecto.Query.t()
def last_subscription_join_query() do def last_subscription_join_query() do
from(subscription in last_subscription_query(), from(subscription in last_subscription_query(),
where: subscription.team_id == parent_as(:team).id where: subscription.team_id == parent_as(:team).id
) )
end end
@spec last_subscription_query() :: Ecto.Query.t()
def last_subscription_query() do def last_subscription_query() do
from(subscription in Plausible.Billing.Subscription, from(subscription in Plausible.Billing.Subscription,
order_by: [desc: subscription.inserted_at, desc: subscription.id], order_by: [desc: subscription.inserted_at, desc: subscription.id],
@ -304,7 +312,9 @@ defmodule Plausible.Teams do
) )
end end
defp get_owned_team(user, opts \\ []) do # Exposed for use in tests
@doc false
def get_owned_team(user, opts \\ []) do
only_not_setup? = Keyword.get(opts, :only_not_setup?, false) only_not_setup? = Keyword.get(opts, :only_not_setup?, false)
query = query =

View File

@ -92,21 +92,26 @@ defmodule PlausibleWeb.SettingsController do
def api_keys(conn, _params) do def api_keys(conn, _params) do
current_user = conn.assigns.current_user current_user = conn.assigns.current_user
current_team = conn.assigns[:current_team]
api_keys = api_keys = Auth.list_api_keys(current_user, current_team)
Repo.preload(current_user, :api_keys).api_keys
render(conn, :api_keys, layout: {PlausibleWeb.LayoutView, :settings}, api_keys: api_keys) render(conn, :api_keys, layout: {PlausibleWeb.LayoutView, :settings}, api_keys: api_keys)
end end
def new_api_key(conn, _params) do def new_api_key(conn, _params) do
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}) current_team = conn.assigns[:current_team]
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, current_team, %{})
render(conn, "new_api_key.html", changeset: changeset) render(conn, "new_api_key.html", changeset: changeset)
end end
def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key}}) do def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key}}) do
case Auth.create_api_key(conn.assigns.current_user, name, key) do current_user = conn.assigns.current_user
current_team = conn.assigns.current_team
case Auth.create_api_key(current_user, current_team, name, key) do
{:ok, _api_key} -> {:ok, _api_key} ->
conn conn
|> put_flash(:success, "API key created successfully") |> put_flash(:success, "API key created successfully")

View File

@ -55,7 +55,9 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
{:ok, api_key, limit_key, hourly_limit} <- find_api_key(conn, token, context), {:ok, api_key, limit_key, hourly_limit} <- find_api_key(conn, token, context),
:ok <- check_api_key_rate_limit(limit_key, hourly_limit), :ok <- check_api_key_rate_limit(limit_key, hourly_limit),
{:ok, conn} <- verify_by_scope(conn, api_key, requested_scope) do {:ok, conn} <- verify_by_scope(conn, api_key, requested_scope) do
assign(conn, :current_user, api_key.user) conn
|> assign(:current_user, api_key.user)
|> assign(:current_team, api_key.team)
else else
error -> send_error(conn, requested_scope, error) error -> send_error(conn, requested_scope, error)
end end
@ -97,8 +99,15 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
end end
defp find_api_key(_conn, token, _) do defp find_api_key(_conn, token, _) do
with {:ok, api_key} <- Auth.find_api_key(token) do case Auth.find_api_key(token) do
{:ok, api_key, limit_key(api_key, nil), Auth.ApiKey.hourly_request_limit()} {:ok, %{api_key: api_key, team: nil}} ->
{:ok, api_key, limit_key(api_key, nil), Auth.ApiKey.hourly_request_limit()}
{:ok, %{api_key: api_key, team: team}} ->
{:ok, api_key, limit_key(api_key, team.identifier), team.hourly_api_request_limit}
{:error, _} = error ->
error
end end
end end
@ -189,6 +198,9 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
is_super_admin? -> is_super_admin? ->
:ok :ok
api_key.team_id && api_key.team_id != site.team_id ->
{:error, :invalid_api_key}
Sites.locked?(site) -> Sites.locked?(site) ->
{:error, :site_locked} {:error, :site_locked}

View File

@ -111,7 +111,9 @@ defmodule PlausibleWeb.LayoutView do
if(not Teams.setup?(current_team), if(not Teams.setup?(current_team),
do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes} do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes}
), ),
%{key: "API Keys", value: "api-keys", icon: :key}, if(not Teams.setup?(current_team),
do: %{key: "API Keys", value: "api-keys", icon: :key}
),
%{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle} %{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@ -127,6 +129,7 @@ defmodule PlausibleWeb.LayoutView do
if(current_team_role in [:owner, :admin, :billing], if(current_team_role in [:owner, :admin, :billing],
do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes} do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes}
), ),
%{key: "API Keys", value: "api-keys", icon: :key},
if(current_team_role == :owner, if(current_team_role == :owner,
do: %{key: "Danger Zone", value: "team/delete", icon: :exclamation_triangle} do: %{key: "Danger Zone", value: "team/delete", icon: :exclamation_triangle}
) )

View File

@ -21,16 +21,21 @@ defmodule Plausible.AuthTest do
describe "create_api_key/3" do describe "create_api_key/3" do
test "creates a new api key" do test "creates a new api key" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
team = team_of(user)
key = Ecto.UUID.generate() key = Ecto.UUID.generate()
assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(user, "my new key", key) assert {:ok, %Auth.ApiKey{} = api_key} = Auth.create_api_key(user, team, "my new key", key)
assert api_key.team_id == team.id
assert api_key.user_id == user.id
end end
test "errors when key already exists" do test "errors when key already exists" do
u1 = new_user(trial_expiry_date: Date.utc_today()) u1 = new_user(trial_expiry_date: Date.utc_today())
t1 = team_of(u1)
u2 = new_user(trial_expiry_date: Date.utc_today()) u2 = new_user(trial_expiry_date: Date.utc_today())
t2 = team_of(u2)
key = Ecto.UUID.generate() key = Ecto.UUID.generate()
assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(u1, "my new key", key) assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(u1, t1, "my new key", key)
assert {:error, changeset} = Auth.create_api_key(u2, "my other key", key) assert {:error, changeset} = Auth.create_api_key(u2, t2, "my other key", key)
assert changeset.errors[:key] == assert changeset.errors[:key] ==
{"has already been taken", {"has already been taken",
@ -38,27 +43,30 @@ defmodule Plausible.AuthTest do
end end
@tag :ee_only @tag :ee_only
test "returns error when user is on a growth plan" do test "returns error when team is on a growth plan" do
user = new_user() |> subscribe_to_growth_plan() user = new_user() |> subscribe_to_growth_plan()
team = team_of(user)
assert {:error, :upgrade_required} = assert {:error, :upgrade_required} =
Auth.create_api_key(user, "my new key", Ecto.UUID.generate()) Auth.create_api_key(user, team, "my new key", Ecto.UUID.generate())
end end
test "creates a key for user on a growth plan when they are an owner of more than one team" do test "creates a key for user in a team with a bunsiness plan" do
user = new_user() |> subscribe_to_growth_plan() user = new_user() |> subscribe_to_business_plan()
team = team_of(user)
another_site = new_site() another_site = new_site()
add_member(another_site.team, user: user, role: :owner) add_member(another_site.team, user: user, role: :owner)
assert {:ok, %Auth.ApiKey{}} = assert {:ok, %Auth.ApiKey{}} =
Auth.create_api_key(user, "my new key", Ecto.UUID.generate()) Auth.create_api_key(user, team, "my new key", Ecto.UUID.generate())
end end
end end
describe "delete_api_key/2" do describe "delete_api_key/2" do
test "deletes the record" do test "deletes the record" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
assert {:ok, api_key} = Auth.create_api_key(user, "my new key", Ecto.UUID.generate()) team = team_of(user)
assert {:ok, api_key} = Auth.create_api_key(user, team, "my new key", Ecto.UUID.generate())
assert :ok = Auth.delete_api_key(user, api_key.id) assert :ok = Auth.delete_api_key(user, api_key.id)
refute Plausible.Repo.reload(api_key) refute Plausible.Repo.reload(api_key)
end end
@ -67,7 +75,10 @@ defmodule Plausible.AuthTest do
me = new_user(trial_expiry_date: Date.utc_today()) me = new_user(trial_expiry_date: Date.utc_today())
other_user = new_user(trial_expiry_date: Date.utc_today()) other_user = new_user(trial_expiry_date: Date.utc_today())
{:ok, other_api_key} = Auth.create_api_key(other_user, "my new key", Ecto.UUID.generate()) other_team = team_of(other_user)
{:ok, other_api_key} =
Auth.create_api_key(other_user, other_team, "my new key", Ecto.UUID.generate())
assert {:error, :not_found} = Auth.delete_api_key(me, other_api_key.id) assert {:error, :not_found} = Auth.delete_api_key(me, other_api_key.id)
assert {:error, :not_found} = Auth.delete_api_key(me, -1) assert {:error, :not_found} = Auth.delete_api_key(me, -1)

View File

@ -3,6 +3,7 @@ defmodule PlausibleWeb.AdminControllerTest do
use Plausible.Teams.Test use Plausible.Teams.Test
alias Plausible.Repo alias Plausible.Repo
alias Plausible.Teams
describe "GET /crm/teams/team/:team_id/usage" do describe "GET /crm/teams/team/:team_id/usage" do
setup [:create_user, :log_in, :create_team] setup [:create_user, :log_in, :create_team]
@ -269,4 +270,158 @@ defmodule PlausibleWeb.AdminControllerTest do
} }
end end
end end
describe "POST /crm/auth/api_key" do
setup [:create_user, :log_in]
@tag :kaffy_quirks
test "creates a team-bound API key", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
another_user = new_user()
team = another_user |> subscribe_to_business_plan() |> team_of()
params = %{
"api_key" => %{
"name" => "Some key",
"key" => Ecto.UUID.generate(),
"scope" => "stats:read:*",
"user_id" => "#{another_user.id}"
}
}
conn = post(conn, "/crm/auth/api_key", params)
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
assert redirected_to(conn, 302) == "/crm/auth/api_key"
assert api_key.team_id == team.id
assert api_key.user_id == another_user.id
end
@tag :kaffy_quirks
test "Creates personal team when creating the api key if there's none", %{
conn: conn,
user: user
} do
patch_env(:super_admin_user_ids, [user.id])
another_user = new_user()
another_team = new_site().team |> Teams.complete_setup()
add_member(another_team, user: another_user, role: :owner)
params = %{
"api_key" => %{
"name" => "Some key",
"key" => Ecto.UUID.generate(),
"scopes" => Jason.encode!(["stats:read:*"]),
"user_id" => "#{another_user.id}"
}
}
conn = post(conn, "/crm/auth/api_key", params)
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
assert {:ok, personal_team} = Teams.get_owned_team(another_user, only_not_setup?: true)
assert redirected_to(conn, 302) == "/crm/auth/api_key"
assert api_key.team_id == personal_team.id
end
@tag :kaffy_quirks
test "Creates team for a particular team if provided", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
another_user = new_user() |> subscribe_to_business_plan()
another_team = new_site().team |> Teams.complete_setup()
add_member(another_team, user: another_user, role: :owner)
params = %{
"api_key" => %{
"team_identifier" => another_team.identifier,
"name" => "Some key",
"key" => Ecto.UUID.generate(),
"scopes" => Jason.encode!(["stats:read:*"]),
"user_id" => "#{another_user.id}"
}
}
conn = post(conn, "/crm/auth/api_key", params)
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
assert redirected_to(conn, 302) == "/crm/auth/api_key"
assert api_key.team_id == another_team.id
end
end
describe "PUT /crm/auth/api_key/:id" do
setup [:create_user, :log_in]
@tag :ee_only
test "updates an API key", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
another_user = new_user()
team = another_user |> subscribe_to_business_plan() |> team_of()
api_key = insert(:api_key, user: user, team: team)
assert api_key.scopes == ["stats:read:*"]
params = %{
"api_key" => %{
"name" => "Some key",
"key" => Ecto.UUID.generate(),
"scopes" => Jason.encode!(["sites:provision:*"]),
"user_id" => "#{another_user.id}"
}
}
conn = put(conn, "/crm/auth/api_key/#{api_key.id}", params)
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
assert redirected_to(conn, 302) == "/crm/auth/api_key"
assert api_key.team_id == team.id
assert api_key.scopes == ["sites:provision:*"]
end
@tag :ee_only
test "leaves legacy API key without a team on update", %{conn: conn, user: user} do
patch_env(:super_admin_user_ids, [user.id])
another_user = new_user()
_team = another_user |> subscribe_to_business_plan() |> team_of()
api_key = insert(:api_key, user: user)
assert api_key.scopes == ["stats:read:*"]
params = %{
"api_key" => %{
"name" => "Some key",
"key" => Ecto.UUID.generate(),
"scopes" => Jason.encode!(["sites:provision:*"]),
"user_id" => "#{another_user.id}"
}
}
conn = put(conn, "/crm/auth/api_key/#{api_key.id}", params)
assert api_key = Repo.get_by(Plausible.Auth.ApiKey, user_id: another_user.id)
assert redirected_to(conn, 302) == "/crm/auth/api_key"
refute api_key.team_id
assert api_key.scopes == ["sites:provision:*"]
end
end
end end

View File

@ -74,6 +74,35 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
} }
} }
end end
test "shows only one team for team scoped key", %{conn: conn, user: user} do
user |> subscribe_to_business_plan()
personal_team = team_of(user)
another_team = new_site().team |> Plausible.Teams.complete_setup()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: personal_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn = get(conn, "/api/v1/sites/teams")
assert json_response(conn, 200) == %{
"teams" => [
%{
"id" => personal_team.identifier,
"name" => "My Personal Sites",
"api_available" => true
}
],
"meta" => %{
"before" => nil,
"after" => nil,
"limit" => 100
}
}
end
end end
describe "POST /api/v1/sites" do describe "POST /api/v1/sites" do
@ -129,6 +158,31 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == team.id assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == team.id
end end
test "creates under a particular team when team-scoped key used", %{conn: conn, user: user} do
personal_team = user |> subscribe_to_business_plan() |> team_of()
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn =
post(conn, "/api/v1/sites", %{
# is ignored
"team_id" => personal_team.identifier,
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
}
assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == another_team.id
end
test "timezone is validated", %{conn: conn} do test "timezone is validated", %{conn: conn} do
conn = conn =
post(conn, "/api/v1/sites", %{ post(conn, "/api/v1/sites", %{
@ -244,6 +298,21 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert json_response(conn, 404) == %{"error" => "Site could not be found"} assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end end
test "cannot delete if team not matching team-scoped API key", %{
conn: conn,
user: user,
site: site
} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
@ -312,6 +381,22 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert %{"url" => ^url} = json_response(conn, 200) assert %{"url" => ^url} = json_response(conn, 200)
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "WordPress"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "returns 400 when site id missing", %{conn: conn} do test "returns 400 when site id missing", %{conn: conn} do
conn = conn =
put(conn, "/api/v1/sites/shared-links", %{ put(conn, "/api/v1/sites/shared-links", %{
@ -459,6 +544,23 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert %{"id" => ^goal_id} = json_response(conn, 200) assert %{"id" => ^goal_id} = json_response(conn, 200)
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "returns 400 when site id missing", %{conn: conn} do test "returns 400 when site id missing", %{conn: conn} do
conn = conn =
put(conn, "/api/v1/sites/goals", %{ put(conn, "/api/v1/sites/goals", %{
@ -580,6 +682,30 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert json_response(conn, 200) == %{"deleted" => true} assert json_response(conn, 200) == %{"deleted" => true}
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
conn1 =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
%{"id" => goal_id} = json_response(conn1, 200)
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn =
delete(conn, "/api/v1/sites/goals/#{goal_id}", %{
site_id: site.domain
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "is 404 when goal cannot be found", %{conn: conn, site: site} do test "is 404 when goal cannot be found", %{conn: conn, site: site} do
conn = conn =
delete(conn, "/api/v1/sites/goals/0", %{ delete(conn, "/api/v1/sites/goals/0", %{
@ -690,6 +816,32 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
} = json_response(conn, 200) } = json_response(conn, 200)
end end
test "implicitly scopes to a team for a team-scoped key", %{
conn: conn,
user: user
} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
site = new_site(team: another_team)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
_owned_site = new_site(owner: user)
other_site = new_site()
add_guest(other_site, user: user, role: :viewer)
other_team_site = new_site()
add_member(other_team_site.team, user: user, role: :viewer)
# `team_id` paramaeter is ignored
conn = get(conn, "/api/v1/sites?team_id=" <> other_team_site.team.identifier)
assert_matches %{
"sites" => [
%{"domain" => ^site.domain}
]
} = json_response(conn, 200)
end
test "handles pagination correctly", %{conn: conn, user: user} do test "handles pagination correctly", %{conn: conn, user: user} do
[ [
%{domain: site1_domain}, %{domain: site1_domain},
@ -827,6 +979,22 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert is_binary(before_cursor) assert is_binary(before_cursor)
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
site = new_site(owner: user)
_guest = add_guest(site, site: site, role: :editor)
conn = get(conn, "/api/v1/sites/guests?site_id=#{site.domain}")
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
end end
describe "PUT /api/v1/sites/guests" do describe "PUT /api/v1/sites/guests" do
@ -900,6 +1068,24 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert_no_emails_delivered() assert_no_emails_delivered()
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user} do
site = new_site(owner: user)
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn =
put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{
"role" => "viewer",
"email" => "test@example.com"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "fails for unknown role", %{conn: conn, user: user} do test "fails for unknown role", %{conn: conn, user: user} do
site = new_site(owner: user) site = new_site(owner: user)
@ -970,6 +1156,20 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert json_response(conn2, 200) == %{"deleted" => true} assert json_response(conn2, 200) == %{"deleted" => true}
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user} do
site = new_site(owner: user)
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn = delete(conn, "/api/v1/sites/guests/test@example.com?site_id=#{site.domain}")
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "won't delete non-guest membership", %{conn: conn, user: user} do test "won't delete non-guest membership", %{conn: conn, user: user} do
site = new_site(owner: user) site = new_site(owner: user)
@ -1031,6 +1231,18 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
} }
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
conn = get(conn, "/api/v1/sites/" <> site.domain)
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
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")
@ -1164,6 +1376,20 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert %{"goals" => [%{"id" => ^goal_id}]} = json_response(conn, 200) assert %{"goals" => [%{"id" => ^goal_id}]} = json_response(conn, 200)
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
_goal = insert(:goal, %{site: site, page_path: "/login"})
conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain)
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "returns error when `site_id` parameter is missing", %{conn: conn} do test "returns error when `site_id` parameter is missing", %{conn: conn} do
conn = get(conn, "/api/v1/sites/goals") conn = get(conn, "/api/v1/sites/goals")
@ -1214,6 +1440,24 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert site.domain_changed_from == old_domain assert site.domain_changed_from == old_domain
end end
test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do
another_team = new_user() |> subscribe_to_business_plan() |> team_of()
add_member(another_team, user: user, role: :admin)
api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
old_domain = site.domain
assert old_domain != "new.example.com"
conn =
put(conn, "/api/v1/sites/#{old_domain}", %{
"domain" => "new.example.com"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "can't make a no-op change", %{conn: conn, site: site} do test "can't make a no-op change", %{conn: conn, site: site} do
conn = conn =
put(conn, "/api/v1/sites/#{site.domain}", %{ put(conn, "/api/v1/sites/#{site.domain}", %{

View File

@ -1030,6 +1030,8 @@ defmodule PlausibleWeb.SettingsControllerTest do
test "can create an API key", %{conn: conn, user: user} do test "can create an API key", %{conn: conn, user: user} do
new_site(owner: user) new_site(owner: user)
team = team_of(user)
conn = conn =
post(conn, Routes.settings_path(conn, :api_keys), %{ post(conn, Routes.settings_path(conn, :api_keys), %{
"api_key" => %{ "api_key" => %{
@ -1042,6 +1044,31 @@ defmodule PlausibleWeb.SettingsControllerTest do
key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one()
assert conn.status == 302 assert conn.status == 302
assert key.name == "all your code are belong to us" assert key.name == "all your code are belong to us"
assert key.team_id == team.id
end
test "can create an API key when switched to another team", %{conn: conn, user: user} do
new_site(owner: user)
team = new_site().team |> Plausible.Teams.complete_setup()
add_member(team, user: user, role: :editor)
conn = set_current_team(conn, team)
conn =
post(conn, Routes.settings_path(conn, :api_keys), %{
"api_key" => %{
"user_id" => user.id,
"name" => "all your code are belong to us",
"key" => "swordfish"
}
})
key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one()
assert conn.status == 302
assert key.name == "all your code are belong to us"
assert key.team_id == team.id
end end
test "cannot create a duplicate API key", %{conn: conn, user: user} do test "cannot create a duplicate API key", %{conn: conn, user: user} do
@ -1095,10 +1122,11 @@ defmodule PlausibleWeb.SettingsControllerTest do
test "can't delete api key that doesn't belong to me", %{conn: conn} do test "can't delete api key that doesn't belong to me", %{conn: conn} do
other_user = new_user() other_user = new_user()
new_site(owner: other_user) new_site(owner: other_user)
team = team_of(other_user)
assert {:ok, %ApiKey{} = api_key} = assert {:ok, %ApiKey{} = api_key} =
%ApiKey{user_id: other_user.id} %ApiKey{user_id: other_user.id}
|> ApiKey.changeset(%{"name" => "other user's key"}) |> ApiKey.changeset(team, %{"name" => "other user's key"})
|> Repo.insert() |> Repo.insert()
conn = delete(conn, Routes.settings_path(conn, :delete_api_key, api_key.id)) conn = delete(conn, Routes.settings_path(conn, :delete_api_key, api_key.id))

View File

@ -287,6 +287,47 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPITest do
assert conn.assigns.site.id == site.id assert conn.assigns.site.id == site.id
end end
test "passes for team-bound API key when team matches", %{conn: conn} do
user = new_user()
_site = new_site(owner: user)
another_site = new_site()
another_team = another_site.team |> Plausible.Teams.complete_setup()
add_member(another_team, user: user, role: :editor)
api_key = insert(:api_key, user: user, team: another_team)
conn =
conn
|> put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/", %{"site_id" => another_site.domain})
|> assign(:api_scope, "stats:read:*")
|> AuthorizePublicAPI.call(nil)
refute conn.halted
assert conn.assigns.current_team.id == another_team.id
assert conn.assigns.current_user.id == user.id
assert conn.assigns.site.id == another_site.id
end
test "halts for team-bound API key when team does not match", %{conn: conn} do
user = new_user()
site = new_site(owner: user)
another_site = new_site()
another_team = another_site.team |> Plausible.Teams.complete_setup()
add_member(another_team, user: user, role: :editor)
api_key = insert(:api_key, user: user, team: another_team)
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
@tag :ee_only @tag :ee_only
test "passes for super admin user even if not a member of the requested site", %{conn: conn} do test "passes for super admin user even if not a member of the requested site", %{conn: conn} do
user = new_user() user = new_user()

View File

@ -75,7 +75,8 @@ defmodule Plausible.TestUtils do
end end
def create_api_key(%{user: user}) do def create_api_key(%{user: user}) do
api_key = Factory.insert(:api_key, user: user) team = Plausible.Teams.Test.team_of(user)
api_key = Factory.insert(:api_key, user: user, team: team)
{:ok, api_key: api_key.key} {:ok, api_key: api_key.key}
end end

View File

@ -17,7 +17,7 @@ if :minio in Keyword.fetch!(ExUnit.configuration(), :include) do
Plausible.TestUtils.ensure_minio() Plausible.TestUtils.ensure_minio()
end end
default_exclude = [:slow, :minio, :migrations] default_exclude = [:slow, :minio, :migrations, :kaffy_quirks]
# avoid slowdowns contacting the code server https://github.com/sasa1977/con_cache/pull/79 # avoid slowdowns contacting the code server https://github.com/sasa1977/con_cache/pull/79
:code.ensure_loaded(ConCache.Lock.Resource) :code.ensure_loaded(ConCache.Lock.Resource)