diff --git a/lib/towerops/organizations/invitation.ex b/lib/towerops/organizations/invitation.ex index 29100a38..061b6f95 100644 --- a/lib/towerops/organizations/invitation.ex +++ b/lib/towerops/organizations/invitation.ex @@ -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 diff --git a/lib/towerops/organizations/membership.ex b/lib/towerops/organizations/membership.ex index a4eb42a0..74155f0f 100644 --- a/lib/towerops/organizations/membership.ex +++ b/lib/towerops/organizations/membership.ex @@ -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(), diff --git a/lib/towerops/organizations/policy.ex b/lib/towerops/organizations/policy.ex index c31b1040..1fb795a2 100644 --- a/lib/towerops/organizations/policy.ex +++ b/lib/towerops/organizations/policy.ex @@ -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 diff --git a/lib/towerops_web/graphql/resolvers/member.ex b/lib/towerops_web/graphql/resolvers/member.ex index 60f7fb11..5dca5014 100644 --- a/lib/towerops_web/graphql/resolvers/member.ex +++ b/lib/towerops_web/graphql/resolvers/member.ex @@ -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, diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex index 763caf4d..2a764046 100644 --- a/lib/towerops_web/graphql/schema.ex +++ b/lib/towerops_web/graphql/schema.ex @@ -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 diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index 5f287f8f..f1efd2d7 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -461,7 +461,8 @@ {format_number(alert.gaiia_impact["total_subscribers"])} subscribers affected - <%= 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 %>
<.icon name="hero-currency-dollar" diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex index be410c3f..18561933 100644 --- a/lib/towerops_web/live/dashboard_live.html.heex +++ b/lib/towerops_web/live/dashboard_live.html.heex @@ -211,12 +211,16 @@ {format_number(@summary.subscribers.total)} - - {format_mrr(@summary.subscribers.total_mrr)}/mo - + <%!-- Financial data: role-gated --%> + <%= if @can_view_financials do %> + + {format_mrr(@summary.subscribers.total_mrr)}/mo + + <% end %>
- <%= 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 %>
@@ -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: "") ]} > <%!-- severity bar --%>
= 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 ]} /> @@ -326,7 +334,8 @@ {format_number(incident.subscribers_affected)}s <% 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 %> {format_mrr(incident.mrr_at_risk)} diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 118004eb..85009f82 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -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, diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index d6ad40df..cb65cfd0 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -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"}]} />
- <%!-- MRR --%> -
-
- <.icon name="hero-currency-dollar" class="h-4 w-4" /> {t("Monthly Revenue")} + <%!-- Financial data: role-gated --%> + <%= if @can_view_financials do %> + <%!-- MRR --%> +
+
+ <.icon name="hero-currency-dollar" class="h-4 w-4" /> {t("Monthly Revenue")} +
+

+ {format_mrr(@site_summary.mrr)} +

-

- {format_mrr(@site_summary.mrr)} -

-
+ <% end %> <%!-- Device Count --%>
diff --git a/lib/towerops_web/live/trace_live/index.html.heex b/lib/towerops_web/live/trace_live/index.html.heex index 5326b75b..1da7fd3a 100644 --- a/lib/towerops_web/live/trace_live/index.html.heex +++ b/lib/towerops_web/live/trace_live/index.html.heex @@ -100,12 +100,15 @@
{String.capitalize(@trace.subscriber.plan)} - - — ${if(is_struct(@trace.subscriber.plan_mrr, Decimal), - do: Decimal.round(@trace.subscriber.plan_mrr, 2), - else: @trace.subscriber.plan_mrr - )}/mo - + <%!-- Financial data: role-gated --%> + <%= if @can_view_financials do %> + + — ${if(is_struct(@trace.subscriber.plan_mrr, Decimal), + do: Decimal.round(@trace.subscriber.plan_mrr, 2), + else: @trace.subscriber.plan_mrr + )}/mo + + <% end %>
{@trace.subscriber.status || "unknown"} - - MRR: ${if(is_struct(@trace.subscriber.mrr, Decimal), - do: Decimal.round(@trace.subscriber.mrr, 2), - else: @trace.subscriber.mrr - )} - + <%!-- Financial data: role-gated --%> + <%= if @can_view_financials do %> + + MRR: ${if(is_struct(@trace.subscriber.mrr, Decimal), + do: Decimal.round(@trace.subscriber.mrr, 2), + else: @trace.subscriber.mrr + )} + + <% end %>
<% else %> diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index a2011e4c..a27e145e 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -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 diff --git a/priv/repo/migrations/20260215233513_add_executive_and_technician_roles.exs b/priv/repo/migrations/20260215233513_add_executive_and_technician_roles.exs new file mode 100644 index 00000000..549001de --- /dev/null +++ b/priv/repo/migrations/20260215233513_add_executive_and_technician_roles.exs @@ -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