lib/openagents/accounts.ex

main at 58e6347eeb72 · 10 KB

defmodule OpenAgents.Accounts do
  @moduledoc "Durable GitHub identity and authentication-attempt authority."

  import Ecto.Query

  alias OpenAgents.Accounts.{OAuthAttempt, TokenVault, User}
  alias OpenAgents.Inference.Credit
  alias OpenAgents.Repo

  @oauth_attempt_retention_seconds 86_400

  @spec upsert_github_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
  def upsert_github_user(profile) when is_map(profile) do
    now = DateTime.utc_now()

    attributes = %{
      github_id: profile.github_id,
      github_login: profile.github_login,
      github_name: Map.get(profile, :github_name),
      github_avatar_url: profile.github_avatar_url,
      last_authenticated_at: now
    }

    %User{}
    |> User.github_changeset(attributes)
    # The credit a new account is granted, written once at creation. The
    # `on_conflict` replacement list below deliberately omits it, so a sign-in
    # by an account that already exists carries its own allowance forward
    # rather than being re-granted the current figure — which is what makes
    # "new accounts get $20" different from "every account now has $20".
    |> Ecto.Changeset.put_change(
      :credit_allowance_microusd,
      Credit.new_account_allowance()
    )
    |> Repo.insert(
      on_conflict:
        {:replace,
         [
           :github_login,
           :github_name,
           :github_avatar_url,
           :last_authenticated_at,
           :updated_at
         ]},
      conflict_target: [:github_id],
      returning: true
    )
  end

  @doc "Seals and stores an explicitly authorized GitHub token and its non-secret metadata."
  @spec store_github_token(User.t(), String.t(), [String.t()]) ::
          {:ok, User.t()} | {:error, atom()}
  def store_github_token(%User{} = user, token, scopes \\ configured_github_scopes())
      when is_binary(token) and is_list(scopes) do
    with true <- valid_scopes?(scopes),
         {:ok, sealed, key_id} <- TokenVault.seal_with_metadata(token) do
      now = DateTime.utc_now()

      user
      |> Ecto.Changeset.change(
        github_token_ciphertext: sealed,
        github_token_key_id: key_id,
        github_token_scopes: scopes,
        github_token_connected_at: now,
        github_token_rotated_at: nil
      )
      |> Ecto.Changeset.check_constraint(:github_token_ciphertext,
        name: :users_github_token_connection_state_check
      )
      |> Repo.update()
      |> case do
        {:ok, updated} -> {:ok, updated}
        {:error, _changeset} -> {:error, :token_storage_failed}
      end
    else
      false -> {:error, :invalid_token_scopes}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc "Unseals the stored GitHub access token. Never expose the result to clients or logs."
  @spec github_token(User.t()) :: {:ok, String.t()} | {:error, atom()}
  def github_token(%User{github_token_ciphertext: sealed}) when is_binary(sealed),
    do: TokenVault.open(sealed)

  def github_token(%User{}), do: {:error, :github_token_missing}

  @doc "Rewraps one retained token with the active key without exposing plaintext."
  @spec rotate_github_token(User.t()) :: {:ok, User.t()} | {:error, atom()}
  def rotate_github_token(%User{} = user) do
    with {:ok, token} <- github_token(user),
         {:ok, sealed, key_id} <- TokenVault.seal_with_metadata(token) do
      replace_github_envelope(user, sealed, key_id)
    end
  end

  @doc "Revokes the provider grant, then removes all local token material and metadata."
  @spec disconnect_github(User.t(), (String.t() -> :ok | {:error, atom()})) ::
          {:ok, User.t()} | {:error, atom()}
  def disconnect_github(%User{} = user, revoker \\ &OpenAgents.GitHubOAuth.revoke/1)
      when is_function(revoker, 1) do
    case github_token(user) do
      {:ok, token} ->
        with :ok <- revoker.(token), do: clear_github_token(user)

      {:error, :github_token_missing} ->
        clear_github_token(user)

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc "Non-secret GitHub connection metadata for UI and data-rights projections."
  @spec github_connection(User.t()) :: map()
  def github_connection(%User{} = user) do
    %{
      connected: is_binary(user.github_token_ciphertext),
      scopes: user.github_token_scopes,
      connected_at: user.github_token_connected_at,
      rotated_at: user.github_token_rotated_at
    }
  end

  @doc """
  Sets whether this account is withheld from the public leaderboard.

  The board is the one projection that crosses the account boundary
  (`INVARIANTS.md` LEADERBOARD-001), so the account it is about decides whether
  it appears. The cached projection is invalidated on every change, including a
  change that removes the account, so opting out takes effect on the next push
  to connected viewers rather than at the next unrelated recompute.
  """
  @spec set_public_leaderboard_opt_out(User.t(), boolean()) ::
          {:ok, User.t()} | {:error, Ecto.Changeset.t()}
  def set_public_leaderboard_opt_out(%User{} = user, opted_out?) when is_boolean(opted_out?) do
    case user |> User.leaderboard_changeset(opted_out?) |> Repo.update() do
      {:ok, updated} ->
        :ok = OpenAgents.Leaderboard.invalidate()
        {:ok, updated}

      {:error, changeset} ->
        {:error, changeset}
    end
  end

  @doc "Rewraps every retained GitHub token atomically; returns only the rotated count."
  @spec rotate_github_tokens!() :: non_neg_integer()
  def rotate_github_tokens! do
    Repo.transaction(fn ->
      from(user in User,
        where: not is_nil(user.github_token_ciphertext),
        order_by: [asc: user.id],
        lock: "FOR UPDATE"
      )
      |> Repo.stream(max_rows: 100)
      |> Enum.reduce(0, fn user, count ->
        case rotate_github_token(user) do
          {:ok, _rotated} -> count + 1
          {:error, reason} -> Repo.rollback(reason)
        end
      end)
    end)
    |> case do
      {:ok, count} -> count
      {:error, reason} -> raise "GitHub token rotation failed: #{reason}"
    end
  end

  def get_user(id) when is_binary(id) do
    case Ecto.UUID.cast(id) do
      {:ok, user_id} -> Repo.get(User, user_id)
      :error -> nil
    end
  end

  def get_user(_invalid), do: nil

  def get_active_user(id) do
    case get_user(id) do
      %User{status: "active"} = user -> {:ok, user}
      %User{status: "banned"} -> {:error, :banned}
      nil -> {:error, :not_found}
    end
  end

  @doc """
  Whether this account may use the operator surface.

  Matched on GitHub's immutable numeric ID, never on the login.
  """
  @spec admin?(User.t() | nil) :: boolean()
  def admin?(%User{status: "active", github_id: github_id}) when is_integer(github_id),
    do: github_id in admin_github_ids()

  def admin?(_user), do: false

  # The owner, by GitHub's immutable numeric id. Operator access is otherwise
  # configured per environment, and `runtime.exs` replaces the list wholesale
  # from `OPENAGENTS_ADMIN_GITHUB_IDS` -- so an environment whose variable is
  # unset, mistyped, or lost in a redeploy would lock the owner out of the very
  # surface used to fix it. Unioned in here rather than defaulted in config so
  # no environment can drop it.
  @owner_github_id 14_167_547

  @doc """
  The operator GitHub IDs: the owner, plus whatever this environment configures.
  """
  @spec admin_github_ids() :: [pos_integer()]
  def admin_github_ids do
    configured =
      :openagents
      |> Application.get_env(:admin_github_ids, [])
      |> Enum.filter(&(is_integer(&1) and &1 > 0))

    Enum.uniq([@owner_github_id | configured])
  end

  @doc false
  def ban_user(%User{} = user, reason_code) when is_binary(reason_code) do
    user
    |> User.ban_changeset(reason_code)
    |> Repo.update()
  end

  def create_oauth_attempt(state, expires_at)
      when is_binary(state) and is_struct(expires_at, DateTime) do
    prune_oauth_attempts()

    %OAuthAttempt{}
    |> OAuthAttempt.create_changeset(%{
      state_digest: state_digest(state),
      expires_at: expires_at
    })
    |> Repo.insert()
  end

  def consume_oauth_attempt(attempt_id, state)
      when is_binary(attempt_id) and is_binary(state) do
    with {:ok, id} <- Ecto.UUID.cast(attempt_id) do
      now = DateTime.utc_now()

      {updated_count, _rows} =
        from(attempt in OAuthAttempt,
          where:
            attempt.id == ^id and attempt.state_digest == ^state_digest(state) and
              is_nil(attempt.consumed_at) and attempt.expires_at > ^now
        )
        |> Repo.update_all(set: [consumed_at: now])

      if updated_count == 1, do: :ok, else: {:error, :invalid_or_consumed_oauth_attempt}
    else
      :error -> {:error, :invalid_or_consumed_oauth_attempt}
    end
  end

  def consume_oauth_attempt(_attempt_id, _state),
    do: {:error, :invalid_or_consumed_oauth_attempt}

  defp prune_oauth_attempts do
    cutoff = DateTime.add(DateTime.utc_now(), -@oauth_attempt_retention_seconds, :second)

    {_deleted_count, nil} =
      Repo.delete_all(
        from(attempt in OAuthAttempt,
          where:
            attempt.expires_at < ^cutoff or
              (not is_nil(attempt.consumed_at) and attempt.consumed_at < ^cutoff)
        )
      )

    :ok
  end

  defp state_digest(state), do: :crypto.hash(:sha256, state)

  defp clear_github_token(%User{} = user) do
    now = DateTime.utc_now()

    {updated, _rows} =
      user
      |> matching_github_token_query()
      |> Repo.update_all(
        set: [
          github_token_ciphertext: nil,
          github_token_key_id: nil,
          github_token_scopes: [],
          github_token_connected_at: nil,
          github_token_rotated_at: nil,
          updated_at: now
        ]
      )

    if updated == 1 do
      {:ok, Repo.get!(User, user.id)}
    else
      {:error, :github_connection_changed}
    end
  end

  defp replace_github_envelope(user, sealed, key_id) do
    now = DateTime.utc_now()

    {updated, _rows} =
      user
      |> matching_github_token_query()
      |> Repo.update_all(
        set: [
          github_token_ciphertext: sealed,
          github_token_key_id: key_id,
          github_token_rotated_at: now,
          updated_at: now
        ]
      )

    if updated == 1 do
      {:ok, Repo.get!(User, user.id)}
    else
      {:error, :github_connection_changed}
    end
  end

  defp matching_github_token_query(%User{id: id, github_token_ciphertext: nil}) do
    from(user in User, where: user.id == ^id and is_nil(user.github_token_ciphertext))
  end

  defp matching_github_token_query(%User{id: id, github_token_ciphertext: ciphertext}) do
    from(user in User, where: user.id == ^id and user.github_token_ciphertext == ^ciphertext)
  end

  defp configured_github_scopes,
    do: Application.fetch_env!(:openagents, :github_oauth_scopes)

  defp valid_scopes?(scopes) do
    scopes in [configured_github_scopes(), OpenAgents.GitHubOAuth.required_scopes()]
  end
end