Add granular org roles: executive, technician

New role system:
- owner: full access + financials
- admin: full access + financials (except org deletion)
- executive: read-only + full financial visibility (MRR, revenue)
- technician: field ops (devices, sites, alerts) without financials
- member: legacy alias for technician (migrated)
- viewer: read-only, no financials

Financial data (MRR, revenue, billing) gated behind can_view_financials
assign in all LiveView templates: dashboard, alerts, trace, site show.

Includes migration to rename existing member roles to technician.
This commit is contained in:
Graham McIntire 2026-02-15 17:40:49 -06:00
parent bc22abb452
commit b4c0407ee0
13 changed files with 173 additions and 61 deletions

View file

@ -16,7 +16,7 @@ defmodule Towerops.Organizations.Invitation do
@foreign_key_type :binary_id
schema "organization_invitations" do
field :email, :string
field :role, Ecto.Enum, values: [:admin, :member, :viewer]
field :role, Ecto.Enum, values: [:admin, :executive, :technician, :member, :viewer]
field :token, :string
field :accepted_at, :utc_datetime
field :expires_at, :utc_datetime
@ -31,7 +31,7 @@ defmodule Towerops.Organizations.Invitation do
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
email: String.t(),
role: :admin | :member | :viewer,
role: :admin | :executive | :technician | :member | :viewer,
token: String.t(),
accepted_at: DateTime.t() | nil,
expires_at: DateTime.t(),
@ -69,12 +69,12 @@ defmodule Towerops.Organizations.Invitation do
end
defp set_default_role(changeset) do
# Default to :member role if not specified
# Default to :technician role if not specified
# Only organization creators can be owners, not invited users
if get_field(changeset, :role) do
changeset
else
put_change(changeset, :role, :member)
put_change(changeset, :role, :technician)
end
end

View file

@ -2,7 +2,14 @@ defmodule Towerops.Organizations.Membership do
@moduledoc """
Membership schema representing user-organization relationships.
Defines user roles and permissions within an organization (owner, admin, member).
Defines user roles and permissions within an organization.
Roles (ordered by privilege):
- `:owner` Full access, can delete organization
- `:admin` Full access except deleting the organization
- `:executive` Read-only with full financial visibility (MRR, revenue, billing data)
- `:technician` Field ops: can manage devices, sites, alerts, but no financial data
- `:viewer` Read-only, no financial data
"""
use Ecto.Schema
@ -15,7 +22,7 @@ defmodule Towerops.Organizations.Membership do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "organization_memberships" do
field :role, Ecto.Enum, values: [:owner, :admin, :member, :viewer]
field :role, Ecto.Enum, values: [:owner, :admin, :executive, :technician, :member, :viewer]
field :is_default, :boolean, default: false
belongs_to :organization, Organization
@ -26,7 +33,7 @@ defmodule Towerops.Organizations.Membership do
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
role: :owner | :admin | :member | :viewer,
role: :owner | :admin | :executive | :technician | :member | :viewer,
is_default: boolean(),
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),

View file

