Add superuser system with user impersonation for admin support

Implement comprehensive admin interface allowing designated superusers to view all users and organizations, impersonate users for debugging, and perform administrative operations. All superuser actions are tracked in audit logs for compliance.

Features:
- Superuser authentication with dedicated admin routes at /admin
- User impersonation with session state preservation
- Admin dashboard with system statistics
- User and organization management interfaces
- Comprehensive audit logging with IP tracking
- Visual impersonation banner with exit capability
- Security controls preventing self-impersonation and superuser-to-superuser impersonation

Database:
- Add is_superuser boolean field to users table
- Create audit_logs table for tracking sensitive operations
- Set graham@mcintire.me as initial superuser
This commit is contained in:
Graham McIntire 2026-01-06 12:50:10 -06:00
parent f29282d9dc
commit 853d548f82
No known key found for this signature in database
22 changed files with 1012 additions and 10 deletions

217
.credo.exs Normal file
View file

@ -0,0 +1,217 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
"web/",
"apps/*/lib/",
"apps/*/src/",
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: true,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.StructFieldAmount, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OneArityFunctionInPipe, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

View file

@ -48,6 +48,24 @@ defmodule Towerops.Accounts do
@doc """
Gets a single user.
Returns `nil` if the User does not exist.
## Examples
iex> get_user("123")
%User{}
iex> get_user("456")
nil
"""
def get_user(id) when is_binary(id) do
Repo.get(User, id)
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples

View file

@ -18,7 +18,7 @@ defmodule Towerops.Accounts.Scope do
alias Towerops.Accounts.User
defstruct user: nil
defstruct user: nil, superuser: nil, impersonating?: false
@doc """
Creates a scope for the given user.
@ -26,8 +26,22 @@ defmodule Towerops.Accounts.Scope do
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
%__MODULE__{user: user, superuser: nil, impersonating?: false}
end
def for_user(nil), do: nil
@doc """
Creates a scope for a superuser impersonating another user.
The `user` field contains the impersonated user (what the superuser sees as),
while the `superuser` field contains the actual superuser performing the impersonation.
"""
def for_impersonation(%User{} = superuser, %User{} = target_user) do
%__MODULE__{
user: target_user,
superuser: superuser,
impersonating?: true
}
end
end

View file

@ -12,6 +12,7 @@ defmodule Towerops.Accounts.User do
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
field :is_superuser, :boolean, default: false
has_many :memberships, Towerops.Organizations.Membership
has_many :organizations, through: [:memberships, :organization]

199
lib/towerops/admin.ex Normal file
View file

@ -0,0 +1,199 @@
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
## User Management
@doc """
Lists all users in the system with their organizations.
## 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{}, ...]
"""
def list_all_users(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
User
|> 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
"""
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)
"""
def delete_user(user_id, superuser_id, ip_address) do
user = Repo.get!(User, user_id)
Repo.transaction(fn ->
# Create audit log before deletion
create_audit_log(%{
action: "user_delete",
superuser_id: superuser_id,
target_user_id: user_id,
metadata: %{email: user.email},
ip_address: ip_address
})
# Delete user (cascades to memberships and tokens)
Repo.delete!(user)
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)
Organization
|> 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
org = Repo.get!(Organization, org_id)
Repo.transaction(fn ->
# Create audit log before deletion
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
})
# Delete organization (cascades to memberships, sites, equipment)
Repo.delete!(org)
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
end

View file

@ -0,0 +1,37 @@
defmodule Towerops.Admin.AuditLog do
@moduledoc """
Schema for audit logs tracking sensitive operations performed by superusers.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "audit_logs" do
field :action, :string
field :metadata, :map
field :ip_address, :string
belongs_to :superuser, User
belongs_to :target_user, User
timestamps(type: :utc_datetime, updated_at: false)
end
@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])
|> validate_inclusion(:action, [
"impersonate_start",
"impersonate_end",
"user_delete",
"org_delete"
])
end
end

View file

