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:
parent
bc22abb452
commit
b4c0407ee0
13 changed files with 173 additions and 61 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue