PhoenixKit.API.Keys (phoenix_kit v2.64.1)

Copy Markdown View Source

API keys: creating, verifying, revoking and rotating the bearer tokens of the admin API.

A key belongs to a user (its owner) and carries a list of scopes ("catalogue.products:write"). What a request may actually do is the key's scopes filtered by the owner's permissions right now:

effective scopes = key.scopes
                   that some enabled admin tool declares
                   and whose tool permission the owner holds

The owner's permissions come from Scope.for_user(user, narrow: false) (the union of all roles, ignoring the role switcher) and Scope.can?/2, on every request. Removing a role from the owner, blocking or deleting the owner therefore takes effect on the next request, whatever the 60 second key row cache still holds (PhoenixKit.API.Keys.Cache). A scope whose module is disabled stays on the key but grants nothing.

Who may create a key

  • the creator holds the api permission;
  • the owner is the creator, or a service user (api_only) they created (an Owner may use any) whose roles' permissions are a subset of the creator's;
  • every scope is one the owner could use (allowed_scopes_for/1).

Authorization failures are atoms (:forbidden, :owner_not_allowed, :owner_inactive, :invalid_scopes); validation failures are changesets.

Token

pk_<8 hex>_<43 base64url> (see PhoenixKit.API.Keys.Token). The token is returned once, by create_key/3 and rotate/3; only a SHA-256 of its secret is stored.

Revocation and rotation

revoked_at in the past means revoked. A revoked_at in the future is a scheduled revocation: the key works until then. rotate/3 uses it to keep the old key alive for a grace period.

rotate/3 takes the person's scope and checks it on the locked row: the person must be able to manage the key (can_manage?/2) and, when the owner is a service user, must still hold every permission that service user holds (the rule of create_key/3), so a demoted creator cannot mint a token beyond their own rights. The new token acts as the key's owner. When an Owner rotates the key of a regular user, the token they receive therefore acts as that user; this is intended.

revoke/2 is different: it takes a bare actor uuid and does not authorize. The caller decides with can_manage?/2 first (the Settings page does, by finding the key through list_keys/1).

Editing scopes

update_scopes/3 replaces the scope list of a live key. The editor must be able to manage the key, and every new scope must be one the key's owner could be given (allowed_scopes_for/1). When the editor is not the owner, a new scope must also be one the editor holds themselves (the permissions of its tools): a creator who was demoted, or a service user whose roles grew, cannot widen the key beyond the editor's own rights. Scopes already on the key may stay or go whoever the editor is. The cached row is dropped, so the next request already sees the new list.

Scopes the owner cannot use right now (module switched off, tool hidden, permission lost, owner blocked or deleted) are not the editor's to change: they are merged back into the saved list and stay on the key, granting nothing until the owner can use them again. Authorization and validation run on the locked, re-read row, never on the struct the caller holds.

Create, revoke, rotate and scope edits are written to PhoenixKit.Activity (module api, permanent: true).

Summary

Functions

Token-less check that a key may be used now: the key is neither revoked nor expired (dead_reason/1 is nil) and its owner is an active user (Auth.get_user/1 |> Auth.ensure_active_user/1, the same lookup verify/2 uses). A caller that needs the reason, or the key's effective scopes, uses Confirm.live_actor/1. The owner is read only when the row itself is alive.

The active owner of a key, {:error, :owner_inactive} when the user is gone, blocked or not confirmed. The one lookup behind verify/2, active?/1 and Confirm.live_actor/1.

The Actor of key held by its (already loaded, active) owner, with the effective scopes. The one builder: verify/2 and Confirm.live_actor/1 both use it, so a field added to the actor reaches the request path and an approved operation alike.

Every scope the owner could be given: a scope of the enabled admin tools, provided the owner holds the permission of every one of those tools that declares it.

The scopes editor_scope may hand to owner's keys: allowed_scopes_for/1 of the owner, narrowed to the scopes whose tool permissions the editor holds too. Editing one's own keys needs no narrowing (the owner is the editor).

Whether the person behind scope may revoke or rotate key: they hold api and are an Owner or created the key.

Creates a key for owner on behalf of the person behind creator_scope.

Why a key row cannot be used now: :revoked (revoked_at has been reached), :expired (expires_at has been reached), or nil. A time in the future has not been reached: a revocation in the future is a scheduled one (the grace period of a rotation) and the key still works until then. When both have been reached the answer is :revoked.

The scopes of a key (or of an Actor) that its owner may use right now.

Fetches a key by uuid; nil for an unknown or malformed uuid.

The scopes scope holds: those of the enabled admin tools, provided scope holds the permission of every one of those tools that declares it (the rule of allowed_scopes_for/1), sorted.

The keys the person behind scope may see: all of them for an Owner, the keys they created themselves for any other holder of api, none otherwise. Newest first.

