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 %>
+ {format_mrr(@site_summary.mrr)} +
- {format_mrr(@site_summary.mrr)} -
-