Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
144 lines
4.5 KiB
Elixir
144 lines
4.5 KiB
Elixir
defmodule Tarakan.Market.Bounty do
|
|
@moduledoc """
|
|
A contract (a "bounty" internally): a funded reward for security work on a public target.
|
|
|
|
Exactly one target reference must be set, matching `target_type`
|
|
(`repository_id`, `pattern_key`, or `canonical_finding_id`). The shape is
|
|
enforced at the changeset level (see `validate_target/1`); the database only
|
|
constrains the enum-like columns.
|
|
|
|
Funding is either fiat (Stripe Checkout escrow, `amount_cents`) or platform
|
|
credits (`credit_amount`, debited from the sponsor at creation). The
|
|
platform `take_rate` is snapshotted at creation so later config changes
|
|
never rewrite open offers.
|
|
"""
|
|
|
|
use Ecto.Schema
|
|
import Ecto.Changeset
|
|
|
|
alias Tarakan.Accounts.Account
|
|
alias Tarakan.Market.BountyClaim
|
|
alias Tarakan.Repositories.Repository
|
|
alias Tarakan.Scans.CanonicalFinding
|
|
|
|
@target_types ~w(repository infestation finding)
|
|
@fundings ~w(fiat credits)
|
|
@statuses ~w(pending_funding open claimed fulfilled payout_pending paid cancelled expired)
|
|
|
|
schema "bounties" do
|
|
field :public_id, Ecto.UUID, autogenerate: true
|
|
field :target_type, :string
|
|
field :pattern_key, :string
|
|
field :title, :string
|
|
field :description, :string
|
|
field :funding, :string
|
|
field :amount_cents, :integer
|
|
field :credit_amount, :integer
|
|
field :take_rate, :decimal
|
|
field :status, :string, default: "pending_funding"
|
|
field :stripe_checkout_session_id, :string
|
|
field :expires_at, :utc_datetime_usec
|
|
|
|
belongs_to :sponsor_account, Account
|
|
belongs_to :repository, Repository
|
|
belongs_to :canonical_finding, CanonicalFinding
|
|
belongs_to :winner_account, Account
|
|
|
|
has_many :claims, BountyClaim
|
|
|
|
timestamps(type: :utc_datetime_usec)
|
|
end
|
|
|
|
def target_types, do: @target_types
|
|
def fundings, do: @fundings
|
|
def statuses, do: @statuses
|
|
|
|
# Only Tarakan.Market builds the attrs map for this changeset - public form
|
|
# params never reach it directly - so programmatic fields (target refs,
|
|
# take_rate, status) are safe to cast, and they must be cast so the
|
|
# validations below can see them.
|
|
@doc false
|
|
def changeset(bounty, attrs) do
|
|
bounty
|
|
|> cast(attrs, [
|
|
:target_type,
|
|
:repository_id,
|
|
:pattern_key,
|
|
:canonical_finding_id,
|
|
:title,
|
|
:description,
|
|
:funding,
|
|
:amount_cents,
|
|
:credit_amount,
|
|
:take_rate,
|
|
:status,
|
|
:expires_at
|
|
])
|
|
|> validate_required([:target_type, :title, :description, :funding, :take_rate])
|
|
|> validate_inclusion(:target_type, @target_types)
|
|
|> validate_inclusion(:funding, @fundings)
|
|
|> validate_inclusion(:status, @statuses)
|
|
|> validate_length(:title, max: 200)
|
|
|> validate_length(:description, max: 5_000)
|
|
|> validate_amount()
|
|
|> validate_target()
|
|
|> unique_constraint(:public_id)
|
|
|> foreign_key_constraint(:repository_id)
|
|
|> foreign_key_constraint(:canonical_finding_id)
|
|
|> check_constraint(:target_type, name: :bounties_target_type_must_be_valid)
|
|
|> check_constraint(:funding, name: :bounties_funding_must_be_valid)
|
|
|> check_constraint(:status, name: :bounties_status_must_be_valid)
|
|
end
|
|
|
|
defp validate_amount(changeset) do
|
|
case get_field(changeset, :funding) do
|
|
"fiat" ->
|
|
changeset
|
|
|> validate_required([:amount_cents], message: "must be at least $1.00")
|
|
|> validate_number(:amount_cents,
|
|
greater_than_or_equal_to: 100,
|
|
message: "must be at least $1.00"
|
|
)
|
|
|
|
"credits" ->
|
|
changeset
|
|
|> validate_required([:credit_amount])
|
|
|> validate_number(:credit_amount, greater_than: 0)
|
|
|
|
_other ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
# Exactly one target reference, and it must match target_type. The database
|
|
# cannot express the type/ref pairing, so this stays changeset-only.
|
|
defp validate_target(changeset) do
|
|
type = get_field(changeset, :target_type)
|
|
|
|
refs = [
|
|
repository_id: get_field(changeset, :repository_id),
|
|
pattern_key: present(get_field(changeset, :pattern_key)),
|
|
canonical_finding_id: get_field(changeset, :canonical_finding_id)
|
|
]
|
|
|
|
set = for {key, value} <- refs, not is_nil(value), do: key
|
|
|
|
expected =
|
|
case type do
|
|
"repository" -> [:repository_id]
|
|
"infestation" -> [:pattern_key]
|
|
"finding" -> [:canonical_finding_id]
|
|
_other -> []
|
|
end
|
|
|
|
if set == expected do
|
|
changeset
|
|
else
|
|
add_error(changeset, :target_type, "must reference exactly one #{type} target")
|
|
end
|
|
end
|
|
|
|
defp present(nil), do: nil
|
|
defp present(""), do: nil
|
|
defp present(value), do: value
|
|
end
|