lib/openagents/reputation/attestation.ex

main at 58e6347eeb72 · 3 KB

defmodule OpenAgents.Reputation.Attestation do
  @moduledoc """
  One signed reputation event about one accepted outcome.

  The `claim` map is the exact object the signature covers. Every column
  beside it is a projection of that claim kept for querying, so a client can
  ignore the columns, canonicalize the claim, and check the signature itself.
  """
  use Ecto.Schema
  import Ecto.Changeset

  alias OpenAgents.Repositories.Repository

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]

  @event_types ~w(completion verification review payment reversal revocation)
  @invalidating_event_types ~w(reversal revocation)
  @transparency_tiers ~w(public repository private)

  schema "reputation_attestations" do
    belongs_to :repository, Repository
    field :issue_number, :integer
    field :event_type, :string
    field :subject_id, :string
    field :issuer_key_id, :string
    field :outcome_kind, :string
    field :outcome_ref, :string
    field :outcome_digest, :string
    field :revision, :string
    field :artifact_digest, :string
    field :policy_id, :string
    field :policy_version, :integer
    field :policy_digest, :string
    field :confidence_ppm, :integer
    field :transparency_tier, :string
    field :attested_at, :utc_datetime_usec
    field :nonce, :string
    field :claim, :map
    field :claim_digest, :string
    field :signature, :string
    field :signature_algorithm, :string
    field :supersedes_digest, :string
    field :revoked_at, :utc_datetime_usec
    field :revocation_reason_code, :string
    field :revoked_by_id, Ecto.UUID
    belongs_to :revokes, __MODULE__
    timestamps()
  end

  @type t :: %__MODULE__{}

  def event_types, do: @event_types
  def invalidating_event_types, do: @invalidating_event_types
  def transparency_tiers, do: @transparency_tiers

  def changeset(record, attributes) do
    required =
      ~w(repository_id issue_number event_type subject_id issuer_key_id outcome_kind outcome_ref
         outcome_digest revision artifact_digest policy_id policy_version policy_digest
         confidence_ppm transparency_tier attested_at nonce claim claim_digest signature
         signature_algorithm)a

    record
    |> cast(attributes, required ++ ~w(supersedes_digest revokes_id)a)
    |> validate_required(required)
    |> validate_inclusion(:event_type, @event_types)
    |> validate_inclusion(:transparency_tier, @transparency_tiers)
    |> validate_number(:issue_number, greater_than: 0)
    |> validate_number(:policy_version, greater_than: 0)
    |> validate_number(:confidence_ppm,
      greater_than_or_equal_to: 0,
      less_than_or_equal_to: 1_000_000
    )
    |> validate_format(:outcome_digest, ~r/\A[0-9a-f]{64}\z/)
    |> validate_format(:artifact_digest, ~r/\A[0-9a-f]{64}\z/)
    |> validate_format(:policy_digest, ~r/\A[0-9a-f]{64}\z/)
    |> validate_format(:claim_digest, ~r/\A[0-9a-f]{64}\z/)
    |> validate_format(:issuer_key_id, ~r/\A[0-9a-f]{64}\z/)
    |> validate_format(:nonce, ~r/\A[0-9a-f]{32}\z/)
    |> validate_length(:subject_id, min: 1, max: 256)
    |> validate_length(:outcome_kind, min: 1, max: 64)
    |> validate_length(:outcome_ref, min: 1, max: 256)
    |> validate_length(:revision, min: 1, max: 128)
    |> foreign_key_constraint(:repository_id)
    |> foreign_key_constraint(:revokes_id)
    |> unique_constraint(:claim_digest)
    |> unique_constraint(:revokes_id)
    |> unique_constraint([:issuer_key_id, :subject_id, :outcome_kind, :outcome_ref, :event_type],
      name: :reputation_attestations_outcome_event_index
    )
  end

  def revocation_changeset(record, attributes) do
    record
    |> cast(attributes, ~w(revoked_at revocation_reason_code revoked_by_id)a)
    |> validate_required(~w(revoked_at revocation_reason_code)a)
    |> validate_length(:revocation_reason_code, min: 1, max: 64)
  end
end