- M2: get_user_by_email/1 uses lower(email) = lower(?) so User@Example.com and user@example.com resolve to the same account; closes a lookalike- registration / password-reset confusion risk. - M8: webhook auth plug no longer echoes "Webhook authentication not configured" — the misconfiguration is logged server-side and the caller gets a generic Internal server error so endpoints can't be probed. - M9: coverages controller logs the underlying KMZ build error and returns a generic "Failed to build KMZ" string — filesystem paths and zip internals no longer leak. - M10: account-data controller no longer crashes with MatchError when an org has zero or multiple memberships; defaults to "member" and treats any owner membership as owner. - M11: ActivityController switches to a hard whitelist (Atom.to_string/1 comparison) instead of String.to_existing_atom/1; unknown filter types are dropped silently and the atom table can't be grown from API input. - M16: title-tracking MutationObserver is stored on `this` so destroyed() actually disconnects it — fixes a memory leak per LV navigation.
179 lines
4.8 KiB
Elixir
179 lines
4.8 KiB
Elixir
defmodule ToweropsWeb.Api.AccountDataController do
|
|
@moduledoc """
|
|
API controller for GDPR Right to Access - allows users to download all their data in JSON format
|
|
"""
|
|
use ToweropsWeb, :controller
|
|
|
|
alias Towerops.Admin
|
|
alias Towerops.Admin.AuditLogger
|
|
alias Towerops.Alerts
|
|
alias Towerops.Devices
|
|
alias Towerops.Organizations
|
|
|
|
@doc """
|
|
Returns all user data in JSON format (GDPR Right to Access compliance)
|
|
"""
|
|
def show(conn, _params) do
|
|
user = conn.assigns.current_scope.user
|
|
|
|
# Verify user account is confirmed before allowing data export
|
|
if user.confirmed_at do
|
|
# 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),
|
|
organizations: build_organizations_data(user),
|
|
devices: build_devices_data(user),
|
|
alerts: build_alerts_data(user),
|
|
audit_logs: build_audit_logs_data(user),
|
|
export_info: %{
|
|
exported_at: DateTime.utc_now(),
|
|
format: "JSON",
|
|
gdpr_article: "Article 15 - Right to Access"
|
|
}
|
|
}
|
|
|
|
conn
|
|
|> put_resp_content_type("application/json")
|
|
|> put_resp_header(
|
|
"content-disposition",
|
|
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
|
|
)
|
|
|> json(data)
|
|
else
|
|
conn
|
|
|> put_status(:forbidden)
|
|
|> json(%{error: "Email address must be confirmed before exporting account data"})
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
# Build profile data
|
|
defp build_profile_data(user) do
|
|
%{
|
|
id: user.id,
|
|
email: user.email,
|
|
first_name: user.first_name,
|
|
last_name: user.last_name,
|
|
timezone: user.timezone,
|
|
is_superuser: user.is_superuser,
|
|
confirmed_at: user.confirmed_at,
|
|
inserted_at: user.inserted_at,
|
|
updated_at: user.updated_at
|
|
}
|
|
end
|
|
|
|
# Build organizations data
|
|
defp build_organizations_data(user) do
|
|
user.id
|
|
|> Organizations.list_user_organizations()
|
|
|> Enum.map(fn org ->
|
|
# Organization has the user's membership preloaded. Handle the
|
|
# zero-or-more-than-one cases defensively — historically this was a
|
|
# plain `[membership] = ...` pattern match that crashed on any
|
|
# unexpected count and produced a 500 from this user-facing endpoint.
|
|
role =
|
|
case org.memberships do
|
|
[%{role: :owner} | _] -> "owner"
|
|
[_first | _] -> "member"
|
|
_ -> "member"
|
|
end
|
|
|
|
%{
|
|
id: org.id,
|
|
name: org.name,
|
|
slug: org.slug,
|
|
role: role,
|
|
inserted_at: org.inserted_at,
|
|
updated_at: org.updated_at
|
|
}
|
|
end)
|
|
end
|
|
|
|
# Build devices data
|
|
defp build_devices_data(user) do
|
|
org_ids =
|
|
user.id
|
|
|> Organizations.list_user_organizations()
|
|
|> Enum.map(& &1.id)
|
|
|
|
if Enum.empty?(org_ids) do
|
|
[]
|
|
else
|
|
org_ids
|
|
|> Devices.list_devices_for_organizations()
|
|
|> Enum.map(fn device ->
|
|
%{
|
|
id: device.id,
|
|
name: device.name,
|
|
ip_address: device.ip_address,
|
|
snmp_community: "[REDACTED]",
|
|
monitoring_enabled: device.monitoring_enabled,
|
|
snmp_enabled: device.snmp_enabled,
|
|
status: device.status,
|
|
organization: %{
|
|
id: device.organization_id,
|
|
name: device.organization.name
|
|
},
|
|
site: %{
|
|
id: device.site_id,
|
|
name: device.site.name
|
|
},
|
|
inserted_at: device.inserted_at,
|
|
updated_at: device.updated_at
|
|
}
|
|
end)
|
|
end
|
|
end
|
|
|
|
# Build alerts data (last 90 days)
|
|
defp build_alerts_data(user) do
|
|
org_ids =
|
|
user.id
|
|
|> Organizations.list_user_organizations()
|
|
|> Enum.map(& &1.id)
|
|
|
|
if Enum.empty?(org_ids) do
|
|
[]
|
|
else
|
|
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
|
|
|
|
org_ids
|
|
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|
|
|> Enum.map(fn alert ->
|
|
%{
|
|
id: alert.id,
|
|
alert_type: alert.alert_type,
|
|
message: alert.message,
|
|
triggered_at: alert.triggered_at,
|
|
resolved_at: alert.resolved_at,
|
|
acknowledged_at: alert.acknowledged_at,
|
|
device: %{
|
|
id: alert.device_id,
|
|
name: alert.device.name
|
|
},
|
|
inserted_at: alert.inserted_at
|
|
}
|
|
end)
|
|
end
|
|
end
|
|
|
|
# Build audit logs data
|
|
defp build_audit_logs_data(user) do
|
|
user.id
|
|
|> Admin.list_audit_logs_for_user(limit: 1000)
|
|
|> Enum.map(fn log ->
|
|
%{
|
|
id: log.id,
|
|
action: log.action,
|
|
actor_email: if(log.superuser, do: log.superuser.email),
|
|
target_user_id: log.target_user_id,
|
|
metadata: log.metadata,
|
|
ip_address: log.ip_address,
|
|
inserted_at: log.inserted_at
|
|
}
|
|
end)
|
|
end
|
|
end
|