diff --git a/apps/explorer/lib/explorer/account/api/key.ex b/apps/explorer/lib/explorer/account/api/key.ex index 7f286ee823..8b20283a16 100644 --- a/apps/explorer/lib/explorer/account/api/key.ex +++ b/apps/explorer/lib/explorer/account/api/key.ex @@ -24,6 +24,8 @@ defmodule Explorer.Account.Api.Key do @attrs ~w(value name identity_id)a + @user_not_found "User not found" + def changeset do %__MODULE__{} |> cast(%{}, @attrs) @@ -35,14 +37,63 @@ defmodule Explorer.Account.Api.Key do |> validate_required(@attrs, message: "Required") |> validate_length(:name, min: 1, max: 255) |> unique_constraint(:value, message: "API key already exists") - |> foreign_key_constraint(:identity_id, message: "User not found") + |> foreign_key_constraint(:identity_id, message: @user_not_found) |> api_key_count_constraint() end + @doc """ + Creates a new API key associated with an identity or returns an error when no identity is specified. + + When `identity_id` is provided in the attributes, the function acquires a lock on the + identity record and creates a new API key within a transaction. If the identity is not + found or if the changeset validation fails, returns an error. + + When `identity_id` is not provided, immediately returns an error with an invalid + changeset. + + ## Parameters + - `attrs`: A map of attributes that may contain: + - `identity_id`: The ID of the identity to associate the API key with + - `name`: The name for the API key (required, 1 to 255 characters) + - `value`: Optional. If not provided, will be auto-generated using UUID v4 + + ## Returns + - `{:ok, api_key}` if the API key was created successfully + - `{:error, changeset}` if validation fails or when no identity is provided + """ + @spec create(map()) :: {:ok, t()} | {:error, Changeset.t()} + def create(%{identity_id: identity_id} = attrs) do + Multi.new() + |> Multi.run(:acquire_identity, fn repo, _changes -> + identity_query = from(identity in Identity, where: identity.id == ^identity_id, lock: "FOR UPDATE") + + case repo.one(identity_query) do + nil -> + {:error, + %__MODULE__{} + |> changeset(Map.put(attrs, :value, generate_api_key())) + |> add_error(:identity_id, @user_not_found, + constraint: :foreign, + constraint_name: "account_api_keys_identity_id_fkey" + )} + + identity -> + {:ok, identity} + end + end) + |> Multi.insert(:api_key, fn _ -> + %__MODULE__{} + |> changeset(Map.put(attrs, :value, generate_api_key())) + end) + |> Repo.account_repo().transaction() + |> case do + {:ok, %{api_key: api_key}} -> {:ok, api_key} + {:error, _failed_operation, error, _changes} -> {:error, error} + end + end + def create(attrs) do - %__MODULE__{} - |> changeset(Map.put(attrs, :value, generate_api_key())) - |> Repo.account_repo().insert() + {:error, %__MODULE__{} |> changeset(Map.put(attrs, :value, generate_api_key()))} end def api_key_count_constraint(%Changeset{changes: %{identity_id: identity_id}} = api_key) do diff --git a/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs b/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs new file mode 100644 index 0000000000..c4fe2b74a5 --- /dev/null +++ b/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs @@ -0,0 +1,20 @@ +defmodule Explorer.Repo.Account.Migrations.RemoveAbusedApiKeys do + use Ecto.Migration + + def up do + execute(""" + WITH ranked_keys AS (SELECT value, + identity_id, + inserted_at, + ROW_NUMBER() OVER ( + PARTITION BY identity_id + ) as row_number + FROM account_api_keys) + DELETE + FROM account_api_keys + WHERE value IN (SELECT value + FROM ranked_keys + WHERE row_number > 3) + """) + end +end