diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex index bb600ccc..e92e7a44 100644 --- a/lib/towerops/admin/audit_log.ex +++ b/lib/towerops/admin/audit_log.ex @@ -15,6 +15,9 @@ defmodule Towerops.Admin.AuditLog do field :action, :string field :metadata, :map field :ip_address, :string + field :user_agent, :string + field :request_path, :string + field :data_accessed, :map belongs_to :superuser, User belongs_to :target_user, User @@ -25,15 +28,40 @@ defmodule Towerops.Admin.AuditLog do @doc false def changeset(audit_log, attrs) do audit_log - |> cast(attrs, [:action, :superuser_id, :target_user_id, :metadata, :ip_address]) - |> validate_required([:action, :superuser_id]) + |> cast(attrs, [ + :action, + :superuser_id, + :target_user_id, + :metadata, + :ip_address, + :user_agent, + :request_path, + :data_accessed + ]) + |> validate_required([:action]) |> validate_inclusion(:action, [ + # Superuser actions "impersonate_start", "impersonate_end", "user_delete", "org_delete", "agent_debug_enable", - "agent_debug_disable" + "agent_debug_disable", + # User data access + "user_data_viewed", + "user_data_exported", + "user_profile_updated", + # Organization actions + "org_data_accessed", + # Device actions + "device_created", + "device_updated", + "device_deleted", + # Monitoring actions + "monitoring_data_queried", + # Security events + "failed_access_attempt", + "privilege_escalation" ]) end end diff --git a/lib/towerops/admin/audit_logger.ex b/lib/towerops/admin/audit_logger.ex new file mode 100644 index 00000000..adca20a6 --- /dev/null +++ b/lib/towerops/admin/audit_logger.ex @@ -0,0 +1,177 @@ +defmodule Towerops.Admin.AuditLogger do + @moduledoc """ + Helper module for creating audit log entries with automatic extraction of + request metadata (IP address, user agent, request path). + + This module provides convenience functions for logging various types of + audit events throughout the application. + """ + + alias Towerops.Admin + + @doc """ + Logs an audit event with automatic extraction of conn metadata. + + ## Parameters + + * `action` - String representing the action being logged + * `conn` - The Plug.Conn struct (can be nil for background jobs) + * `opts` - Keyword list of options: + * `:actor_id` - ID of the user performing the action (optional) + * `:target_user_id` - ID of the user being acted upon (optional) + * `:metadata` - Additional metadata map (optional) + * `:data_accessed` - Map of data fields that were accessed (optional) + + ## Examples + + iex> log_event("user_data_viewed", conn, actor_id: admin_id, target_user_id: user_id) + {:ok, %AuditLog{}} + """ + def log_event(action, conn \\ nil, opts \\ []) do + attrs = build_attrs(action, conn, opts) + Admin.create_audit_log(attrs) + end + + @doc """ + Logs when a user's data is viewed by an admin or another user. + """ + def log_user_data_viewed(conn, viewer_id, target_user_id, data_accessed) do + log_event("user_data_viewed", conn, + actor_id: viewer_id, + target_user_id: target_user_id, + data_accessed: data_accessed + ) + end + + @doc """ + Logs when a user exports their data. + """ + def log_user_data_exported(conn, user_id) do + log_event("user_data_exported", conn, + actor_id: user_id, + target_user_id: user_id, + metadata: %{export_format: "json"} + ) + end + + @doc """ + Logs when a user's profile is updated. + """ + def log_user_profile_updated(conn, user_id, fields_changed) do + log_event("user_profile_updated", conn, + actor_id: user_id, + target_user_id: user_id, + data_accessed: %{fields_changed: fields_changed} + ) + end + + @doc """ + Logs when organization data is accessed. + """ + def log_org_data_accessed(conn, user_id, org_id, action_detail) do + log_event("org_data_accessed", conn, + actor_id: user_id, + metadata: %{organization_id: org_id, action: action_detail} + ) + end + + @doc """ + Logs device creation. + """ + def log_device_created(conn, user_id, device_id, device_name) do + log_event("device_created", conn, + actor_id: user_id, + metadata: %{device_id: device_id, device_name: device_name} + ) + end + + @doc """ + Logs device update. + """ + def log_device_updated(conn, user_id, device_id, fields_changed) do + log_event("device_updated", conn, + actor_id: user_id, + metadata: %{device_id: device_id}, + data_accessed: %{fields_changed: fields_changed} + ) + end + + @doc """ + Logs device deletion. + """ + def log_device_deleted(conn, user_id, device_id, device_name) do + log_event("device_deleted", conn, + actor_id: user_id, + metadata: %{device_id: device_id, device_name: device_name} + ) + end + + @doc """ + Logs when monitoring data is queried. + """ + def log_monitoring_data_queried(conn, user_id, device_id, time_range) do + log_event("monitoring_data_queried", conn, + actor_id: user_id, + metadata: %{device_id: device_id, time_range: time_range} + ) + end + + @doc """ + Logs failed access attempts. + """ + def log_failed_access_attempt(conn, user_id, resource, reason) do + log_event("failed_access_attempt", conn, + actor_id: user_id, + metadata: %{resource: resource, reason: reason} + ) + end + + @doc """ + Logs privilege escalations (e.g., becoming org admin). + """ + def log_privilege_escalation(conn, user_id, from_role, to_role, context) do + log_event("privilege_escalation", conn, + actor_id: user_id, + metadata: %{from_role: from_role, to_role: to_role, context: context} + ) + end + + # Private helper functions + + defp build_attrs(action, conn, opts) do + base_attrs = %{ + action: action, + superuser_id: Keyword.get(opts, :actor_id), + target_user_id: Keyword.get(opts, :target_user_id), + metadata: Keyword.get(opts, :metadata, %{}), + data_accessed: Keyword.get(opts, :data_accessed) + } + + if conn do + base_attrs + |> Map.put(:ip_address, get_ip_address(conn)) + |> Map.put(:user_agent, get_user_agent(conn)) + |> Map.put(:request_path, get_request_path(conn)) + else + base_attrs + end + end + + defp get_ip_address(%Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "x-forwarded-for") do + [ip | _] -> ip |> String.split(",") |> List.first() |> String.trim() + [] -> to_string(:inet_parse.ntoa(conn.remote_ip)) + end + end + + defp get_user_agent(%Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "user-agent") do + [ua | _] -> ua + [] -> nil + end + end + + defp get_request_path(%Plug.Conn{} = conn) do + conn.request_path + end +end diff --git a/lib/towerops_web/controllers/api/account_data_controller.ex b/lib/towerops_web/controllers/api/account_data_controller.ex index db684527..386683e6 100644 --- a/lib/towerops_web/controllers/api/account_data_controller.ex +++ b/lib/towerops_web/controllers/api/account_data_controller.ex @@ -6,6 +6,7 @@ defmodule ToweropsWeb.Api.AccountDataController do alias Towerops.Accounts alias Towerops.Admin + alias Towerops.Admin.AuditLogger alias Towerops.Alerts alias Towerops.Devices alias Towerops.Organizations @@ -16,6 +17,9 @@ defmodule ToweropsWeb.Api.AccountDataController do def show(conn, _params) do user = conn.assigns.current_scope.user + # Log the data export for audit trail + AuditLogger.log_user_data_exported(conn, user.id) + # Gather all user data data = %{ profile: build_profile_data(user), diff --git a/lib/towerops_web/controllers/page_html/privacy.html.heex b/lib/towerops_web/controllers/page_html/privacy.html.heex index 58525e02..b4e9a7c6 100644 --- a/lib/towerops_web/controllers/page_html/privacy.html.heex +++ b/lib/towerops_web/controllers/page_html/privacy.html.heex @@ -88,9 +88,112 @@
-

Cookies and Tracking

+

Cookie Policy

+ +

+ What Are Cookies? +

- We use essential cookies to maintain your session and preferences. We do not use third-party tracking or advertising cookies. + Cookies are small text files that are placed on your device when you visit our website. They help us provide you with a better experience by remembering your preferences and keeping you logged in. +

+ +

+ What Cookies Do We Use? +

+

+ Towerops uses only essential cookies + that are strictly necessary for the operation of our platform. We do not use third-party tracking, advertising, or analytics cookies. +

+ +
+

Essential Cookies

+

+ These cookies are required for our website to function properly and cannot be disabled: +

+ +
+ +

+ Third-Party Cookies +

+

+ We do not use any third-party cookies, tracking pixels, or analytics services that would place cookies on your device. +

+ +

Managing Cookies

+

+ Since we only use essential cookies required for the website to function, disabling them will prevent you from using Towerops. However, you can manage cookies through your browser settings: +

+ + +

+ Cookie Consent (EU/EEA Users) +

+

+ If you are accessing Towerops from the European Union or European Economic Area, you will see a cookie consent banner on your first visit. Since we only use essential cookies necessary for the website to function, this banner is informational. Under GDPR, essential cookies do not require explicit consent as they are strictly necessary for the service you have requested. +

+ +

+ Changes to Our Cookie Policy +

+

+ If we introduce new types of cookies in the future (such as analytics or marketing cookies), we will update this policy and, where required, request your consent before using them.

diff --git a/lib/towerops_web/live/account_live/activity.ex b/lib/towerops_web/live/account_live/activity.ex new file mode 100644 index 00000000..e9b76783 --- /dev/null +++ b/lib/towerops_web/live/account_live/activity.ex @@ -0,0 +1,278 @@ +defmodule ToweropsWeb.AccountLive.Activity do + @moduledoc """ + LiveView for displaying user's audit log activity (GDPR transparency). + Shows both actions performed by the user and actions performed on the user's data. + """ + use ToweropsWeb, :live_view + + alias Towerops.Admin + + @impl true + def mount(_params, _session, socket) do + user = socket.assigns.current_scope.user + + # Get audit logs for this user (both as actor and target) + audit_logs = Admin.list_audit_logs_for_user(user.id, limit: 100) + + {:ok, + socket + |> assign(:page_title, "Activity Log") + |> assign(:audit_logs, audit_logs)} + end + + @impl true + def render(assigns) do + ~H""" + +
+

+ Activity Log +

+

+ View all actions performed by you and on your account data for transparency and security. +

+
+ +
+ <%= if Enum.empty?(@audit_logs) do %> +
+ <.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No activity logs +

+

+ Your activity will appear here as you use the platform. +

+
+ <% else %> +
+
    + <%= for log <- @audit_logs do %> +
  • +
    +
    +
    + + {format_action(log.action)} + +

    + {action_description(log)} +

    +
    + +
    + <%= if log.superuser do %> +
    + <.icon name="hero-user" class="h-4 w-4" /> + + By: {log.superuser.email} + +
    + <% end %> + + <%= if log.request_path do %> +
    + <.icon name="hero-link" class="h-4 w-4" /> + + {log.request_path} + +
    + <% end %> + + <%= if log.ip_address do %> +
    + <.icon name="hero-globe-alt" class="h-4 w-4" /> + IP: {log.ip_address} +
    + <% end %> + + <%= if log.data_accessed && map_size(log.data_accessed) > 0 do %> +
    + <.icon name="hero-eye" class="h-4 w-4 mt-0.5" /> +
    + Data accessed: + + {inspect(log.data_accessed, pretty: true, limit: 3)} + +
    +
    + <% end %> + + <%= if log.metadata && map_size(log.metadata) > 0 do %> +
    + + View metadata + +
    +                              {Jason.encode!(log.metadata, pretty: true)}
    +                            
    +
    + <% end %> +
    +
    + +
    +

    + {format_timestamp(log.inserted_at)} +

    +
    +
    +
  • + <% end %> +
+
+ +
+
+ <.icon + name="hero-information-circle" + class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" + /> +
+

GDPR Transparency

+

+ This activity log shows actions performed by you and on your account data. + Records are retained for 3 years for compliance and security purposes. +

+
+
+
+ <% end %> +
+
+ """ + end + + # Helper functions + + defp action_badge_class(action) do + case action do + "user_data_viewed" -> + "bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20" + + "user_data_exported" -> + "bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20" + + "user_profile_updated" -> + "bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20" + + "device_created" -> + "bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20" + + "device_updated" -> + "bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20" + + "device_deleted" -> + "bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20" + + "failed_access_attempt" -> + "bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20" + + "privilege_escalation" -> + "bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-500/10 dark:text-purple-400 dark:ring-purple-500/20" + + "impersonate_start" -> + "bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20" + + "impersonate_end" -> + "bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20" + + _ -> + "bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20" + end + end + + defp format_action(action) do + action + |> String.replace("_", " ") + |> String.capitalize() + end + + defp action_description(log) do + case log.action do + "user_data_viewed" -> + if log.superuser do + "Admin viewed your account data" + else + "You viewed your account data" + end + + "user_data_exported" -> + "You exported your account data" + + "user_profile_updated" -> + "You updated your profile" + + "device_created" -> + device_name = get_in(log.metadata, ["device_name"]) || "device" + "You created device: #{device_name}" + + "device_updated" -> + device_id = get_in(log.metadata, ["device_id"]) + "You updated device (ID: #{device_id})" + + "device_deleted" -> + device_name = get_in(log.metadata, ["device_name"]) || "device" + "You deleted device: #{device_name}" + + "org_data_accessed" -> + org_id = get_in(log.metadata, ["organization_id"]) + action_detail = get_in(log.metadata, ["action"]) || "accessed" + "You #{action_detail} organization data (ID: #{org_id})" + + "failed_access_attempt" -> + resource = get_in(log.metadata, ["resource"]) || "resource" + "Failed access attempt to: #{resource}" + + "privilege_escalation" -> + from_role = get_in(log.metadata, ["from_role"]) || "member" + to_role = get_in(log.metadata, ["to_role"]) || "admin" + "Privilege changed from #{from_role} to #{to_role}" + + "impersonate_start" -> + if log.superuser do + "Admin #{log.superuser.email} started impersonating you" + else + "Impersonation started" + end + + "impersonate_end" -> + if log.superuser do + "Admin #{log.superuser.email} stopped impersonating you" + else + "Impersonation ended" + end + + _ -> + format_action(log.action) + end + end + + defp format_timestamp(timestamp) do + now = DateTime.utc_now() + diff_seconds = DateTime.diff(now, timestamp, :second) + + cond do + diff_seconds < 60 -> + "Just now" + + diff_seconds < 3600 -> + minutes = div(diff_seconds, 60) + "#{minutes} #{if minutes == 1, do: "minute", else: "minutes"} ago" + + diff_seconds < 86_400 -> + hours = div(diff_seconds, 3600) + "#{hours} #{if hours == 1, do: "hour", else: "hours"} ago" + + diff_seconds < 604_800 -> + days = div(diff_seconds, 86_400) + "#{days} #{if days == 1, do: "day", else: "days"} ago" + + true -> + Calendar.strftime(timestamp, "%b %d, %Y at %I:%M %p") + end + end +end diff --git a/lib/towerops_web/live/admin/audit_live/index.ex b/lib/towerops_web/live/admin/audit_live/index.ex new file mode 100644 index 00000000..c333494f --- /dev/null +++ b/lib/towerops_web/live/admin/audit_live/index.ex @@ -0,0 +1,448 @@ +defmodule ToweropsWeb.Admin.AuditLive.Index do + @moduledoc """ + Admin interface for viewing and searching audit logs. + Provides comprehensive search, filtering, and export capabilities. + """ + use ToweropsWeb, :live_view + + import Ecto.Query + + alias Towerops.Repo + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Audit Logs") + |> assign(:search_email, "") + |> assign(:search_action, "") + |> assign(:date_from, "") + |> assign(:date_to, "") + |> assign(:page, 1) + |> assign(:per_page, 50) + |> load_audit_logs()} + end + + @impl true + def handle_event("search", params, socket) do + {:noreply, + socket + |> assign(:search_email, params["email"] || "") + |> assign(:search_action, params["action"] || "") + |> assign(:date_from, params["date_from"] || "") + |> assign(:date_to, params["date_to"] || "") + |> assign(:page, 1) + |> load_audit_logs()} + end + + @impl true + def handle_event("clear_filters", _params, socket) do + {:noreply, + socket + |> assign(:search_email, "") + |> assign(:search_action, "") + |> assign(:date_from, "") + |> assign(:date_to, "") + |> assign(:page, 1) + |> load_audit_logs()} + end + + @impl true + def handle_event("export", _params, socket) do + # Generate CSV export + logs = fetch_audit_logs(socket, limit: :all) + csv_content = generate_csv(logs) + + {:noreply, + push_event(socket, "download", %{ + filename: "audit-logs-#{Date.utc_today()}.csv", + content: csv_content, + mime_type: "text/csv" + })} + end + + @impl true + def handle_event("next_page", _params, socket) do + {:noreply, + socket + |> assign(:page, socket.assigns.page + 1) + |> load_audit_logs()} + end + + @impl true + def handle_event("prev_page", _params, socket) do + {:noreply, + socket + |> assign(:page, max(1, socket.assigns.page - 1)) + |> load_audit_logs()} + end + + @impl true + def render(assigns) do + ~H""" + +
+

+ Audit Logs +

+

+ View and search all system audit logs. Records are retained for 3 years. +

+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + +
+
+
+ + +
+ <%= if Enum.empty?(@audit_logs) do %> +
+ <.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No audit logs found +

+

+ Try adjusting your search filters. +

+
+ <% else %> +
+
+ + + + + + + + + + + + + <%= for log <- @audit_logs do %> + + + + + + + + + <% end %> + +
+ Timestamp + + Action + + Actor + + Target User + + IP Address + + Details +
+ {Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S")} + + + {format_action(log.action)} + + + <%= if log.superuser do %> + {log.superuser.email} + <% else %> + System + <% end %> + + <%= if log.target_user do %> + {log.target_user.email} + <% else %> + - + <% end %> + + {log.ip_address || "-"} + + <%= if log.request_path do %> +
+ + {log.request_path} + +
+ <% end %> + <%= if log.metadata && map_size(log.metadata) > 0 do %> +
+ + Metadata + +
+                              {Jason.encode!(log.metadata, pretty: true)}
+                            
+
+ <% end %> +
+
+ + +
+
+ Page {@page} • Showing {length(@audit_logs)} records +
+
+ + +
+
+
+ <% end %> +
+
+ """ + end + + # Private functions + + defp load_audit_logs(socket) do + logs = fetch_audit_logs(socket, limit: socket.assigns.per_page) + assign(socket, :audit_logs, logs) + end + + defp fetch_audit_logs(socket, opts) do + limit = Keyword.get(opts, :limit, socket.assigns.per_page) + offset = (socket.assigns.page - 1) * socket.assigns.per_page + + query = + from(a in Towerops.Admin.AuditLog, + preload: [:superuser, :target_user], + order_by: [desc: a.inserted_at] + ) + + query = apply_filters(query, socket) + + if limit == :all do + Repo.all(query) + else + query + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + end + end + + defp apply_filters(query, socket) do + query + |> filter_by_email(socket.assigns.search_email) + |> filter_by_action(socket.assigns.search_action) + |> filter_by_date_range(socket.assigns.date_from, socket.assigns.date_to) + end + + defp filter_by_email(query, ""), do: query + + defp filter_by_email(query, email) do + from(a in query, + left_join: su in assoc(a, :superuser), + left_join: tu in assoc(a, :target_user), + where: ilike(su.email, ^"%#{email}%") or ilike(tu.email, ^"%#{email}%") + ) + end + + defp filter_by_action(query, ""), do: query + defp filter_by_action(query, action), do: where(query, [a], a.action == ^action) + + defp filter_by_date_range(query, "", ""), do: query + + defp filter_by_date_range(query, from_date, to_date) do + query = + if from_date == "" do + query + else + {:ok, from_datetime} = NaiveDateTime.new(Date.from_iso8601!(from_date), ~T[00:00:00]) + where(query, [a], a.inserted_at >= ^from_datetime) + end + + if to_date == "" do + query + else + {:ok, to_datetime} = NaiveDateTime.new(Date.from_iso8601!(to_date), ~T[23:59:59]) + where(query, [a], a.inserted_at <= ^to_datetime) + end + end + + defp generate_csv(logs) do + headers = "Timestamp,Action,Actor Email,Target User Email,IP Address,Request Path,Metadata\n" + + rows = + Enum.map_join(logs, "\n", fn log -> + Enum.map_join( + [ + Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S"), + log.action, + if(log.superuser, do: log.superuser.email, else: "System"), + if(log.target_user, do: log.target_user.email, else: ""), + log.ip_address || "", + log.request_path || "", + Jason.encode!(log.metadata || %{}) + ], + ",", + &escape_csv_field/1 + ) + end) + + headers <> rows + end + + defp escape_csv_field(field) when is_binary(field) do + if String.contains?(field, [",", "\"", "\n"]) do + "\"#{String.replace(field, "\"", "\"\"")}\"" + else + field + end + end + + defp escape_csv_field(_), do: "" + + defp available_actions do + [ + "impersonate_start", + "impersonate_end", + "user_delete", + "org_delete", + "user_data_viewed", + "user_data_exported", + "user_profile_updated", + "org_data_accessed", + "device_created", + "device_updated", + "device_deleted", + "monitoring_data_queried", + "failed_access_attempt", + "privilege_escalation" + ] + end + + defp action_badge_class(action) do + case action do + "user_data_viewed" -> "bg-blue-50 text-blue-700 ring-blue-600/20" + "user_data_exported" -> "bg-green-50 text-green-700 ring-green-600/20" + "device_created" -> "bg-green-50 text-green-700 ring-green-600/20" + "device_updated" -> "bg-yellow-50 text-yellow-700 ring-yellow-600/20" + "device_deleted" -> "bg-red-50 text-red-700 ring-red-600/20" + "failed_access_attempt" -> "bg-red-50 text-red-700 ring-red-600/20" + "impersonate_start" -> "bg-orange-50 text-orange-700 ring-orange-600/20" + "impersonate_end" -> "bg-orange-50 text-orange-700 ring-orange-600/20" + _ -> "bg-gray-50 text-gray-700 ring-gray-600/20" + end + end + + defp format_action(action) do + action + |> String.replace("_", " ") + |> String.capitalize() + end +end diff --git a/lib/towerops_web/live/admin/user_live/index.ex b/lib/towerops_web/live/admin/user_live/index.ex index 49b6d49a..c2ba93e6 100644 --- a/lib/towerops_web/live/admin/user_live/index.ex +++ b/lib/towerops_web/live/admin/user_live/index.ex @@ -5,11 +5,28 @@ defmodule ToweropsWeb.Admin.UserLive.Index do use ToweropsWeb, :live_view alias Towerops.Admin + alias Towerops.Admin.AuditLogger @impl true def mount(_params, _session, socket) do users = Admin.list_all_users() ip = get_connect_info_ip(socket) + admin = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user + + # Log admin viewing user data + user_ids = Enum.map(users, & &1.id) + + AuditLogger.log_event("user_data_viewed", nil, + actor_id: admin.id, + metadata: %{ + admin_page: "user_list", + users_count: length(users) + }, + data_accessed: %{ + user_ids: user_ids, + fields: ["id", "email", "name", "is_superuser", "confirmed_at"] + } + ) {:ok, socket diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex index b15c6b04..a8c6bebf 100644 --- a/lib/towerops_web/live/agent_live/index.html.heex +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -133,6 +133,11 @@ {agent.metadata["hostname"]} <% end %> + <%= if agent.metadata["version"] do %> +
+ v{agent.metadata["version"]} +
+ <% end %> <:col :let={agent} label="Created"> @@ -232,6 +237,11 @@ {agent.metadata["hostname"]} <% end %> + <%= if agent.metadata["version"] do %> +
+ v{agent.metadata["version"]} +
+ <% end %> <:col :let={agent} label="Created"> diff --git a/lib/towerops_web/live/agent_live/show.html.heex b/lib/towerops_web/live/agent_live/show.html.heex index 762f7350..98366150 100644 --- a/lib/towerops_web/live/agent_live/show.html.heex +++ b/lib/towerops_web/live/agent_live/show.html.heex @@ -211,6 +211,14 @@ Agent Metadata
+ <%= if @agent_token.metadata["version"] do %> +
+
Version
+
+ {@agent_token.metadata["version"]} +
+
+ <% end %> <%= if @agent_token.metadata["hostname"] do %>
Hostname
diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index fe77a2c2..1f35c86a 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -2,6 +2,7 @@ defmodule ToweropsWeb.DeviceLive.Form do @moduledoc false use ToweropsWeb, :live_view + alias Towerops.Admin.AuditLogger alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Device, as: DeviceSchema @@ -226,8 +227,14 @@ defmodule ToweropsWeb.DeviceLive.Form do @impl true def handle_event("delete", _params, socket) do - case Devices.delete_device(socket.assigns.device) do + device = socket.assigns.device + + case Devices.delete_device(device) do {:ok, _} -> + # Log device deletion + user = socket.assigns.current_scope.user + AuditLogger.log_device_deleted(nil, user.id, device.id, device.name) + {:noreply, socket |> put_flash(:info, "Device deleted successfully") @@ -292,6 +299,10 @@ defmodule ToweropsWeb.DeviceLive.Form do case Devices.create_device(device_params, opts) do {:ok, device} -> + # Log device creation + user = socket.assigns.current_scope.user + AuditLogger.log_device_created(nil, user.id, device.id, device.name) + # Handle agent assignment after device creation handle_agent_assignment(device.id, agent_token_id) @@ -346,6 +357,11 @@ defmodule ToweropsWeb.DeviceLive.Form do case Devices.update_device(old_device, device_params) do {:ok, device} -> + # Log device update + user = socket.assigns.current_scope.user + fields_changed = Map.keys(device_params) + AuditLogger.log_device_updated(nil, user.id, device.id, fields_changed) + # device update handle_agent_assignment(device.id, agent_token_id) diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 9fd18dd7..f91eee9b 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -2,17 +2,22 @@ defmodule ToweropsWeb.Org.SettingsLive do @moduledoc false use ToweropsWeb, :live_view + alias Towerops.Admin.AuditLogger alias Towerops.Agents alias Towerops.Organizations @impl true def mount(_params, _session, socket) do organization = socket.assigns.current_scope.organization + user = socket.assigns.current_scope.user available_agents = Agents.list_organization_agent_tokens(organization.id) # Get assignment breakdown to show impact assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id) + # Log organization data access + AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings") + changeset = Organizations.change_organization(organization) {:ok, diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index d1ef8eb6..e820350c 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -7,6 +7,7 @@ defmodule ToweropsWeb.UserSettingsLive do use ToweropsWeb, :live_view alias Towerops.Accounts + alias Towerops.Admin.AuditLogger alias Towerops.MobileSessions @impl true @@ -58,6 +59,10 @@ defmodule ToweropsWeb.UserSettingsLive do case Accounts.update_user_profile(user, user_params) do {:ok, _updated_user} -> + # Log profile update + fields_changed = Map.keys(user_params) + AuditLogger.log_user_profile_updated(nil, user.id, fields_changed) + socket = socket |> put_flash(:info, "Profile updated successfully.") diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 319cdd8b..626598dd 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -205,6 +205,7 @@ defmodule ToweropsWeb.Router do live "/", DashboardLive, :index live "/users", UserLive.Index, :index live "/organizations", OrgLive.Index, :index + live "/audit", AuditLive.Index, :index end end @@ -223,6 +224,7 @@ defmodule ToweropsWeb.Router do live "/users/settings", UserSettingsLive, :index live "/users/my-data", AccountLive.MyData, :index + live "/account/activity", AccountLive.Activity, :index end end diff --git a/priv/repo/migrations/20260130232533_add_audit_log_fields.exs b/priv/repo/migrations/20260130232533_add_audit_log_fields.exs new file mode 100644 index 00000000..64ec0ad8 --- /dev/null +++ b/priv/repo/migrations/20260130232533_add_audit_log_fields.exs @@ -0,0 +1,13 @@ +defmodule Towerops.Repo.Migrations.AddAuditLogFields do + use Ecto.Migration + + def change do + alter table(:audit_logs) do + add :user_agent, :text + add :request_path, :string + add :data_accessed, :map + end + + create index(:audit_logs, [:request_path]) + end +end