@ -59,6 +59,10 @@ defmodule ToweropsWeb.Layouts do
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current scope (contains user and impersonation state)"
attr :current_organization, :any,
default: nil,
doc: "the current organization (can be nil for pages without org context)"
@ -72,6 +76,32 @@ defmodule ToweropsWeb.Layouts do
def authenticated(assigns) do
~H"""
<div class="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<!-- Impersonation Banner -->
<%= if @current_scope && @current_scope.impersonating? do %>
<div class="bg-yellow-400 border-b-2 border-yellow-600">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span class="font-medium">
Impersonating: {@current_scope.user.email}
</span>
</div>
<form action={~p"/admin/impersonate"} method="post">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input type="hidden" name="_method" value="delete" />
<button
type="submit"
class="px-3 py-1 bg-white text-yellow-900 hover:bg-yellow-50 rounded font-medium text-sm"
>
Exit Impersonation
</button>
</form>
</div>
</div>
</div>
<% end %>
<nav class="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
@ -133,6 +163,15 @@ defmodule ToweropsWeb.Layouts do
>
Switch Org
</.link>
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
<.link
navigate={~p"/admin"}
class="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-shield-check" class="w-4 h-4" /> Admin Panel
</.link>
<% end %>
<.link
navigate={~p"/users/settings"}
class="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"

View file

@ -0,0 +1,37 @@
<main class="min-h-screen bg-zinc-50">
<nav class="bg-white border-b border-zinc-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<.link navigate={~p"/admin"} class="flex items-center text-xl font-bold text-zinc-900">
Towerops Admin
</.link>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<.link
navigate={~p"/admin/users"}
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-zinc-700 hover:text-zinc-900"
>
Users
</.link>
<.link
navigate={~p"/admin/organizations"}
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-zinc-700 hover:text-zinc-900"
>
Organizations
</.link>
</div>
</div>
<div class="flex items-center">
<.link navigate={~p"/orgs"} class="text-sm text-zinc-600 hover:text-zinc-900">
Back to App
</.link>
</div>
</div>
</div>
</nav>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<.flash_group flash={@flash} />
{@inner_content}
</div>
</main>

View file

@ -0,0 +1,22 @@
defmodule ToweropsWeb.AdminController do
@moduledoc """
Controller for admin operations including user impersonation.
"""
use ToweropsWeb, :controller
alias ToweropsWeb.UserAuth
@doc """
Start impersonating a user.
"""
def start_impersonate(conn, %{"user_id" => user_id}) do
UserAuth.start_impersonation(conn, user_id)
end
@doc """
Stop impersonating and return to superuser.
"""
def stop_impersonate(conn, _params) do
UserAuth.stop_impersonation(conn)
end
end

View file

@ -0,0 +1,22 @@
defmodule ToweropsWeb.Admin.DashboardLive do
@moduledoc """
Admin dashboard showing system statistics and recent audit logs.
"""
use ToweropsWeb, :live_view
alias Towerops.Admin
@impl true
def mount(_params, _session, socket) do
user_count = Admin.count_users()
org_count = Admin.count_organizations()
recent_logs = Admin.list_audit_logs(limit: 10)
{:ok,
socket
|> assign(:page_title, "Admin Dashboard")
|> assign(:user_count, user_count)
|> assign(:org_count, org_count)
|> assign(:recent_logs, recent_logs)}
end
end

View file

@ -0,0 +1,40 @@
<Layouts.admin flash={@flash}>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-zinc-900">Admin Dashboard</h1>
<p class="text-zinc-600">System overview and recent activity</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-white rounded-lg border border-zinc-200 p-6">
<h2 class="text-lg font-semibold text-zinc-900 mb-2">Users</h2>
<p class="text-3xl font-bold text-zinc-900">{@user_count}</p>
<.link navigate={~p"/admin/users"} class="text-blue-600 hover:underline text-sm">
View all users →
</.link>
</div>
<div class="bg-white rounded-lg border border-zinc-200 p-6">
<h2 class="text-lg font-semibold text-zinc-900 mb-2">Organizations</h2>
<p class="text-3xl font-bold text-zinc-900">{@org_count}</p>
<.link navigate={~p"/admin/organizations"} class="text-blue-600 hover:underline text-sm">
View all organizations →
</.link>
</div>
</div>
<div class="bg-white rounded-lg border border-zinc-200">
<div class="px-4 py-3 border-b border-zinc-200">
<h2 class="text-lg font-semibold text-zinc-900">Recent Audit Logs</h2>
</div>
<.table id="audit-logs" rows={@recent_logs}>
<:col :let={log} label="Action">{log.action}</:col>
<:col :let={log} label="Superuser">{log.superuser && log.superuser.email}</:col>
<:col :let={log} label="Target">{log.target_user && log.target_user.email}</:col>
<:col :let={log} label="Time">
{Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S")}
</:col>
</.table>
</div>
</div>
</Layouts.admin>

View file

@ -0,0 +1,42 @@
defmodule ToweropsWeb.Admin.OrgLive.Index do
@moduledoc """
Admin interface for viewing and managing organizations.
"""
use ToweropsWeb, :live_view
alias Towerops.Admin
@impl true
def mount(_params, _session, socket) do
orgs = Admin.list_all_organizations()
{:ok,
socket
|> assign(:page_title, "All Organizations")
|> assign(:organizations, orgs)}
end
@impl true
def handle_event("delete_org", %{"id" => org_id}, socket) do
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
ip = get_connect_info_ip(socket)
case Admin.delete_organization(org_id, superuser.id, ip) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Organization deleted successfully")
|> assign(:organizations, Admin.list_all_organizations())}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to delete organization")}
end
end
defp get_connect_info_ip(socket) do
case get_connect_info(socket, :peer_data) do
%{address: address} -> to_string(:inet_parse.ntoa(address))
_ -> "unknown"
end
end
end

View file

@ -0,0 +1,27 @@
<Layouts.admin flash={@flash}>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-zinc-900">All Organizations</h1>
<p class="text-zinc-600">Manage organizations</p>
</div>
<div class="bg-white rounded-lg border border-zinc-200">
<.table id="organizations" rows={@organizations}>
<:col :let={org} label="Name">{org.name}</:col>
<:col :let={org} label="Slug">{org.slug}</:col>
<:col :let={org} label="Members">{length(org.memberships)}</:col>
<:col :let={org} label="Created">{Calendar.strftime(org.inserted_at, "%Y-%m-%d")}</:col>
<:col :let={org} label="">
<button
phx-click="delete_org"
phx-value-id={org.id}
data-confirm="Are you sure? This will delete all sites, equipment, and data for this organization."
class="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</:col>
</.table>
</div>
</div>
</Layouts.admin>

View file

@ -0,0 +1,47 @@
defmodule ToweropsWeb.Admin.UserLive.Index do
@moduledoc """
Admin interface for viewing and managing users.
"""
use ToweropsWeb, :live_view
alias Towerops.Admin
@impl true
def mount(_params, _session, socket) do
users = Admin.list_all_users()
{:ok,
socket
|> assign(:page_title, "All Users")
|> assign(:users, users)}
end
@impl true
def handle_event("impersonate", %{"id" => user_id}, socket) do
{:noreply, redirect(socket, to: ~p"/admin/impersonate/#{user_id}")}
end
@impl true
def handle_event("delete_user", %{"id" => user_id}, socket) do
superuser = socket.assigns.current_scope.superuser || socket.assigns.current_scope.user
ip = get_connect_info_ip(socket)
case Admin.delete_user(user_id, superuser.id, ip) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "User deleted successfully")
|> assign(:users, Admin.list_all_users())}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to delete user")}
end
end
defp get_connect_info_ip(socket) do
case get_connect_info(socket, :peer_data) do
%{address: address} -> to_string(:inet_parse.ntoa(address))
_ -> "unknown"
end
end
end

View file

@ -0,0 +1,40 @@
<Layouts.admin flash={@flash}>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-zinc-900">All Users</h1>
<p class="text-zinc-600">Manage system users</p>
</div>
<div class="bg-white rounded-lg border border-zinc-200">
<.table id="users" rows={@users}>
<:col :let={user} label="Email">{user.email}</:col>
<:col :let={user} label="Superuser">{if user.is_superuser, do: "Yes", else: "No"}</:col>
<:col :let={user} label="Organizations">{length(user.organizations)}</:col>
<:col :let={user} label="Joined">{Calendar.strftime(user.inserted_at, "%Y-%m-%d")}</:col>
<:col :let={user} label="">
<div class="flex gap-2">
<form action={~p"/admin/impersonate/#{user.id}"} method="post">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<button
type="submit"
disabled={user.is_superuser}
class="text-sm text-blue-600 hover:text-blue-800 disabled:text-zinc-400 disabled:cursor-not-allowed"
>
Impersonate
</button>
</form>
<button
phx-click="delete_user"
phx-value-id={user.id}
data-confirm="Are you sure you want to delete this user?"
disabled={user.is_superuser}
class="text-sm text-red-600 hover:text-red-800 disabled:text-zinc-400 disabled:cursor-not-allowed"
>
Delete
</button>
</div>
</:col>
</.table>
</div>
</div>
</Layouts.admin>

View file

@ -76,6 +76,29 @@ defmodule ToweropsWeb.Router do
delete "/users/log-out", UserSessionController, :delete
end
## Admin routes (superuser only)
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :require_superuser]
post "/admin/impersonate/:user_id", AdminController, :start_impersonate
delete "/admin/impersonate", AdminController, :stop_impersonate
end
live_session :require_superuser,
on_mount: [
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_superuser}
] do
scope "/admin", ToweropsWeb.Admin do
pipe_through [:browser, :require_authenticated_user, :require_superuser]
live "/", DashboardLive, :index
live "/users", UserLive.Index, :index
live "/organizations", OrgLive.Index, :index
end
end
## Organization routes
live_session :require_authenticated_user,

View file

@ -66,15 +66,35 @@ defmodule ToweropsWeb.UserAuth do
Authenticates the user by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
Handles impersonation by checking session state.
"""
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
# Check if currently impersonating
if get_session(conn, :impersonating) do
superuser_id = get_session(conn, :superuser_id)
with {token, conn} <- ensure_user_token(conn),
{user, _token_inserted_at} <- Accounts.get_user_by_session_token(token),
superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id) do
assign(conn, :current_scope, Scope.for_impersonation(superuser, user))
else
_ ->
# Impersonation invalid, clear it
conn
|> delete_session(:superuser_id)
|> delete_session(:impersonating)
|> assign(:current_scope, Scope.for_user(nil))
end
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
# Normal authentication flow
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
end
end
end
@ -121,7 +141,9 @@ defmodule ToweropsWeb.UserAuth do
# Do not renew session if the user is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
defp renew_session(conn, user)
when is_map_key(conn.assigns, :current_scope) and not is_nil(conn.assigns.current_scope) and
not is_nil(conn.assigns.current_scope.user) and conn.assigns.current_scope.user.id == user.id do
conn
end
@ -211,6 +233,22 @@ defmodule ToweropsWeb.UserAuth do
end
end
@doc """
Plug for routes that require the user to be a superuser.
"""
def require_superuser(conn, _opts) do
user = conn.assigns.current_scope && conn.assigns.current_scope.user
if user && user.is_superuser do
conn
else
conn
|> put_flash(:error, "You must be a superuser to access this page.")
|> redirect(to: ~p"/orgs")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
@ -332,6 +370,22 @@ defmodule ToweropsWeb.UserAuth do
{:cont, socket}
end
def on_mount(:require_superuser, _params, session, socket) do
socket = mount_current_scope(socket, session)
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if user && user.is_superuser do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "You must be a superuser to access this page.")
|> LiveView.redirect(to: ~p"/orgs")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
if user_token = session["user_token"] do
@ -344,4 +398,78 @@ defmodule ToweropsWeb.UserAuth do
end
end)
end
@doc """
Start impersonating a user as a superuser.
Stores the original superuser ID in the session and updates the current scope
to show the impersonated user. Creates an audit log entry.
"""
def start_impersonation(conn, target_user_id) do
superuser = conn.assigns.current_scope.user
target_user = Accounts.get_user!(target_user_id)
# Prevent impersonating self or other superusers
if target_user.id == superuser.id do
conn
|> put_flash(:error, "You cannot impersonate yourself.")
|> redirect(to: ~p"/admin/users")
else
if target_user.is_superuser do
conn
|> put_flash(:error, "You cannot impersonate other superusers.")
|> redirect(to: ~p"/admin/users")
else
# Create audit log
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
Towerops.Admin.create_audit_log(%{
action: "impersonate_start",
superuser_id: superuser.id,
target_user_id: target_user.id,
metadata: %{target_email: target_user.email},
ip_address: ip
})
# Store superuser ID in session and update scope
conn
|> put_session(:superuser_id, superuser.id)
|> put_session(:impersonating, true)
|> assign(:current_scope, Scope.for_impersonation(superuser, target_user))
|> put_flash(:info, "Now impersonating #{target_user.email}")
|> redirect(to: ~p"/orgs")
end
end
end
@doc """
Stop impersonating and restore the superuser session.
Removes impersonation state from the session and restores the original superuser
scope. Creates an audit log entry.
"""
def stop_impersonation(conn) do
superuser_id = get_session(conn, :superuser_id)
target_user = conn.assigns.current_scope.user
superuser = Accounts.get_user!(superuser_id)
# Create audit log
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
Towerops.Admin.create_audit_log(%{
action: "impersonate_end",
superuser_id: superuser_id,
target_user_id: target_user.id,
metadata: %{target_email: target_user.email},
ip_address: ip
})
# Restore superuser session
conn
|> delete_session(:superuser_id)
|> delete_session(:impersonating)
|> assign(:current_scope, Scope.for_user(superuser))
|> put_flash(:info, "Stopped impersonating")
|> redirect(to: ~p"/admin")
end
end

