diff --git a/extra/lib/plausible/auth/sso.ex b/extra/lib/plausible/auth/sso.ex
index ecebc0a774..90c3fc6576 100644
--- a/extra/lib/plausible/auth/sso.ex
+++ b/extra/lib/plausible/auth/sso.ex
@@ -8,11 +8,14 @@ defmodule Plausible.Auth.SSO do
alias Plausible.Auth
alias Plausible.Auth.SSO
+ alias Plausible.Billing.Subscription
alias Plausible.Repo
alias Plausible.Teams
use Plausible.Auth.SSO.Domain.Status
+ require Plausible.Billing.Subscription.Status
+
@type policy_attr() ::
{:sso_default_role, Teams.Policy.sso_member_role()}
| {:sso_session_timeout_minutes, non_neg_integer()}
@@ -74,7 +77,7 @@ defmodule Plausible.Auth.SSO do
@spec provision_user(SSO.Identity.t()) ::
{:ok, :standard | :sso | :integration, Teams.Team.t(), Auth.User.t()}
| {:error, :integration_not_found | :over_limit}
- | {:error, :multiple_memberships, Teams.Team.t(), Auth.User.t()}
+ | {:error, :multiple_memberships | :active_personal_team, Teams.Team.t(), Auth.User.t()}
def provision_user(identity) do
case find_user(identity) do
{:ok, :standard, user, integration, domain} ->
@@ -394,6 +397,7 @@ defmodule Plausible.Auth.SSO do
with :ok <- ensure_team_member(integration.team, user),
:ok <- ensure_one_membership(user, integration.team),
+ :ok <- ensure_empty_personal_team(user, integration.team),
:ok <- Auth.UserSessions.revoke_all(user),
{:ok, user} <- Repo.update(changeset) do
{:ok, :standard, integration.team, user}
@@ -467,12 +471,31 @@ defmodule Plausible.Auth.SSO do
end
defp ensure_one_membership(user, team) do
- query = Teams.Users.teams_query(user)
-
- if Repo.aggregate(query, :count) > 1 do
+ if Teams.Users.team_member?(user, except: [team.id], only_setup?: true) do
{:error, :multiple_memberships, team, user}
else
:ok
end
end
+
+ defp ensure_empty_personal_team(user, team) do
+ case Teams.get_by_owner(user, only_not_setup?: true) do
+ {:ok, personal_team} ->
+ subscription = Teams.Billing.get_subscription(personal_team)
+
+ no_subscription? =
+ is_nil(subscription) or subscription.status == Subscription.Status.deleted()
+
+ zero_sites? = Teams.owned_sites_count(personal_team) == 0
+
+ if no_subscription? and zero_sites? do
+ :ok
+ else
+ {:error, :active_personal_team, team, user}
+ end
+
+ {:error, :no_team} ->
+ :ok
+ end
+ end
end
diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex
index 04f505b94b..4ea2b84cf9 100644
--- a/lib/plausible/auth/auth.ex
+++ b/lib/plausible/auth/auth.ex
@@ -57,9 +57,23 @@ defmodule Plausible.Auth do
Repo.get_by(Auth.User, opts)
end
- @spec get_user_by(Keyword.t()) :: {:ok, Auth.User.t()} | {:error, :user_not_found}
- def get_user_by(opts) do
- case Repo.get_by(Auth.User, opts) do
+ @spec lookup(String.t()) :: {:ok, Auth.User.t()} | {:error, :user_not_found}
+ def lookup(email) do
+ query =
+ on_ee do
+ from(
+ u in Auth.User,
+ left_join: tm in assoc(u, :team_memberships),
+ on: u.type == :sso and tm.role == :owner,
+ left_join: t in assoc(tm, :team),
+ where: u.email == ^email,
+ where: u.type == :standard or (u.type == :sso and t.setup_complete == true)
+ )
+ else
+ from(u in Auth.User, where: u.email == ^email)
+ end
+
+ case Repo.one(query) do
%Auth.User{} = user -> {:ok, user}
nil -> {:error, :user_not_found}
end
diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex
index ea20294e08..42f6f1bef6 100644
--- a/lib/plausible/teams.ex
+++ b/lib/plausible/teams.ex
@@ -141,13 +141,12 @@ defmodule Plausible.Teams do
If the user already has an owner membership in an existing team,
that team is returned.
-
- If the user has a non-guest membership other than owner, `:no_team` error
- is returned.
"""
- @spec get_or_create(Auth.User.t()) :: {:ok, Teams.Team.t()} | {:error, :multiple_teams}
+ @spec get_or_create(Auth.User.t()) ::
+ {:ok, Teams.Team.t()} | {:error, :multiple_teams | :permission_denied}
def get_or_create(user) do
- with {:error, :no_team} <- get_owned_team(user, only_not_setup?: true) do
+ with :ok <- check_user_type(user),
+ {:error, :no_team} <- get_owned_team(user, only_not_setup?: true) do
case create_my_team(user) do
{:ok, team} ->
{:ok, team}
@@ -160,6 +159,14 @@ defmodule Plausible.Teams do
@spec force_create_my_team(Auth.User.t()) :: Teams.Team.t()
def force_create_my_team(user) do
+ # This is going to crash hard for SSO user. This shouldn't happen
+ # under normal circumstances except in case of a _very_ unlucky timing.
+ # Manual resolution is necessary anyway.
+ case check_user_type(user) do
+ :ok -> :pass
+ _ -> raise "SSO user tried to force create a personal team"
+ end
+
{:ok, team} =
Repo.transaction(fn ->
clear_autocreated(user)
@@ -212,10 +219,10 @@ defmodule Plausible.Teams do
end
end
- @spec get_by_owner(Auth.User.t()) ::
+ @spec get_by_owner(Auth.User.t(), Keyword.t()) ::
{:ok, Teams.Team.t()} | {:error, :no_team | :multiple_teams}
- def get_by_owner(user) do
- get_owned_team(user)
+ def get_by_owner(user, opts \\ []) do
+ get_owned_team(user, opts)
end
@spec update_accept_traffic_until(Teams.Team.t()) :: Teams.Team.t()
@@ -344,6 +351,14 @@ defmodule Plausible.Teams do
end
end
+ defp check_user_type(user) do
+ if Plausible.Users.type(user) == :sso do
+ {:error, :permission_denied}
+ else
+ :ok
+ end
+ end
+
defp clear_autocreated(user) do
Repo.update_all(
from(tm in Teams.Membership,
diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex
index 07a21f840f..663e4b7b0e 100644
--- a/lib/plausible/teams/invitations.ex
+++ b/lib/plausible/teams/invitations.ex
@@ -252,7 +252,11 @@ defmodule Plausible.Teams.Invitations do
team_invitation = Repo.preload(team_invitation, [:team, :inviter])
now = NaiveDateTime.utc_now(:second)
- do_accept(team_invitation, user, now, guest_invitations: [])
+ if Plausible.Users.type(user) == :sso do
+ {:error, :permission_denied}
+ else
+ do_accept(team_invitation, user, now, guest_invitations: [])
+ end
end
@doc false
diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex
index 60279b89e2..873378bee2 100644
--- a/lib/plausible/teams/memberships.ex
+++ b/lib/plausible/teams/memberships.ex
@@ -46,13 +46,20 @@ defmodule Plausible.Teams.Memberships do
end
end
+ @spec can_add_site?(Teams.Team.t(), Auth.User.t()) :: boolean()
def can_add_site?(team, user) do
- case team_role(team, user) do
- {:ok, role} when role in [:owner, :admin, :editor] ->
- true
+ user_type = Plausible.Users.type(user)
- _ ->
- false
+ role =
+ case team_role(team, user) do
+ {:ok, role} -> role
+ {:error, _} -> :not_a_member
+ end
+
+ case {user_type, role, team} do
+ {:sso, :owner, %{setup_complete: false}} -> false
+ {_, role, _} when role in [:owner, :admin, :editor] -> true
+ _ -> false
end
end
diff --git a/lib/plausible/teams/memberships/remove.ex b/lib/plausible/teams/memberships/remove.ex
index 6019a5bd0e..28bf1eedbf 100644
--- a/lib/plausible/teams/memberships/remove.ex
+++ b/lib/plausible/teams/memberships/remove.ex
@@ -17,7 +17,7 @@ defmodule Plausible.Teams.Memberships.Remove do
{:ok, _} =
Repo.transaction(fn ->
- Repo.delete!(team_membership)
+ delete_membership!(team_membership)
Plausible.Segments.after_user_removed_from_team(
team_membership.team,
@@ -33,6 +33,18 @@ defmodule Plausible.Teams.Memberships.Remove do
end
end
+ defp delete_membership!(team_membership) do
+ user = team_membership.user
+
+ Repo.delete!(team_membership)
+
+ if Plausible.Users.type(user) == :sso do
+ {:ok, :deleted} = Plausible.Auth.delete_user(user)
+ end
+
+ :ok
+ end
+
defp check_can_remove_membership(:owner, _), do: :ok
defp check_can_remove_membership(:admin, role) when role != :owner, do: :ok
defp check_can_remove_membership(_, _), do: {:error, :permission_denied}
diff --git a/lib/plausible/teams/memberships/update_role.ex b/lib/plausible/teams/memberships/update_role.ex
index 867e229e88..b505ef776d 100644
--- a/lib/plausible/teams/memberships/update_role.ex
+++ b/lib/plausible/teams/memberships/update_role.ex
@@ -3,6 +3,8 @@ defmodule Plausible.Teams.Memberships.UpdateRole do
Service for updating role of a team member.
"""
+ use Plausible
+
alias Plausible.Repo
alias Plausible.Teams
alias Plausible.Teams.Memberships
@@ -23,9 +25,9 @@ defmodule Plausible.Teams.Memberships.UpdateRole do
new_role,
granting_to_self?
),
- :ok <- check_owner_can_get_demoted(team, team_membership.role, new_role) do
- team_membership = Repo.preload(team_membership, :user)
-
+ :ok <- check_owner_can_get_demoted(team, team_membership.role, new_role),
+ team_membership = Repo.preload(team_membership, :user),
+ :ok <- check_can_promote_to_owner(team, team_membership.user, new_role) do
if team_membership.role == :guest and new_role != :guest do
team_membership.user.email
|> PlausibleWeb.Email.guest_to_team_member_promotion(
@@ -46,6 +48,29 @@ defmodule Plausible.Teams.Memberships.UpdateRole do
end
end
+ on_ee do
+ defp check_can_promote_to_owner(team, user, :owner) do
+ if team.policy.force_sso == :all_but_owners and not Plausible.Auth.TOTP.enabled?(user) do
+ {:error, :mfa_disabled}
+ else
+ :ok
+ end
+ end
+
+ defp check_can_promote_to_owner(_team, _user, _new_role), do: :ok
+ else
+ defp check_can_promote_to_owner(_team, _user, _new_role) do
+ # The `else` branch is not reachable.
+ # This a workaround for Elixir 1.18+ compiler
+ # being too smart.
+ if :erlang.phash2(1, 1) == 0 do
+ :ok
+ else
+ {:error, :mfa_disabled}
+ end
+ end
+ end
+
defp check_valid_role(role) do
if role in (Teams.Membership.roles() -- [:guest]) do
:ok
diff --git a/lib/plausible/teams/users.ex b/lib/plausible/teams/users.ex
index 45a00e2679..8e92e9ffe7 100644
--- a/lib/plausible/teams/users.ex
+++ b/lib/plausible/teams/users.ex
@@ -63,15 +63,26 @@ defmodule Plausible.Teams.Users do
def team_member?(user, opts \\ []) do
excluded_team_ids = Keyword.get(opts, :except, [])
+ only_setup? = Keyword.get(opts, :only_setup?, false)
- Repo.exists?(
+ query =
from(
tm in Teams.Membership,
where: tm.user_id == ^user.id,
where: tm.role != :guest,
where: tm.team_id not in ^excluded_team_ids
)
- )
+
+ query =
+ if only_setup? do
+ query
+ |> join(:inner, [tm], t in assoc(tm, :team), as: :team)
+ |> where([team: t], t.setup_complete == true)
+ else
+ query
+ end
+
+ Repo.exists?(query)
end
def has_sites?(user, opts \\ []) do
diff --git a/lib/plausible/users.ex b/lib/plausible/users.ex
index e8a2bdb2ea..6c72b73b38 100644
--- a/lib/plausible/users.ex
+++ b/lib/plausible/users.ex
@@ -2,11 +2,32 @@ defmodule Plausible.Users do
@moduledoc """
User context
"""
+ use Plausible
+
import Ecto.Query
alias Plausible.Auth
alias Plausible.Repo
+ on_ee do
+ @spec type(Auth.User.t()) :: :standard | :sso
+ def type(user) do
+ user.type
+ end
+ else
+ @spec type(Auth.User.t()) :: :standard
+ def type(_user) do
+ # The `else` branch is not reachable.
+ # This a workaround for Elixir 1.18+ compiler
+ # being too smart.
+ if :erlang.phash2(1, 1) == 0 do
+ :standard
+ else
+ :sso
+ end
+ end
+ end
+
@spec bump_last_seen(Auth.User.t() | pos_integer(), NaiveDateTime.t()) :: :ok
def bump_last_seen(%Auth.User{id: user_id}, now) do
bump_last_seen(user_id, now)
diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex
index b490acc98d..afd442d6ad 100644
--- a/lib/plausible_web/controllers/auth_controller.ex
+++ b/lib/plausible_web/controllers/auth_controller.ex
@@ -30,7 +30,7 @@ defmodule PlausibleWeb.AuthController do
:activate_form,
:activate,
:request_activation_code,
- :initiate_2fa,
+ :initiate_2fa_setup,
:verify_2fa_setup_form,
:verify_2fa_setup,
:disable_2fa,
@@ -40,6 +40,9 @@ defmodule PlausibleWeb.AuthController do
]
)
+ plug Plausible.Plugs.RestrictUserType,
+ [deny: :sso] when action in [:delete_me, :disable_2fa]
+
plug(
:clear_2fa_user
when action not in [
@@ -169,21 +172,21 @@ defmodule PlausibleWeb.AuthController do
def password_reset_request(conn, %{"email" => email} = params) do
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
- user = Repo.get_by(Plausible.Auth.User, email: email)
+ case Auth.lookup(email) do
+ {:ok, _user} ->
+ token = Auth.Token.sign_password_reset(email)
+ url = PlausibleWeb.Endpoint.url() <> "/password/reset?token=#{token}"
+ email_template = PlausibleWeb.Email.password_reset_email(email, url)
+ Plausible.Mailer.deliver_later(email_template)
- if user do
- token = Auth.Token.sign_password_reset(email)
- url = PlausibleWeb.Endpoint.url() <> "/password/reset?token=#{token}"
- email_template = PlausibleWeb.Email.password_reset_email(email, url)
- Plausible.Mailer.deliver_later(email_template)
+ Logger.debug(
+ "Password reset e-mail sent. In dev environment GET /sent-emails for details."
+ )
- Logger.debug(
- "Password reset e-mail sent. In dev environment GET /sent-emails for details."
- )
+ render(conn, "password_reset_request_success.html", email: email)
- render(conn, "password_reset_request_success.html", email: email)
- else
- render(conn, "password_reset_request_success.html", email: email)
+ {:error, _} ->
+ render(conn, "password_reset_request_success.html", email: email)
end
else
render(conn, "password_reset_request_form.html",
@@ -234,7 +237,7 @@ defmodule PlausibleWeb.AuthController do
def login(conn, %{"email" => email, "password" => password} = params) do
with :ok <- Auth.rate_limit(:login_ip, conn),
- {:ok, user} <- Auth.get_user_by(email: email),
+ {:ok, user} <- Auth.lookup(email),
:ok <- Auth.rate_limit(:login_user, user),
:ok <- Auth.check_password(user, password),
:ok <- check_2fa_verified(conn, user) do
diff --git a/lib/plausible_web/controllers/settings_controller.ex b/lib/plausible_web/controllers/settings_controller.ex
index 1c0da88beb..102c24b952 100644
--- a/lib/plausible_web/controllers/settings_controller.ex
+++ b/lib/plausible_web/controllers/settings_controller.ex
@@ -17,6 +17,9 @@ defmodule PlausibleWeb.SettingsController do
plug Plausible.Plugs.AuthorizeTeamAccess,
[:owner] when action in [:team_danger_zone, :delete_team]
+ plug Plausible.Plugs.RestrictUserType,
+ [deny: :sso] when action in [:update_name, :update_email, :update_password]
+
def index(conn, _params) do
redirect(conn, to: Routes.settings_path(conn, :preferences))
end
diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex
index 2203902ba4..f49c668e64 100644
--- a/lib/plausible_web/live/team_management.ex
+++ b/lib/plausible_web/live/team_management.ex
@@ -314,6 +314,13 @@ defmodule PlausibleWeb.Live.TeamManagement do
"The team has to have at least one owner"
)
+ {{:error, :mfa_disabled}, _} ->
+ socket
+ |> put_live_flash(
+ :error,
+ "User must have 2FA enabled to become an owner"
+ )
+
{{:error, {:over_limit, limit}}, _} ->
socket
|> put_live_flash(
diff --git a/lib/plausible_web/plugs/restrict_user_type.ex b/lib/plausible_web/plugs/restrict_user_type.ex
new file mode 100644
index 0000000000..8b1c2a055d
--- /dev/null
+++ b/lib/plausible_web/plugs/restrict_user_type.ex
@@ -0,0 +1,25 @@
+defmodule Plausible.Plugs.RestrictUserType do
+ @moduledoc """
+ Plug for restricting user access by type.
+ """
+
+ import Plug.Conn
+
+ alias PlausibleWeb.Router.Helpers, as: Routes
+
+ def init(opts) do
+ Keyword.fetch!(opts, :deny)
+ end
+
+ def call(conn, deny_type) do
+ user = conn.assigns[:current_user]
+
+ if user && Plausible.Users.type(user) == deny_type do
+ conn
+ |> Phoenix.Controller.redirect(to: Routes.site_path(conn, :index))
+ |> halt()
+ else
+ conn
+ end
+ end
+end
diff --git a/lib/plausible_web/templates/settings/preferences.html.heex b/lib/plausible_web/templates/settings/preferences.html.heex
index 2623fff2b5..33211c5abe 100644
--- a/lib/plausible_web/templates/settings/preferences.html.heex
+++ b/lib/plausible_web/templates/settings/preferences.html.heex
@@ -1,5 +1,5 @@
<.settings_tiles>
- <.tile>
+ <.tile :if={Plausible.Users.type(@current_user) == :standard}>
<:title>
Your Name
@@ -20,6 +20,18 @@
+ <.tile :if={Plausible.Users.type(@current_user) == :sso}>
+ <:title>
+ Your Name
+
+ <:subtitle>
+ The name associated with your account
+
+ <.form :let={f} for={@name_changeset}>
+ <.input type="text" field={f[:name]} disabled={true} label="Name" width="w-1/2" />
+
+
+
<.tile docs="dashboard-appearance">
<:title>
Dashboard Appearance
diff --git a/lib/plausible_web/templates/settings/security.html.heex b/lib/plausible_web/templates/settings/security.html.heex
index 6081b7e79d..104182ee11 100644
--- a/lib/plausible_web/templates/settings/security.html.heex
+++ b/lib/plausible_web/templates/settings/security.html.heex
@@ -1,5 +1,5 @@
<.settings_tiles>
- <.tile docs="change-email">
+ <.tile :if={Plausible.Users.type(@current_user) == :standard} docs="change-email">
<:title>
Email Address
@@ -31,7 +31,26 @@
- <.tile docs="reset-password">
+ <.tile :if={Plausible.Users.type(@current_user) == :sso}>
+ <:title>
+ Email Address
+
+ <:subtitle>
+ Address associated with your account
+
+ <.form :let={f} for={@email_changeset}>
+ <.input
+ type="text"
+ name="user[current_email]"
+ value={f.data.email}
+ label="Current Email"
+ width="w-1/2"
+ disabled
+ />
+
+
+
+ <.tile :if={Plausible.Users.type(@current_user) == :standard} docs="reset-password">
<:title>
Password
@@ -97,6 +116,7 @@
<.button
+ disabled={Plausible.Users.type(@current_user) == :sso}
x-on:click="disable2FAOpen = true; $refs.disable2FAPassword.value = ''"
theme="danger"
mt?={false}
diff --git a/lib/plausible_web/user_auth.ex b/lib/plausible_web/user_auth.ex
index 1c48dd0994..39dd6be60a 100644
--- a/lib/plausible_web/user_auth.ex
+++ b/lib/plausible_web/user_auth.ex
@@ -64,7 +64,8 @@ defmodule PlausibleWeb.UserAuth do
to: Routes.sso_path(conn, :login_form, error: error, return_to: redirect_path)
)
- {:error, :multiple_memberships, team, user} ->
+ {:error, reason, team, user}
+ when reason in [:multiple_memberships, :active_personal_team] ->
redirect_path = Routes.site_path(conn, :index, __team: team.identifier)
log_in_user(conn, user, redirect_path)
diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex
index ffe2d4d428..f464bb904d 100644
--- a/lib/plausible_web/views/layout_view.ex
+++ b/lib/plausible_web/views/layout_view.ex
@@ -113,7 +113,9 @@ defmodule PlausibleWeb.LayoutView do
if(not Teams.setup?(current_team),
do: %{key: "API Keys", value: "api-keys", icon: :key}
),
- %{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
+ if(Plausible.Users.type(conn.assigns.current_user) == :standard,
+ do: %{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
+ )
]
|> Enum.reject(&is_nil/1)
}
diff --git a/test/plausible/auth/sso/domains_test.exs b/test/plausible/auth/sso/domains_test.exs
index e3501ff4f8..b82aacbf62 100644
--- a/test/plausible/auth/sso/domains_test.exs
+++ b/test/plausible/auth/sso/domains_test.exs
@@ -9,11 +9,15 @@ defmodule Plausible.Auth.SSO.DomainsTest do
use Plausible.Auth.SSO.Domain.Status
use Oban.Testing, repo: Plausible.Repo
+ alias Plausible.Auth
alias Plausible.Auth.SSO
alias Plausible.Teams
setup do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
+
team = new_site(owner: owner).team
integration = SSO.initiate_saml_integration(team)
@@ -338,14 +342,5 @@ defmodule Plausible.Auth.SSO.DomainsTest do
defp generate_domain() do
"example-#{Enum.random(1..10_000)}.com"
end
-
- defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
- %SSO.Identity{
- id: id,
- name: name,
- email: email,
- expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
- }
- end
end
end
diff --git a/test/plausible/auth/sso_test.exs b/test/plausible/auth/sso_test.exs
index 813aabaf82..d6357a008a 100644
--- a/test/plausible/auth/sso_test.exs
+++ b/test/plausible/auth/sso_test.exs
@@ -256,6 +256,36 @@ defmodule Plausible.Auth.SSOTest do
assert sso_user.last_sso_login
end
+ test "provisions SSO user from existing user with personal team", %{
+ integration: integration,
+ team: team,
+ domain: domain,
+ sso_domain: sso_domain
+ } do
+ user = new_user(email: "jane@" <> domain, name: "Jane Sculley")
+ {:ok, _} = Plausible.Teams.get_or_create(user)
+ add_member(team, user: user, role: :editor)
+
+ # guest membership on a site on another team should not affect provisioning
+ another_team_site = new_site()
+ add_guest(another_team_site, user: user, role: :editor)
+
+ identity = new_identity(user.name, user.email)
+
+ assert {:ok, :standard, matched_team, sso_user} = SSO.provision_user(identity)
+
+ assert matched_team.id == team.id
+ assert sso_user.id == user.id
+ assert sso_user.email == identity.email
+ assert sso_user.type == :sso
+ assert sso_user.name == identity.name
+ assert sso_user.sso_identity_id == identity.id
+ assert sso_user.sso_integration_id == integration.id
+ assert sso_user.sso_domain_id == sso_domain.id
+ assert sso_user.email_verified
+ assert sso_user.last_sso_login
+ end
+
test "provisions existing SSO user", %{
integration: integration,
team: team,
@@ -318,7 +348,7 @@ defmodule Plausible.Auth.SSOTest do
} do
user = new_user(email: "jane@" <> domain, name: "Jane Sculley")
add_member(team, user: user, role: :editor)
- another_team = new_site().team
+ another_team = new_site().team |> Plausible.Teams.complete_setup()
add_member(another_team, user: user, role: :viewer)
identity = new_identity(user.name, user.email)
@@ -329,6 +359,43 @@ defmodule Plausible.Auth.SSOTest do
assert matched_user.id == user.id
end
+ test "does not provision from existing user with personal team with subscription", %{
+ team: team,
+ domain: domain
+ } do
+ user =
+ new_user(email: "jane@" <> domain, name: "Jane Sculley") |> subscribe_to_growth_plan()
+
+ add_member(team, user: user, role: :editor)
+
+ identity = new_identity(user.name, user.email)
+
+ assert {:error, :active_personal_team, matched_team, matched_user} =
+ SSO.provision_user(identity)
+
+ assert matched_team.id == team.id
+ assert matched_user.id == user.id
+ end
+
+ test "does not provision from existing user with personal team with site", %{
+ team: team,
+ domain: domain
+ } do
+ user = new_user(email: "jane@" <> domain, name: "Jane Sculley")
+
+ new_site(owner: user)
+
+ add_member(team, user: user, role: :editor)
+
+ identity = new_identity(user.name, user.email)
+
+ assert {:error, :active_personal_team, matched_team, matched_user} =
+ SSO.provision_user(identity)
+
+ assert matched_team.id == team.id
+ assert matched_user.id == user.id
+ end
+
test "does not provision new SSO user from identity when team is over members limit", %{
domain: domain,
team: team
@@ -446,7 +513,9 @@ defmodule Plausible.Auth.SSOTest do
describe "check_force_sso/2" do
test "returns ok when conditions are met for setting all_but_owners" do
# Owner with MFA enabled
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -465,7 +534,9 @@ defmodule Plausible.Auth.SSOTest do
end
test "returns error when one owner does not have MFA configured" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Owner without MFA
@@ -489,7 +560,9 @@ defmodule Plausible.Auth.SSOTest do
test "returns error when there's no provisioned SSO user present" do
# Owner with MFA enabled
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -504,7 +577,9 @@ defmodule Plausible.Auth.SSOTest do
test "returns error when there's no verified SSO domain present" do
# Owner with MFA enabled
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -519,7 +594,9 @@ defmodule Plausible.Auth.SSOTest do
test "returns error when there's no SSO domain present" do
# Owner with MFA enabled
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -530,7 +607,9 @@ defmodule Plausible.Auth.SSOTest do
test "returns error when there's no SSO integration present" do
# Owner with MFA enabled
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
assert {:error, :no_integration} = SSO.check_force_sso(team, :all_but_owners)
@@ -545,7 +624,9 @@ defmodule Plausible.Auth.SSOTest do
describe "set_enforce_sso/2" do
test "sets enforce mode to all_but_owners when conditions met" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -573,7 +654,9 @@ defmodule Plausible.Auth.SSOTest do
end
test "sets enforce mode to none" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -599,7 +682,9 @@ defmodule Plausible.Auth.SSOTest do
describe "check_can_remove_integration/1" do
test "returns ok if conditions to remove integration met" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -622,7 +707,9 @@ defmodule Plausible.Auth.SSOTest do
end
test "returns error if force SSO enabled" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -648,7 +735,9 @@ defmodule Plausible.Auth.SSOTest do
end
test "returns error if SSO user present" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -670,7 +759,9 @@ defmodule Plausible.Auth.SSOTest do
describe "remove_integration/1,2" do
test "removes integration when conditions met" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -696,7 +787,9 @@ defmodule Plausible.Auth.SSOTest do
end
test "returns error when conditions not met" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -719,7 +812,9 @@ defmodule Plausible.Auth.SSOTest do
end
test "succeeds when SSO user present and force flag set" do
- owner = new_user(totp_enabled: true, totp_secret: "secret")
+ owner = new_user()
+ {:ok, owner, _} = Auth.TOTP.initiate(owner)
+ {:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
team = new_site(owner: owner).team
# Setup integration
@@ -792,14 +887,5 @@ defmodule Plausible.Auth.SSOTest do
refute_enqueued(worker: SSO.Domain.Verification.Worker, args: %{domain: domain})
end
end
-
- defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
- %SSO.Identity{
- id: id,
- name: name,
- email: email,
- expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
- }
- end
end
end
diff --git a/test/plausible/site/sites_test.exs b/test/plausible/site/sites_test.exs
index 6a1a87b154..74333d6af6 100644
--- a/test/plausible/site/sites_test.exs
+++ b/test/plausible/site/sites_test.exs
@@ -1,5 +1,6 @@
defmodule Plausible.SitesTest do
use Plausible.DataCase
+ use Plausible
use Plausible.Teams.Test
alias Plausible.Sites
@@ -117,6 +118,30 @@ defmodule Plausible.SitesTest do
end
end
+ on_ee do
+ describe "create a site - SSO user" do
+ setup [:create_user, :create_team, :create_site, :setup_sso, :provision_sso_user]
+
+ test "creates a site for SSO user in a setup team", %{user: user, team: team} do
+ params = %{"domain" => "example.com", "timezone" => "Europe/London"}
+
+ assert {:ok, %{site: %{domain: "example.com", timezone: "Europe/London"}}} =
+ Sites.create(user, params, team)
+ end
+
+ test "does not allow creating a site in SSO user's personal team", %{team: team} do
+ user = add_member(team, role: :editor)
+ {:ok, personal_team} = Plausible.Teams.get_or_create(user)
+ identity = new_identity(user.name, user.email)
+ {:ok, _, _, user} = Plausible.Auth.SSO.provision_user(identity)
+
+ params = %{"domain" => "example.com", "timezone" => "Europe/London"}
+
+ assert {:error, _, :permission_denied, _} = Sites.create(user, params, personal_team)
+ end
+ end
+ end
+
describe "stats_start_date" do
test "is nil if site has no stats" do
site = insert(:site)
diff --git a/test/plausible/teams/invitations/accept_test.exs b/test/plausible/teams/invitations/accept_test.exs
index 387ca85c97..9530e27b95 100644
--- a/test/plausible/teams/invitations/accept_test.exs
+++ b/test/plausible/teams/invitations/accept_test.exs
@@ -108,6 +108,21 @@ defmodule Plausible.Teams.Invitations.AcceptTest do
end
end
+ on_ee do
+ describe "accept_invitation/3 - team invitations - SSO user" do
+ setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
+
+ test "does not allow accepting invite by SSO user", %{user: invitee} do
+ inviter = new_user()
+ team = new_site(owner: inviter).team
+
+ invitation = invite_member(team, invitee, inviter: inviter, role: :editor)
+
+ assert {:error, :permission_denied} = Accept.accept(invitation.invitation_id, invitee)
+ end
+ end
+ end
+
describe "accept_invitation/3 - guest invitations" do
test "converts an invitation into a membership" do
inviter = new_user()
diff --git a/test/plausible/teams/memberships/remove_test.exs b/test/plausible/teams/memberships/remove_test.exs
index bbd55ba385..e367524b68 100644
--- a/test/plausible/teams/memberships/remove_test.exs
+++ b/test/plausible/teams/memberships/remove_test.exs
@@ -24,6 +24,8 @@ defmodule Plausible.Teams.Memberships.RemoveTest do
to: [nil: collaborator.email],
subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked"
)
+
+ assert Repo.reload(user)
end
test "when member is removed, associated personal segment is deleted" do
@@ -123,4 +125,25 @@ defmodule Plausible.Teams.Memberships.RemoveTest do
assert_team_membership(another_member, team, :viewer)
end
end
+
+ on_ee do
+ describe "SSO user" do
+ setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
+
+ test "removes SSO user along with membership", %{team: team, user: user} do
+ owner = add_member(team, role: :owner)
+
+ assert {:ok, _} = Remove.remove(team, user.id, owner)
+
+ refute_team_member(user, team)
+
+ assert_email_delivered_with(
+ to: [nil: user.email],
+ subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked"
+ )
+
+ refute Repo.reload(user)
+ end
+ end
+ end
end
diff --git a/test/plausible/teams/memberships/update_role_test.exs b/test/plausible/teams/memberships/update_role_test.exs
index e4887650bf..01ccc88848 100644
--- a/test/plausible/teams/memberships/update_role_test.exs
+++ b/test/plausible/teams/memberships/update_role_test.exs
@@ -116,4 +116,75 @@ defmodule Plausible.Teams.Memberships.UpdateRoleTest do
subject: @subject_prefix <> "Welcome to \"#{team.name}\" team"
)
end
+
+ on_ee do
+ describe "SSO user" do
+ setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
+
+ test "updates an SSO member's role by user id", %{team: team, user: user} do
+ collaborator = add_member(team, role: :viewer)
+
+ {:ok, _, _, collaborator} =
+ new_identity(collaborator.name, collaborator.email)
+ |> Plausible.Auth.SSO.provision_user()
+
+ assert {:ok, _} = UpdateRole.update(team, collaborator.id, "editor", user)
+
+ assert_team_membership(collaborator, team, :editor)
+ end
+
+ test "updates an SSO member's role to owner when no Force SSO set", %{
+ team: team,
+ user: user
+ } do
+ collaborator = add_member(team, role: :viewer)
+
+ {:ok, _, _, collaborator} =
+ new_identity(collaborator.name, collaborator.email)
+ |> Plausible.Auth.SSO.provision_user()
+
+ assert {:ok, _} = UpdateRole.update(team, collaborator.id, "owner", user)
+
+ assert_team_membership(collaborator, team, :owner)
+ end
+
+ test "updates an SSO member's role with Force SSO to Owner provided they have 2FA enabled",
+ %{
+ team: team,
+ user: user
+ } do
+ {:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
+ {:ok, user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify)
+ {:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners)
+ collaborator = add_member(team, role: :viewer)
+
+ {:ok, _, _, collaborator} =
+ new_identity(collaborator.name, collaborator.email)
+ |> Plausible.Auth.SSO.provision_user()
+
+ {:ok, collaborator, _} = Plausible.Auth.TOTP.initiate(collaborator)
+ {:ok, collaborator, _} = Plausible.Auth.TOTP.enable(collaborator, :skip_verify)
+
+ assert {:ok, _} = UpdateRole.update(team, collaborator.id, "owner", user)
+
+ assert_team_membership(collaborator, team, :owner)
+ end
+
+ test "does not update SSO member's role to Owner if they don't have 2FA enabled", %{
+ team: team,
+ user: user
+ } do
+ {:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
+ {:ok, user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify)
+ {:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners)
+ collaborator = add_member(team, role: :viewer)
+
+ {:ok, _, _, collaborator} =
+ new_identity(collaborator.name, collaborator.email)
+ |> Plausible.Auth.SSO.provision_user()
+
+ assert {:error, :mfa_disabled} = UpdateRole.update(team, collaborator.id, "owner", user)
+ end
+ end
+ end
end
diff --git a/test/plausible/teams_test.exs b/test/plausible/teams_test.exs
index bb1a7ab543..7417564a9b 100644
--- a/test/plausible/teams_test.exs
+++ b/test/plausible/teams_test.exs
@@ -172,6 +172,26 @@ defmodule Plausible.TeamsTest do
end
end
+ on_ee do
+ describe "get_or_create/1 - SSO user" do
+ setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
+
+ test "does not allow creating personal team to SSO user", %{user: user} do
+ assert {:error, :permission_denied} = Teams.get_or_create(user)
+ end
+ end
+
+ describe "force_create_my_team/1 - SSO user" do
+ setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
+
+ test "crashes when trying to create a team for SSO user", %{user: user} do
+ assert_raise RuntimeError, ~r/SSO user tried to force create a personal team/, fn ->
+ Teams.force_create_my_team(user)
+ end
+ end
+ end
+ end
+
describe "get_by_owner/1" do
test "returns error if user does not own any team" do
user = new_user()
diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs
index 61fa51133f..ec4c8aaf8b 100644
--- a/test/plausible_web/controllers/auth_controller_test.exs
+++ b/test/plausible_web/controllers/auth_controller_test.exs
@@ -501,6 +501,23 @@ defmodule PlausibleWeb.AuthControllerTest do
assert redirected_to(conn) == "/sites"
end
+ test "valid email and password, user on multiple teams - logs the user in", %{conn: conn} do
+ user = insert(:user, password: "password")
+
+ # first team
+ new_site(owner: user)
+
+ # another team
+ another_team = new_site().team |> Plausible.Teams.complete_setup()
+ add_member(another_team, user: user, role: :owner)
+
+ conn = post(conn, "/login", email: user.email, password: "password")
+
+ assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions)
+ assert get_session(conn, :user_token) == token
+ assert redirected_to(conn) == "/sites"
+ end
+
test "valid email and password with return_to set - redirects properly", %{conn: conn} do
user = insert(:user, password: "password")
@@ -569,6 +586,76 @@ defmodule PlausibleWeb.AuthControllerTest do
refute get_session(conn, :user_token)
end
+ on_ee do
+ test "SSO owner user - logs in", %{conn: conn} do
+ owner = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password")
+ team = new_site(owner: owner).team
+ team = Plausible.Teams.complete_setup(team)
+
+ # Setup SSO
+ integration = Auth.SSO.initiate_saml_integration(team)
+
+ {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com")
+ _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true)
+
+ identity = new_identity(owner.name, owner.email)
+ {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity)
+
+ conn = post(conn, "/login", email: owner.email, password: "password")
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+
+ assert conn.resp_cookies["session_2fa"].max_age == 0
+ assert %{sessions: [%{token: token}]} = owner |> Repo.reload!() |> Repo.preload(:sessions)
+ assert get_session(conn, :user_token) == token
+ end
+
+ test "SSO user other than owner - renders login form again", %{conn: conn} do
+ owner = new_user()
+ team = new_site(owner: owner).team
+ member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password")
+ add_member(team, user: member, role: :viewer)
+
+ # Setup SSO
+ integration = Auth.SSO.initiate_saml_integration(team)
+
+ {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com")
+ _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true)
+
+ identity = new_identity(member.name, member.email)
+ {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity)
+
+ conn = post(conn, "/login", email: member.email, password: "password")
+
+ assert get_session(conn, :user_token) == nil
+ assert html_response(conn, 200) =~ "Enter your account credentials"
+ end
+
+ test "SSO user other than owner with personal team - renders login form again", %{
+ conn: conn
+ } do
+ owner = new_user()
+ team = new_site(owner: owner).team
+ member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password")
+ {:ok, _} = Plausible.Teams.get_or_create(member)
+ add_member(team, user: member, role: :viewer)
+
+ # Setup SSO
+ integration = Auth.SSO.initiate_saml_integration(team)
+
+ {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com")
+ _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true)
+
+ identity = new_identity(member.name, member.email)
+ {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity)
+
+ conn = post(conn, "/login", email: member.email, password: "password")
+
+ assert get_session(conn, :user_token) == nil
+ assert html_response(conn, 200) =~ "Enter your account credentials"
+ end
+ end
+
test "email does not exist - renders login form again", %{conn: conn} do
conn = post(conn, "/login", email: "user@example.com", password: "password")
@@ -639,6 +726,43 @@ defmodule PlausibleWeb.AuthControllerTest do
end
end
+ on_ee do
+ describe "POST /password/request-reset - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user]
+
+ test "initiates reset for owner SSO user email", %{conn: conn, user: user} do
+ mock_captcha_success()
+ conn = post(conn, "/password/request-reset", %{email: user.email})
+
+ assert html_response(conn, 200)
+
+ assert_email_delivered_with(
+ subject: "Plausible password reset",
+ to: [nil: user.email]
+ )
+ end
+
+ test "does not initiate reset for non-owner SSO user", %{conn: conn, user: user, team: team} do
+ add_member(team, role: :owner)
+
+ assert {:ok, _} =
+ Plausible.Teams.Memberships.UpdateRole.update(team, user.id, "editor", user)
+
+ assert Plausible.Teams.Memberships.team_role(team, user) == {:ok, :editor}
+
+ mock_captcha_success()
+ conn = post(conn, "/password/request-reset", %{email: user.email})
+
+ assert html_response(conn, 200)
+
+ refute_email_delivered_with(
+ subject: "Plausible password reset",
+ to: [nil: user.email]
+ )
+ end
+ end
+ end
+
describe "GET /password/reset" do
test "with valid token - shows form", %{conn: conn} do
user = insert(:user)
@@ -703,6 +827,20 @@ defmodule PlausibleWeb.AuthControllerTest do
end
end
+ on_ee do
+ describe "DELETE /me - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
+
+ test "refuses to delete SSO user", %{conn: conn, user: user} do
+ conn = delete(conn, "/me")
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+
+ assert Repo.reload(user)
+ end
+ end
+ end
+
describe "DELETE /me" do
setup [:create_user, :log_in, :create_site]
use Plausible.Repo
@@ -1079,6 +1217,23 @@ defmodule PlausibleWeb.AuthControllerTest do
end
end
+ on_ee do
+ describe "POST /2fa/disable - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
+
+ test "refuses to disable for SSO user", %{conn: conn, user: user} do
+ {:ok, user, _} = Auth.TOTP.initiate(user)
+ {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
+
+ conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"})
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+
+ assert user |> Repo.reload!() |> Auth.TOTP.enabled?()
+ end
+ end
+ end
+
describe "POST /2fa/recovery_codes" do
setup [:create_user, :log_in]
diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs
index fb62a4bff8..67e569e666 100644
--- a/test/plausible_web/controllers/settings_controller_test.exs
+++ b/test/plausible_web/controllers/settings_controller_test.exs
@@ -1,6 +1,7 @@
defmodule PlausibleWeb.SettingsControllerTest do
use PlausibleWeb.ConnCase, async: true
use Bamboo.Test
+ use Plausible
use Plausible.Repo
use Plausible.Teams.Test
@@ -672,6 +673,23 @@ defmodule PlausibleWeb.SettingsControllerTest do
end
end
+ on_ee do
+ describe "POST /preferences/name - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
+
+ test "refuses to update for SSO user", %{conn: conn, user: user} do
+ conn =
+ post(conn, Routes.settings_path(conn, :update_name), %{
+ "user" => %{"name" => "New name"}
+ })
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+
+ assert Repo.reload!(user).name == user.name
+ end
+ end
+ end
+
describe "POST /security/password" do
setup [:create_user, :log_in]
@@ -843,6 +861,37 @@ defmodule PlausibleWeb.SettingsControllerTest do
end
end
+ on_ee do
+ describe "POST /security/password - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
+
+ test "refuses to update for SSO user", %{conn: conn, user: user} do
+ password = "very-long-very-secret-123"
+ new_password = "super-long-super-secret-999"
+
+ original =
+ user
+ |> Auth.User.set_password(password)
+ |> Repo.update!()
+
+ conn =
+ post(conn, Routes.settings_path(conn, :update_password), %{
+ "user" => %{
+ "password" => new_password,
+ "old_password" => password,
+ "password_confirmation" => new_password
+ }
+ })
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+
+ current_hash = Repo.reload!(user).password_hash
+ assert current_hash == original.password_hash
+ assert Plausible.Auth.Password.match?(password, current_hash)
+ end
+ end
+ end
+
describe "POST /security/email" do
setup [:create_user, :log_in]
@@ -948,6 +997,34 @@ defmodule PlausibleWeb.SettingsControllerTest do
end
end
+ on_ee do
+ describe "POST /security/email - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
+
+ test "refuses to update for SSO user", %{conn: conn, user: user} do
+ password = "very-long-very-secret-123"
+
+ user
+ |> Auth.User.set_password(password)
+ |> Repo.update!()
+
+ assert user.email_verified
+
+ conn =
+ post(conn, Routes.settings_path(conn, :update_email), %{
+ "user" => %{"email" => "new" <> user.email, "password" => password}
+ })
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+
+ updated_user = Repo.reload!(user)
+
+ assert updated_user.email == user.email
+ assert updated_user.email_verified
+ end
+ end
+ end
+
describe "POST /security/email/cancel" do
setup [:create_user, :log_in]
@@ -1249,6 +1326,45 @@ defmodule PlausibleWeb.SettingsControllerTest do
end
end
+ on_ee do
+ describe "Account Settings - SSO user" do
+ setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
+
+ test "does not allow to update name in preferences", %{conn: conn} do
+ conn = get(conn, Routes.settings_path(conn, :preferences))
+ assert html = html_response(conn, 200)
+ refute html =~ "Change Name"
+ end
+
+ test "does not allow to update email in security settings", %{conn: conn} do
+ conn = get(conn, Routes.settings_path(conn, :security))
+ assert html = html_response(conn, 200)
+ refute html =~ "Change Email"
+ end
+
+ test "does not allow to change password in security settings", %{conn: conn} do
+ conn = get(conn, Routes.settings_path(conn, :security))
+ assert html = html_response(conn, 200)
+ refute html =~ "Change Password"
+ end
+
+ test "does not allow to disable 2FA in security settings", %{conn: conn, user: user} do
+ {:ok, user, _} = Auth.TOTP.initiate(user)
+ {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
+
+ conn = get(conn, Routes.settings_path(conn, :security))
+ assert html = html_response(conn, 200)
+ assert text_of_element(html, "button[disabled]") =~ "Disable 2FA"
+ end
+
+ test "does not show account danger zone", %{conn: conn} do
+ conn = get(conn, Routes.settings_path(conn, :preferences))
+ assert html = html_response(conn, 200)
+ refute html =~ "/settings/danger-zone"
+ end
+ end
+ end
+
describe "Team Settings" do
setup [:create_user, :log_in]
diff --git a/test/plausible_web/live/sso_management_test.exs b/test/plausible_web/live/sso_management_test.exs
index 688aa4859c..05ca8a2bcc 100644
--- a/test/plausible_web/live/sso_management_test.exs
+++ b/test/plausible_web/live/sso_management_test.exs
@@ -11,6 +11,7 @@ defmodule PlausibleWeb.Live.SSOMangementTest do
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
+ alias Plausible.Auth
alias Plausible.Auth.SSO
@cert_pem """
@@ -241,9 +242,8 @@ defmodule PlausibleWeb.Live.SSOMangementTest do
assert element_exists?(html, "button#enable-force-sso-toggle[disabled]")
- user
- |> Ecto.Changeset.change(totp_enabled: true, totp_secret: "secret")
- |> Plausible.Repo.update!()
+ {:ok, user, _} = Auth.TOTP.initiate(user)
+ {:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify)
identity = new_identity("Lance Wurst", "lance@org.example.com")
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
@@ -277,15 +277,6 @@ defmodule PlausibleWeb.Live.SSOMangementTest do
{:ok, lv, html} = live(conn, Routes.sso_path(conn, :sso_settings))
{lv, html}
end
-
- defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
- %SSO.Identity{
- id: id,
- name: name,
- email: email,
- expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
- }
- end
end
end
end
diff --git a/test/plausible_web/live/team_management_test.exs b/test/plausible_web/live/team_management_test.exs
index d8432800cf..0396ad9c77 100644
--- a/test/plausible_web/live/team_management_test.exs
+++ b/test/plausible_web/live/team_management_test.exs
@@ -50,6 +50,51 @@ defmodule PlausibleWeb.Live.TeamMangementTest do
end
end
+ on_ee do
+ describe "live - SSO user" do
+ setup [
+ :create_user,
+ :create_team,
+ :setup_sso,
+ :provision_sso_user,
+ :log_in,
+ :setup_team
+ ]
+
+ test "fails to save layout with SSO user updated to owner with Force SSO but without MFA",
+ %{
+ conn: conn,
+ team: team,
+ user: user
+ } do
+ {:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
+ {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify)
+ {:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners)
+ member = add_member(team, role: :viewer)
+
+ {:ok, _, _, _member} =
+ new_identity(member.name, member.email)
+ |> Plausible.Auth.SSO.provision_user()
+
+ lv = get_liveview(conn)
+
+ html = render(lv)
+
+ assert text_of_element(
+ html,
+ "#{member_el()}:nth-of-type(1) button"
+ ) == "Owner"
+
+ assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer"
+
+ change_role(lv, 2, "owner")
+ html = render(lv)
+
+ assert html =~ "User must have 2FA enabled to become an owner"
+ end
+ end
+ end
+
describe "live" do
setup [:create_user, :log_in, :create_team, :setup_team]
diff --git a/test/plausible_web/user_auth_test.exs b/test/plausible_web/user_auth_test.exs
index 295619674c..46cf421897 100644
--- a/test/plausible_web/user_auth_test.exs
+++ b/test/plausible_web/user_auth_test.exs
@@ -76,7 +76,7 @@ defmodule PlausibleWeb.UserAuthTest do
conn: conn,
user: user
} do
- team = new_site(owner: user).team
+ team = new_site(owner: user).team |> Plausible.Teams.complete_setup()
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!()
@@ -189,12 +189,12 @@ defmodule PlausibleWeb.UserAuthTest do
conn: conn,
user: user
} do
- team = new_site().team
+ team = new_site().team |> Plausible.Teams.complete_setup()
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!()
add_member(team, user: user, role: :editor)
- another_team = new_site().team
+ another_team = new_site().team |> Plausible.Teams.complete_setup()
add_member(another_team, user: user, role: :viewer)
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
@@ -211,13 +211,31 @@ defmodule PlausibleWeb.UserAuthTest do
assert get_session(conn, :user_token)
end
- defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
- %SSO.Identity{
- id: id,
- name: name,
- email: email,
- expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
- }
+ test "passes through for user matching SSO identity with active personal team, redirecting to team",
+ %{
+ conn: conn,
+ user: user
+ } do
+ team = new_site().team
+ integration = SSO.initiate_saml_integration(team)
+ domain = "example-#{Enum.random(1..10_000)}.com"
+ user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!()
+ add_member(team, user: user, role: :editor)
+ # personal team with site created
+ new_site(owner: user)
+
+ {:ok, sso_domain} = SSO.Domains.add(integration, domain)
+ _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
+
+ identity = new_identity(user.name, user.email)
+
+ conn =
+ conn
+ |> init_session()
+ |> UserAuth.log_in_user(identity)
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier)
+ assert get_session(conn, :user_token)
end
end
end
diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex
index ad38e3edd5..60c61904f8 100644
--- a/test/support/test_utils.ex
+++ b/test/support/test_utils.ex
@@ -1,5 +1,6 @@
defmodule Plausible.TestUtils do
use Plausible.Repo
+ use Plausible
alias Plausible.Factory
defmacro __using__(_) do
@@ -52,6 +53,7 @@ defmodule Plausible.TestUtils do
conn =
conn
+ |> Plug.Conn.fetch_session()
|> Plug.Conn.put_session(:current_team_id, team.identifier)
{:ok, conn: conn, team: team}
@@ -99,6 +101,27 @@ defmodule Plausible.TestUtils do
{:ok, conn: conn}
end
+ on_ee do
+ alias Plausible.Auth.SSO
+
+ def setup_sso(%{team: team}) do
+ team = Plausible.Teams.complete_setup(team)
+ integration = SSO.initiate_saml_integration(team)
+
+ {:ok, sso_domain} = SSO.Domains.add(integration, "example.com")
+ _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
+
+ {:ok, team: team, sso_integration: integration, sso_domain: sso_domain}
+ end
+
+ def provision_sso_user(%{user: user}) do
+ identity = new_identity(user.name, user.email)
+ {:ok, _, _, sso_user} = SSO.provision_user(identity)
+
+ {:ok, user: sso_user}
+ end
+ end
+
def init_session(conn) do
opts =
Plug.Session.init(
@@ -328,4 +351,15 @@ defmodule Plausible.TestUtils do
ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(tmp_dir) end)
tmp_dir
end
+
+ on_ee do
+ def new_identity(name, email, id \\ Ecto.UUID.generate()) do
+ %Plausible.Auth.SSO.Identity{
+ id: id,
+ name: name,
+ email: email,
+ expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
+ }
+ end
+ end
end