From 853d548f820023cb4c8e0c2eee5a09a825021298 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 6 Jan 2026 12:50:10 -0600 Subject: [PATCH] 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 --- .credo.exs | 217 ++++++++++++++++++ lib/towerops/accounts.ex | 18 ++ lib/towerops/accounts/scope.ex | 18 +- lib/towerops/accounts/user.ex | 1 + lib/towerops/admin.ex | 199 ++++++++++++++++ lib/towerops/admin/audit_log.ex | 37 +++ lib/towerops_web/components/layouts.ex | 39 ++++ .../components/layouts/admin.html.heex | 37 +++ .../controllers/admin_controller.ex | 22 ++ lib/towerops_web/live/admin/dashboard_live.ex | 22 ++ .../live/admin/dashboard_live.html.heex | 40 ++++ lib/towerops_web/live/admin/org_live/index.ex | 42 ++++ .../live/admin/org_live/index.html.heex | 27 +++ .../live/admin/user_live/index.ex | 47 ++++ .../live/admin/user_live/index.html.heex | 40 ++++ lib/towerops_web/router.ex | 23 ++ lib/towerops_web/user_auth.ex | 142 +++++++++++- mix.exs | 3 +- mix.lock | 2 + ...260106184002_add_is_superuser_to_users.exs | 11 + .../20260106184003_create_audit_logs.exs | 21 ++ .../20260106184151_set_initial_superuser.exs | 14 ++ 22 files changed, 1012 insertions(+), 10 deletions(-) create mode 100644 .credo.exs create mode 100644 lib/towerops/admin.ex create mode 100644 lib/towerops/admin/audit_log.ex create mode 100644 lib/towerops_web/components/layouts/admin.html.heex create mode 100644 lib/towerops_web/controllers/admin_controller.ex create mode 100644 lib/towerops_web/live/admin/dashboard_live.ex create mode 100644 lib/towerops_web/live/admin/dashboard_live.html.heex create mode 100644 lib/towerops_web/live/admin/org_live/index.ex create mode 100644 lib/towerops_web/live/admin/org_live/index.html.heex create mode 100644 lib/towerops_web/live/admin/user_live/index.ex create mode 100644 lib/towerops_web/live/admin/user_live/index.html.heex create mode 100644 priv/repo/migrations/20260106184002_add_is_superuser_to_users.exs create mode 100644 priv/repo/migrations/20260106184003_create_audit_logs.exs create mode 100644 priv/repo/migrations/20260106184151_set_initial_superuser.exs diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 00000000..8238884a --- /dev/null +++ b/.credo.exs @@ -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 `. 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`. + # + ] + } + } + ] +} diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index ffdb7087..50116ac3 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -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 diff --git a/lib/towerops/accounts/scope.ex b/lib/towerops/accounts/scope.ex index 3358e1fb..86a78973 100644 --- a/lib/towerops/accounts/scope.ex +++ b/lib/towerops/accounts/scope.ex @@ -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 diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index 08418170..ed628a39 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -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] diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex new file mode 100644 index 00000000..6334da97 --- /dev/null +++ b/lib/towerops/admin.ex @@ -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 diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex new file mode 100644 index 00000000..40fde001 --- /dev/null +++ b/lib/towerops/admin/audit_log.ex @@ -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 diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 84f4a519..045a548e 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -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"""
+ + <%= if @current_scope && @current_scope.impersonating? do %> +
+
+
+
+ <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> + + Impersonating: {@current_scope.user.email} + +
+
+ + + +
+
+
+
+ <% end %> +