fix: handle simultaneous api key creation (#11233)

* fix: handle simultaneous api key creation

* Fix migration

Co-Authored-By: Victor Baranov <baranov.viktor.27@gmail.com>

---------

Co-authored-by: Victor Baranov <baranov.viktor.27@gmail.com>
production-optimism
Maxim Filonov 6 days ago committed by GitHub
parent d5709dfda9
commit 6493574864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 59
      apps/explorer/lib/explorer/account/api/key.ex
  2. 20
      apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs

@ -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

@ -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
Loading…
Cancel
Save