lib/mix/tasks/relyra.metadata.pin.ex

defmodule Mix.Tasks.Relyra.Metadata.Pin do
  @moduledoc """
  Pins a SHA-256 trust fingerprint onto a connection's metadata source.

  Used by IaC adopters (Terraform / Pulumi) and operators who manage
  trust state via scripts. The admin LiveView fingerprint UX (deferred
  to v0.6) shares the same underlying changeset
  (`MetadataSource.auto_refresh_changeset/2`) so the two paths cannot
  drift.

      mix relyra.metadata.pin <connection_id> --fingerprint <sha256_hex> --repo MyApp.Repo

  Multiple `--fingerprint` flags may be supplied in one invocation
  (rotation window — D-17 multi-valued anchor).

  Operator MUST verify the fingerprint out-of-band before running this
  command. The fingerprint is the SHA-256 of the IdP's signing-cert
  (lowercase hex, no colons), computed via:

      openssl x509 -in metadata-signing.pem -outform DER \\
        | openssl dgst -sha256 \\
        | tr 'A-F' 'a-f'

  The pin REPLACES the source's `metadata_trust_fingerprints` array.
  Supply every currently-pinned fingerprint plus the new one to extend
  (this matches the "explicit always" Relyra strict-defaults principle).
  """
  @shortdoc "Pin a SHA-256 metadata trust fingerprint on a connection."

  use Mix.Task

  @impl true
  def run(args) do
    Mix.Task.run("app.start")

    {opts, argv, _invalid} =
      OptionParser.parse(args,
        strict: [fingerprint: :keep, repo: :string],
        aliases: [f: :fingerprint, r: :repo]
      )

    connection_id =
      List.first(argv) ||
        Mix.raise(
          "connection_id is required: mix relyra.metadata.pin <connection_id> --fingerprint <hex>"
        )

    fingerprints = Keyword.get_values(opts, :fingerprint)

    if fingerprints == [] do
      Mix.raise("at least one --fingerprint is required")
    end

    repo_string =
      Keyword.get(opts, :repo) ||
        Mix.raise("--repo is required")

    repo =
      try do
        String.to_existing_atom(repo_string)
      rescue
        ArgumentError -> Mix.raise("Repo module #{repo_string} is not loaded")
      end

    case Relyra.Metadata.pin_trust_fingerprint(
           connection_id,
           %{metadata_trust_fingerprints: Enum.map(fingerprints, &String.downcase/1)},
           repo: repo
         ) do
      {:ok, _updated} ->
        Mix.shell().info(
          "relyra.metadata.pin: pinned #{length(fingerprints)} fingerprint(s) on #{connection_id}."
        )

        :ok

      {:error, error} ->
        Mix.raise("relyra.metadata.pin failed: #{error.message}")
    end
  end
end