diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex index 06b79e8c..d9ee78a1 100644 --- a/lib/towerops/agents.ex +++ b/lib/towerops/agents.ex @@ -39,10 +39,35 @@ defmodule Towerops.Agents do end end + @doc """ + Creates a new cloud poller agent token (superadmin only). + + Cloud pollers are application-wide agents that are not tied to any organization + and can poll devices for any organization. + + Returns {:ok, agent_token, token_string} where token_string is the plain token + that should be shown to the user ONCE and never stored. + + ## Examples + + iex> create_cloud_poller("Cloud Poller 1") + {:ok, %AgentToken{is_cloud_poller: true}, "abc123..."} + + """ + def create_cloud_poller(name) do + {token, changeset} = AgentToken.build_token(nil, name, is_cloud_poller: true) + + case Repo.insert(changeset) do + {:ok, agent_token} -> {:ok, agent_token, token} + {:error, changeset} -> {:error, changeset} + end + end + @doc """ Lists all agent tokens for an organization. Returns a list of agent tokens ordered by creation date (newest first). + Does not include cloud pollers. ## Examples @@ -52,7 +77,25 @@ defmodule Towerops.Agents do """ def list_organization_agent_tokens(organization_id) do AgentToken - |> where([t], t.organization_id == ^organization_id) + |> where([t], t.organization_id == ^organization_id and t.is_cloud_poller == false) + |> order_by([t], desc: t.inserted_at) + |> Repo.all() + end + + @doc """ + Lists all cloud poller agent tokens (superadmin only). + + Returns a list of cloud poller agent tokens ordered by creation date (newest first). + + ## Examples + + iex> list_cloud_pollers() + [%AgentToken{is_cloud_poller: true}, ...] + + """ + def list_cloud_pollers do + AgentToken + |> where([t], t.is_cloud_poller == true) |> order_by([t], desc: t.inserted_at) |> Repo.all() end diff --git a/lib/towerops/agents/agent_token.ex b/lib/towerops/agents/agent_token.ex index 59159f1b..e0d1b913 100644 --- a/lib/towerops/agents/agent_token.ex +++ b/lib/towerops/agents/agent_token.ex @@ -23,6 +23,7 @@ defmodule Towerops.Agents.AgentToken do field :last_seen_at, :utc_datetime field :last_ip, :string field :enabled, :boolean, default: true + field :is_cloud_poller, :boolean, default: false field :metadata, :map, default: %{} belongs_to :organization, Organization @@ -37,8 +38,9 @@ defmodule Towerops.Agents.AgentToken do last_seen_at: DateTime.t() | nil, last_ip: String.t() | nil, enabled: boolean(), + is_cloud_poller: boolean(), metadata: map(), - organization_id: Ecto.UUID.t(), + organization_id: Ecto.UUID.t() | nil, organization: Ecto.Association.NotLoaded.t() | Organization.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() @@ -50,6 +52,8 @@ defmodule Towerops.Agents.AgentToken do Returns a tuple of {token_string, changeset}. The token_string is stored in the database and can be retrieved later for display to authorized users. + For cloud pollers, pass `is_cloud_poller: true` in opts and organization_id will be nil. + ## Examples iex> {token, changeset} = AgentToken.build_token(org_id, "My Agent") @@ -58,28 +62,51 @@ defmodule Towerops.Agents.AgentToken do iex> byte_size(Base.url_decode64!(token, padding: false)) 48 + iex> {token, changeset} = AgentToken.build_token(nil, "Cloud Poller", is_cloud_poller: true) + iex> changeset.changes.is_cloud_poller + true + """ - def build_token(organization_id, name) do + def build_token(organization_id, name, opts \\ []) do + is_cloud_poller = Keyword.get(opts, :is_cloud_poller, false) + # Generate 48 bytes (384 bits) of cryptographically secure random data token = :crypto.strong_rand_bytes(@rand_size) encoded_token = Base.url_encode64(token, padding: false) + attrs = %{ + organization_id: organization_id, + name: name, + token: encoded_token, + is_cloud_poller: is_cloud_poller + } + changeset = %__MODULE__{} - |> cast( - %{ - organization_id: organization_id, - name: name, - token: encoded_token - }, - [:organization_id, :name, :token] - ) - |> validate_required([:organization_id, :name, :token]) + |> cast(attrs, [:organization_id, :name, :token, :is_cloud_poller]) + |> validate_cloud_poller_constraints() + |> validate_required([:name, :token]) |> unique_constraint(:token) {encoded_token, changeset} end + defp validate_cloud_poller_constraints(changeset) do + is_cloud_poller = get_field(changeset, :is_cloud_poller) + organization_id = get_field(changeset, :organization_id) + + cond do + is_cloud_poller && organization_id != nil -> + add_error(changeset, :organization_id, "must be nil for cloud pollers") + + !is_cloud_poller && organization_id == nil -> + add_error(changeset, :organization_id, "can't be blank for non-cloud pollers") + + true -> + changeset + end + end + @doc """ Changeset for updating an agent token. diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex index 5dd8a17c..0258c92a 100644 --- a/lib/towerops_web/live/agent_live/index.ex +++ b/lib/towerops_web/live/agent_live/index.ex @@ -10,8 +10,17 @@ defmodule ToweropsWeb.AgentLive.Index do @impl true def mount(_params, _session, socket) do organization = socket.assigns.current_organization + current_user = socket.assigns.current_scope.user agent_tokens = Agents.list_organization_agent_tokens(organization.id) + # If superadmin, also load cloud pollers + cloud_pollers = + if current_user.is_superuser do + Agents.list_cloud_pollers() + else + [] + end + # device counts for each agent (both direct and total with inheritance) equipment_counts = Map.new(agent_tokens, fn token -> @@ -32,6 +41,7 @@ defmodule ToweropsWeb.AgentLive.Index do socket |> assign(:page_title, "Remote Agents") |> assign(:agent_tokens, agent_tokens) + |> assign(:cloud_pollers, cloud_pollers) |> assign(:device_counts, equipment_counts) |> assign(:agent_health_stats, agent_health_stats) |> assign(:assignment_breakdown, assignment_breakdown) @@ -39,7 +49,7 @@ defmodule ToweropsWeb.AgentLive.Index do |> assign(:agent_image, agent_image) |> assign(:new_token, nil) |> assign(:show_token_modal, false) - |> assign(:agent_form, to_form(%{"name" => ""}))} + |> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))} end @impl true @@ -49,45 +59,20 @@ defmodule ToweropsWeb.AgentLive.Index do @impl true def handle_event("create_agent", params, socket) do - name = - case params do - %{"agent_form" => %{"name" => n}} -> n - %{"name" => n} -> n - end + {name, is_cloud_poller} = parse_agent_params(params) - organization = socket.assigns.current_organization - - case Agents.create_agent_token(organization.id, name) do - {:ok, agent_token, token} -> - agent_tokens = Agents.list_organization_agent_tokens(organization.id) - - # device counts - equipment_counts = - Map.new(agent_tokens, fn t -> - direct = Agents.count_assigned_devices(t.id) - total = length(Agents.list_agent_polling_targets(t.id)) - {t.id, %{direct: direct, total: total}} - end) - - # Refresh health statistics - agent_health_stats = Stats.get_organization_agent_health(organization.id) - assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id) - offline_agents = Stats.get_offline_agents(organization.id) - - {:noreply, - socket - |> assign(:agent_tokens, agent_tokens) - |> assign(:device_counts, equipment_counts) - |> assign(:agent_health_stats, agent_health_stats) - |> assign(:assignment_breakdown, assignment_breakdown) - |> assign(:offline_agents, offline_agents) - |> assign(:new_token, %{agent_token: agent_token, token: token}) - |> assign(:show_token_modal, true) - |> assign(:agent_form, to_form(%{"name" => ""})) - |> put_flash(:info, "Agent created successfully")} + with :ok <- validate_cloud_poller_permission(socket.assigns.current_scope.user, is_cloud_poller), + {:ok, agent_token, token} <- create_agent(socket.assigns.current_organization, name, is_cloud_poller) do + handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) + else + {:error, :unauthorized} -> + {:noreply, put_flash(socket, :error, "Only superadmins can create cloud pollers")} {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to create agent")} + error_message = + if is_cloud_poller, do: "Failed to create cloud poller", else: "Failed to create agent" + + {:noreply, put_flash(socket, :error, error_message)} end end @@ -174,4 +159,79 @@ defmodule ToweropsWeb.AgentLive.Index do defp apply_action(socket, :index, _params) do socket end + + defp parse_agent_params(params) do + case params do + %{"agent_form" => %{"name" => n, "is_cloud_poller" => cp}} -> + {n, cp == "true" || cp == true} + + %{"agent_form" => %{"name" => n}} -> + {n, false} + + %{"name" => n} -> + {n, false} + end + end + + defp validate_cloud_poller_permission(user, is_cloud_poller) do + if is_cloud_poller && !user.is_superuser do + {:error, :unauthorized} + else + :ok + end + end + + defp create_agent(organization, name, is_cloud_poller) do + if is_cloud_poller do + Agents.create_cloud_poller(name) + else + Agents.create_agent_token(organization.id, name) + end + end + + defp handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) do + organization = socket.assigns.current_organization + agent_tokens = Agents.list_organization_agent_tokens(organization.id) + + cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope.user) + + equipment_counts = calculate_device_counts(agent_tokens) + + agent_health_stats = Stats.get_organization_agent_health(organization.id) + assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id) + offline_agents = Stats.get_offline_agents(organization.id) + + success_message = get_success_message(is_cloud_poller) + + {:noreply, + socket + |> assign(:agent_tokens, agent_tokens) + |> assign(:cloud_pollers, cloud_pollers) + |> assign(:device_counts, equipment_counts) + |> assign(:agent_health_stats, agent_health_stats) + |> assign(:assignment_breakdown, assignment_breakdown) + |> assign(:offline_agents, offline_agents) + |> assign(:new_token, %{agent_token: agent_token, token: token}) + |> assign(:show_token_modal, true) + |> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false})) + |> put_flash(:info, success_message)} + end + + defp load_cloud_pollers_if_superuser(user) do + if user.is_superuser, do: Agents.list_cloud_pollers(), else: [] + end + + defp calculate_device_counts(agent_tokens) do + Map.new(agent_tokens, fn t -> + direct = Agents.count_assigned_devices(t.id) + total = length(Agents.list_agent_polling_targets(t.id)) + {t.id, %{direct: direct, total: total}} + end) + end + + defp get_success_message(is_cloud_poller) do + if is_cloud_poller, + do: "Cloud poller created successfully", + else: "Agent created successfully" + end end diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex index 33376744..39e41b49 100644 --- a/lib/towerops_web/live/agent_live/index.html.heex +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -30,6 +30,13 @@ placeholder="e.g., Datacenter A" required /> + <%= if @current_scope.user.is_superuser do %> + <.input + field={@agent_form[:is_cloud_poller]} + type="checkbox" + label="Cloud Poller (Application-wide agent, not tied to this organization)" + /> + <% end %>
<.button type="submit" variant="primary"> Create Agent @@ -157,6 +164,103 @@
<% end %> + + <%!-- Cloud Pollers Section (Superadmin Only) --%> + <%= if @current_scope.user.is_superuser && @cloud_pollers != [] do %> +
+

