towerops/lib/towerops/admin.ex

456 lines
12 KiB
Elixir

defmodule Towerops.Admin do
@moduledoc """
The Admin context for superuser operations.
Provides functions for managing users, organizations, and audit logging.
"""
import Ecto.Query
alias Towerops.Accounts.User
alias Towerops.Admin.AuditLog
alias Towerops.Organizations.Organization
alias Towerops.Repo
require Logger
## User Management
@doc """
Lists all users in the system with their organizations and device counts.
## Options
* `:limit` - Maximum number of users to return (default: 100)
* `:offset` - Number of users to skip (default: 0)
## Examples
iex> list_all_users()
[%User{}, ...]
iex> list_all_users(limit: 50, offset: 100)
[%User{}, ...]
"""
@spec list_all_users(Keyword.t()) :: [User.t()]
def list_all_users(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
# Subquery to count devices across all user's organizations
device_count_query =
from d in "devices",
join: m in "organization_memberships",
on: d.organization_id == m.organization_id,
where: m.user_id == parent_as(:user).id,
select: count(d.id)
User
|> from(as: :user)
|> select_merge([u], %{device_count: subquery(device_count_query)})
|> order_by([u], desc: u.inserted_at)
|> limit(^limit)
|> offset(^offset)
|> preload(:organizations)
|> Repo.all()
end
@doc """
Returns the total count of users in the system.
## Examples
iex> count_users()
42
"""
@spec count_users() :: integer()
def count_users do
Repo.aggregate(User, :count)
end
@doc """
Deletes a user and creates an audit log entry.
## Examples
iex> delete_user(user_id, superuser_id, "127.0.0.1")
{:ok, %User{}}
iex> delete_user(invalid_id, superuser_id, "127.0.0.1")
** (Ecto.NoResultsError)
"""
@spec delete_user(String.t(), String.t(), String.t()) :: {:ok, User.t()} | {:error, any()}
def delete_user(user_id, superuser_id, ip_address) do
with {:ok, user} <- fetch_user(user_id) do
do_delete_user(user, superuser_id, ip_address)
end
end
defp fetch_user(user_id) do
case Repo.get(User, user_id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
defp do_delete_user(user, superuser_id, ip_address) do
Repo.transaction(fn ->
create_audit_log(%{
action: "user_delete",
superuser_id: superuser_id,
target_user_id: user.id,
metadata: %{email: user.email},
ip_address: ip_address
})
Towerops.Accounts.anonymize_user_login_history(user.id)
Towerops.Accounts.anonymize_user_browser_sessions(user.id)
case Repo.delete(user) do
{:ok, deleted_user} ->
deleted_user
{:error, changeset} ->
Logger.error("Failed to delete user: #{inspect(changeset)}")
Repo.rollback(changeset)
end
end)
end
## Organization Management
@doc """
Lists all organizations in the system with their memberships.
## Options
* `:limit` - Maximum number of organizations to return (default: 100)
* `:offset` - Number of organizations to skip (default: 0)
## Examples
iex> list_all_organizations()
[%Organization{}, ...]
iex> list_all_organizations(limit: 50, offset: 100)
[%Organization{}, ...]
"""
def list_all_organizations(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
device_count_query =
from(d in "devices",
where: d.organization_id == parent_as(:org).id,
select: count(d.id)
)
Organization
|> from(as: :org)
|> select_merge([o], %{device_count: subquery(device_count_query)})
|> order_by([o], desc: o.inserted_at)
|> limit(^limit)
|> offset(^offset)
|> preload(memberships: :user)
|> Repo.all()
end
@doc """
Returns the total count of organizations in the system.
## Examples
iex> count_organizations()
15
"""
def count_organizations do
Repo.aggregate(Organization, :count)
end
@doc """
Deletes an organization and creates an audit log entry.
## Examples
iex> delete_organization(org_id, superuser_id, "127.0.0.1")
{:ok, %Organization{}}
iex> delete_organization(invalid_id, superuser_id, "127.0.0.1")
** (Ecto.NoResultsError)
"""
def delete_organization(org_id, superuser_id, ip_address) do
with {:ok, org} <- fetch_organization(org_id) do
do_delete_organization(org, superuser_id, ip_address)
end
end
defp fetch_organization(org_id) do
case Repo.get(Organization, org_id) do
nil -> {:error, :not_found}
org -> {:ok, org}
end
end
defp do_delete_organization(org, superuser_id, ip_address) do
Repo.transaction(fn ->
create_audit_log(%{
action: "org_delete",
superuser_id: superuser_id,
target_user_id: nil,
metadata: %{name: org.name, slug: org.slug},
ip_address: ip_address
})
case Repo.delete(org) do
{:ok, deleted_org} ->
deleted_org
{:error, changeset} ->
Logger.error("Failed to delete organization: #{inspect(changeset)}")
Repo.rollback(changeset)
end
end)
end
@doc """
Updates billing overrides for an organization with audit logging.
Only superusers should call this function. Creates an audit log entry
recording the change.
"""
def update_billing_overrides(org_id, attrs, superuser_id, ip_address) do
with {:ok, org} <- fetch_organization(org_id),
changeset = Organization.billing_override_changeset(org, attrs),
true <- changeset.valid? do
do_update_billing_overrides(changeset, org, superuser_id, ip_address)
else
{:error, _} = error -> error
false -> {:error, Organization.billing_override_changeset(%Organization{}, attrs)}
end
end
defp do_update_billing_overrides(changeset, org, superuser_id, ip_address) do
Repo.transaction(fn ->
create_audit_log(%{
action: "org_billing_override_updated",
superuser_id: superuser_id,
metadata: %{
organization_id: org.id,
organization_name: org.name,
changes: changeset.changes
},
ip_address: ip_address
})
case Repo.update(changeset) do
{:ok, updated_org} ->
updated_org
{:error, changeset} ->
Logger.error("Failed to update billing overrides: #{inspect(changeset.errors)}")
Repo.rollback(changeset)
end
end)
end
## Audit Logging
@doc """
Creates an audit log entry.
## Examples
iex> create_audit_log(%{action: "impersonate_start", superuser_id: superuser_id, target_user_id: user_id})
{:ok, %AuditLog{}}
iex> create_audit_log(%{action: "invalid"})
{:error, %Ecto.Changeset{}}
"""
def create_audit_log(attrs) do
%AuditLog{}
|> AuditLog.changeset(attrs)
|> Repo.insert()
end
@doc """
Lists recent audit logs with associated users.
## Options
* `:limit` - Maximum number of logs to return (default: 100)
## Examples
iex> list_audit_logs()
[%AuditLog{}, ...]
iex> list_audit_logs(limit: 50)
[%AuditLog{}, ...]
"""
def list_audit_logs(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
AuditLog
|> order_by([a], desc: a.inserted_at)
|> limit(^limit)
|> preload([:superuser, :target_user])
|> Repo.all()
end
@doc """
Returns audit logs related to a specific user (GDPR data access).
"""
def list_audit_logs_for_user(user_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
AuditLog
|> where([a], a.target_user_id == ^user_id or a.superuser_id == ^user_id)
|> order_by([a], desc: a.inserted_at)
|> limit(^limit)
|> preload([:superuser, :target_user])
|> Repo.all()
end
## Global Pricing Management
@doc """
Update global pricing settings.
Creates new Stripe Price if price changed, migrates all active subscriptions,
updates ApplicationSettings, and creates audit log.
Returns {:ok, migration_report} or {:error, reason}.
"""
def update_global_pricing(attrs, superuser_id, ip_address) do
alias Towerops.Billing
alias Towerops.Billing.StripeClient
alias Towerops.Settings
alias Towerops.Settings.ApplicationSetting
with {:ok, validated} <- validate_pricing_attrs(attrs),
old_values = get_current_pricing(),
{:ok, new_price_id, migration_report} <- maybe_update_stripe_price(validated, old_values),
:ok <- update_pricing_settings(validated, new_price_id),
:ok <- create_pricing_audit_log(old_values, validated, migration_report, superuser_id, ip_address) do
{:ok, migration_report}
end
end
defp validate_pricing_attrs(attrs) do
free_devices = Map.get(attrs, "default_free_devices")
price = Map.get(attrs, "default_price_per_device")
with {:ok, free} <- validate_free_devices(free_devices),
{:ok, price} <- validate_price(price) do
{:ok, %{free_devices: free, price: price}}
end
end
defp validate_free_devices(nil), do: {:ok, nil}
defp validate_free_devices(value) when is_binary(value) do
case Integer.parse(value) do
{int, _} when int > 0 and int < 10_000 -> {:ok, int}
_ -> {:error, :invalid_free_devices}
end
end
defp validate_price(nil), do: {:ok, nil}
defp validate_price(value) when is_binary(value) do
case Decimal.parse(value) do
{decimal, _} ->
if Decimal.compare(decimal, "0.01") in [:gt, :eq] and
Decimal.compare(decimal, "999.99") in [:lt, :eq] do
{:ok, value}
else
{:error, :invalid_price}
end
:error ->
{:error, :invalid_price}
end
end
defp get_current_pricing do
alias Towerops.Settings
%{
free_devices: Settings.get_setting("default_free_devices"),
price: Settings.get_setting("default_price_per_device")
}
end
defp maybe_update_stripe_price(validated, old_values) do
alias Towerops.Billing
alias Towerops.Billing.StripeClient
price_changed = validated.price && validated.price != old_values.price
if price_changed do
case StripeClient.create_price(validated.price) do
{:ok, %{"id" => price_id}} ->
migration_report = Billing.migrate_all_subscriptions_to_price(price_id)
{:ok, price_id, migration_report}
{:error, reason} ->
{:error, {:stripe_error, reason}}
end
else
{:ok, nil, %{total: 0, succeeded: 0, failed: 0, failures: []}}
end
end
defp update_pricing_settings(validated, new_price_id) do
alias Towerops.Settings
alias Towerops.Settings.ApplicationSetting
if validated.free_devices do
Settings.update_setting("default_free_devices", validated.free_devices)
end
if validated.price do
Settings.update_setting("default_price_per_device", validated.price)
end
if new_price_id do
# Create setting if doesn't exist
case Settings.get_setting_record("stripe_price_id") do
nil ->
%ApplicationSetting{}
|> ApplicationSetting.changeset(%{
key: "stripe_price_id",
value: new_price_id,
value_type: "string",
description: "Current Stripe Price ID for metered billing"
})
|> Repo.insert()
_setting ->
Settings.update_setting("stripe_price_id", new_price_id)
end
end
:ok
end
defp create_pricing_audit_log(old_values, validated, migration_report, superuser_id, ip_address) do
metadata = %{
old_free_devices: old_values.free_devices,
new_free_devices: validated.free_devices,
old_price: old_values.price,
new_price: validated.price,
migration_total: migration_report.total,
migration_succeeded: migration_report.succeeded,
migration_failed: migration_report.failed
}
create_audit_log(%{
action: "global_pricing_updated",
superuser_id: superuser_id,
ip_address: ip_address,
metadata: metadata
})
:ok
end
end