diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml
index e815ead78e..7dfb10f30a 100644
--- a/.github/workflows/elixir.yml
+++ b/.github/workflows/elixir.yml
@@ -102,7 +102,7 @@ jobs:
- run: make minio
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'
env:
MINIO_HOST_FOR_CLICKHOUSE: "172.17.0.1"
diff --git a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex
index 4e3a0bb930..a1c0fa0942 100644
--- a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex
+++ b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex
@@ -14,8 +14,8 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
@pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000]
def index(conn, params) do
- team = Teams.get(params["team_id"])
user = conn.assigns.current_user
+ team = conn.assigns.current_team || Teams.get(params["team_id"])
page =
user
@@ -30,9 +30,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def guests_index(conn, params) do
user = conn.assigns.current_user
+ team = conn.assigns.current_team
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]
page = site |> Sites.list_guests_query() |> paginate(params, opts)
@@ -54,10 +55,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def teams_index(conn, params) do
user = conn.assigns.current_user
+ team = conn.assigns.current_team
page =
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)
json(conn, %{
@@ -78,9 +80,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def goals_index(conn, params) do
user = conn.assigns.current_user
+ team = conn.assigns.current_team
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 =
site
|> Plausible.Goals.for_site_query()
@@ -110,7 +113,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
def create_site(conn, params) do
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
{:ok, %{site: site}} ->
@@ -146,7 +149,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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} ->
json(conn, %{
domain: site.domain,
@@ -160,7 +166,10 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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, _} = Plausible.Site.Removal.run(site)
json(conn, %{"deleted" => true})
@@ -171,8 +180,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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
- 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
json(conn, site)
else
@@ -187,10 +199,13 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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"),
{:ok, email} <- expect_param_key(params, "email"),
{: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))
if existing do
@@ -230,9 +245,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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"),
{: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))
case existing do
@@ -262,9 +280,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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"),
{: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 =
@@ -296,9 +317,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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"),
{: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
json(conn, goal)
else
@@ -314,9 +338,12 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
end
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"),
{: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
json(conn, %{"deleted" => true})
else
@@ -345,10 +372,19 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
}
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
- nil -> {:error, :site_not_found}
- site -> {:ok, site}
+ nil ->
+ {: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
diff --git a/lib/plausible/auth/api_key.ex b/lib/plausible/auth/api_key.ex
index b6834d32fb..8b40f81470 100644
--- a/lib/plausible/auth/api_key.ex
+++ b/lib/plausible/auth/api_key.ex
@@ -18,6 +18,7 @@ defmodule Plausible.Auth.ApiKey do
field :key_hash, :string
field :key_prefix, :string
+ belongs_to :team, Plausible.Teams.Team
belongs_to :user, Plausible.Auth.User
timestamps()
@@ -25,17 +26,24 @@ defmodule Plausible.Auth.ApiKey do
def hourly_request_limit(), do: @hourly_request_limit
- def changeset(schema, attrs \\ %{}) do
- schema
+ def changeset(struct, team, attrs) do
+ struct
|> cast(attrs, @required ++ @optional)
|> validate_required(@required)
|> maybe_put_key()
|> process_key()
+ |> maybe_put_team(team)
|> unique_constraint(:key_hash, error_key: :key)
+ |> unique_constraint([:team_id, :user_id], error_key: :team)
end
- def update(schema, attrs \\ %{}) do
- schema
+ # NOTE: needed only because of lacking introspection in Kaffy
+ def changeset(struct, attrs) do
+ changeset(struct, nil, attrs)
+ end
+
+ def update(struct, attrs \\ %{}) do
+ struct
|> cast(attrs, [:name, :user_id, :scopes])
|> validate_required([:user_id, :name])
end
@@ -57,6 +65,12 @@ defmodule Plausible.Auth.ApiKey do
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
if get_change(changeset, :key) do
changeset
diff --git a/lib/plausible/auth/api_key_admin.ex b/lib/plausible/auth/api_key_admin.ex
index d87d7ffdc3..09b6bb4a44 100644
--- a/lib/plausible/auth/api_key_admin.ex
+++ b/lib/plausible/auth/api_key_admin.ex
@@ -1,35 +1,76 @@
defmodule Plausible.Auth.ApiKeyAdmin do
+ @moduledoc """
+ Stats and Sites API key logic for CRM.
+ """
use Plausible.Repo
+ alias Plausible.Auth
+ alias Plausible.Teams
+
def search_fields(_schema) do
[
:name,
- user: [:name, :email]
+ user: [:name, :email],
+ team: [:name, :identifier]
]
end
def custom_index_query(_conn, _schema, query) do
- from(r in query, preload: [:user])
+ from(r in query, preload: [:user, team: :owners])
end
def create_changeset(schema, attrs) do
- scopes = [attrs["scope"]]
- Plausible.Auth.ApiKey.changeset(struct(schema, %{}), Map.merge(%{"scopes" => scopes}, attrs))
+ team = Teams.get(attrs["team_identifier"])
+
+ 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
- def update_changeset(schema, attrs) do
- Plausible.Auth.ApiKey.update(schema, attrs)
+ def update_changeset(entry, attrs) do
+ Auth.ApiKey.update(entry, attrs)
end
@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
"""
+
+ @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
[
name: nil,
key: %{create: :readonly, update: :hidden, help_text: @plaintext_key_help},
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
]
end
@@ -39,7 +80,55 @@ defmodule Plausible.Auth.ApiKeyAdmin do
key_prefix: nil,
name: nil,
scopes: nil,
- owner: %{value: & &1.user.email}
+ owner: %{value: &get_owner/1},
+ team: %{value: &get_team/1}
]
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("""
+ #{team_name}
+ """)
+ 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 =
+ """
+ #{escaped_name}
+
+ #{escaped_email}
+ """
+
+ {:safe, owner_html}
+ end
+
+ defp html_escape(string) do
+ string
+ |> Phoenix.HTML.html_escape()
+ |> Phoenix.HTML.safe_to_string()
+ end
end
diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex
index 5771261bab..143d1f6b9e 100644
--- a/lib/plausible/auth/auth.ex
+++ b/lib/plausible/auth/auth.ex
@@ -5,7 +5,11 @@ defmodule Plausible.Auth do
use Plausible
use Plausible.Repo
+
+ import Ecto.Query
+
alias Plausible.Auth
+ alias Plausible.Billing
alias Plausible.RateLimit
alias Plausible.Teams
@@ -154,13 +158,25 @@ defmodule Plausible.Auth do
def is_super_admin?(_), do: false
end
- @spec create_api_key(Auth.User.t(), String.t(), String.t()) ::
- {:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required}
- def create_api_key(user, name, key) do
- params = %{name: name, user_id: user.id, key: key}
- changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params)
+ @spec list_api_keys(Auth.User.t(), Teams.Team.t() | nil) :: [Auth.ApiKey.t()]
+ def list_api_keys(user, team) do
+ query =
+ from(a in Auth.ApiKey,
+ 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)
end
end
@@ -176,7 +192,7 @@ defmodule Plausible.Auth do
end
@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}
def find_api_key(raw_key, opts \\ []) do
{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)
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
hashed_key = Auth.ApiKey.do_hash(raw_key)
query =
from(api_key in Auth.ApiKey,
join: user in assoc(api_key, :user),
+ left_join: team in assoc(api_key, :team),
where: api_key.key_hash == ^hashed_key,
- preload: [user: user]
+ preload: [user: user, team: team]
)
- if found = Repo.one(query) do
- {:ok, found}
- else
- {:error, :invalid_api_key}
+ case Repo.one(query) do
+ nil ->
+ {: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
defp find_api_key(raw_key, :site, nil) do
- with {:ok, api_key} <- find_api_key(raw_key, nil, nil) do
- {:ok, %{api_key: api_key, team: nil}}
- end
+ find_api_key(raw_key, nil, nil)
end
defp find_api_key(raw_key, :site, domain) do
- with {:ok, api_key} <- find_api_key(raw_key, nil, nil) do
- team = find_team_by_site(domain)
- {:ok, %{api_key: api_key, team: team}}
+ case find_api_key(raw_key, nil, nil) do
+ {:ok, %{api_key: api_key, team: nil}} ->
+ 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
@@ -223,21 +258,6 @@ defmodule Plausible.Auth do
)
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(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn)
end
diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex
index 07e26b1d55..9cf60f3a80 100644
--- a/lib/plausible/teams.ex
+++ b/lib/plausible/teams.ex
@@ -42,7 +42,13 @@ defmodule Plausible.Teams do
end
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
@spec get!(pos_integer() | binary()) :: Teams.Team.t()
@@ -291,12 +297,14 @@ defmodule Plausible.Teams do
end
end
+ @spec last_subscription_join_query() :: Ecto.Query.t()
def last_subscription_join_query() do
from(subscription in last_subscription_query(),
where: subscription.team_id == parent_as(:team).id
)
end
+ @spec last_subscription_query() :: Ecto.Query.t()
def last_subscription_query() do
from(subscription in Plausible.Billing.Subscription,
order_by: [desc: subscription.inserted_at, desc: subscription.id],
@@ -304,7 +312,9 @@ defmodule Plausible.Teams do
)
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)
query =
diff --git a/lib/plausible_web/controllers/settings_controller.ex b/lib/plausible_web/controllers/settings_controller.ex
index 1a6f80896a..e7ef51d4ee 100644
--- a/lib/plausible_web/controllers/settings_controller.ex
+++ b/lib/plausible_web/controllers/settings_controller.ex
@@ -92,21 +92,26 @@ defmodule PlausibleWeb.SettingsController do
def api_keys(conn, _params) do
current_user = conn.assigns.current_user
+ current_team = conn.assigns[:current_team]
- api_keys =
- Repo.preload(current_user, :api_keys).api_keys
+ api_keys = Auth.list_api_keys(current_user, current_team)
render(conn, :api_keys, layout: {PlausibleWeb.LayoutView, :settings}, api_keys: api_keys)
end
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)
end
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} ->
conn
|> put_flash(:success, "API key created successfully")
diff --git a/lib/plausible_web/plugs/authorize_public_api.ex b/lib/plausible_web/plugs/authorize_public_api.ex
index 4a7788d92b..1d5b4f9e15 100644
--- a/lib/plausible_web/plugs/authorize_public_api.ex
+++ b/lib/plausible_web/plugs/authorize_public_api.ex
@@ -55,7 +55,9 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
{:ok, api_key, limit_key, hourly_limit} <- find_api_key(conn, token, context),
:ok <- check_api_key_rate_limit(limit_key, hourly_limit),
{: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
error -> send_error(conn, requested_scope, error)
end
@@ -97,8 +99,15 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
end
defp find_api_key(_conn, token, _) do
- with {:ok, api_key} <- Auth.find_api_key(token) do
- {:ok, api_key, limit_key(api_key, nil), Auth.ApiKey.hourly_request_limit()}
+ case Auth.find_api_key(token) do
+ {: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
@@ -189,6 +198,9 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do
is_super_admin? ->
:ok
+ api_key.team_id && api_key.team_id != site.team_id ->
+ {:error, :invalid_api_key}
+
Sites.locked?(site) ->
{:error, :site_locked}
diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex
index 67f33ee1db..4d8730ad36 100644
--- a/lib/plausible_web/views/layout_view.ex
+++ b/lib/plausible_web/views/layout_view.ex
@@ -111,7 +111,9 @@ defmodule PlausibleWeb.LayoutView do
if(not Teams.setup?(current_team),
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}
]
|> Enum.reject(&is_nil/1)
@@ -127,6 +129,7 @@ defmodule PlausibleWeb.LayoutView do
if(current_team_role in [:owner, :admin, :billing],
do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes}
),
+ %{key: "API Keys", value: "api-keys", icon: :key},
if(current_team_role == :owner,
do: %{key: "Danger Zone", value: "team/delete", icon: :exclamation_triangle}
)
diff --git a/test/plausible/auth/auth_test.exs b/test/plausible/auth/auth_test.exs
index 3d72c134a6..f4834afc5c 100644
--- a/test/plausible/auth/auth_test.exs
+++ b/test/plausible/auth/auth_test.exs
@@ -21,16 +21,21 @@ defmodule Plausible.AuthTest do
describe "create_api_key/3" do
test "creates a new api key" do
user = new_user(trial_expiry_date: Date.utc_today())
+ team = team_of(user)
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
test "errors when key already exists" do
u1 = new_user(trial_expiry_date: Date.utc_today())
+ t1 = team_of(u1)
u2 = new_user(trial_expiry_date: Date.utc_today())
+ t2 = team_of(u2)
key = Ecto.UUID.generate()
- assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(u1, "my new key", key)
- assert {:error, changeset} = Auth.create_api_key(u2, "my other key", key)
+ assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(u1, t1, "my new key", key)
+ assert {:error, changeset} = Auth.create_api_key(u2, t2, "my other key", key)
assert changeset.errors[:key] ==
{"has already been taken",
@@ -38,27 +43,30 @@ defmodule Plausible.AuthTest do
end
@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()
+ team = team_of(user)
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
- test "creates a key for user on a growth plan when they are an owner of more than one team" do
- user = new_user() |> subscribe_to_growth_plan()
+ test "creates a key for user in a team with a bunsiness plan" do
+ user = new_user() |> subscribe_to_business_plan()
+ team = team_of(user)
another_site = new_site()
add_member(another_site.team, user: user, role: :owner)
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
describe "delete_api_key/2" do
test "deletes the record" do
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)
refute Plausible.Repo.reload(api_key)
end
@@ -67,7 +75,10 @@ defmodule Plausible.AuthTest do
me = 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, -1)
diff --git a/test/plausible_web/controllers/admin_controller_test.exs b/test/plausible_web/controllers/admin_controller_test.exs
index 127e2498de..2e16f5955e 100644
--- a/test/plausible_web/controllers/admin_controller_test.exs
+++ b/test/plausible_web/controllers/admin_controller_test.exs
@@ -3,6 +3,7 @@ defmodule PlausibleWeb.AdminControllerTest do
use Plausible.Teams.Test
alias Plausible.Repo
+ alias Plausible.Teams
describe "GET /crm/teams/team/:team_id/usage" do
setup [:create_user, :log_in, :create_team]
@@ -269,4 +270,158 @@ defmodule PlausibleWeb.AdminControllerTest do
}
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
diff --git a/test/plausible_web/controllers/api/external_sites_controller_test.exs b/test/plausible_web/controllers/api/external_sites_controller_test.exs
index 61fe3f1227..37526ad688 100644
--- a/test/plausible_web/controllers/api/external_sites_controller_test.exs
+++ b/test/plausible_web/controllers/api/external_sites_controller_test.exs
@@ -74,6 +74,35 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
}
}
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
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
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
conn =
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"}
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
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)
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
conn =
put(conn, "/api/v1/sites/shared-links", %{
@@ -459,6 +544,23 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert %{"id" => ^goal_id} = json_response(conn, 200)
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
conn =
put(conn, "/api/v1/sites/goals", %{
@@ -580,6 +682,30 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert json_response(conn, 200) == %{"deleted" => true}
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
conn =
delete(conn, "/api/v1/sites/goals/0", %{
@@ -690,6 +816,32 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
} = json_response(conn, 200)
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
[
%{domain: site1_domain},
@@ -827,6 +979,22 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert is_binary(before_cursor)
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
describe "PUT /api/v1/sites/guests" do
@@ -900,6 +1068,24 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert_no_emails_delivered()
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
site = new_site(owner: user)
@@ -970,6 +1156,20 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert json_response(conn2, 200) == %{"deleted" => true}
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
site = new_site(owner: user)
@@ -1031,6 +1231,18 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
}
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
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)
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
conn = get(conn, "/api/v1/sites/goals")
@@ -1214,6 +1440,24 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert site.domain_changed_from == old_domain
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
conn =
put(conn, "/api/v1/sites/#{site.domain}", %{
diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs
index 6aac420b09..9eb1fef9eb 100644
--- a/test/plausible_web/controllers/settings_controller_test.exs
+++ b/test/plausible_web/controllers/settings_controller_test.exs
@@ -1030,6 +1030,8 @@ defmodule PlausibleWeb.SettingsControllerTest do
test "can create an API key", %{conn: conn, user: user} do
new_site(owner: user)
+ team = team_of(user)
+
conn =
post(conn, Routes.settings_path(conn, :api_keys), %{
"api_key" => %{
@@ -1042,6 +1044,31 @@ defmodule PlausibleWeb.SettingsControllerTest do
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
+
+ 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
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
other_user = new_user()
new_site(owner: other_user)
+ team = team_of(other_user)
assert {:ok, %ApiKey{} = api_key} =
%ApiKey{user_id: other_user.id}
- |> ApiKey.changeset(%{"name" => "other user's key"})
+ |> ApiKey.changeset(team, %{"name" => "other user's key"})
|> Repo.insert()
conn = delete(conn, Routes.settings_path(conn, :delete_api_key, api_key.id))
diff --git a/test/plausible_web/plugs/authorize_public_api_test.exs b/test/plausible_web/plugs/authorize_public_api_test.exs
index ec6cb8c9a0..aaf41853fb 100644
--- a/test/plausible_web/plugs/authorize_public_api_test.exs
+++ b/test/plausible_web/plugs/authorize_public_api_test.exs
@@ -287,6 +287,47 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPITest do
assert conn.assigns.site.id == site.id
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
test "passes for super admin user even if not a member of the requested site", %{conn: conn} do
user = new_user()
diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex
index fc00a9c2e6..04104242e0 100644
--- a/test/support/test_utils.ex
+++ b/test/support/test_utils.ex
@@ -75,7 +75,8 @@ defmodule Plausible.TestUtils do
end
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}
end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index fef0ce84c6..1fbe5e797d 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -17,7 +17,7 @@ if :minio in Keyword.fetch!(ExUnit.configuration(), :include) do
Plausible.TestUtils.ensure_minio()
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
:code.ensure_loaded(ConCache.Lock.Resource)