towerops/lib/towerops/organizations.ex
Graham McIntire 92eb583a8b
fix siteless devices not inheriting org default agent
devices assigned directly to an org (no site) were excluded from
apply_agent_to_all_equipment due to inner join through site table.
also fix fallback agent resolution to check device.organization
when device.site is nil.
2026-02-11 12:20:53 -06:00

482 lines
14 KiB
Elixir

defmodule Towerops.Organizations do
@moduledoc """
The Organizations context.
"""
import Ecto.Query
alias Towerops.Agents.AgentAssignment
alias Towerops.Contexts.ConfigChangeTracker
alias Towerops.Devices.Device
alias Towerops.Organizations.Invitation
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
alias Towerops.Organizations.Policy
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Repo
alias Towerops.Sites.Site
## Organizations
@doc """
Returns the list of organizations for a user.
"""
@spec list_user_organizations(String.t()) :: [Organization.t()]
def list_user_organizations(user_id) do
Repo.all(
from(o in Organization,
join: m in Membership,
on: m.organization_id == o.id,
where: m.user_id == ^user_id,
order_by: [desc: m.inserted_at],
preload: [memberships: m]
)
)
end
@doc """
Checks if a user has access to an organization.
"""
@spec user_has_access?(String.t(), String.t()) :: boolean()
def user_has_access?(user_id, organization_id) do
Repo.exists?(
from m in Membership,
where: m.user_id == ^user_id and m.organization_id == ^organization_id
)
end
@doc """
Gets a single organization by ID.
"""
@spec get_organization!(String.t()) :: Organization.t()
def get_organization!(id), do: Repo.get!(Organization, id)
@doc """
Gets a single organization by slug.
"""
@spec get_organization_by_slug!(String.t()) :: Organization.t()
def get_organization_by_slug!(slug) do
Repo.get_by!(Organization, slug: slug)
end
@doc """
Creates an organization and adds the creator as owner.
## Options
* `:bypass_limits` - When true, bypasses free organization limit (for superusers)
"""
@dialyzer {:nowarn_function, create_organization: 2}
@dialyzer {:nowarn_function, create_organization: 3}
@spec create_organization(map(), String.t(), Keyword.t()) :: {:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
def create_organization(attrs, user_id, opts \\ []) do
bypass_limits = Keyword.get(opts, :bypass_limits, false)
subscription_plan = Map.get(attrs, :subscription_plan) || Map.get(attrs, "subscription_plan") || "free"
# Check free org limit before creating (unless bypassing or creating non-free org)
with :ok <- check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do
do_create_organization(attrs, user_id)
end
end
defp check_free_org_limit(true, _plan, _user_id, _attrs), do: :ok
defp check_free_org_limit(_bypass, plan, _user_id, _attrs) when plan != "free", do: :ok
defp check_free_org_limit(false, "free", user_id, attrs) do
if SubscriptionLimits.can_create_free_organization?(user_id) do
:ok
else
changeset =
%Organization{}
|> Organization.changeset(attrs)
|> Ecto.Changeset.add_error(
:base,
"You already have a free organization. Upgrade your existing organization to create additional ones."
)
{:error, changeset}
end
end
defp do_create_organization(attrs, user_id) do
# Check if user has any existing organizations
has_existing_orgs =
Repo.exists?(
from m in Membership,
where: m.user_id == ^user_id
)
multi =
Ecto.Multi.new()
|> Ecto.Multi.insert(:organization, Organization.changeset(%Organization{}, attrs))
|> Ecto.Multi.insert(:membership, fn %{organization: organization} ->
Membership.changeset(%Membership{}, %{
organization_id: organization.id,
user_id: user_id,
role: :owner,
# Set as default if this is the user's first organization
is_default: !has_existing_orgs
})
end)
case Repo.transaction(multi) do
{:ok, %{organization: organization}} -> {:ok, organization}
{:error, :organization, changeset, _} -> {:error, changeset}
{:error, :membership, changeset, _} -> {:error, changeset}
end
end
@doc """
Updates an organization.
"""
def update_organization(%Organization{} = organization, attrs) do
old_config = ConfigChangeTracker.capture_config_state(organization)
old_use_sites = organization.use_sites
with {:ok, updated_organization} <- organization |> Organization.changeset(attrs) |> Repo.update() do
propagate_organization_changes(updated_organization, old_config, old_use_sites)
{:ok, updated_organization}
end
end
defp propagate_organization_changes(updated_organization, old_config, old_use_sites) do
propagate_snmp_changes(updated_organization, old_config)
propagate_mikrotik_changes(updated_organization, old_config)
handle_sites_disabled(updated_organization, old_use_sites)
end
defp propagate_snmp_changes(updated_organization, old_config) do
if ConfigChangeTracker.snmp_changed?(updated_organization, old_config) do
Towerops.Devices.propagate_organization_community_change(
updated_organization.id,
updated_organization.snmp_community
)
end
end
defp propagate_mikrotik_changes(updated_organization, old_config) do
if ConfigChangeTracker.mikrotik_changed?(updated_organization, old_config) do
mikrotik_attrs = ConfigChangeTracker.extract_mikrotik_attrs(updated_organization)
Towerops.Devices.propagate_organization_mikrotik_change(
updated_organization.id,
mikrotik_attrs
)
end
end
defp handle_sites_disabled(updated_organization, old_use_sites) do
if old_use_sites && !updated_organization.use_sites do
clear_all_site_assignments(updated_organization.id)
end
end
@doc """
Deletes an organization.
"""
def delete_organization(%Organization{} = organization) do
Repo.delete(organization)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking organization changes.
"""
def change_organization(%Organization{} = organization, attrs \\ %{}) do
Organization.changeset(organization, attrs)
end
## Memberships
@doc """
Gets a user's membership for an organization.
"""
def get_membership(organization_id, user_id) do
Repo.get_by(Membership, organization_id: organization_id, user_id: user_id)
end
@doc """
Gets a user's membership for an organization, raises if not found.
"""
def get_membership!(organization_id, user_id) do
Repo.get_by!(Membership, organization_id: organization_id, user_id: user_id)
end
@doc """
Lists all memberships for an organization.
"""
def list_organization_memberships(organization_id) do
Repo.all(
from(m in Membership,
where: m.organization_id == ^organization_id,
preload: [:user],
order_by: [asc: m.inserted_at]
)
)
end
@doc """
Lists users who should receive alert notifications for an organization.
Returns owners and admins.
"""
def list_organization_notification_recipients(organization_id) do
Repo.all(
from(u in Towerops.Accounts.User,
join: m in Membership,
on: m.user_id == u.id,
where: m.organization_id == ^organization_id,
where: m.role in [:owner, :admin],
select: u
)
)
end
@doc """
Creates a membership (adds a user to an organization).
"""
def create_membership(attrs) do
%Membership{}
|> Membership.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a membership (changes a user's role).
"""
def update_membership(%Membership{} = membership, attrs) do
membership
|> Membership.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a membership (removes a user from an organization).
"""
def delete_membership(%Membership{} = membership) do
Repo.delete(membership)
end
@doc """
Gets the user's default organization membership.
"""
def get_default_membership(user_id) do
Repo.one(
from m in Membership,
where: m.user_id == ^user_id and m.is_default == true,
preload: [:organization]
)
end
@doc """
Sets an organization as the user's default.
Clears any existing default and sets the new one.
"""
def set_default_organization(user_id, organization_id) do
# Verify user has access to this organization
if user_has_access?(user_id, organization_id) do
multi =
Ecto.Multi.new()
|> Ecto.Multi.update_all(:clear_old_default, clear_default_query(user_id), set: [is_default: false])
|> Ecto.Multi.update_all(
:set_new_default,
set_default_query(user_id, organization_id),
set: [is_default: true]
)
case Repo.transaction(multi) do
{:ok, _} -> {:ok, get_organization!(organization_id)}
{:error, _, changeset, _} -> {:error, changeset}
end
else
{:error, :not_found}
end
end
defp clear_default_query(user_id) do
from m in Membership,
where: m.user_id == ^user_id and m.is_default == true
end
defp set_default_query(user_id, organization_id) do
from m in Membership,
where: m.user_id == ^user_id and m.organization_id == ^organization_id
end
## Invitations
@doc """
Lists pending invitations for an organization.
"""
def list_pending_invitations(organization_id) do
now = DateTime.utc_now()
Repo.all(
from(i in Invitation,
where: i.organization_id == ^organization_id,
where: is_nil(i.accepted_at),
where: i.expires_at > ^now,
preload: [:invited_by],
order_by: [desc: i.inserted_at]
)
)
end
@doc """
Creates an invitation.
"""
def create_invitation(attrs) do
%Invitation{}
|> Invitation.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets an invitation by token.
"""
def get_invitation_by_token(token) do
now = DateTime.utc_now()
Repo.one(
from(i in Invitation,
where: i.token == ^token,
where: is_nil(i.accepted_at),
where: i.expires_at > ^now,
preload: [:organization]
)
)
end
@doc """
Accepts an invitation and creates a membership.
"""
@dialyzer {:nowarn_function, accept_invitation: 2}
def accept_invitation(invitation, user_id) do
multi = Ecto.Multi.new()
multi =
Ecto.Multi.update(multi, :invitation, fn _ ->
Ecto.Changeset.change(invitation, %{
accepted_at: DateTime.truncate(DateTime.utc_now(), :second),
accepted_by_id: user_id
})
end)
multi =
Ecto.Multi.insert(multi, :membership, fn _ ->
Membership.changeset(%Membership{}, %{
organization_id: invitation.organization_id,
user_id: user_id,
role: invitation.role
})
end)
case Repo.transaction(multi) do
{:ok, %{membership: membership}} -> {:ok, membership}
{:error, _failed_operation, changeset, _changes_so_far} -> {:error, changeset}
end
end
@doc """
Deletes an invitation.
"""
def delete_invitation(%Invitation{} = invitation) do
Repo.delete(invitation)
end
## Bulk Configuration Updates
@doc """
Applies organization's SNMP configuration to all devices in the organization.
This updates all devices records to use the organization's SNMP version and community string,
overwriting any existing device-level or site-level configurations.
Returns the number of device records updated.
"""
def apply_snmp_config_to_all_equipment(organization_id) do
organization = get_organization!(organization_id)
Repo.update_all(from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id),
set: [
snmp_version: organization.snmp_version,
snmp_community: organization.snmp_community,
updated_at: DateTime.truncate(DateTime.utc_now(), :second)
]
)
end
@doc """
Applies organization's default agent to all devices in the organization.
This creates/updates agent assignments for all devices to use the organization's
default agent, overwriting any existing device-level or site-level assignments.
Returns the number of device records assigned.
"""
def apply_agent_to_all_equipment(organization_id) do
organization = Repo.get!(Organization, organization_id)
if organization.default_agent_token_id do
# device IDs for this organization (includes devices with and without sites)
device_ids =
Repo.all(from(e in Device, where: e.organization_id == ^organization_id, select: e.id))
# Delete existing assignments
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
# Create new assignments
now = DateTime.truncate(DateTime.utc_now(), :second)
assignments =
Enum.map(device_ids, fn device_id ->
%{
device_id: device_id,
agent_token_id: organization.default_agent_token_id,
inserted_at: now,
updated_at: now
}
end)
{count, _} = Repo.insert_all(AgentAssignment, assignments)
{count, nil}
else
# No default agent set, delete all assignments
device_ids =
Repo.all(from(e in Device, where: e.organization_id == ^organization_id, select: e.id))
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
end
end
@doc """
Clears all site_id assignments for devices in an organization when sites are disabled.
Returns the number of device records updated.
"""
def clear_all_site_assignments(organization_id) do
# Clear site assignments from all devices
Repo.update_all(
from(d in Device, where: d.organization_id == ^organization_id),
set: [
site_id: nil,
updated_at: DateTime.truncate(DateTime.utc_now(), :second)
]
)
# Delete all sites for the organization
Repo.delete_all(from(s in Site, where: s.organization_id == ^organization_id))
end
## Authorization
@doc """
Checks if a membership has permission to perform an action on a resource.
## Examples
iex> can?(membership, :edit, :device)
true
iex> can?(membership, :delete, :organization)
false
"""
defdelegate can?(membership, action, resource), to: Policy
end