diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 1d17f43f..d8cbbce5 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -620,6 +620,24 @@ defmodule Towerops.Accounts do 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. + + ## Examples + + iex> grant_sudo_mode(user) + {:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}} + """ + def grant_sudo_mode(%User{} = user) do + now = DateTime.utc_now() + + user + |> User.sudo_changeset(%{last_sudo_at: now}) + |> Repo.update() + end + @doc """ Returns an `%Ecto.Changeset{}` for changing the user email. diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index f28e21f8..5fd5f5a1 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -30,6 +30,7 @@ defmodule Towerops.Accounts.User do field :privacy_policy_consent, :boolean, virtual: true field :terms_of_service_consent, :boolean, virtual: true field :password_breach_count, :integer, virtual: true + field :last_sudo_at, :utc_datetime has_many :memberships, Membership has_many :organizations, through: [:memberships, :organization] @@ -49,6 +50,7 @@ defmodule Towerops.Accounts.User do timezone: String.t(), totp_secret: binary() | nil, totp_verified_at: DateTime.t() | nil, + last_sudo_at: DateTime.t() | nil, memberships: NotLoaded.t() | [Membership.t()], organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()], inserted_at: DateTime.t(), @@ -229,6 +231,15 @@ defmodule Towerops.Accounts.User do change(user, confirmed_at: now) end + @doc """ + Changeset for updating sudo mode timestamp. + """ + def sudo_changeset(user, attrs) do + user + |> cast(attrs, [:last_sudo_at]) + |> validate_required([:last_sudo_at]) + end + @doc """ Verifies the password. diff --git a/priv/repo/migrations/20260201195548_add_last_sudo_at_to_users.exs b/priv/repo/migrations/20260201195548_add_last_sudo_at_to_users.exs new file mode 100644 index 00000000..acbf4e3c --- /dev/null +++ b/priv/repo/migrations/20260201195548_add_last_sudo_at_to_users.exs @@ -0,0 +1,9 @@ +defmodule Towerops.Repo.Migrations.AddLastSudoAtToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :last_sudo_at, :utc_datetime + end + end +end diff --git a/test/towerops/accounts/grant_sudo_mode_test.exs b/test/towerops/accounts/grant_sudo_mode_test.exs new file mode 100644 index 00000000..e3451a27 --- /dev/null +++ b/test/towerops/accounts/grant_sudo_mode_test.exs @@ -0,0 +1,44 @@ +defmodule Towerops.Accounts.GrantSudoModeTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Accounts + alias Towerops.Accounts.User + + describe "grant_sudo_mode/1" do + test "updates user's last_sudo_at timestamp" do + user = user_fixture() + assert is_nil(user.last_sudo_at) + + {:ok, updated_user} = Accounts.grant_sudo_mode(user) + + assert %DateTime{} = updated_user.last_sudo_at + assert DateTime.diff(DateTime.utc_now(), updated_user.last_sudo_at, :second) < 2 + end + + test "updates an already set last_sudo_at timestamp" do + user = user_fixture() + + # First grant + {:ok, user_with_sudo} = Accounts.grant_sudo_mode(user) + first_timestamp = user_with_sudo.last_sudo_at + + # Wait to ensure different timestamp (at least 1 second) + Process.sleep(1100) + + # Second grant should update the timestamp + {:ok, updated_user} = Accounts.grant_sudo_mode(user_with_sudo) + assert DateTime.after?(updated_user.last_sudo_at, first_timestamp) + end + + test "validates changeset" do + user = user_fixture() + + # Test the changeset validation directly + changeset = User.sudo_changeset(user, %{}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).last_sudo_at + end + end +end