+ Cloud Pollers +

+

+ Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins. +

+ <.table id="cloud-pollers-table" rows={@cloud_pollers}> + <:col :let={agent} label="Name"> +
+ <.link + navigate={~p"/orgs/#{@current_organization.slug}/agents/#{agent.id}"} + class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + {agent.name} + + + Cloud Poller + +
+ <%= if agent.metadata["version"] do %> +
+ v{agent.metadata["version"]} +
+ <% end %> + + + <:col :let={agent} label="Status"> + <% {status, label} = agent_status(agent) %> + + + {label} + + + + <:col :let={agent} label="Last Seen"> +
+ <.timestamp datetime={agent.last_seen_at} timezone={@timezone} /> +
+ <%= if agent.last_seen_at do %> +
+ {format_datetime(agent.last_seen_at, @timezone)} +
+ <% end %> + <%= if agent.last_ip do %> +
+ {agent.last_ip} +
+ <% end %> + + + <:col :let={agent} label="Metadata"> + <%= if agent.metadata["hostname"] do %> +
+ <.icon name="hero-computer-desktop" class="h-4 w-4 inline" /> + {agent.metadata["hostname"]} +
+ <% end %> + + + <:col :let={agent} label="Created"> +
+ {ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)} +
+ + + <:action :let={agent}> + <%= if agent.enabled do %> +
+ + +
+ <% else %> + Disabled + <% end %> + + +
+ <% end %> <%= if @show_token_modal && @new_token do %> diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index fc1eb2bf..4cdffff7 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -15,6 +15,10 @@ defmodule ToweropsWeb.DeviceLive.Form do sites = Sites.list_organization_sites(organization.id) agents = Agents.list_organization_agent_tokens(organization.id) + # Include cloud pollers in the available agents list + cloud_pollers = Agents.list_cloud_pollers() + all_agents = agents ++ cloud_pollers + # Redirect to sites page if no sites exist if Enum.empty?(sites) do {:ok, @@ -26,7 +30,7 @@ defmodule ToweropsWeb.DeviceLive.Form do socket |> assign(:organization, organization) |> assign(:available_sites, sites) - |> assign(:available_agents, agents) + |> assign(:available_agents, all_agents) |> assign(:preselected_site_id, params["site_id"]) |> assign(:snmp_test_result, nil) |> assign(:duplicate_device, nil) diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index 47a4112c..ac7b6aff 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -112,7 +112,16 @@ type="select" label="Remote Agent" prompt="Inherit from site/organization" - options={Enum.map(@available_agents, &{&1.name, &1.id})} + options={ + Enum.map(@available_agents, fn agent -> + label = + if agent.is_cloud_poller, + do: "#{agent.name} (Cloud Poller)", + else: agent.name + + {label, agent.id} + end) + } /> <%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %> <%= case @agent_source do %> diff --git a/mix.lock b/mix.lock index 0e965e84..1de8fcde 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,6 @@ "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "delta_crdt": {:hex, :delta_crdt, "0.6.5", "c7bb8c2c7e60f59e46557ab4e0224f67ba22f04c02826e273738f3dcc4767adc", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6ae23a525d30f96494186dd11bf19ed9ae21d9fe2c1f1b217d492a7cc7294ae"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, @@ -30,14 +29,11 @@ "gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "honeybadger": {:hex, :honeybadger, "0.24.1", "13ffe56b4d148649c8fbb0e091fefecc5d8e8eb7ade684b6900085a947d741d5", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:process_tree, "~> 0.2.1", [hex: :process_tree, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c97d5a82c42298b9935dbc0a7e3c14372c8f55257f603828258ef9f7e0da892"}, - "horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, - "libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"}, - "merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, diff --git a/priv/repo/migrations/20260123234053_add_is_cloud_poller_to_agent_tokens.exs b/priv/repo/migrations/20260123234053_add_is_cloud_poller_to_agent_tokens.exs new file mode 100644 index 00000000..35135fcf --- /dev/null +++ b/priv/repo/migrations/20260123234053_add_is_cloud_poller_to_agent_tokens.exs @@ -0,0 +1,21 @@ +defmodule Towerops.Repo.Migrations.AddIsCloudPollerToAgentTokens do + use Ecto.Migration + + def change do + alter table(:agent_tokens) do + add :is_cloud_poller, :boolean, default: false, null: false + end + + create index(:agent_tokens, [:is_cloud_poller]) + + # Make organization_id nullable for cloud pollers + execute "ALTER TABLE agent_tokens ALTER COLUMN organization_id DROP NOT NULL", + "ALTER TABLE agent_tokens ALTER COLUMN organization_id SET NOT NULL" + + # Add check constraint: cloud pollers must have null organization_id + create constraint(:agent_tokens, :cloud_poller_no_org, + check: + "(is_cloud_poller = false AND organization_id IS NOT NULL) OR (is_cloud_poller = true AND organization_id IS NULL)" + ) + end +end diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs index 4d0597d0..78b956db 100644 --- a/test/towerops/agents_test.exs +++ b/test/towerops/agents_test.exs @@ -1154,4 +1154,228 @@ defmodule Towerops.AgentsTest do assert Ecto.assoc_loaded?(device.site.organization) end end + + describe "create_cloud_poller/1" do + test "creates cloud poller with no organization" do + assert {:ok, agent_token, token} = Agents.create_cloud_poller("Cloud Poller 1") + assert is_binary(token) + assert byte_size(token) > 0 + assert agent_token.organization_id == nil + assert agent_token.name == "Cloud Poller 1" + assert agent_token.enabled == true + assert agent_token.is_cloud_poller == true + assert is_binary(agent_token.token) + assert agent_token.token == token + end + + test "returns error with empty name" do + assert {:error, changeset} = Agents.create_cloud_poller("") + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + end + + describe "list_cloud_pollers/0" do + test "returns all cloud pollers ordered by created date" do + {:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1") + {:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2") + + cloud_pollers = Agents.list_cloud_pollers() + + assert length(cloud_pollers) == 2 + # Verify both cloud pollers are in the list + cloud_ids = Enum.map(cloud_pollers, & &1.id) + assert cloud1.id in cloud_ids + assert cloud2.id in cloud_ids + end + + test "returns empty list when no cloud pollers exist" do + assert Agents.list_cloud_pollers() == [] + end + + test "does not include organization agents" do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + {:ok, _org_agent, _} = Agents.create_agent_token(org.id, "Org Agent") + {:ok, cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller") + + cloud_pollers = Agents.list_cloud_pollers() + + assert length(cloud_pollers) == 1 + assert hd(cloud_pollers).id == cloud_agent.id + assert hd(cloud_pollers).is_cloud_poller == true + end + end + + describe "list_organization_agent_tokens/1 - cloud poller exclusion" do + test "excludes cloud pollers from organization agents", %{organization: org} do + {:ok, org_agent1, _} = Agents.create_agent_token(org.id, "Org Agent 1") + {:ok, org_agent2, _} = Agents.create_agent_token(org.id, "Org Agent 2") + {:ok, _cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller") + + org_agents = Agents.list_organization_agent_tokens(org.id) + + assert length(org_agents) == 2 + assert Enum.any?(org_agents, &(&1.id == org_agent1.id)) + assert Enum.any?(org_agents, &(&1.id == org_agent2.id)) + assert Enum.all?(org_agents, &(&1.is_cloud_poller == false)) + end + end + + describe "cloud poller validation constraints" do + test "cloud poller cannot have organization_id" do + # Attempt to create cloud poller with organization_id should fail + {_token, changeset} = AgentToken.build_token("fake-org-id", "Invalid", is_cloud_poller: true) + refute changeset.valid? + assert "must be nil for cloud pollers" in errors_on(changeset).organization_id + end + + test "regular agent must have organization_id" do + # Attempt to create regular agent without organization_id should fail + {_token, changeset} = AgentToken.build_token(nil, "Invalid", is_cloud_poller: false) + refute changeset.valid? + assert "can't be blank for non-cloud pollers" in errors_on(changeset).organization_id + end + end + + describe "cloud poller polling targets" do + setup do + user = user_fixture() + {:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id) + {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id) + + {:ok, site1} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: org1.id}) + {:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org2.id}) + + {:ok, cloud_poller, _} = Agents.create_cloud_poller("Cloud Poller") + + %{ + org1: org1, + org2: org2, + site1: site1, + site2: site2, + cloud_poller: cloud_poller + } + end + + test "cloud poller can be assigned to devices across organizations", %{ + site1: site1, + site2: site2, + cloud_poller: cloud_poller + } do + # Create devices in different organizations + {:ok, device1} = + Towerops.Devices.create_device(%{ + name: "Device 1", + ip_address: "192.168.1.1", + site_id: site1.id, + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public" + }) + + {:ok, device2} = + Towerops.Devices.create_device(%{ + name: "Device 2", + ip_address: "192.168.2.1", + site_id: site2.id, + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public" + }) + + # Assign both devices to cloud poller + {:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device1.id) + {:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device2.id) + + # Cloud poller should see both devices across organizations + targets = Agents.list_agent_polling_targets(cloud_poller.id) + assert length(targets) == 2 + assert Enum.any?(targets, &(&1.id == device1.id)) + assert Enum.any?(targets, &(&1.id == device2.id)) + end + + test "cloud poller can be organization default for multiple orgs", %{ + org1: org1, + org2: org2, + site1: site1, + site2: site2, + cloud_poller: cloud_poller + } do + # Set cloud poller as default for both organizations + {:ok, _} = + Towerops.Organizations.update_organization(org1, %{ + default_agent_token_id: cloud_poller.id + }) + + {:ok, _} = + Towerops.Organizations.update_organization(org2, %{ + default_agent_token_id: cloud_poller.id + }) + + # Create devices in both organizations + {:ok, device1} = + Towerops.Devices.create_device(%{ + name: "Device 1", + ip_address: "192.168.1.1", + site_id: site1.id, + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public" + }) + + {:ok, device2} = + Towerops.Devices.create_device(%{ + name: "Device 2", + ip_address: "192.168.2.1", + site_id: site2.id, + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public" + }) + + # Cloud poller should see both devices via organization inheritance + targets = Agents.list_agent_polling_targets(cloud_poller.id) + assert length(targets) == 2 + assert Enum.any?(targets, &(&1.id == device1.id)) + assert Enum.any?(targets, &(&1.id == device2.id)) + end + + test "cloud poller can be site default for sites in different orgs", %{ + site1: site1, + site2: site2, + cloud_poller: cloud_poller + } do + # Set cloud poller as default for both sites + {:ok, _} = Towerops.Sites.update_site(site1, %{agent_token_id: cloud_poller.id}) + {:ok, _} = Towerops.Sites.update_site(site2, %{agent_token_id: cloud_poller.id}) + + # Create devices in both sites + {:ok, device1} = + Towerops.Devices.create_device(%{ + name: "Device 1", + ip_address: "192.168.1.1", + site_id: site1.id, + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public" + }) + + {:ok, device2} = + Towerops.Devices.create_device(%{ + name: "Device 2", + ip_address: "192.168.2.1", + site_id: site2.id, + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public" + }) + + # Cloud poller should see both devices via site inheritance + targets = Agents.list_agent_polling_targets(cloud_poller.id) + assert length(targets) == 2 + assert Enum.any?(targets, &(&1.id == device1.id)) + assert Enum.any?(targets, &(&1.id == device2.id)) + end + end end diff --git a/test/towerops_web/live/agent_live/index_test.exs b/test/towerops_web/live/agent_live/index_test.exs index 2a2526de..7f64cb76 100644 --- a/test/towerops_web/live/agent_live/index_test.exs +++ b/test/towerops_web/live/agent_live/index_test.exs @@ -200,4 +200,292 @@ defmodule ToweropsWeb.AgentLive.IndexTest do assert render(view) =~ "Remote Agents" end end + + describe "cloud poller creation - superadmin only" do + setup %{user: _user, organization: organization} do + # Create a superadmin user + superadmin = user_fixture() + + superadmin = + superadmin + |> Ecto.Changeset.change(%{is_superuser: true}) + |> Towerops.Repo.update!() + + # Add superadmin to the organization + {:ok, _membership} = + Towerops.Organizations.create_membership(%{ + organization_id: organization.id, + user_id: superadmin.id, + role: "admin" + }) + + %{superadmin: superadmin} + end + + test "superadmin can create cloud poller", %{ + conn: conn, + superadmin: superadmin, + organization: organization + } do + conn = + conn + |> log_in_user(superadmin) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, view, html} = live(conn) + + # Superadmin should see the cloud poller checkbox + assert html =~ "Cloud Poller" + + # Create a cloud poller + html = + render_submit(view, "create_agent", %{ + "agent_form" => %{"name" => "Test Cloud Poller", "is_cloud_poller" => "true"} + }) + + assert html =~ "Cloud poller created successfully" + assert html =~ "Test Cloud Poller" + + # Verify cloud poller was created + cloud_pollers = Agents.list_cloud_pollers() + assert length(cloud_pollers) == 1 + assert hd(cloud_pollers).name == "Test Cloud Poller" + assert hd(cloud_pollers).is_cloud_poller == true + assert hd(cloud_pollers).organization_id == nil + end + + test "non-superadmin cannot create cloud poller", %{ + conn: conn, + user: user, + organization: organization + } do + conn = + conn + |> log_in_user(user) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, view, html} = live(conn) + + # Regular user should NOT see the cloud poller checkbox + refute html =~ "Cloud Poller (Application-wide agent" + + # Attempt to create a cloud poller (bypassing UI) + html = + render_submit(view, "create_agent", %{ + "agent_form" => %{"name" => "Unauthorized Cloud Poller", "is_cloud_poller" => "true"} + }) + + assert html =~ "Only superadmins can create cloud pollers" + + # Verify cloud poller was NOT created + cloud_pollers = Agents.list_cloud_pollers() + assert Enum.empty?(cloud_pollers) + + # Verify organization agent was NOT created + org_agents = Agents.list_organization_agent_tokens(organization.id) + assert Enum.empty?(org_agents) + end + + test "superadmin can create regular agent by not checking cloud poller", %{ + conn: conn, + superadmin: superadmin, + organization: organization + } do + conn = + conn + |> log_in_user(superadmin) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, view, _html} = live(conn) + + # Create a regular agent (is_cloud_poller = false) + html = + render_submit(view, "create_agent", %{ + "agent_form" => %{"name" => "Regular Agent", "is_cloud_poller" => "false"} + }) + + assert html =~ "Agent created successfully" + assert html =~ "Regular Agent" + + # Verify regular agent was created + org_agents = Agents.list_organization_agent_tokens(organization.id) + assert length(org_agents) == 1 + assert hd(org_agents).name == "Regular Agent" + assert hd(org_agents).is_cloud_poller == false + assert hd(org_agents).organization_id == organization.id + + # Verify cloud poller was NOT created + cloud_pollers = Agents.list_cloud_pollers() + assert Enum.empty?(cloud_pollers) + end + end + + describe "cloud poller visibility" do + setup %{user: _user, organization: organization} do + # Create a superadmin user + superadmin = user_fixture() + + superadmin = + superadmin + |> Ecto.Changeset.change(%{is_superuser: true}) + |> Towerops.Repo.update!() + + # Add superadmin to the organization + {:ok, _membership} = + Towerops.Organizations.create_membership(%{ + organization_id: organization.id, + user_id: superadmin.id, + role: "admin" + }) + + # Create some cloud pollers + {:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1") + {:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2") + + # Create organization agents + {:ok, org_agent, _} = Agents.create_agent_token(organization.id, "Org Agent") + + %{superadmin: superadmin, cloud1: cloud1, cloud2: cloud2, org_agent: org_agent} + end + + test "superadmin sees cloud pollers in separate section", %{ + conn: conn, + superadmin: superadmin, + organization: organization, + cloud1: cloud1, + cloud2: cloud2, + org_agent: org_agent + } do + conn = + conn + |> log_in_user(superadmin) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, _view, html} = live(conn) + + # Should see cloud pollers section header + assert html =~ "Cloud Pollers" + + # Should see both cloud pollers + assert html =~ cloud1.name + assert html =~ cloud2.name + + # Should see organization agent in main section + assert html =~ org_agent.name + + # Should see visual distinction for cloud pollers + assert html =~ "Cloud Poller" or html =~ "cloud-poller" + end + + test "non-superadmin does not see cloud pollers section", %{ + conn: conn, + user: user, + organization: organization, + cloud1: cloud1, + cloud2: cloud2, + org_agent: org_agent + } do + conn = + conn + |> log_in_user(user) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, _view, html} = live(conn) + + # Should NOT see cloud pollers section header + refute html =~ "Cloud Pollers" + + # Should NOT see cloud poller names + refute html =~ cloud1.name + refute html =~ cloud2.name + + # Should see organization agent in main section + assert html =~ org_agent.name + end + end + + describe "cloud poller management" do + setup %{organization: organization} do + # Create a superadmin user + superadmin = user_fixture() + + superadmin = + superadmin + |> Ecto.Changeset.change(%{is_superuser: true}) + |> Towerops.Repo.update!() + + # Add superadmin to the organization + {:ok, _membership} = + Towerops.Organizations.create_membership(%{ + organization_id: organization.id, + user_id: superadmin.id, + role: "admin" + }) + + {:ok, cloud_poller, _} = Agents.create_cloud_poller("Test Cloud Poller") + + %{superadmin: superadmin, cloud_poller: cloud_poller} + end + + test "superadmin can delete cloud poller", %{ + conn: conn, + superadmin: superadmin, + organization: organization, + cloud_poller: cloud_poller + } do + conn = + conn + |> log_in_user(superadmin) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, view, _html} = live(conn) + + html = render_click(view, "delete_agent", %{"id" => cloud_poller.id}) + + assert html =~ "Agent deleted successfully" + + # Verify cloud poller was deleted + cloud_pollers = Agents.list_cloud_pollers() + assert Enum.empty?(cloud_pollers) + end + + test "superadmin can regenerate cloud poller token", %{ + conn: conn, + superadmin: superadmin, + organization: organization, + cloud_poller: cloud_poller + } do + conn = + conn + |> log_in_user(superadmin) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, view, _html} = live(conn) + + html = render_click(view, "regenerate_token", %{"id" => cloud_poller.id}) + + assert html =~ "New token generated successfully" + assert html =~ "Test Cloud Poller" + end + + test "superadmin can view cloud poller setup instructions", %{ + conn: conn, + superadmin: superadmin, + organization: organization, + cloud_poller: cloud_poller + } do + conn = + conn + |> log_in_user(superadmin) + |> get("/orgs/#{organization.slug}/agents") + + {:ok, view, _html} = live(conn) + + html = render_click(view, "show_setup", %{"id" => cloud_poller.id}) + + assert html =~ "Test Cloud Poller" + # Setup modal should show token and instructions + assert html =~ cloud_poller.token or html =~ "docker run" or html =~ "Setup" + end + end end