PhoenixKit.API.Confirm (phoenix_kit v3.5.0)

Copy Markdown View Source

Confirmation codes of dangerous API operations, and the helpers the approval queue shares.

A dangerous call without a code changes nothing and returns a preview plus a code (issue_token/6). Repeating the call with the same arguments and the code runs it. The code is valid only when all of these hold:

  • its row is unused and has not expired;
  • it was issued to the same actor (an API key, or a client-token hash for a public tool);
  • it was issued for the same tool and the same arguments (input_hash, a hash of the arguments without the control fields);
  • the fingerprint (a hash of the current values the operation touches) is still the one in the row.

lookup_token/5 checks the cheap parts and changes nothing. claim_token/3 is the atomic part: one UPDATE that sets used only if the row is still unused, not expired, and its fingerprint equals the fresh one the caller computed by running the handler as a dry run. Exactly one caller can win, so two simultaneous confirmations run the operation once; the loser sees {:error, :used}. A batch that then fails part-way keeps its code spent.

live_actor/1 rebuilds an actor from a key row without a token, for the approval queue. expire_due/1, sweep_stuck/2 and prune/1 are the maintenance functions the cron workers call.

A person's live session

A write a live session asks for (PhoenixKit.API.Executor.execute_as/5) is held in a row bound to the person (actor_user_uuid), not to a code: the session is never given one (issue_token/6 refuses a :user actor, and lookup_token/5 never accepts a session's row). issue_for_user/6 stores the row, every call a new one, for api_user_confirm_ttl_seconds; the executor redeems it by its uuid (PhoenixKit.API.Executor.redeem_as/6), repeating the call with the arguments the caller brings, and checks them against the row as for a code. This module only stores and finds those rows:

A uuid that does not exist and one of another person get the same {:error, :unknown}, so nobody learns whether somebody else's confirmation exists.

A write held for a linked Telegram chat is bound to it: the row's context names the bot and the chat (origin_context/1, stored by issue_for_user/7), and a chat reads (get_for_user/3), turns down (reject_for_user/3) or redeems (PhoenixKit.API.Executor.redeem_as/6) only the rows held for itself. The person's site session may act on a chat's row: the same person, with a stronger sign-in.

Summary

Functions

Spends a code, atomically. fresh_fingerprint is the fingerprint of the values the operation touches now. :ok for exactly one caller; {:error, :used}, {:error, :rejected} (a session's row the person turned down), {:error, :expired} or {:error, :changed} for the others.

Marks the pending approvals and the unused codes whose expires_at has passed as expired (an approval's input is cleared). Returns how many of each.

Whether the row was held for the linked Telegram chat session_ref: its context names that bot's connection and that chat (the chat id compared as a string); only these three keys are compared, so a key added to the context later does not unbind a row. A row made on the site, one of another chat or bot, and one without a context are not. A session_ref that is not a well-formed chat is not an origin to compare, so this is false for it.

What the page that shows a session's confirmation needs: %{uuid, tool, preview, status, expires_at} of the row confirmation_uuid, if it belongs to the user of the live session session_ref ({session_token_uuid, user_uuid}). status is "unused", "used", "rejected" or "expired" (an unused row past its time reads "expired").

Holds a write of the live session of actor (a :user) for tool_name and input_hash, with preview and fingerprint, until the person confirms it, for api_user_confirm_ttl_seconds. Every call stores a new row: an earlier one is never handed out again. No code is made. The insert is not logged (log: false): its parameters carry the preview, the data the person works on.

Issues a code for actor, tool_name and input_hash, holding preview and fingerprint. The row lives api_confirm_ttl_seconds.

The actor of an API key as it stands now, without a token: the key is read from the database (not the cache), must not be revoked or expired (Keys.dead_reason/1), and its owner must be active (and, for a personal key, hold api_personal_keys or api, else {:error, :personal_keys_not_allowed}); scopes are the key's scopes filtered by the owner's live permissions. This is the request-path check of the executor, for code that has no request.

The person's row confirmation_uuid, whatever its status. {:error, :unknown} for a uuid that names no row, a row of somebody else, an approval, a code of a key, or a value that is not a uuid: the answer never tells them apart.

The cheap checks of a presented code. Changes nothing. {:ok, row} or the first reason it is refused.

The context a write held for session_ref is stored with: for a linked Telegram chat ({:telegram_link, user_uuid, connection_uuid, chat_id}) %{"origin" => "telegram", "connection_uuid" => ..., "chat_id" => ...} with the chat id as a string; %{} for a session or anything else.

Deletes the rows nobody needs any more. Returns how many rows went.

The cheap checks of a session's row before it is redeemed, as for a code: the same tool and arguments (:wrong_input), still unused and not expired (:used, :rejected, :expired). Changes nothing.

Replaces a session's row whose values changed since its preview (claim_token/3 said :changed) by a new one holding the fresh preview and fingerprint. The old row goes from unused to expired in the same transaction, so it can never be redeemed after the new one exists. {:error, reason} when the old row is no longer unused (it was used or rejected meanwhile), and nothing is stored. Neither statement is logged (log: false), as in issue_for_user/6.

The person turns down their confirmation confirmation_uuid: one atomic UPDATE from unused (and not expired) to rejected, so a rejected row can never be redeemed and a redeemed one never rejected. {:error, :used | :rejected | :expired} when it is no longer waiting; {:error, :unauthorized}, {:error, :unknown} and, for a chat, {:error, :foreign} as in get_for_user/3. The person may turn a row down after losing the right to the tool: nothing runs.

Fails every approval that is still approved or executing although nothing has touched it for stale_after_seconds (default 15 minutes): the process that was running it died. The error interrupted is added to whatever partial results it had reported, its input is cleared, and the outcome is logged and announced. Returns how many.

Functions

claim_token(confirmation, fresh_fingerprint, now \\ DateTime.utc_now())

@spec claim_token(PhoenixKit.API.Confirm.Confirmation.t(), String.t(), DateTime.t()) ::
  :ok | {:error, :used | :expired | :changed | :rejected}

Spends a code, atomically. fresh_fingerprint is the fingerprint of the values the operation touches now. :ok for exactly one caller; {:error, :used}, {:error, :rejected} (a session's row the person turned down), {:error, :expired} or {:error, :changed} for the others.

expire_due(now \\ DateTime.utc_now())

@spec expire_due(DateTime.t()) :: %{
  approvals: non_neg_integer(),
  tokens: non_neg_integer()
}

Marks the pending approvals and the unused codes whose expires_at has passed as expired (an approval's input is cleared). Returns how many of each.

from_origin?(arg1, session_ref)

@spec from_origin?(PhoenixKit.API.Confirm.Confirmation.t(), term()) :: boolean()

Whether the row was held for the linked Telegram chat session_ref: its context names that bot's connection and that chat (the chat id compared as a string); only these three keys are compared, so a key added to the context later does not unbind a row. A row made on the site, one of another chat or bot, and one without a context are not. A session_ref that is not a well-formed chat is not an origin to compare, so this is false for it.

get_for_user(confirmation_uuid, session_ref, now \\ DateTime.utc_now())

@spec get_for_user(term(), term(), DateTime.t()) ::
  {:ok,
   %{
     uuid: String.t(),
     tool: String.t(),
     preview: map(),
     status: String.t(),
     expires_at: DateTime.t()
   }}
  | {:error, :unauthorized | :unknown | :forbidden | :foreign}

What the page that shows a session's confirmation needs: %{uuid, tool, preview, status, expires_at} of the row confirmation_uuid, if it belongs to the user of the live session session_ref ({session_token_uuid, user_uuid}). status is "unused", "used", "rejected" or "expired" (an unused row past its time reads "expired").

session_ref may also be a linked Telegram chat ({:telegram_link, user_uuid, connection_uuid, chat_id}), checked now as PhoenixKit.API.Executor checks it (the link, the account gate, the default role). Such a chat reads only a row held for that very chat (from_origin?/2): the person's row made on the site, or for another chat or bot, is {:error, :foreign}, with no preview.

{:error, :unauthorized} when the session or chat cannot act any more (SessionRef.scope_for_session/2), {:error, :unknown} as in lookup_for_user/2, and {:error, :forbidden}, without the preview, when the session may no longer write with the row's tool, whatever the row's status: the tool must still be callable (its module on, the module's API on, the tool not hidden), its scope and permission held by the session's scope now, and its module in api_session_write_modules, as PhoenixKit.API.Executor.redeem_as/6 checks before it runs.

The core does not tell an impersonated session from the person's own: the caller must refuse a session that PhoenixKitWeb.Users.MultiSession.impersonated?/1 marks before it calls, so an administrator acting as somebody else does not read their previews.

issue_for_user(actor, tool_name, input_hash, preview, fingerprint, now \\ DateTime.utc_now(), opts \\ [])

@spec issue_for_user(
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  map(),
  String.t(),
  DateTime.t(),
  keyword()
) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()}
  | {:error, :no_binding | Ecto.Changeset.t()}

Holds a write of the live session of actor (a :user) for tool_name and input_hash, with preview and fingerprint, until the person confirms it, for api_user_confirm_ttl_seconds. Every call stores a new row: an earlier one is never handed out again. No code is made. The insert is not logged (log: false): its parameters carry the preview, the data the person works on.

Option :context (a map, %{} by default; anything else is stored as %{}) is stored in the row's context. The executor puts the origin there for a write held for a linked Telegram chat (origin_context/1); from_origin?/2 reads it back. The function trusts the :context it is given, so a caller can mint a row that claims any chat's origin: only trusted code, with a reference it has checked, may call it.

issue_token(actor, tool_name, input_hash, preview, fingerprint, now \\ DateTime.utc_now())

@spec issue_token(
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  map(),
  String.t(),
  DateTime.t()
) ::
  {:ok,
   %{
     token: String.t(),
     row: PhoenixKit.API.Confirm.Confirmation.t(),
     expires_in_seconds: non_neg_integer()
   }}
  | {:error, :no_secret | :no_binding | Ecto.Changeset.t()}

Issues a code for actor, tool_name and input_hash, holding preview and fingerprint. The row lives api_confirm_ttl_seconds.

A key that repeats the very same call (same tool, arguments and current values) while its earlier code is still unused and unexpired gets that code again, with the time it has left: an agent that polls a dangerous call does not pile up rows. A public actor is bound to a client token it chose itself, so nothing is shared between two of its calls; what keeps that table small is the early prune of never-used codes (prune/1) and the rate limit of the public API.

live_actor(key_uuid)

@spec live_actor(String.t()) ::
  {:ok, PhoenixKit.API.Actor.t()}
  | {:error,
     :not_found
     | :revoked
     | :expired
     | :owner_inactive
     | :personal_keys_not_allowed}

The actor of an API key as it stands now, without a token: the key is read from the database (not the cache), must not be revoked or expired (Keys.dead_reason/1), and its owner must be active (and, for a personal key, hold api_personal_keys or api, else {:error, :personal_keys_not_allowed}); scopes are the key's scopes filtered by the owner's live permissions. This is the request-path check of the executor, for code that has no request.

lookup_for_user(confirmation_uuid, user_uuid)

@spec lookup_for_user(term(), term()) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()} | {:error, :unknown}

The person's row confirmation_uuid, whatever its status. {:error, :unknown} for a uuid that names no row, a row of somebody else, an approval, a code of a key, or a value that is not a uuid: the answer never tells them apart.

lookup_token(token, actor, tool_name, input_hash, now \\ DateTime.utc_now())

@spec lookup_token(
  String.t(),
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  DateTime.t()
) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()}
  | {:error,
     :malformed | :unknown | :wrong_binding | :wrong_input | :used | :expired}

The cheap checks of a presented code. Changes nothing. {:ok, row} or the first reason it is refused.

origin_context(arg1)

@spec origin_context(term()) :: map()

The context a write held for session_ref is stored with: for a linked Telegram chat ({:telegram_link, user_uuid, connection_uuid, chat_id}) %{"origin" => "telegram", "connection_uuid" => ..., "chat_id" => ...} with the chat id as a string; %{} for a session or anything else.

prune(now \\ DateTime.utc_now())

@spec prune(DateTime.t()) :: non_neg_integer()

Deletes the rows nobody needs any more. Returns how many rows went.

  • a code that was never used (still unused, marked expired, or rejected by the person of a session) one hour after it ran out: every dangerous call without a code inserts one, and a public client can ask for many, so these must not stay for a month;
  • a code that was used 30 days after it ran out (it is the trace of what was confirmed);
  • an approval 30 days after its last change, once decided, executed, failed, found stale or expired. A pending, approved or executing approval is never deleted.

redeemable(row, tool_name, input_hash, now \\ DateTime.utc_now())

@spec redeemable(
  PhoenixKit.API.Confirm.Confirmation.t(),
  String.t(),
  String.t(),
  DateTime.t()
) ::
  :ok | {:error, :wrong_input | :used | :rejected | :expired}

The cheap checks of a session's row before it is redeemed, as for a code: the same tool and arguments (:wrong_input), still unused and not expired (:used, :rejected, :expired). Changes nothing.

reissue_for_user(confirmation, actor, tool_name, input_hash, preview, fingerprint, now \\ DateTime.utc_now(), opts \\ [])

@spec reissue_for_user(
  PhoenixKit.API.Confirm.Confirmation.t(),
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  map(),
  String.t(),
  DateTime.t(),
  keyword()
) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()}
  | {:error,
     :used | :rejected | :expired | :changed | :no_binding | Ecto.Changeset.t()}

Replaces a session's row whose values changed since its preview (claim_token/3 said :changed) by a new one holding the fresh preview and fingerprint. The old row goes from unused to expired in the same transaction, so it can never be redeemed after the new one exists. {:error, reason} when the old row is no longer unused (it was used or rejected meanwhile), and nothing is stored. Neither statement is logged (log: false), as in issue_for_user/6.

The new row keeps the context of the old one (a write held for a chat stays that chat's); option :context replaces it.

reject_for_user(confirmation_uuid, session_ref, now \\ DateTime.utc_now())

@spec reject_for_user(term(), term(), DateTime.t()) ::
  :ok
  | {:error,
     :unauthorized
     | :unknown
     | :foreign
     | :used
     | :rejected
     | :expired
     | :changed}

The person turns down their confirmation confirmation_uuid: one atomic UPDATE from unused (and not expired) to rejected, so a rejected row can never be redeemed and a redeemed one never rejected. {:error, :used | :rejected | :expired} when it is no longer waiting; {:error, :unauthorized}, {:error, :unknown} and, for a chat, {:error, :foreign} as in get_for_user/3. The person may turn a row down after losing the right to the tool: nothing runs.

As for get_for_user/2, the caller must refuse an impersonated session (PhoenixKitWeb.Users.MultiSession.impersonated?/1) before it calls.

sweep_stuck(now \\ DateTime.utc_now(), stale_after_seconds \\ 900)

@spec sweep_stuck(DateTime.t(), pos_integer()) :: non_neg_integer()

Fails every approval that is still approved or executing although nothing has touched it for stale_after_seconds (default 15 minutes): the process that was running it died. The error interrupted is added to whatever partial results it had reported, its input is cleared, and the outcome is logged and announced. Returns how many.