See if we can avoid exposing user id

This commit is contained in:
Adam Rutkowski 2024-11-13 11:25:44 +01:00
parent e57e1b52a0
commit 672429b9d1
9 changed files with 234 additions and 123 deletions

View File

@ -123,6 +123,7 @@ defmodule Plausible.Teams.Adapter.Read.Sites do
where: tm.team_id == ^site.team_id, where: tm.team_id == ^site.team_id,
where: tm.role == :owner, where: tm.role == :owner,
select: %Plausible.Site.Membership{ select: %Plausible.Site.Membership{
id: 0,
user_id: tm.user_id, user_id: tm.user_id,
role: tm.role role: tm.role
} }
@ -135,6 +136,7 @@ defmodule Plausible.Teams.Adapter.Read.Sites do
inner_join: tm in assoc(gm, :team_membership), inner_join: tm in assoc(gm, :team_membership),
where: gm.site_id == ^site.id, where: gm.site_id == ^site.id,
select: %Plausible.Site.Membership{ select: %Plausible.Site.Membership{
id: gm.id,
user_id: tm.user_id, user_id: tm.user_id,
role: role:
fragment( fragment(

View File

@ -114,6 +114,16 @@ defmodule Plausible.Teams.Memberships do
:ok :ok
end end
def get_guest_membership(id) do
query =
from gm in Teams.GuestMembership,
inner_join: tm in assoc(gm, :team_membership),
where: gm.id == ^id,
preload: [team_membership: tm]
Repo.one(query)
end
defp get_guest_membership(site_id, user_id) do defp get_guest_membership(site_id, user_id) do
query = query =
from( from(

View File

@ -15,6 +15,7 @@ defmodule PlausibleWeb.Site.MembershipController do
use Plausible use Plausible
alias Plausible.Sites alias Plausible.Sites
alias Plausible.Site.{Membership, Memberships} alias Plausible.Site.{Membership, Memberships}
alias Plausible.Teams
@only_owner_is_allowed_to [:transfer_ownership_form, :transfer_ownership] @only_owner_is_allowed_to [:transfer_ownership_form, :transfer_ownership]
@ -151,11 +152,21 @@ defmodule PlausibleWeb.Site.MembershipController do
|> Enum.map(fn {k, v} -> {v, k} end) |> Enum.map(fn {k, v} -> {v, k} end)
|> Enum.into(%{}) |> Enum.into(%{})
def update_role_by_user(conn, %{"id" => user_id, "new_role" => new_role_str}) do def update_role(conn, %{"membership_id" => id, "new_role" => new_role_str}) do
%{site: site, current_user: current_user, current_user_role: current_user_role} = conn.assigns %{site: site, current_user: current_user, current_user_role: current_user_role} = conn.assigns
membership = membership =
Membership |> Repo.get_by!(user_id: user_id, site_id: site.id) |> Repo.preload(:user) if Teams.read_team_schemas?(current_user) do
guest_membership = Teams.Memberships.get_guest_membership(id)
if guest_membership do
Membership
|> Repo.get_by!(user_id: guest_membership.team_membership.user_id, site_id: site.id)
|> Repo.preload(:user)
end
else
Membership |> Repo.get_by!(id: id, site_id: site.id) |> Repo.preload(:user)
end
new_role = Map.fetch!(@role_mappings, new_role_str) new_role = Map.fetch!(@role_mappings, new_role_str)
@ -204,14 +215,29 @@ defmodule PlausibleWeb.Site.MembershipController do
defp can_grant_role_to_other?(:admin, :viewer), do: true defp can_grant_role_to_other?(:admin, :viewer), do: true
defp can_grant_role_to_other?(_, _), do: false defp can_grant_role_to_other?(_, _), do: false
def remove_member_by_user(conn, %{"id" => user_id} = _params) do def remove_member(conn, %{"membership_id" => id} = _params) do
current_user = conn.assigns.current_user
site = conn.assigns.site site = conn.assigns.site
site_id = site.id
membership_id =
if Teams.read_team_schemas?(current_user) do
guest_membership =
Teams.Memberships.get_guest_membership(id)
if guest_membership do
(Repo.get_by(Membership,
user_id: guest_membership.team_membership.user_id,
site_id: site.id
) || %{id: 0}).id
end
else
id
end
membership_q = membership_q =
from m in Membership, from m in Membership,
where: m.user_id == ^user_id, where: m.id == ^membership_id,
where: m.site_id == ^site_id, where: m.site_id == ^site.id,
inner_join: user in assoc(m, :user), inner_join: user in assoc(m, :user),
inner_join: site in assoc(m, :site), inner_join: site in assoc(m, :site),
preload: [user: user, site: site] preload: [user: user, site: site]

View File

@ -451,11 +451,11 @@ defmodule PlausibleWeb.Router do
get "/sites/:domain/transfer-ownership", Site.MembershipController, :transfer_ownership_form get "/sites/:domain/transfer-ownership", Site.MembershipController, :transfer_ownership_form
post "/sites/:domain/transfer-ownership", Site.MembershipController, :transfer_ownership post "/sites/:domain/transfer-ownership", Site.MembershipController, :transfer_ownership
put "/sites/:domain/memberships/u/:id/role/:new_role", put "/sites/:domain/memberships/:membership_id/role/:new_role",
Site.MembershipController, Site.MembershipController,
:update_role_by_user :update_role
delete "/sites/:domain/memberships/u/:id", Site.MembershipController, :remove_member_by_user delete "/sites/:domain/memberships/:membership_id", Site.MembershipController, :remove_member
get "/sites/:domain/weekly-report/unsubscribe", UnsubscribeController, :weekly_report get "/sites/:domain/weekly-report/unsubscribe", UnsubscribeController, :weekly_report
get "/sites/:domain/monthly-report/unsubscribe", UnsubscribeController, :monthly_report get "/sites/:domain/monthly-report/unsubscribe", UnsubscribeController, :monthly_report

View File

@ -15,7 +15,7 @@
<div class="flow-root"> <div class="flow-root">
<ul class="divide-y divide-gray-200 dark:divide-gray-400"> <ul class="divide-y divide-gray-200 dark:divide-gray-400">
<%= for membership <- @memberships do %> <%= for membership <- @memberships do %>
<li class="py-4" id={"membership-#{membership.user_id}"}> <li class="py-4" id={"membership-#{membership.id}"}>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img <img
@ -124,9 +124,9 @@
href={ href={
Routes.membership_path( Routes.membership_path(
@conn, @conn,
:update_role_by_user, :update_role,
@site.domain, @site.domain,
membership.user.id, membership.id,
"admin" "admin"
) )
} }
@ -164,9 +164,9 @@
href={ href={
Routes.membership_path( Routes.membership_path(
@conn, @conn,
:update_role_by_user, :update_role,
@site.domain, @site.domain,
membership.user.id, membership.id,
"viewer" "viewer"
) )
} }
@ -205,9 +205,9 @@
href={ href={
Routes.membership_path( Routes.membership_path(
@conn, @conn,
:remove_member_by_user, :remove_member,
@site.domain, @site.domain,
membership.user.id membership.id
) )
} }
method="delete" method="delete"

View File

@ -215,7 +215,7 @@ defmodule Plausible.SitesTest do
new_site(owner: user1, domain: "one.example.com") new_site(owner: user1, domain: "one.example.com")
site2 = new_site(domain: "two.example.com") site2 = new_site(domain: "two.example.com")
user1 = site2 |> add_guest(user: user1, role: :viewer) user1 = add_guest(site2, user: user1, role: :viewer).user
{:ok, _} = Sites.toggle_pin(user1, site2) {:ok, _} = Sites.toggle_pin(user1, site2)

View File

@ -289,16 +289,21 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
end end
describe "PUT /sites/memberships/:id/role/:new_role" do describe "PUT /sites/memberships/:id/role/:new_role" do
test "updates a site member's role by user id", %{conn: conn, user: user} do test "updates a site member's role", %{conn: conn, user: user} do
site = new_site(owner: user) site = new_site(owner: user)
collaborator = add_guest(site, role: :editor) collaborator = add_guest(site, role: :editor)
assert_team_membership(collaborator, site.team, :editor) %{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = collaborator
assert_team_membership(collaborator.user, site.team, :editor)
put(conn, "/sites/#{site.domain}/memberships/u/#{collaborator.id}/role/viewer") if Plausible.Teams.read_team_schemas?(user) do
put(conn, "/sites/#{site.domain}/memberships/#{gm_id}/role/viewer")
else
put(conn, "/sites/#{site.domain}/memberships/#{sm_id}/role/viewer")
end
assert_team_membership(collaborator, site.team, :viewer) assert_team_membership(collaborator.user, site.team, :viewer)
old_model_membership = Repo.get_by(Plausible.Site.Membership, user_id: collaborator.id) old_model_membership = Repo.get_by(Plausible.Site.Membership, user_id: collaborator.user.id)
assert old_model_membership.role == :viewer assert old_model_membership.role == :viewer
end end
@ -321,7 +326,14 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
guest_membership = guest_membership =
insert(:guest_membership, team_membership: team_membership, site: site, role: :editor) insert(:guest_membership, team_membership: team_membership, site: site, role: :editor)
put(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}/role/viewer") if Plausible.Teams.read_team_schemas?(user) do
put(conn, "/sites/#{site.domain}/memberships/#{guest_membership.id}/role/viewer")
else
put(
conn,
"/sites/#{site.domain}/memberships/#{Enum.at(site.memberships, 1).id}/role/viewer"
)
end
assert Repo.reload!(guest_membership).role == :viewer assert Repo.reload!(guest_membership).role == :viewer
end end
@ -330,13 +342,18 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
conn: conn, conn: conn,
user: user user: user
} do } do
site = insert(:site, memberships: [build(:site_membership, user: user, role: :admin)]) site = new_site()
collaborator = add_guest(site, role: :editor, user: user)
%{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = collaborator
conn = put(conn, "/sites/#{site.domain}/memberships/u/#{user.id}/role/viewer") conn =
if Plausible.Teams.read_team_schemas?(user) do
put(conn, "/sites/#{site.domain}/memberships/#{gm_id}/role/viewer")
else
put(conn, "/sites/#{site.domain}/memberships/#{sm_id}/role/viewer")
end
membership = Repo.get_by(Plausible.Site.Membership, user_id: user.id) assert_team_membership(collaborator.user, site.team, :viewer)
assert membership.role == :viewer
assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}" assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}"
end end
@ -346,8 +363,14 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
} do } do
site = new_site() site = new_site()
admin = add_guest(site, user: user, role: :editor) admin = add_guest(site, user: user, role: :editor)
%{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = admin
conn = put(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}/role/owner") conn =
if Plausible.Teams.read_team_schemas?(user) do
put(conn, "/sites/#{site.domain}/memberships/#{gm_id}/role/owner")
else
put(conn, "/sites/#{site.domain}/memberships/#{sm_id}/role/owner")
end
assert_team_membership(user, site.team, :editor) assert_team_membership(user, site.team, :editor)
@ -359,16 +382,24 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
conn: conn, conn: conn,
user: user user: user
} do } do
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)]) # In normal circumstances, there is no guest_membership for the owner,
# so the user won't be able to downgrade themselves
if not Plausible.Teams.read_team_schemas?(user) do
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
conn = put(conn, "/sites/#{site.domain}/memberships/u/#{user.id}/role/admin") conn =
put(
conn,
"/sites/#{site.domain}/memberships/#{List.first(site.memberships).id}/role/admin"
)
membership = Repo.get_by(Plausible.Site.Membership, user_id: user.id) membership = Repo.get_by(Plausible.Site.Membership, user_id: user.id)
assert membership.role == :owner assert membership.role == :owner
assert Phoenix.Flash.get(conn.assigns.flash, :error) == assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You are not allowed to grant the admin role" "You are not allowed to grant the admin role"
end
end end
test "admin can make another user admin", %{ test "admin can make another user admin", %{
@ -379,18 +410,30 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
add_guest(site, user: user, role: :editor) add_guest(site, user: user, role: :editor)
viewer = add_guest(site, user: new_user(), role: :viewer) viewer = add_guest(site, user: new_user(), role: :viewer)
%{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = viewer
conn = put(conn, "/sites/#{site.domain}/memberships/u/#{viewer.id}/role/admin") conn =
if Plausible.Teams.read_team_schemas?(user) do
put(conn, "/sites/#{site.domain}/memberships/#{gm_id}/role/admin")
else
put(conn, "/sites/#{site.domain}/memberships/#{sm_id}/role/admin")
end
assert_team_membership(viewer, site.team, :editor) assert_team_membership(viewer.user, site.team, :editor)
assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}/settings/people" assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}/settings/people"
end end
test "admin can't make themselves an owner", %{conn: conn, user: user} do test "admin can't make themselves an owner", %{conn: conn, user: user} do
site = new_site() site = new_site()
add_guest(site, user: user, role: :editor) admin = add_guest(site, user: user, role: :editor)
%{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = admin
conn = put(conn, "/sites/#{site.domain}/memberships/u/#{user.id}/role/owner") conn =
if Plausible.Teams.read_team_schemas?(user) do
put(conn, "/sites/#{site.domain}/memberships/#{gm_id}/role/owner")
else
put(conn, "/sites/#{site.domain}/memberships/#{sm_id}/role/owner")
end
assert_team_membership(user, site.team, :editor) assert_team_membership(user, site.team, :editor)
@ -400,40 +443,41 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
end end
describe "DELETE /sites/:domain/memberships/:id" do describe "DELETE /sites/:domain/memberships/:id" do
test "removes a member from a site by user id", %{conn: conn, user: user} do test "removes a member from a site", %{conn: conn, user: user} do
site = new_site(owner: user) site = new_site(owner: user)
admin = add_guest(site, role: :editor) admin = add_guest(site, role: :editor)
%{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = admin
conn =
if Plausible.Teams.read_team_schemas?(user) do
delete(conn, "/sites/#{site.domain}/memberships/#{gm_id}")
else
delete(conn, "/sites/#{site.domain}/memberships/#{sm_id}")
end
conn = delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}")
assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed" assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed"
refute Repo.exists?(from sm in Plausible.Site.Membership, where: sm.user_id == ^admin.id) refute Repo.reload(admin.site_membership)
refute Repo.reload(admin.guest_membership)
refute Repo.reload(admin.team_membership)
end end
@tag :teams @tag :teams
test "syncs member removal to team", %{conn: conn, user: user} do test "syncs member removal to team", %{conn: conn, user: user} do
admin = insert(:user) site = new_site(owner: user)
admin = add_guest(site, role: :editor)
site = conn =
insert(:site, if Plausible.Teams.read_team_schemas?(user) do
memberships: [ delete(conn, "/sites/#{site.domain}/memberships/#{admin.guest_membership.id}")
build(:site_membership, user: user, role: :owner), else
build(:site_membership, user: admin, role: :admin) delete(conn, "/sites/#{site.domain}/memberships/#{admin.site_membership.id}")
] end
)
|> Plausible.Teams.load_for_site()
team_membership =
insert(:team_membership, user: admin, team: site.team, role: :guest)
guest_membership =
insert(:guest_membership, team_membership: team_membership, site: site, role: :editor)
conn = delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}")
assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed" assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed"
refute Repo.reload(guest_membership) refute Repo.reload(admin.guest_membership)
refute Repo.reload(team_membership) refute Repo.reload(admin.team_membership)
end end
@tag :teams @tag :teams
@ -441,84 +485,85 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do
conn: conn, conn: conn,
user: user user: user
} do } do
admin = insert(:user) if not Plausible.Teams.read_team_schemas?(user) do
admin = insert(:user)
site = site =
insert(:site, insert(:site,
memberships: [ memberships: [
build(:site_membership, user: user, role: :owner), build(:site_membership, user: user, role: :owner),
build(:site_membership, user: admin, role: :admin) build(:site_membership, user: admin, role: :admin)
] ]
) )
|> Plausible.Teams.load_for_site() |> Plausible.Teams.load_for_site()
another_site = another_site =
insert(:site, insert(:site,
team: site.team, team: site.team,
memberships: [ memberships: [
build(:site_membership, user: user, role: :owner), build(:site_membership, user: user, role: :owner),
build(:site_membership, user: admin, role: :admin) build(:site_membership, user: admin, role: :admin)
] ]
) )
|> Plausible.Teams.load_for_site() |> Plausible.Teams.load_for_site()
team_membership = team_membership =
insert(:team_membership, user: admin, team: site.team, role: :guest) insert(:team_membership, user: admin, team: site.team, role: :guest)
guest_membership = guest_membership =
insert(:guest_membership, team_membership: team_membership, site: site, role: :editor) insert(:guest_membership, team_membership: team_membership, site: site, role: :editor)
another_guest_membership = another_guest_membership =
insert(:guest_membership, insert(:guest_membership,
team_membership: team_membership, team_membership: team_membership,
site: another_site, site: another_site,
role: :editor role: :editor
) )
conn = delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}") conn =
assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed" delete(conn, "/sites/#{site.domain}/memberships/#{Enum.at(site.memberships, 1).id}")
refute Repo.reload(guest_membership) assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed"
assert Repo.reload(another_guest_membership)
assert Repo.reload(team_membership) refute Repo.reload(guest_membership)
assert Repo.reload(another_guest_membership)
assert Repo.reload(team_membership)
end
end end
test "fails to remove a member from a foreign site", %{conn: conn, user: user} do test "fails to remove a member from a foreign site", %{conn: conn, user: user} do
foreign_site = foreign_site = new_site()
insert(:site, foreign_admin = add_guest(foreign_site, role: :editor)
memberships: [
build(:site_membership, user: build(:user), role: :admin)
]
)
[foreign_membership] = foreign_site.memberships site = new_site(owner: user)
site = conn =
insert(:site, if Plausible.Teams.read_team_schemas?(user) do
memberships: [ delete(conn, "/sites/#{site.domain}/memberships/#{foreign_admin.guest_membership.id}")
build(:site_membership, user: user, role: :owner) else
] delete(conn, "/sites/#{site.domain}/memberships/#{foreign_admin.site_membership.id}")
) end
conn = delete(conn, "/sites/#{site.domain}/memberships/u/#{foreign_membership.user_id}")
assert Phoenix.Flash.get(conn.assigns.flash, :error) == assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Failed to find membership to remove" "Failed to find membership to remove"
assert Repo.exists?( assert Repo.reload(foreign_admin.site_membership)
from sm in Plausible.Site.Membership, assert Repo.reload(foreign_admin.guest_membership)
where: sm.user_id == ^foreign_membership.user.id
)
end end
test "notifies the user who has been removed via email", %{conn: conn, user: user} do test "notifies the user who has been removed via email", %{conn: conn, user: user} do
site = new_site() site = new_site()
admin = add_guest(site, user: user, role: :editor) admin = add_guest(site, user: user, role: :editor)
%{site_membership: %{id: sm_id}, guest_membership: %{id: gm_id}} = admin
delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}") if Plausible.Teams.read_team_schemas?(user) do
delete(conn, "/sites/#{site.domain}/memberships/#{gm_id}")
else
delete(conn, "/sites/#{site.domain}/memberships/#{sm_id}")
end
assert_email_delivered_with( assert_email_delivered_with(
to: [nil: admin.email], to: [nil: admin.user.email],
subject: @subject_prefix <> "Your access to #{site.domain} has been revoked" subject: @subject_prefix <> "Your access to #{site.domain} has been revoked"
) )
end end