@ -1,6 +1,20 @@
defmodule Towerops.Organizations.Policy do
@moduledoc """
Authorization policy for organization resources.
## Roles
| Role | Write | Financials | Description |
|--------------|-------|------------|------------------------------------------|
| `:owner` | | | Full access, can delete organization |
| `:admin` | | | Full access except deleting organization |
| `:executive` | | | Read-only with full financial visibility |
| `:technician`| * | | Field ops: devices, sites, alerts only |
| `:member` | * | | Legacy alias for technician |
| `:viewer` | | | Read-only, no financial data |
*Technicians/members can create/edit devices, sites, and alerts but cannot
manage memberships, invitations, integrations, or billing configuration.
"""
alias Towerops.Organizations.Membership
@ -15,6 +29,12 @@ defmodule Towerops.Organizations.Policy do
iex> can?(%Membership{role: :viewer}, :edit, :device)
false
iex> can?(%Membership{role: :executive}, :view, :financials)
true
iex> can?(%Membership{role: :technician}, :view, :financials)
false
"""
def can?(%Membership{role: role}, action, resource) do
check_permission(role, action, resource)
@ -22,25 +42,50 @@ defmodule Towerops.Organizations.Policy do
def can?(nil, _action, _resource), do: false
# Owner permissions - can do everything
@doc """
Returns true if the given role can view financial data (MRR, revenue, billing).
"""
def can_view_financials?(%Membership{role: role}), do: financials_visible?(role)
def can_view_financials?(nil), do: false
defp financials_visible?(role) when role in [:owner, :admin, :executive], do: true
defp financials_visible?(_role), do: false
# ── Owner ──────────────────────────────────────────────────────────────
defp check_permission(:owner, _action, _resource), do: true
# Admin permissions
# ── Admin ──────────────────────────────────────────────────────────────
defp check_permission(:admin, :delete, :organization), do: false
defp check_permission(:admin, _action, _resource), do: true
# Member permissions
defp check_permission(:member, action, :organization) when action in [:view, :list], do: true
# ── Executive (read-only + financials) ─────────────────────────────────
defp check_permission(:executive, action, _resource) when action in [:view, :list], do: true
defp check_permission(:executive, :acknowledge, :alert), do: true
defp check_permission(:executive, _action, _resource), do: false
defp check_permission(:member, :delete, _resource), do: false
defp check_permission(:member, action, :membership) when action in [:create, :edit], do: false
defp check_permission(:member, action, :invitation) when action in [:create, :delete], do: false
# ── Technician (field ops, no financials) ──────────────────────────────
defp check_permission(:technician, :view, :financials), do: false
defp check_permission(:technician, action, :organization) when action in [:view, :list], do: true
defp check_permission(:technician, :delete, _resource), do: false
defp check_permission(:technician, action, :membership) when action in [:create, :edit], do: false
defp check_permission(:technician, action, :invitation) when action in [:create, :delete], do: false
defp check_permission(:technician, action, :integration) when action in [:create, :edit, :delete], do: false
defp check_permission(:member, action, resource)
when action in [:view, :list, :create, :edit] and resource in [:site, :device, :alert], do: true
defp check_permission(:technician, action, resource)
when action in [:view, :list, :create, :edit] and
resource in [:site, :device, :alert],
do: true
# Viewer permissions - read-only
defp check_permission(:technician, :acknowledge, :alert), do: true
defp check_permission(:technician, _action, _resource), do: false
# ── Member (legacy, same as technician) ────────────────────────────────
defp check_permission(:member, action, resource),
do: check_permission(:technician, action, resource)
# ── Viewer (read-only, no financials) ──────────────────────────────────
defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true
defp check_permission(:viewer, :view, :financials), do: false
defp check_permission(:viewer, :acknowledge, :alert), do: true
defp check_permission(:viewer, _action, _resource), do: false
end

View file

@ -13,7 +13,7 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
role = Map.get(args, :role, "member")
role = Map.get(args, :role, "technician")
case Organizations.create_invitation(%{
email: email,

View file

@ -167,7 +167,7 @@ defmodule ToweropsWeb.GraphQL.Schema do
# Member management
field :send_invitation, :invitation do
arg(:email, non_null(:string))
arg(:role, :string, default_value: "member")
arg(:role, :string, default_value: "technician")
resolve(&ToweropsWeb.GraphQL.Resolvers.Member.invite/3)
end

View file

@ -461,7 +461,8 @@
{format_number(alert.gaiia_impact["total_subscribers"])} subscribers affected
</span>
</div>
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials && alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
<div class="flex items-center gap-2">
<.icon
name="hero-currency-dollar"

View file

@ -211,12 +211,16 @@
<span class="font-bold text-sm text-gray-900 dark:text-white">
{format_number(@summary.subscribers.total)}
</span>
<span class="text-gray-400 dark:text-gray-500">
{format_mrr(@summary.subscribers.total_mrr)}/mo
</span>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials do %>
<span class="text-gray-400 dark:text-gray-500">
{format_mrr(@summary.subscribers.total_mrr)}/mo
</span>
<% end %>
</div>
<%= if mrr_at_risk_positive?(@impact_summary.mrr_at_risk) do %>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials and mrr_at_risk_positive?(@impact_summary.mrr_at_risk) do %>
<span class="hidden text-gray-300 dark:text-gray-600 sm:inline">│</span>
<div class="flex items-center gap-1.5 px-2.5 py-1.5 bg-red-100/80 dark:bg-red-900/30 rounded">
<span class="text-red-500 dark:text-red-400 text-[10px] uppercase font-sans font-medium">
@ -278,18 +282,22 @@
:for={incident <- Enum.take(@active_incidents, 10)}
class={[
"group",
incident_severity_classes(incident.mrr_at_risk)
if(@can_view_financials, do: incident_severity_classes(incident.mrr_at_risk), else: "")
]}
>
<td class="w-1 p-0">
<%!-- severity bar --%>
<div class={[
"w-1 h-full min-h-[2.5rem]",
cond do
decimal_to_float(incident.mrr_at_risk) >= 1000 -> "bg-red-500"
decimal_to_float(incident.mrr_at_risk) >= 100 -> "bg-orange-500"
decimal_to_float(incident.mrr_at_risk) > 0 -> "bg-yellow-500"
true -> "bg-gray-400"
if @can_view_financials do
cond do
decimal_to_float(incident.mrr_at_risk) >= 1000 -> "bg-red-500"
decimal_to_float(incident.mrr_at_risk) >= 100 -> "bg-orange-500"
decimal_to_float(incident.mrr_at_risk) > 0 -> "bg-yellow-500"
true -> "bg-gray-400"
end
else
"bg-gray-400"
end
]} />
</td>
@ -326,7 +334,8 @@
{format_number(incident.subscribers_affected)}s
</span>
<% end %>
<%= if mrr_at_risk_positive?(incident.mrr_at_risk) do %>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials and mrr_at_risk_positive?(incident.mrr_at_risk) do %>
<span class="text-amber-600 dark:text-amber-400" title="MRR at risk">
{format_mrr(incident.mrr_at_risk)}
</span>

View file

@ -91,7 +91,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
# Members tab assigns - loaded lazily
|> assign(:members, [])
|> assign(:pending_invitations, [])
|> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))
# Integration assigns - loaded lazily when tab is selected
|> assign(:providers, @providers)
|> assign(:integrations, %{})
@ -198,7 +198,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
socket
|> put_flash(:info, t("Invitation sent to %{email}", email: email))
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))}
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))}
{:error, changeset} ->
{:noreply,

View file

@ -625,7 +625,7 @@
field={@invite_form[:role]}
type="select"
label={t("Role")}
options={[{"Admin", "admin"}, {"Member", "member"}, {"Viewer", "viewer"}]}
options={[{"Admin", "admin"}, {"Executive", "executive"}, {"Technician", "technician"}, {"Viewer", "viewer"}]}
/>
<button
type="submit"
@ -772,7 +772,10 @@
:admin ->
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
:member ->
:executive ->
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
role when role in [:technician, :member] ->
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
:viewer ->
@ -801,8 +804,11 @@
<option value="admin" selected={member.role == :admin}>
{t("Admin")}
</option>
<option value="member" selected={member.role == :member}>
{t("Member")}
<option value="executive" selected={member.role == :executive}>
{t("Executive")}
</option>
<option value="technician" selected={member.role in [:technician, :member]}>
{t("Technician")}
</option>
<option value="viewer" selected={member.role == :viewer}>
{t("Viewer")}

View file

@ -79,15 +79,18 @@
{format_number(@site_summary.subscribers || 0)}
</p>
</div>
<%!-- MRR --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-currency-dollar" class="h-4 w-4" /> {t("Monthly Revenue")}
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials do %>
<%!-- MRR --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<.icon name="hero-currency-dollar" class="h-4 w-4" /> {t("Monthly Revenue")}
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_mrr(@site_summary.mrr)}
</p>
</div>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_mrr(@site_summary.mrr)}
</p>
</div>
<% end %>
<%!-- Device Count --%>
<div class="rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4 transition-shadow hover:shadow-sm">
<div class="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">

View file

@ -100,12 +100,15 @@
</div>
<div :if={@trace.subscriber.plan} class="text-sm text-gray-600 dark:text-gray-300">
{String.capitalize(@trace.subscriber.plan)}
<span :if={@trace.subscriber.plan_mrr} class="font-medium">
— ${if(is_struct(@trace.subscriber.plan_mrr, Decimal),
do: Decimal.round(@trace.subscriber.plan_mrr, 2),
else: @trace.subscriber.plan_mrr
)}/mo
</span>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials do %>
<span :if={@trace.subscriber.plan_mrr} class="font-medium">
— ${if(is_struct(@trace.subscriber.plan_mrr, Decimal),
do: Decimal.round(@trace.subscriber.plan_mrr, 2),
else: @trace.subscriber.plan_mrr
)}/mo
</span>
<% end %>
</div>
<div
:if={@trace.subscriber.speed_download}
@ -134,15 +137,18 @@
<span class={["badge badge-sm", status_badge_class(@trace.subscriber.status)]}>
{@trace.subscriber.status || "unknown"}
</span>
<span
:if={@trace.subscriber.mrr}
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
MRR: ${if(is_struct(@trace.subscriber.mrr, Decimal),
do: Decimal.round(@trace.subscriber.mrr, 2),
else: @trace.subscriber.mrr
)}
</span>
<%!-- Financial data: role-gated --%>
<%= if @can_view_financials do %>
<span
:if={@trace.subscriber.mrr}
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
MRR: ${if(is_struct(@trace.subscriber.mrr, Decimal),
do: Decimal.round(@trace.subscriber.mrr, 2),
else: @trace.subscriber.mrr
)}
</span>
<% end %>
</div>
</div>
<% else %>

View file

@ -434,9 +434,12 @@ defmodule ToweropsWeb.UserAuth do
membership = Towerops.Organizations.get_membership(organization.id, scope.user.id)
if membership do
alias Towerops.Organizations.Policy
conn
|> assign(:current_scope, Scope.put_organization(scope, organization))
|> assign(:current_membership, membership)
|> assign(:can_view_financials, Policy.can_view_financials?(membership))
|> put_session(:current_organization_id, organization.id)
else
conn
@ -744,12 +747,14 @@ defmodule ToweropsWeb.UserAuth do
halt_with_error(socket, "You don't have access to any organizations.")
mem ->
alias Towerops.Organizations.Policy
scope = socket.assigns.current_scope
{:cont,
socket
|> Phoenix.Component.assign(:current_scope, Scope.put_organization(scope, organization))
|> Phoenix.Component.assign(:current_membership, mem)}
|> Phoenix.Component.assign(:current_membership, mem)
|> Phoenix.Component.assign(:can_view_financials, Policy.can_view_financials?(mem))}
end
end

View file

@ -0,0 +1,30 @@
defmodule Towerops.Repo.Migrations.AddExecutiveAndTechnicianRoles do
use Ecto.Migration
@moduledoc """
Migrates existing 'member' roles to 'technician'.
The role column is a varchar (Ecto.Enum handles validation in app code),
so no DDL changes needed just data migration.
New roles added:
- 'executive' read-only with full financial visibility
- 'technician' replaces 'member', field ops without financial access
"""
def up do
# Migrate existing 'member' memberships to 'technician'
execute "UPDATE organization_memberships SET role = 'technician' WHERE role = 'member'"
execute "UPDATE organization_invitations SET role = 'technician' WHERE role = 'member'"
end
def down do
# Revert technician back to member
execute "UPDATE organization_memberships SET role = 'member' WHERE role = 'technician'"
execute "UPDATE organization_invitations SET role = 'member' WHERE role = 'technician'"
# Revert executive to admin (closest equivalent)
execute "UPDATE organization_memberships SET role = 'admin' WHERE role = 'executive'"
execute "UPDATE organization_invitations SET role = 'admin' WHERE role = 'executive'"
end
end