towerops/lib/towerops_web/graphql/resolvers/member.ex
Graham McIntire 3a408a8dc1 Security hardening + performance fixes across codebase
CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
2026-06-21 17:40:50 -05:00

121 lines
4 KiB
Elixir

defmodule ToweropsWeb.GraphQL.Resolvers.Member do
@moduledoc "GraphQL resolvers for member and invitation management."
alias Towerops.Organizations
alias Towerops.Organizations.Invitation
alias Towerops.Repo
alias ToweropsWeb.GraphQL.Resolvers.Helpers
alias ToweropsWeb.ScopedResource
# Roles that are allowed to send invitations
@invite_roles [:owner, :admin]
# Role hierarchy for checking if inviter can assign a given role
@role_rank %{owner: 0, admin: 1, executive: 2, technician: 3, member: 4, viewer: 5}
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
members = Organizations.list_organization_members(org_id)
{:ok, members}
end
def list(_parent, _args, _resolution), do: Helpers.authentication_error()
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
role = Map.get(args, :role, "technician")
with {:ok, _membership} <- authorize_invite(org_id, user.id, role) do
case Organizations.create_invitation(%{
email: email,
role: role,
organization_id: org_id,
invited_by_id: user.id
}) do
{:ok, invitation} -> {:ok, invitation}
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
end
end
end
def invite(_parent, _args, _resolution), do: Helpers.authentication_error()
defp authorize_invite(org_id, user_id, requested_role) do
case Organizations.get_membership(org_id, user_id) do
nil ->
{:error, "not a member of this organization"}
membership when membership.role not in @invite_roles ->
{:error, "only admins and owners can send invitations"}
membership ->
authorize_role(membership, requested_role)
end
end
defp authorize_role(membership, requested_role) do
case parse_role(requested_role) do
nil ->
{:error, "invalid role: #{requested_role}"}
requested_role_atom ->
if @role_rank[membership.role] <= @role_rank[requested_role_atom] do
{:ok, membership}
else
{:error, "cannot assign a role higher than your own"}
end
end
end
defp parse_role(role) when is_binary(role) do
atom = String.to_existing_atom(role)
if Map.has_key?(@role_rank, atom), do: atom
rescue
ArgumentError -> nil
end
def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
with {:ok, invitation} <- fetch_org_invitation(id, org_id) do
case Repo.delete(invitation) do
{:ok, _} -> {:ok, %{success: true, message: "Invitation cancelled"}}
{:error, _} -> {:ok, %{success: false, message: "Could not cancel invitation"}}
end
end
end
def cancel_invitation(_parent, _args, _resolution), do: Helpers.authentication_error()
def remove(_parent, %{id: user_id}, %{context: %{organization_id: org_id}}) do
case Organizations.remove_member(org_id, user_id) do
{:ok, _} -> {:ok, %{success: true, message: "Member removed"}}
{:error, :cannot_remove_owner} -> {:error, "Cannot remove owner"}
{:error, :not_found} -> {:error, "Member not found"}
end
end
def remove(_parent, _args, _resolution), do: Helpers.authentication_error()
def update_role(_parent, %{id: user_id, role: role}, %{context: %{organization_id: org_id}}) do
case Organizations.update_member_role(org_id, user_id, role) do
{:ok, membership} ->
membership = Repo.preload(membership, :user)
{:ok, membership}
{:error, :cannot_change_owner_role} ->
{:error, "Cannot change owner role"}
{:error, :not_found} ->
{:error, "Member not found"}
{:error, changeset} ->
{:error, Helpers.format_changeset_errors(changeset)}
end
end
def update_role(_parent, _args, _resolution), do: Helpers.authentication_error()
defp fetch_org_invitation(id, org_id) do
case ScopedResource.fetch(Invitation, id, org_id) do
{:ok, invitation} -> {:ok, invitation}
{:error, _reason} -> {:error, "Invitation not found"}
end
end
end