Revokes a key now. Revoking a key that is already revoked changes nothing. The row is read again, so a stale struct neither overwrites an earlier revocation nor fails; a key that no longer exists is {:error, :not_found}.

Issues a new key with the same owner, scopes, flags, IP list and expiry, and retires the old one after grace_hours (0 to 24; 0 revokes it now).

The prefix of a token ("pk_<prefix>_<secret>" gives prefix), or nil. Safe to log: the prefix is not secret.

Replaces the scopes of a live key.

Verifies a bearer token presented from ip.

Functions

active?(key)

@spec active?(PhoenixKit.API.Keys.Key.t()) :: boolean()

Token-less check that a key may be used now: the key is neither revoked nor expired (dead_reason/1 is nil) and its owner is an active user (Auth.get_user/1 |> Auth.ensure_active_user/1, the same lookup verify/2 uses). A caller that needs the reason, or the key's effective scopes, uses Confirm.live_actor/1. The owner is read only when the row itself is alive.

active_owner(key)

@spec active_owner(PhoenixKit.API.Keys.Key.t()) ::
  {:ok, PhoenixKit.Users.Auth.User.t()} | {:error, :owner_inactive}

The active owner of a key, {:error, :owner_inactive} when the user is gone, blocked or not confirmed. The one lookup behind verify/2, active?/1 and Confirm.live_actor/1.

actor(key, owner, ip)

@spec actor(
  PhoenixKit.API.Keys.Key.t(),
  PhoenixKit.Users.Auth.User.t(),
  String.t() | nil
) ::
  PhoenixKit.API.Actor.t()

The Actor of key held by its (already loaded, active) owner, with the effective scopes. The one builder: verify/2 and Confirm.live_actor/1 both use it, so a field added to the actor reaches the request path and an approved operation alike.

allowed_scopes_for(owner)

@spec allowed_scopes_for(PhoenixKit.Users.Auth.User.t()) :: [String.t()]

Every scope the owner could be given: a scope of the enabled admin tools, provided the owner holds the permission of every one of those tools that declares it.

allowed_scopes_for(owner, editor_scope)

@spec allowed_scopes_for(
  PhoenixKit.Users.Auth.User.t(),
  PhoenixKit.Users.Auth.Scope.t()
) :: [
  String.t()
]

The scopes editor_scope may hand to owner's keys: allowed_scopes_for/1 of the owner, narrowed to the scopes whose tool permissions the editor holds too. Editing one's own keys needs no narrowing (the owner is the editor).

can_manage?(scope, key)

@spec can_manage?(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.API.Keys.Key.t()) ::
  boolean()

Whether the person behind scope may revoke or rotate key: they hold api and are an Owner or created the key.

create_key(owner, creator_scope, attrs)

@spec create_key(
  PhoenixKit.Users.Auth.User.t(),
  PhoenixKit.Users.Auth.Scope.t(),
  map()
) ::
  {:ok, PhoenixKit.API.Keys.Key.t(), String.t()}
  | {:error, Ecto.Changeset.t() | atom()}

Creates a key for owner on behalf of the person behind creator_scope.

attrs takes name (required), scopes, requires_approval, allowed_ips (CIDR blocks or bare addresses; stored in canonical form) and expires_at, with atom or string keys (not mixed).

Returns the stored key and the token, which exists nowhere else and is never shown again.

dead_reason(key)

@spec dead_reason(PhoenixKit.API.Keys.Key.t()) :: :revoked | :expired | nil

Why a key row cannot be used now: :revoked (revoked_at has been reached), :expired (expires_at has been reached), or nil. A time in the future has not been reached: a revocation in the future is a scheduled one (the grace period of a rotation) and the key still works until then. When both have been reached the answer is :revoked.

effective_scopes(arg1)

@spec effective_scopes(PhoenixKit.API.Keys.Key.t() | PhoenixKit.API.Actor.t()) :: [
  String.t()
]

The scopes of a key (or of an Actor) that its owner may use right now.

Empty when the owner is blocked or deleted. For code that holds a Key row or an old Actor (the Settings page, tests). A request does not need it: the Actor returned by verify/2 already carries these scopes and the Executor trusts them.

get_key(uuid)

@spec get_key(String.t()) :: PhoenixKit.API.Keys.Key.t() | nil

Fetches a key by uuid; nil for an unknown or malformed uuid.

held_scopes_of(scope)

@spec held_scopes_of(PhoenixKit.Users.Auth.Scope.t()) :: [String.t()]

The scopes scope holds: those of the enabled admin tools, provided scope holds the permission of every one of those tools that declares it (the rule of allowed_scopes_for/1), sorted.

