enhance user auditing

This commit is contained in:
Graham McIntire 2026-01-30 17:38:07 -06:00
parent 5256929186
commit 6ca22c5dd0
14 changed files with 1120 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -88,9 +88,112 @@
</section>
<section class="space-y-4 mt-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Cookies and Tracking</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Cookie Policy</h2>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6">
What Are Cookies?
</h3>
<p class="text-gray-700 dark:text-gray-300">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6">
What Cookies Do We Use?
</h3>
<p class="text-gray-700 dark:text-gray-300">
Towerops uses only <strong class="font-semibold">essential cookies</strong>
that are strictly necessary for the operation of our platform. We do not use third-party tracking, advertising, or analytics cookies.
</p>
<div class="mt-4">
<h4 class="text-base font-semibold text-gray-900 dark:text-white">Essential Cookies</h4>
<p class="text-gray-700 dark:text-gray-300 mt-2">
These cookies are required for our website to function properly and cannot be disabled:
</p>
<ul class="list-disc pl-6 space-y-3 text-gray-700 dark:text-gray-300 mt-3">
<li>
<strong class="font-semibold">Session Cookie (_towerops_web_key)</strong>
<br />
<span class="text-sm">
Purpose: Maintains your login session and authentication state
</span>
<br />
<span class="text-sm">
Duration: Expires when you close your browser or log out
</span>
</li>
<li>
<strong class="font-semibold">CSRF Token</strong>
<br />
<span class="text-sm">
Purpose: Protects against Cross-Site Request Forgery attacks
</span>
<br />
<span class="text-sm">Duration: Expires when you close your browser</span>
</li>
<li>
<strong class="font-semibold">LiveView Session</strong>
<br />
<span class="text-sm">
Purpose: Enables real-time updates and interactive features
</span>
<br />
<span class="text-sm">Duration: Expires when you close your browser</span>
</li>
<li>
<strong class="font-semibold">Cookie Consent (cookie_consent)</strong>
<br />
<span class="text-sm">
Purpose: Remembers your cookie consent preference (EU/EEA users only)
</span>
<br />
<span class="text-sm">Duration: 1 year</span>
</li>
</ul>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6">
Third-Party Cookies
</h3>
<p class="text-gray-700 dark:text-gray-300">
We do not use any third-party cookies, tracking pixels, or analytics services that would place cookies on your device.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6">Managing Cookies</h3>
<p class="text-gray-700 dark:text-gray-300">
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:
</p>
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300 mt-3">
<li>
<strong class="font-semibold">Chrome:</strong>
Settings → Privacy and security → Cookies and other site data
</li>
<li>
<strong class="font-semibold">Firefox:</strong>
Settings → Privacy & Security → Cookies and Site Data
</li>
<li>
<strong class="font-semibold">Safari:</strong>
Preferences → Privacy → Manage Website Data
</li>
<li>
<strong class="font-semibold">Edge:</strong>
Settings → Cookies and site permissions → Manage and delete cookies
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6">
Cookie Consent (EU/EEA Users)
</h3>
<p class="text-gray-700 dark:text-gray-300">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6">
Changes to Our Cookie Policy
</h3>
<p class="text-gray-700 dark:text-gray-300">
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.
</p>
</section>

View file

@ -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"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="settings">
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Activity Log
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
View all actions performed by you and on your account data for transparency and security.
</p>
</div>
<div class="mt-8 space-y-4">
<%= if Enum.empty?(@audit_logs) do %>
<div class="text-center py-12 bg-white dark:bg-gray-900 rounded-lg shadow">
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
No activity logs
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your activity will appear here as you use the platform.
</p>
</div>
<% else %>
<div class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
<%= for log <- @audit_logs do %>
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<span class={[
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
action_badge_class(log.action)
]}>
{format_action(log.action)}
</span>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{action_description(log)}
</p>
</div>
<div class="mt-2 flex flex-col gap-1 text-sm text-gray-500 dark:text-gray-400">
<%= if log.superuser do %>
<div class="flex items-center gap-1">
<.icon name="hero-user" class="h-4 w-4" />
<span>
By: <span class="font-medium">{log.superuser.email}</span>
</span>
</div>
<% end %>
<%= if log.request_path do %>
<div class="flex items-center gap-1">
<.icon name="hero-link" class="h-4 w-4" />
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">
{log.request_path}
</code>
</div>
<% end %>
<%= if log.ip_address do %>
<div class="flex items-center gap-1">
<.icon name="hero-globe-alt" class="h-4 w-4" />
<span>IP: {log.ip_address}</span>
</div>
<% end %>
<%= if log.data_accessed && map_size(log.data_accessed) > 0 do %>
<div class="flex items-start gap-1">
<.icon name="hero-eye" class="h-4 w-4 mt-0.5" />
<div>
<span class="font-medium">Data accessed:</span>
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded ml-1">
{inspect(log.data_accessed, pretty: true, limit: 3)}
</code>
</div>
</div>
<% end %>
<%= if log.metadata && map_size(log.metadata) > 0 do %>
<details class="mt-1">
<summary class="cursor-pointer text-xs text-blue-600 dark:text-blue-400 hover:underline">
View metadata
</summary>
<pre class="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto">
{Jason.encode!(log.metadata, pretty: true)}
</pre>
</details>
<% end %>
</div>
</div>
<div class="ml-4 flex-shrink-0 text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">
{format_timestamp(log.inserted_at)}
</p>
</div>
</div>
</li>
<% end %>
</ul>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0"
/>
<div class="text-sm text-blue-800 dark:text-blue-300">
<p class="font-medium">GDPR Transparency</p>
<p class="mt-1">
This activity log shows actions performed by you and on your account data.
Records are retained for 3 years for compliance and security purposes.
</p>
</div>
</div>
</div>
<% end %>
</div>
</Layouts.authenticated>
"""
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

View file

@ -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"""
<Layouts.admin flash={@flash} timezone={@current_scope.user.timezone}>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Audit Logs
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
View and search all system audit logs. Records are retained for 3 years.
</p>
</div>
<!-- Search Filters -->
<div class="mt-8 bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<form phx-submit="search" class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
User Email
</label>
<input
type="text"
name="email"
id="email"
value={@search_email}
placeholder="user@example.com"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label for="action" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Action
</label>
<select
name="action"
id="action"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
>
<option value="">All Actions</option>
<%= for action <- available_actions() do %>
<option value={action} selected={@search_action == action}>
{format_action(action)}
</option>
<% end %>
</select>
</div>
<div>
<label
for="date_from"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
From Date
</label>
<input
type="date"
name="date_from"
id="date_from"
value={@date_from}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
To Date
</label>
<input
type="date"
name="date_to"
id="date_to"
value={@date_to}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div class="flex gap-3">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-magnifying-glass" class="h-4 w-4 mr-2" /> Search
</button>
<button
type="button"
phx-click="clear_filters"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-x-mark" class="h-4 w-4 mr-2" /> Clear
</button>
<button
type="button"
phx-click="export"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4 mr-2" /> Export CSV
</button>
</div>
</form>
</div>
<!-- Results -->
<div class="mt-8">
<%= if Enum.empty?(@audit_logs) do %>
<div class="text-center py-12 bg-white dark:bg-gray-900 rounded-lg shadow">
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
No audit logs found
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Try adjusting your search filters.
</p>
</div>
<% else %>
<div class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Timestamp
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Action
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actor
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Target User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
IP Address
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Details
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<%= for log <- @audit_logs do %>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S")}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={[
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
action_badge_class(log.action)
]}>
{format_action(log.action)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if log.superuser do %>
{log.superuser.email}
<% else %>
<span class="text-gray-400">System</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if log.target_user do %>
{log.target_user.email}
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.ip_address || "-"}
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= if log.request_path do %>
<div class="text-xs">
<code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">
{log.request_path}
</code>
</div>
<% end %>
<%= if log.metadata && map_size(log.metadata) > 0 do %>
<details class="mt-1">
<summary class="cursor-pointer text-xs text-blue-600 dark:text-blue-400 hover:underline">
Metadata
</summary>
<pre class="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto max-w-md">
{Jason.encode!(log.metadata, pretty: true)}
</pre>
</details>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-700 dark:text-gray-300">
Page {@page} Showing {length(@audit_logs)} records
</div>
<div class="flex gap-2">
<button
type="button"
phx-click="prev_page"
disabled={@page == 1}
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
type="button"
phx-click="next_page"
disabled={length(@audit_logs) < @per_page}
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
<% end %>
</div>
</Layouts.admin>
"""
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

View file

@ -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

View file

@ -133,6 +133,11 @@
{agent.metadata["hostname"]}
</div>
<% end %>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
v{agent.metadata["version"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Created">
@ -232,6 +237,11 @@
{agent.metadata["hostname"]}
</div>
<% end %>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
v{agent.metadata["version"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Created">

View file

@ -211,6 +211,14 @@
Agent Metadata
</h3>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= if @agent_token.metadata["version"] do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Version</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">
{@agent_token.metadata["version"]}
</dd>
</div>
<% end %>
<%= if @agent_token.metadata["hostname"] do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Hostname</dt>

View file

@ -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)

View file

@ -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,

View file

@ -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.")

View file

@ -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

View file

@ -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