View file

@ -70,7 +70,8 @@ defmodule Towerops.MixProject do
{:mox, "~> 1.0", only: :test},
{:styler, "~> 1.10", only: [:dev, :test], runtime: false},
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end

View file

@ -1,8 +1,10 @@
%{
"bandit": {:hex, :bandit, "1.9.0", "6dc1ff2c30948dfecf32db574cc3447c7b9d70e0b61140098df3818870b01b76", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "2538aaa1663b40ca9cbd8ca1f8a540cb49e5baf34c6ffef068369cc45f9146f2"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},

View file

@ -0,0 +1,11 @@
defmodule Towerops.Repo.Migrations.AddIsSuperuserToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :is_superuser, :boolean, default: false, null: false
end
create index(:users, [:is_superuser])
end
end

View file

@ -0,0 +1,21 @@
defmodule Towerops.Repo.Migrations.CreateAuditLogs do
use Ecto.Migration
def change do
create table(:audit_logs, primary_key: false) do
add :id, :binary_id, primary_key: true
add :action, :string, null: false
add :superuser_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :target_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :metadata, :map, default: %{}
add :ip_address, :string
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:audit_logs, [:superuser_id])
create index(:audit_logs, [:target_user_id])
create index(:audit_logs, [:action])
create index(:audit_logs, [:inserted_at])
end
end

View file

@ -0,0 +1,14 @@
defmodule Towerops.Repo.Migrations.SetInitialSuperuser do
use Ecto.Migration
import Ecto.Query
def up do
from(u in "users", where: u.email == "graham@mcintire.me")
|> Towerops.Repo.update_all(set: [is_superuser: true])
end
def down do
from(u in "users", where: u.email == "graham@mcintire.me")
|> Towerops.Repo.update_all(set: [is_superuser: false])
end
end