diff --git a/assets/js/cookie_consent.js b/assets/js/cookie_consent.js index 80f52504..6f6cbf66 100644 --- a/assets/js/cookie_consent.js +++ b/assets/js/cookie_consent.js @@ -36,7 +36,11 @@ function acceptCookies() { const expiryDate = new Date(); expiryDate.setFullYear(expiryDate.getFullYear() + 1); - document.cookie = `cookie_consent=accepted; expires=${expiryDate.toUTCString()}; path=/; SameSite=Lax; Secure`; + // Only add Secure flag in production (requires HTTPS) + const isProduction = window.location.protocol === 'https:'; + const secureFlag = isProduction ? '; Secure' : ''; + + document.cookie = `cookie_consent=accepted; expires=${expiryDate.toUTCString()}; path=/; SameSite=Lax${secureFlag}`; // Hide the banner with animation const banner = document.getElementById('cookie-consent-banner'); diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex index 5f982e23..51f11604 100644 --- a/lib/towerops/admin.ex +++ b/lib/towerops/admin.ex @@ -196,4 +196,18 @@ defmodule Towerops.Admin do |> preload([:superuser, :target_user]) |> Repo.all() end + + @doc """ + Returns audit logs related to a specific user (GDPR data access). + """ + def list_audit_logs_for_user(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + + AuditLog + |> where([a], a.target_user_id == ^user_id or a.superuser_id == ^user_id) + |> order_by([a], desc: a.inserted_at) + |> limit(^limit) + |> preload([:superuser, :target_user]) + |> Repo.all() + end end diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 13e11f8c..04a95f3e 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -102,6 +102,32 @@ defmodule Towerops.Alerts do Repo.all(query) end + @doc """ + Returns alerts for multiple organizations (for GDPR data access). + Accepts a `since` option to filter alerts created after a specific date. + """ + def list_alerts_for_organizations(organization_ids, opts \\ []) when is_list(organization_ids) do + since = Keyword.get(opts, :since) + + query = + from(a in Alert, + join: e in assoc(a, :device), + join: s in assoc(e, :site), + where: s.organization_id in ^organization_ids, + order_by: [desc: a.triggered_at], + preload: [device: e] + ) + + query = + if since do + where(query, [a], a.inserted_at >= ^since) + else + query + end + + Repo.all(query) + end + @doc """ Gets a single alert. where: s.organization_id == ^organization_id, diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index f7a8a0d9..b5f7ada5 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -61,6 +61,20 @@ defmodule Towerops.Devices do Repo.all(query) end + @doc """ + Returns all devices for multiple organizations (for GDPR data access). + """ + def list_devices_for_organizations(organization_ids) when is_list(organization_ids) do + Repo.all( + from(e in DeviceSchema, + join: s in assoc(e, :site), + where: s.organization_id in ^organization_ids, + order_by: [asc: s.organization_id, asc: e.name], + preload: [site: s] + ) + ) + end + @doc """ Returns the count of devices for an organization. """ @@ -350,6 +364,94 @@ defmodule Towerops.Devices do end end + @doc """ + Resolves the effective SNMP community string for a device. + + Resolution order: + 1. If device has its own community string (source = "device"), use it + 2. If source = "site", inherit from site's community string + 3. If site has no community, inherit from organization's community string + 4. If no community found anywhere, return nil + """ + def resolve_snmp_community(%DeviceSchema{} = device) do + device = Repo.preload(device, site: :organization) + + case device.snmp_community_source do + "device" -> + # Use device-specific community + device.snmp_community + + _ -> + # Inherit from site or organization + device.site.snmp_community || device.site.organization.snmp_community + end + end + + @doc """ + Propagates SNMP community string changes from a site to all devices + that inherit from it (source = "site"). + + This updates the actual snmp_community value on devices so they have + the resolved value cached and ready for agent communication. + """ + def propagate_site_community_change(site_id, new_community) do + # Find all devices in this site that inherit community from site + devices_to_update = + Repo.all(from(d in DeviceSchema, where: d.site_id == ^site_id, where: d.snmp_community_source == "site")) + + # Update each device's community string + Enum.each(devices_to_update, fn device -> + device + |> Ecto.Changeset.change(%{snmp_community: new_community}) + |> Repo.update() + + # Broadcast assignment change to trigger agent job list refresh + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}:assignments", + {:assignments_changed, :community_updated} + ) + end) + + :ok + end + + @doc """ + Propagates SNMP community string changes from an organization to all + devices in sites that have no site-level community (source = "site" + and site.snmp_community is nil). + """ + def propagate_organization_community_change(organization_id, new_community) do + # Find all devices in this org's sites that: + # 1. Inherit from site (source = "site") + # 2. Their site has no community string (so they fall back to org) + devices_to_update = + Repo.all( + from(d in DeviceSchema, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id, + where: d.snmp_community_source == "site", + where: is_nil(s.snmp_community) or s.snmp_community == "" + ) + ) + + # Update each device's community string + Enum.each(devices_to_update, fn device -> + device + |> Ecto.Changeset.change(%{snmp_community: new_community}) + |> Repo.update() + + # Broadcast assignment change to trigger agent job list refresh + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}:assignments", + {:assignments_changed, :community_updated} + ) + end) + + :ok + end + @doc """ Deletes device. """ diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex index 44e39f39..2a9ec077 100644 --- a/lib/towerops/devices/device.ex +++ b/lib/towerops/devices/device.ex @@ -34,6 +34,7 @@ defmodule Towerops.Devices.Device do field :snmp_enabled, :boolean, default: true field :snmp_version, :string, default: "2c" field :snmp_community, :string + field :snmp_community_source, :string, default: "device" field :snmp_port, :integer, default: 161 field :last_discovery_at, :utc_datetime field :last_snmp_poll_at, :utc_datetime @@ -85,6 +86,7 @@ defmodule Towerops.Devices.Device do :snmp_enabled, :snmp_version, :snmp_community, + :snmp_community_source, :snmp_port, :status, :last_checked_at, @@ -99,6 +101,7 @@ defmodule Towerops.Devices.Device do |> validate_ip_address() |> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600) |> validate_snmp() + |> update_community_source() |> foreign_key_constraint(:site_id) end @@ -149,4 +152,22 @@ defmodule Towerops.Devices.Device do changeset end end + + defp update_community_source(changeset) do + # If user explicitly set a community string, mark source as "device" + # If it's blank/nil, mark source as "site" (will inherit from site or org) + case get_change(changeset, :snmp_community) do + nil -> + # No change to community, keep existing source + changeset + + "" -> + # Explicitly cleared, set to inherit from site + put_change(changeset, :snmp_community_source, "site") + + _value -> + # Explicitly set a value, mark as device-specific + put_change(changeset, :snmp_community_source, "device") + end + end end diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index 865639cb..738df4c8 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -115,9 +115,25 @@ defmodule Towerops.Organizations do Updates an organization. """ def update_organization(%Organization{} = organization, attrs) do - organization - |> Organization.changeset(attrs) - |> Repo.update() + old_community = organization.snmp_community + + case organization + |> Organization.changeset(attrs) + |> Repo.update() do + {:ok, updated_organization} = result -> + # If community string changed, propagate to inheriting devices + if updated_organization.snmp_community != old_community do + Towerops.Devices.propagate_organization_community_change( + updated_organization.id, + updated_organization.snmp_community + ) + end + + result + + error -> + error + end end @doc """ diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex index 2ceadfb0..d9cdf90d 100644 --- a/lib/towerops/sites.ex +++ b/lib/towerops/sites.ex @@ -96,9 +96,22 @@ defmodule Towerops.Sites do Updates a site. """ def update_site(%Site{} = site, attrs) do - site - |> Site.changeset(attrs) - |> Repo.update() + old_community = site.snmp_community + + case site + |> Site.changeset(attrs) + |> Repo.update() do + {:ok, updated_site} = result -> + # If community string changed, propagate to inheriting devices + if updated_site.snmp_community != old_community do + Towerops.Devices.propagate_site_community_change(updated_site.id, updated_site.snmp_community) + end + + result + + error -> + error + end end @doc """ diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index b8959e71..048536cf 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -232,7 +232,7 @@ defmodule ToweropsWeb.AgentChannel do device_id: device.id, snmp_device: %SnmpDevice{ ip: device.ip_address, - community: device.snmp_community, + community: Devices.resolve_snmp_community(device), version: device.snmp_version, port: device.snmp_port || 161, monitoring_enabled: device.monitoring_enabled || false, @@ -251,7 +251,7 @@ defmodule ToweropsWeb.AgentChannel do device_id: device.id, snmp_device: %SnmpDevice{ ip: device.ip_address, - community: device.snmp_community, + community: Devices.resolve_snmp_community(device), version: device.snmp_version, port: device.snmp_port || 161, monitoring_enabled: device.monitoring_enabled || false, diff --git a/lib/towerops_web/components/cookie_consent.ex b/lib/towerops_web/components/cookie_consent.ex index eb845824..82e7ec24 100644 --- a/lib/towerops_web/components/cookie_consent.ex +++ b/lib/towerops_web/components/cookie_consent.ex @@ -24,6 +24,13 @@ defmodule ToweropsWeb.Components.CookieConsent do attr :requires_consent, :boolean, required: true def banner(assigns) do + # Debug logging + require Logger + + Logger.info( + "CookieConsent.banner called with requires_consent=#{inspect(assigns[:requires_consent])}, all assign keys: #{inspect(Map.keys(assigns))}" + ) + ~H""" <%= if @requires_consent do %>