feat: add Accounts.grant_sudo_mode/1 function

This commit is contained in:
Graham McIntire 2026-02-01 14:02:41 -06:00
parent 303196bb8e
commit ada851e7ed
No known key found for this signature in database
4 changed files with 82 additions and 0 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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