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