lib/openagents/deployments/workflow_grant.ex

58e6347eeb72 · 3 KB

defmodule OpenAgents.Deployments.WorkflowGrant do
  @moduledoc """
  A short-lived credential a workflow run holds to act on one repository.

  The grant binds a repository, an optional environment, the source ref and
  workflow it was issued for, and the exact workflow run it belongs to. Only the
  token digest is stored. The grant cannot widen: authorization compares the
  request against these bound values rather than against anything the caller
  sends.
  """

  use Ecto.Schema

  import Ecto.Changeset

  @scopes ~w(deployments:request deployments:checks)
  @maximum_lifetime_seconds 3_600

  @type t :: %__MODULE__{}

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

  schema "deployment_workflow_grants" do
    field :token_digest, :string
    field :audience, :string
    field :source_ref, :string
    field :source_workflow, :string
    field :workflow_run_id, :string
    field :scopes, {:array, :string}, default: []
    field :expires_at, :utc_datetime_usec
    field :revoked_at, :utc_datetime_usec

    belongs_to :repository, OpenAgents.Repositories.Repository
    belongs_to :environment, OpenAgents.Deployments.Environment
    belongs_to :created_by_user, OpenAgents.Accounts.User

    timestamps()
  end

  @doc false
  def changeset(grant, attrs) do
    grant
    |> cast(attrs, [:audience, :source_ref, :source_workflow, :workflow_run_id, :scopes])
    |> validate_required([
      :token_digest,
      :audience,
      :source_ref,
      :source_workflow,
      :workflow_run_id,
      :expires_at
    ])
    |> validate_length(:audience, min: 1, max: 120)
    |> validate_length(:source_ref, min: 1, max: 255)
    |> validate_length(:source_workflow, min: 1, max: 120)
    |> validate_length(:workflow_run_id, min: 1, max: 64)
    |> validate_scopes()
    |> validate_lifetime()
    |> unique_constraint(:token_digest)
  end

  @doc "The scopes a workflow grant can carry."
  @spec scopes() :: [String.t()]
  def scopes, do: @scopes

  @doc "The longest lifetime a grant may be issued for."
  @spec maximum_lifetime_seconds() :: pos_integer()
  def maximum_lifetime_seconds, do: @maximum_lifetime_seconds

  @doc "Whether the grant is usable at `now`."
  @spec usable?(t(), DateTime.t()) :: boolean()
  def usable?(%__MODULE__{} = grant, %DateTime{} = now) do
    is_nil(grant.revoked_at) and DateTime.compare(grant.expires_at, now) == :gt
  end

  defp validate_scopes(changeset) do
    validate_change(changeset, :scopes, fn :scopes, scopes ->
      cond do
        scopes == [] -> [scopes: "must name at least one scope"]
        Enum.any?(scopes, &(&1 not in @scopes)) -> [scopes: "contains an unknown scope"]
        true -> []
      end
    end)
  end

  defp validate_lifetime(changeset) do
    expires_at = get_field(changeset, :expires_at)

    if is_nil(expires_at) or
         DateTime.diff(expires_at, DateTime.utc_now()) <= @maximum_lifetime_seconds do
      changeset
    else
      add_error(changeset, :expires_at, "exceeds the maximum grant lifetime")
    end
  end
end