enhance user auditing
This commit is contained in:
parent
5256929186
commit
6ca22c5dd0
14 changed files with 1120 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
177
lib/towerops/admin/audit_logger.ex
Normal file
177
lib/towerops/admin/audit_logger.ex
Normal 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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
278
lib/towerops_web/live/account_live/activity.ex
Normal file
278
lib/towerops_web/live/account_live/activity.ex
Normal 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
|
||||
448
lib/towerops_web/live/admin/audit_live/index.ex
Normal file
448
lib/towerops_web/live/admin/audit_live/index.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
13
priv/repo/migrations/20260130232533_add_audit_log_fields.exs
Normal file
13
priv/repo/migrations/20260130232533_add_audit_log_fields.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue