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 holdsThe 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
apipermission; - 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 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
@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.
@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.
@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.
@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.
@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).
@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.
@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.
@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.
@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.
@spec get_key(String.t()) :: PhoenixKit.API.Keys.Key.t() | nil
Fetches a key by uuid; nil for an unknown or malformed uuid.
@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.
@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.
@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).
@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.
The prefix of a token ("pk_<prefix>_<secret>" gives prefix), or nil.
Safe to log: the prefix is not secret.
@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_scopemust 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_scopesis the desired list of active scopes: a list of strings, and every scope that is not on the key yet must be inallowed_scopes_for/1of the key's owner and, unless the editor is the owner, inallowed_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 notnew_scopesnames 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: onlyrevoke/2removes it (rotate/3copies 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.
@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.