towerops/lib/towerops_web/permissions.ex
2026-06-14 08:27:57 -05:00

159 lines
4.1 KiB
Elixir

defmodule ToweropsWeb.Permissions do
@moduledoc """
Authorization helpers for checking user permissions in LiveViews, controllers, and templates.
Integrates with `Towerops.Organizations.Policy` to provide a simple, reusable API
for permission checks based on organization membership roles.
## Usage in LiveViews
import ToweropsWeb.Permissions
def handle_event("delete_backup", _params, socket) do
if can?(socket, :delete, :backup) do
# Delete backup
else
{:noreply, put_flash(socket, :error, t("You don't have permission to delete backups"))}
end
end
## Usage in Templates
<%= if can?(@current_scope, :delete, :backup) do %>
<button phx-click="delete_backup">Delete</button>
<% end %>
## Usage in Controllers
import ToweropsWeb.Permissions
def delete(conn, _params) do
if can?(conn.assigns.current_scope, :delete, :backup) do
# Delete backup
else
conn
|> put_flash(:error, t("You don't have permission to delete backups"))
|> redirect(to: ~p"/")
end
end
"""
alias Phoenix.LiveView.Socket
alias Towerops.Accounts.Scope
alias Towerops.Organizations.Policy
require Logger
@doc """
Checks if the current user can perform an action on a resource.
Accepts either a socket (LiveView) or a Scope struct directly.
Returns `true` if:
- User is a superuser (can do everything)
- User's organization membership role allows the action per Policy
## Examples
iex> can?(socket, :delete, :backup)
true
iex> can?(%Scope{}, :edit, :device)
false
"""
def can?(%Socket{} = socket, action, resource) do
can?(socket.assigns.current_scope, action, resource)
end
def can?(%Scope{} = scope, action, resource) do
if Scope.superuser?(scope), do: true, else: do_can?(scope, action, resource)
end
def can?(nil, _action, _resource), do: false
@doc """
Checks if the current user is an organization owner.
Accepts either a socket (LiveView) or a Scope struct directly.
## Examples
iex> owner?(socket)
true
iex> owner?(%Scope{})
false
"""
def owner?(%Socket{} = socket) do
owner?(socket.assigns.current_scope)
end
def owner?(%Scope{} = scope) do
if Scope.superuser?(scope), do: true, else: do_owner?(scope)
end
def owner?(nil), do: false
@doc """
Checks if the current user is an organization owner or admin.
Accepts either a socket (LiveView) or a Scope struct directly.
## Examples
iex> admin?(socket)
true
iex> admin?(%Scope{})
false
"""
def admin?(%Socket{} = socket) do
admin?(socket.assigns.current_scope)
end
def admin?(%Scope{} = scope) do
if Scope.superuser?(scope), do: true, else: do_admin?(scope)
end
def admin?(nil), do: false
# Pattern-matched private helpers for permission checks
defp do_can?(%Scope{organization: nil}, _action, _resource), do: false
defp do_can?(%Scope{} = scope, action, resource) do
scope |> get_membership() |> Policy.can?(action, resource)
end
defp do_owner?(%Scope{organization: nil}), do: false
defp do_owner?(%Scope{} = scope) do
membership = get_membership(scope)
membership && membership.role == :owner
end
defp do_admin?(%Scope{organization: nil}), do: false
defp do_admin?(%Scope{} = scope) do
membership = get_membership(scope)
membership && membership.role in [:owner, :admin]
end
defp get_membership(%Scope{organization: org, user: user}) when not is_nil(org) and not is_nil(user) do
if Ecto.assoc_loaded?(org.memberships) do
Enum.find(org.memberships, &(&1.user_id == user.id))
else
# Memberships aren't preloaded — the permission check silently
# treats the user as having no role and denies access. That used
# to look indistinguishable from a real denial; surface it so
# missing preloads are noticed and fixed at the call site.
Logger.warning(
"Permission check on user=#{user.id} org=#{org.id} without preloaded memberships — denying access by default"
)
nil
end
end
defp get_membership(_scope), do: nil
end