lib/openagents/forge/promotion.ex

58e6347eeb72 · 9 KB

defmodule OpenAgents.Forge.Promotion do
  @moduledoc """
  The one authority path for promoting a pushed commit as the fleet target.

  Promotion is release authority over the OpenAgents fleet itself, so it is
  not part of the tenant deployment control plane (`OpenAgents.Deployments`)
  and no tenant credential reaches it. Two callers exist and both come through
  here: the `/admin/forge` **Promote** button and
  `OpenAgentsWeb.FleetTargetController`. Neither one restates the policy, so
  the browser and the API cannot drift apart.

  What this module decides, once, for both:

  - The promoting account is an operator *at this moment*. Removing an account
    from the allowlist stops its next promotion, credential or not.
  - The environment is named exactly, and only `production` exists today.
  - The commit is named exactly. `OpenAgents.Forge.Targets.promote/4` still
    owns the WAL-backed existence check, the 40-character SHA format, and the
    deployable-repository check, so a push can never promote itself.
  - A caller-generated idempotency key names one promotion. Replaying it with
    the same bytes returns the original target; replaying it with different
    bytes is a conflict.
  - An optional expected-current-target ID is a compare-and-set precondition,
    so two concurrent operators cannot unknowingly supersede each other.

  The receipt is the append-only `forge_fleet_targets` row that
  `OpenAgents.Forge.Targets.promote/4` writes, carrying the promoting
  operator's identity in `promoted_by` exactly as the browser path always
  has. The bounded audit event beside it records the principal, the source
  channel, and a digest of the idempotency key — never the key itself.
  """

  import Ecto.Query

  alias OpenAgents.Accounts
  alias OpenAgents.Accounts.User
  alias OpenAgents.Audit
  alias OpenAgents.Forge.{Target, Targets}
  alias OpenAgents.Repo

  @environments ["production"]
  @sources ["operator_console", "api"]

  @type source :: String.t()
  @type result :: %{target: Target.t(), replayed: boolean()}

  @doc "Environments this surface admits. Fleet promotion has exactly one."
  @spec environments() :: [String.t()]
  def environments, do: @environments

  @doc "The immutable principal string recorded on a promotion receipt."
  @spec principal(User.t()) :: String.t()
  def principal(%User{github_id: github_id}), do: "operator:" <> to_string(github_id)

  @doc """
  Promote `attrs["sha"]` as the fleet target for `attrs["repo"]`.

  Returns `{:ok, %{target: target, replayed: replayed?}}`, where `replayed?`
  is true when an identical idempotency key already named this promotion.
  """
  @spec promote(User.t() | nil, map()) :: {:ok, result()} | {:error, term()}
  def promote(user, attrs) when is_map(attrs) do
    with {:ok, operator} <- operator(user, attrs),
         {:ok, request} <- request(attrs) do
      serialized(request.repo, fn -> admit(operator, request) end)
    end
  end

  defp operator(%User{} = user, attrs) do
    if Accounts.admin?(user) do
      {:ok, user}
    else
      refuse(user, attrs, :not_operator)
    end
  end

  defp operator(_user, _attrs), do: {:error, :not_operator}

  # Every promotion for one repository is decided under one cluster-wide lock,
  # so the idempotency lookup, the precondition, and the insert are one
  # decision rather than three racing reads.
  defp serialized(repo, work) do
    :global.trans({{:forge_target_promote, repo}, self()}, work)
  end

  defp admit(operator, request) do
    with {:ok, :new} <- replay(request),
         :ok <- precondition(request) do
      insert(operator, request)
    else
      {:ok, {:replayed, target}} -> {:ok, %{target: target, replayed: true}}
      {:error, reason} -> {:error, reason}
    end
  end

  defp insert(operator, request) do
    case Targets.promote(request.repo, request.sha, principal(operator),
           details: details(operator, request)
         ) do
      {:ok, target} ->
        record(operator, request, target)
        {:ok, %{target: target, replayed: false}}

      {:error, reason} ->
        record_refusal(operator, request, reason)
        {:error, reason}
    end
  end

  defp details(operator, request) do
    %{
      "promoted_by_user_id" => operator.id,
      "promotion_environment" => request.environment,
      "promotion_request_digest" => request.request_digest,
      "promotion_source" => request.source
    }
    |> maybe_put("idempotency_key_digest", request.idempotency_key_digest)
    |> maybe_put("promotion_request_id", request.request_id)
  end

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  # A key names one promotion. The same key with the same bytes is the same
  # promotion; the same key with different bytes is a caller bug, and
  # answering it with the original target would hide a wrong deployment.
  defp replay(%{idempotency_key_digest: nil}), do: {:ok, :new}

  defp replay(request) do
    Target
    |> where([target], target.repo == ^request.repo)
    |> where(
      [target],
      fragment("?->>'idempotency_key_digest'", target.details) ==
        ^request.idempotency_key_digest
    )
    |> order_by([target], desc: target.inserted_at)
    |> limit(1)
    |> Repo.one()
    |> case do
      nil ->
        {:ok, :new}

      %Target{details: %{"promotion_request_digest" => digest}} = target
      when digest == request.request_digest ->
        {:ok, {:replayed, target}}

      %Target{} ->
        {:error, :idempotency_conflict}
    end
  end

  defp precondition(%{expected_current_target_id: nil}), do: :ok

  defp precondition(request) do
    case Targets.current(request.repo) do
      %Target{id: id} when id == request.expected_current_target_id -> :ok
      _superseded -> {:error, :precondition_failed}
    end
  end

  defp request(attrs) do
    with {:ok, repo} <- bounded(attrs, "repo", :invalid_repository),
         {:ok, sha} <- bounded(attrs, "sha", :invalid_sha),
         {:ok, source} <- source(attrs),
         {:ok, environment} <- environment(attrs),
         {:ok, key} <- idempotency_key(attrs, source),
         {:ok, expected} <- expected_current_target_id(attrs) do
      {:ok,
       %{
         environment: environment,
         expected_current_target_id: expected,
         idempotency_key_digest: key && digest(key),
         repo: repo,
         request_digest: digest(Enum.join([repo, sha, environment], "\n")),
         request_id: request_id(attrs),
         sha: sha,
         source: source
       }}
    end
  end

  defp bounded(attrs, key, reason) do
    case value(attrs, key) do
      binary when is_binary(binary) and byte_size(binary) in 1..255 -> {:ok, binary}
      _invalid -> {:error, reason}
    end
  end

  defp source(attrs) do
    case value(attrs, "source") do
      source when source in @sources -> {:ok, source}
      nil -> {:ok, "api"}
      _invalid -> {:error, :invalid_source}
    end
  end

  defp environment(attrs) do
    case value(attrs, "environment") do
      environment when environment in @environments -> {:ok, environment}
      _invalid -> {:error, :unsupported_environment}
    end
  end

  # The browser button is one operator's one click, so it needs no key. Every
  # other caller is a script that can retry, and a retry without a key is how
  # a fleet gets promoted twice.
  defp idempotency_key(_attrs, "operator_console"), do: {:ok, nil}

  defp idempotency_key(attrs, _source) do
    case value(attrs, "idempotency_key") do
      key when is_binary(key) and byte_size(key) in 8..255 -> {:ok, key}
      _invalid -> {:error, :invalid_idempotency_key}
    end
  end

  defp expected_current_target_id(attrs) do
    case value(attrs, "expected_current_target_id") do
      nil -> {:ok, nil}
      id when is_binary(id) -> cast_uuid(id)
      _invalid -> {:error, :invalid_expected_target}
    end
  end

  defp cast_uuid(id) do
    case Ecto.UUID.cast(id) do
      {:ok, uuid} -> {:ok, uuid}
      :error -> {:error, :invalid_expected_target}
    end
  end

  defp request_id(attrs) do
    case value(attrs, "request_id") do
      id when is_binary(id) and byte_size(id) in 1..128 -> id
      _absent -> nil
    end
  end

  defp value(attrs, key) do
    case Map.get(attrs, key, Map.get(attrs, String.to_existing_atom(key))) do
      "" -> nil
      other -> other
    end
  rescue
    ArgumentError -> Map.get(attrs, key)
  end

  defp record(operator, request, target) do
    Audit.record!(
      "forge.fleet_target.promoted",
      {:operator, operator.id},
      "forge_fleet_target",
      target.id,
      metadata: audit_metadata(request, %{"result" => "promoted", "sha" => target.sha})
    )
  end

  defp record_refusal(operator, request, reason) do
    Audit.record!(
      "forge.fleet_target.promotion_refused",
      {:operator, operator.id},
      "forge_repository",
      request.repo,
      metadata: audit_metadata(request, %{"result" => "refused", "reason" => reason_code(reason)})
    )
  end

  defp refuse(user, attrs, reason) do
    Audit.record!(
      "forge.fleet_target.promotion_refused",
      {:user, user && user.id},
      "forge_repository",
      to_string(Map.get(attrs, "repo") || Map.get(attrs, :repo) || "unknown"),
      metadata: %{"reason" => Atom.to_string(reason), "result" => "refused"}
    )

    {:error, reason}
  end

  defp audit_metadata(request, extra) do
    %{
      "environment" => request.environment,
      "repo" => request.repo,
      "request_digest" => request.request_digest,
      "source" => request.source
    }
    |> maybe_put("idempotency_key_digest", request.idempotency_key_digest)
    |> maybe_put("request_id", request.request_id)
    |> Map.merge(extra)
  end

  defp reason_code(reason) when is_atom(reason), do: Atom.to_string(reason)
  defp reason_code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
  defp reason_code(_reason), do: "invalid"

  defp digest(value), do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower)
end