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:
parent
8d98a75cd5
commit
94e9a20038
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
def update_changeset(schema, attrs) do
|
||||
Plausible.Auth.ApiKey.update(schema, attrs)
|
||||
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(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("""
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
case Repo.one(query) do
|
||||
nil ->
|
||||
{:error, :invalid_api_key}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_api_key(raw_key, :site, nil) do
|
||||
with {:ok, api_key} <- find_api_key(raw_key, nil, nil) do
|
||||
%{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
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}", %{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue