448 lines
17 KiB
Elixir
448 lines
17 KiB
Elixir
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
|