2.8 KiB · text History aab4e3f
defmodule GitGud.Packages.SigningKey do
  @moduledoc """
  A namespace's keypair for one registry format.

  Scoped to the namespace — an organization, or a user for personal
  repositories — rather than to a repository, because the key is a
  trust anchor for a *publisher*. Per-repository keys meant a machine
  installing from five repositories in one organization needed five
  entries in `/etc/apt/keyrings`, for what its sources list describes
  as one publisher.

  The public half is served to clients verbatim — it is what a consumer
  pins with `mix hex.repo add --public-key` or `signed-by=` — and the
  private half is encrypted at rest with the `GitGud.Secrets` master
  key. Rows keep the `key_id` they were wrapped under so a master-key
  rotation can re-wrap them the same way secret rows are.

  Exactly one of `organization_id` / `user_id` is set; the database
  enforces it, because a row with neither is a private key nothing can
  ever look up again.
  """
  use Ecto.Schema

  import Ecto.Changeset

  alias GitGud.Accounts.User
  alias GitGud.Organizations.Organization

  schema "package_signing_keys" do
    field :format, :string
    field :public_key, :string
    field :private_key_ciphertext, :binary, redact: true
    field :private_key_iv, :binary, redact: true
    field :private_key_tag, :binary, redact: true
    field :key_id, :string

    belongs_to :organization, Organization
    belongs_to :user, User

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(key, attrs) do
    key
    |> cast(attrs, [
      :format,
      :public_key,
      :private_key_ciphertext,
      :private_key_iv,
      :private_key_tag,
      :key_id,
      :organization_id,
      :user_id
    ])
    |> validate_required([
      :format,
      :public_key,
      :private_key_ciphertext,
      :private_key_iv,
      :private_key_tag,
      :key_id
    ])
    |> validate_one_owner()
    |> unique_constraint([:organization_id, :format],
      name: :package_signing_keys_organization_format_index
    )
    |> unique_constraint([:user_id, :format], name: :package_signing_keys_user_format_index)
    |> check_constraint(:organization_id, name: :package_signing_keys_one_owner)
  end

  defp validate_one_owner(changeset) do
    case {get_field(changeset, :organization_id), get_field(changeset, :user_id)} do
      {nil, nil} ->
        add_error(changeset, :organization_id, "a signing key needs a namespace")

      {org, user} when not is_nil(org) and not is_nil(user) ->
        add_error(changeset, :organization_id, "a signing key belongs to one namespace")

      _ ->
        changeset
    end
  end

  @doc """
  The `belongs_to` field and id for a namespace, as the query and
  changeset both want it.
  """
  def owner_fields(%Organization{id: id}), do: [organization_id: id]
  def owner_fields(%User{id: id}), do: [user_id: id]
end