View File

@ -526,19 +526,34 @@ defmodule PlausibleWeb.SiteControllerTest do
conn = get(conn, "/#{site.domain}/settings/people") conn = get(conn, "/#{site.domain}/settings/people")
resp = html_response(conn, 200) resp = html_response(conn, 200)
owner_row = {owner_row, editor_row, viewer_row} =
text_of_element(resp, "#membership-#{user.id}") if Plausible.Teams.read_team_schemas?(user) do
{
text_of_element(resp, "#membership-0"),
text_of_element(resp, "#membership-#{editor.guest_membership.id}"),
text_of_element(resp, "#membership-#{viewer.guest_membership.id}")
}
else
owner_membership =
user
|> Repo.preload(:site_memberships)
|> Map.fetch!(:site_memberships)
|> Enum.at(1)
editor_row = text_of_element(resp, "#membership-#{editor.id}") {
viewer_row = text_of_element(resp, "#membership-#{viewer.id}") text_of_element(resp, "#membership-#{owner_membership.id}"),
text_of_element(resp, "#membership-#{editor.site_membership.id}"),
text_of_element(resp, "#membership-#{viewer.site_membership.id}")
}
end
assert owner_row =~ user.email assert owner_row =~ user.email
assert owner_row =~ "Owner" assert owner_row =~ "Owner"
assert editor_row =~ editor.email assert editor_row =~ editor.user.email
refute editor_row =~ "Owner" refute editor_row =~ "Owner"
assert viewer_row =~ viewer.email assert viewer_row =~ viewer.user.email
refute viewer_row =~ "Owner" refute viewer_row =~ "Owner"
end end

View File

@ -60,12 +60,25 @@ defmodule Plausible.Teams.Test do
role = Keyword.fetch!(args, :role) role = Keyword.fetch!(args, :role)
team = Repo.preload(site, :team).team team = Repo.preload(site, :team).team
insert(:site_membership, user: user, role: translate_role_to_old_model(role), site: site) site_membership =
insert(:site_membership, user: user, role: translate_role_to_old_model(role), site: site)
team_membership = insert(:team_membership, team: team, user: user, role: :guest) team_membership = insert(:team_membership, team: team, user: user, role: :guest)
insert(:guest_membership, team_membership: team_membership, site: site, role: role)
user |> Repo.preload([:site_memberships, :team_memberships]) guest_membership =
insert(:guest_membership, team_membership: team_membership, site: site, role: role)
user =
user
|> Repo.reload!()
|> Repo.preload([:site_memberships, team_memberships: :guest_memberships])
%{
user: user,
site_membership: site_membership,
guest_membership: guest_membership,
team_membership: team_membership
}
end end
def invite_guest(site, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do def invite_guest(site, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do