towerops/lib/towerops/accounts.ex
Graham McIntire d1403c8069 Fix failing tests and clean up code
- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior
- Fix dynamic_extra_test vendor post-processing tests to seed sensor data
- Fix activity_controller_test to seed devices for feed data
- Fix session_manager_test to create browser session for test
- Fix topology_test link creation for connection test
- Fix device_monitor/driver_worker tests for unique job constraints
- Fix accounts_test expired_tokens assertion (magic link token is expired)
- Fix happy_path_test and show_events_test to seed monitor data
- Fix admin user_live_test user.name -> user.email (no name field)
- Fix schema_test to seed activity data
- Fix mobile_qr_live_test to match actual template text
- Fix SnmpKit.MIB doctests and tests for enriched return values
- Fix onboarding_live, mobile_controller, mib_test weak assertions
- Remove dead code and fix credo warnings
2026-06-16 14:54:34 -05:00

521 lines
17 KiB
Elixir

defmodule Towerops.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.Consents
alias Towerops.Accounts.Emails
alias Towerops.Accounts.LoginHistory
alias Towerops.Accounts.MagicLinks
alias Towerops.Accounts.Passwords
alias Towerops.Accounts.PolicyVersions
alias Towerops.Accounts.Sessions
alias Towerops.Accounts.TOTP
alias Towerops.Accounts.User
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Admin.AuditLogger
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
alias Towerops.Repo
require Logger
## Database getters
@doc """
Gets a user by email.
## Examples
iex> Accounts.get_user_by_email("unknown@example.com")
nil
"""
@spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do
# Case-insensitive match so `User@Example.com` and `user@example.com`
# resolve to the same account — prevents lookalike registrations and
# password-reset confusion.
Repo.one(from u in User, where: fragment("lower(?) = lower(?)", u.email, ^email))
end
@doc """
Gets a user by email and password.
## Examples
iex> Accounts.get_user_by_email_and_password("foo@example.com", "correct_password")
nil
iex> Accounts.get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Returns `nil` if the User does not exist.
## Examples
iex> Accounts.get_user("00000000-0000-0000-0000-000000000000")
nil
"""
@spec get_user(String.t()) :: User.t() | 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.
"""
@spec get_user!(String.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
"""
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
end
@doc """
Registers a user with email and password.
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs)
Repo.transaction(fn ->
with {:ok, user} <- Repo.insert(user_changeset),
:ok <- Consents.grant_consents_sequential(user, attrs) do
user
else
{:error, changeset} -> Repo.rollback(changeset)
end
end)
end
@doc """
Registers a user with email and password, and creates a default organization.
"""
@spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user_with_organization(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs)
Repo.transaction(fn ->
with {:ok, user} <- Repo.insert(user_changeset),
org_name = attrs["organization_name"] || "Personal",
{:ok, _org} <- Towerops.Organizations.create_organization(%{name: org_name}, user.id),
:ok <- Consents.grant_consents_sequential(user, attrs) do
user
else
{:error, %Ecto.Changeset{} = changeset} -> Repo.rollback(changeset)
{:error, reason} -> Repo.rollback(reason)
end
end)
end
## TOTP / Two-Factor Authentication
## (Implementation lives in `Towerops.Accounts.TOTP`.)
defdelegate generate_totp_secret(), to: TOTP, as: :generate_secret
defdelegate generate_totp_uri(user, secret), to: TOTP, as: :generate_uri
defdelegate generate_totp_qr_code(user, secret), to: TOTP, as: :generate_qr_code
defdelegate verify_totp(secret, code), to: TOTP, as: :verify
defdelegate enable_totp(user, secret), to: TOTP, as: :enable
defdelegate totp_enabled?(user), to: TOTP, as: :enabled?
defdelegate verify_user_totp(user, code), to: TOTP, as: :verify_user
defdelegate list_user_totp_devices(user_id), to: TOTP, as: :list_user_devices
defdelegate count_user_totp_devices(user_id), to: TOTP, as: :count_user_devices
defdelegate create_totp_device(user_id, name), to: TOTP, as: :create_device
defdelegate create_totp_device(user_id, name, secret), to: TOTP, as: :create_device
defdelegate verify_new_totp_device(device_id, user_id), to: TOTP, as: :verify_new_device
defdelegate verify_user_totp_any_device(user, code), to: TOTP, as: :verify_user_any_device
defdelegate delete_totp_device(device_id, user_id), to: TOTP, as: :delete_device
defdelegate rename_totp_device(device_id, user_id, new_name), to: TOTP, as: :rename_device
defdelegate generate_recovery_codes(user_id), to: TOTP
defdelegate verify_recovery_code(user_id, code), to: TOTP
defdelegate count_unused_recovery_codes(user_id), to: TOTP
defdelegate list_user_recovery_codes(user_id), to: TOTP
defdelegate verify_user_mfa(user, code), to: TOTP
defdelegate verify_totp_only(user, code), to: TOTP
## Settings
@doc """
Returns an `%Ecto.Changeset{}` for changing the user profile.
"""
def change_user_profile(user, attrs \\ %{}) do
User.profile_changeset(user, attrs)
end
@doc """
Updates the user profile.
"""
def update_user_profile(user, attrs) do
user
|> User.profile_changeset(attrs)
|> Repo.update()
end
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last sudo verification was done no further
than the specified minutes ago. Default is 10 minutes (pass -10 as the minutes arg).
"""
def sudo_mode?(user, minutes \\ -10)
def sudo_mode?(%User{last_sudo_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Grants sudo mode to a user by updating their last_sudo_at timestamp.
Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
"""
def grant_sudo_mode(%User{} = user) do
now = Towerops.Time.now()
with {:ok, updated_user} <-
user
|> User.sudo_changeset(%{last_sudo_at: now})
|> Repo.update() do
# Log sudo mode grant for security auditing
AuditLogger.log_event("sudo_mode_granted", nil,
actor_id: user.id,
target_user_id: user.id,
metadata: %{granted_at: now}
)
# Set authenticated_at virtual field for sudo_mode? check
{:ok, %{updated_user | authenticated_at: now}}
end
end
## Email management
## (Implementation lives in `Towerops.Accounts.Emails`.)
defdelegate change_user_email(user, attrs \\ %{}, opts \\ []), to: Emails
defdelegate update_user_email(user, token), to: Emails
defdelegate deliver_user_update_email_instructions(user, current_email, update_email_url_fun), to: Emails
## Password management
## (Implementation lives in `Towerops.Accounts.Passwords`.)
defdelegate change_user_password(user, attrs \\ %{}, opts \\ []), to: Passwords
defdelegate update_user_password(user, attrs), to: Passwords
defdelegate deliver_user_reset_password_instructions(user, reset_password_url_fun), to: Passwords
defdelegate get_user_by_reset_password_token(token), to: Passwords
defdelegate reset_user_password(user, attrs), to: Passwords
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
case Repo.insert(user_token) do
{:ok, _} ->
token
{:error, changeset} ->
Logger.error("Failed to insert session token: #{inspect(changeset.errors)}")
raise Ecto.InvalidChangesetError, changeset: changeset
end
end
@doc """
Generates a session token and returns both the token and the user_token record.
This is used when creating browser sessions to link them to the token ID.
"""
def generate_user_session_token_with_record(user) do
{token, user_token_changeset} = UserToken.build_session_token(user)
case Repo.insert(user_token_changeset) do
{:ok, user_token} ->
{token, user_token}
{:error, changeset} ->
Logger.error("Failed to insert session token with record: #{inspect(changeset.errors)}")
raise Ecto.InvalidChangesetError, changeset: changeset
end
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the user token ID for a given session token value.
Returns the token ID if found, otherwise `nil`.
"""
def get_user_token_id_by_value(token) when is_binary(token) do
hashed_token = :crypto.hash(:sha256, token)
Repo.one(from(ut in UserToken, where: ut.token == ^hashed_token and ut.context == "session", select: ut.id))
end
## Magic-link login
## (Implementation lives in `Towerops.Accounts.MagicLinks`.)
defdelegate get_user_by_magic_link_token(token), to: MagicLinks
defdelegate login_user_by_magic_link(token), to: MagicLinks
defdelegate deliver_login_instructions(user, magic_link_url_fun), to: MagicLinks
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## Token helpers (shared)
##
## These are public-but-internal: they are used by extracted submodules
## (`Passwords`, `MagicLinks`, `Emails`) and must therefore be callable from
## outside this module. They are not part of the public API.
@doc false
@spec unwrap_transaction_result({:ok, {:ok, term()} | {:error, term()}} | {:error, term()}) ::
{:ok, term()} | {:error, term()}
def unwrap_transaction_result(result) do
case result do
{:ok, {:ok, value}} -> {:ok, value}
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end
@doc false
def update_user_and_delete_all_tokens(changeset) do
changeset
|> do_update_user_and_delete_tokens()
|> Repo.transaction()
|> unwrap_transaction_result()
end
defp do_update_user_and_delete_tokens(changeset) do
fn ->
case Repo.update(changeset) do
{:ok, user} ->
tokens_to_expire = Repo.all(from t in UserToken, where: t.user_id == ^user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
{:error, changeset} ->
{:error, changeset}
end
end
end
## User Consent
## (Implementation lives in `Towerops.Accounts.Consents`.)
defdelegate grant_consent(user_id, consent_type), to: Consents
defdelegate revoke_consent(consent_id), to: Consents
defdelegate list_user_consents(user_id), to: Consents
defdelegate get_active_consent(user_id, consent_type), to: Consents
defdelegate has_consent?(user_id, consent_type), to: Consents
defdelegate has_all_required_consents?(user_id), to: Consents
## Policy Version Management
## (Implementation lives in `Towerops.Accounts.PolicyVersions`.)
defdelegate create_policy_version(attrs), to: PolicyVersions
defdelegate get_current_policy_version(policy_type), to: PolicyVersions
defdelegate get_policy_version(policy_type, version), to: PolicyVersions
defdelegate list_policy_versions(policy_type), to: PolicyVersions
defdelegate needs_reconsent?(user_id, policy_type), to: PolicyVersions
defdelegate policies_needing_reconsent(user_id), to: PolicyVersions
## Login Attempts
## (Implementation lives in `Towerops.Accounts.LoginHistory`.)
defdelegate record_login_attempt(attrs), to: LoginHistory
defdelegate list_user_login_history(user_id, opts \\ []), to: LoginHistory
defdelegate count_user_login_attempts(user_id, opts \\ []), to: LoginHistory
defdelegate anonymize_user_login_history(user_id), to: LoginHistory
## Browser Sessions
## (Implementation lives in `Towerops.Accounts.Sessions`.)
defdelegate create_browser_session(attrs), to: Sessions, as: :create
defdelegate list_active_browser_sessions(user_id), to: Sessions, as: :list_active
defdelegate get_browser_session_by_token(user_token_id), to: Sessions, as: :get_by_token
defdelegate get_browser_session_by_token_value(token), to: Sessions, as: :get_by_token_value
defdelegate touch_browser_session(session), to: Sessions, as: :touch
defdelegate revoke_browser_session(session_id, user_id, current_token_id), to: Sessions, as: :revoke
defdelegate revoke_all_other_sessions(user_id, current_token_id), to: Sessions, as: :revoke_all_other
defdelegate anonymize_user_browser_sessions(user_id), to: Sessions, as: :anonymize_for_user
defdelegate delete_expired_browser_sessions(), to: Sessions, as: :delete_expired
@doc """
Delivers confirmation instructions to the given user.
"""
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) do
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
case Repo.insert(user_token) do
{:ok, _} ->
UserNotifier.deliver_confirmation_email(user, confirmation_url_fun.(encoded_token))
{:error, changeset} ->
Logger.error("Failed to insert confirmation token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
@doc """
Confirms a user by the given token.
"""
@spec confirm_user(String.t()) :: {:ok, User.t()} | :error
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
{:ok, confirmed_user} <- confirm_user_transaction(user) do
{:ok, confirmed_user}
else
_ -> :error
end
end
@spec confirm_user_transaction(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
defp confirm_user_transaction(user) do
Repo.transaction(fn ->
case Repo.update(User.confirm_changeset(user)) do
{:ok, confirmed_user} ->
_ = Repo.delete_all(UserToken.by_user_and_contexts_query(user, ["confirm"]))
confirmed_user
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
end
## Account Deletion (GDPR Right to Erasure)
@doc """
Returns organizations where the user is the sole owner.
Used to check if a user can safely delete their account without
orphaning organizations.
"""
@spec sole_owner_organizations(String.t()) :: [Organization.t()]
def sole_owner_organizations(user_id) do
user_owner_org_ids =
Membership
|> where([m], m.user_id == ^user_id and m.role == :owner)
|> select([m], m.organization_id)
|> Repo.all()
if user_owner_org_ids == [] do
[]
else
sole_owner_ids =
Membership
|> where([m], m.organization_id in ^user_owner_org_ids and m.role == :owner)
|> group_by([m], m.organization_id)
|> having([m], count(m.id) == 1)
|> select([m], m.organization_id)
|> Repo.all()
Organization
|> where([o], o.id in ^sole_owner_ids)
|> Repo.all()
end
end
@doc """
Deletes a user's account (self-service, GDPR Article 17).
Verifies the user's password before proceeding. Checks for sole-owner
organizations and blocks deletion if found.
Within a transaction:
1. Creates an audit log entry
2. Anonymizes login history
3. Anonymizes browser sessions
4. Deletes the user (cascades to memberships, tokens)
Returns `{:ok, user}` on success, or an error tuple.
"""
@spec delete_account(User.t(), String.t()) ::
{:ok, User.t()}
| {:error, :invalid_password}
| {:error, :sole_owner, [String.t()]}
def delete_account(%User{} = user, password) when is_binary(password) do
if User.valid_password?(user, password) do
do_delete_account(user)
else
{:error, :invalid_password}
end
end
defp do_delete_account(user) do
sole_owner_orgs = sole_owner_organizations(user.id)
if sole_owner_orgs == [] do
delete_account_transaction(user)
else
org_names = Enum.map(sole_owner_orgs, & &1.name)
{:error, :sole_owner, org_names}
end
end
defp delete_account_transaction(user) do
Repo.transaction(fn ->
{:ok, _} =
Towerops.Admin.create_audit_log(%{
action: "account_deletion_requested",
superuser_id: user.id,
target_user_id: user.id,
metadata: %{email: user.email, self_service: true}
})
anonymize_user_login_history(user.id)
anonymize_user_browser_sessions(user.id)
case Repo.delete(user) do
{:ok, deleted_user} ->
deleted_user
{:error, changeset} ->
Logger.error("Failed to delete user account: #{inspect(changeset)}")
# Raise to trigger rollback - never call Repo.rollback/1 directly
raise Ecto.InvalidChangesetError, action: :delete, changeset: changeset
end
end)
end
end