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:
parent
f29282d9dc
commit
853d548f82
22 changed files with 1012 additions and 10 deletions
217
.credo.exs
Normal file
217
.credo.exs
Normal 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`.
|
||||
#
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
199
lib/towerops/admin.ex
Normal 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
|
||||
37
lib/towerops/admin/audit_log.ex
Normal file
37
lib/towerops/admin/audit_log.ex
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
37
lib/towerops_web/components/layouts/admin.html.heex
Normal file
37
lib/towerops_web/components/layouts/admin.html.heex
Normal 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>
|
||||
22
lib/towerops_web/controllers/admin_controller.ex
Normal file
22
lib/towerops_web/controllers/admin_controller.ex
Normal 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
|
||||
22
lib/towerops_web/live/admin/dashboard_live.ex
Normal file
22
lib/towerops_web/live/admin/dashboard_live.ex
Normal 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
|
||||
40
lib/towerops_web/live/admin/dashboard_live.html.heex
Normal file
40
lib/towerops_web/live/admin/dashboard_live.html.heex
Normal 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>
|
||||
42
lib/towerops_web/live/admin/org_live/index.ex
Normal file
42
lib/towerops_web/live/admin/org_live/index.ex
Normal 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
|
||||
27
lib/towerops_web/live/admin/org_live/index.html.heex
Normal file
27
lib/towerops_web/live/admin/org_live/index.html.heex
Normal 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>
|
||||
47
lib/towerops_web/live/admin/user_live/index.ex
Normal file
47
lib/towerops_web/live/admin/user_live/index.ex
Normal 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
|
||||
40
lib/towerops_web/live/admin/user_live/index.html.heex
Normal file
40
lib/towerops_web/live/admin/user_live/index.html.heex
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
21
priv/repo/migrations/20260106184003_create_audit_logs.exs
Normal file
21
priv/repo/migrations/20260106184003_create_audit_logs.exs
Normal 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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue