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 %> +