allowed_scopes_for/1 asks it of the owner's full scope (narrow: false); a caller acting through a live session (PhoenixKit.API.Executor.execute_as/5) asks it of the session's scope, which is narrowed to the session's active role.

list_keys(scope)

@spec list_keys(PhoenixKit.Users.Auth.Scope.t()) :: [PhoenixKit.API.Keys.Key.t()]

The keys the person behind scope may see: all of them for an Owner, the keys they created themselves for any other holder of api, none otherwise. Newest first.

revoke(key, actor_uuid)

@spec revoke(PhoenixKit.API.Keys.Key.t(), String.t() | nil) ::
  {:ok, PhoenixKit.API.Keys.Key.t()} | {:error, term()}

Revokes a key now. Revoking a key that is already revoked changes nothing. The row is read again, so a stale struct neither overwrites an earlier revocation nor fails; a key that no longer exists is {:error, :not_found}.

Unlike rotate/3 and update_scopes/3, which take the person's scope and authorize it, this takes a bare actor_uuid (only for the activity entry) and checks nothing: the CALLER must authorize with can_manage?/2 first (the Settings page does, by finding the key through list_keys/1).

rotate(key, grace_hours, creator_scope)

@spec rotate(PhoenixKit.API.Keys.Key.t(), 0..24, PhoenixKit.Users.Auth.Scope.t()) ::
  {:ok, PhoenixKit.API.Keys.Key.t(), String.t()} | {:error, term()}

Issues a new key with the same owner, scopes, flags, IP list and expiry, and retires the old one after grace_hours (0 to 24; 0 revokes it now).

The old key keeps working until then, so a client can switch over without downtime. The creator must be able to manage the key (can_manage?/2) and, when the owner is a service user, still hold every permission the service user holds ({:error, :owner_not_allowed}, as in create_key/3). Returns the new key and its token.

The row is locked and read again first. A key that is gone is {:error, :not_found}, one that is revoked or expired :revoked / :expired (also through a stale struct), and one whose retirement is already scheduled :rotation_pending.

token_prefix(token)

@spec token_prefix(String.t()) :: String.t() | nil

The prefix of a token ("pk_<prefix>_<secret>" gives prefix), or nil. Safe to log: the prefix is not secret.

update_scopes(key, new_scopes, editor_scope)

@spec update_scopes(
  PhoenixKit.API.Keys.Key.t(),
  [String.t()],
  PhoenixKit.Users.Auth.Scope.t()
) ::
  {:ok, PhoenixKit.API.Keys.Key.t()}
  | {:error, :forbidden | :invalid_scopes | :revoked | :not_found}

Replaces the scopes of a live key.

  • editor_scope must be able to manage the key (can_manage?/2), else {:error, :forbidden};
  • the key is re-read: gone gives {:error, :not_found}, revoked (even if the struct passed in is stale) {:error, :revoked};
  • new_scopes is the desired list of active scopes: a list of strings, and every scope that is not on the key yet must be in allowed_scopes_for/1 of the key's owner and, unless the editor is the owner, in allowed_scopes_for/2 (the editor holds the permissions of its tools too), else {:error, :invalid_scopes}. Blank entries and duplicates are dropped; an empty list is allowed;
  • scopes already on the key that the owner cannot use right now are kept untouched: their module is switched off, their tool is hidden, the owner lost the permission, or the owner is blocked or deleted (then every scope is inactive and saving [] changes nothing). Saving never drops them, whether or not new_scopes names them. A named inactive scope keeps the position requested for it; the others are appended after the requested scopes. An inactive scope cannot be removed while it is inactive: only revoke/2 removes it (rotate/3 copies it). It grants nothing meanwhile (effective_scopes/1) and works again once the owner can use it.

On success the cached row is dropped on every node and a permanent api.key_scopes_updated entry (from / to) is written. Saving the scopes the key already has, in any order, changes and logs nothing.

verify(token, ip)

@spec verify(term(), String.t() | nil) ::
  {:ok, PhoenixKit.API.Actor.t()}
  | {:error,
     :invalid_token | :revoked | :expired | :ip_not_allowed | :owner_inactive}

Verifies a bearer token presented from ip.

Checks, in order: the token's shape and secret, revocation, expiry, the IP allow-list, and that the owner exists and is active. On success returns an Actor whose scopes are the key's effective scopes for this request.

Never raises on bad input, whatever its type (a missing header arrives as nil); a malformed token is :invalid_token, the same answer as an unknown key, so the response does not tell the two apart.

The returned Actor already carries the effective scopes of this request (the key's scopes filtered by the owner's live permissions and by the tools that are enabled right now), and verify/2 itself writes last_used_at / last_used_ip. The Executor therefore trusts actor.scopes and does not call effective_scopes/1 again for the same request: the owner lookup, the Scope.for_user/2 build and the tool-registry scan happen once, here.