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; for personal keys also :impersonated,
:impersonation_unknown, :personal_key_limit, :scopes_not_narrowing and, on a
request, :personal_keys_not_allowed); validation failures are changesets.
Personal keys
A personal key (personal = true, V226) is a person's own key, made through
create_personal_key/3 (the "My API keys" page) by a holder of api_personal_keys or
api. Nothing else sets the flag: create_key/3 never does, whatever its attributes
say, so every key made on Settings -> API, and every key made before V226, is not
personal and behaves exactly as described above. A personal key:
- is owned and created by the same person, never a service user;
- must expire, no later than the end of the UTC day
PhoenixKit.API.PersonalKeysConfig.max_days/0days from today, and cannot need approval (nobody but an Owner could decide, and the owner may not see the queue); - counts towards
PhoenixKit.API.PersonalKeysConfig.max_count/0live personal keys per person (a key in the grace period of a rotation included), so a person never has more. A creation, and a rotation with a grace period (rotate/4), are checked under one advisory lock per person; a rotation without a grace period replaces the key one for one and is always allowed; - works only while its owner holds
api_personal_keysorapi(Scope.for_user/2,narrow: false). The check is one private owner lookup thatactive_owner/1andverify/2both use (soactive?/1andConfirm.live_actor/1too), so taking the permission from a role stops the key on the next request; giving it back revives it; - is managed (rotated, its scopes edited) by its owner alone:
can_manage?/2. An Owner may revoke someone else's personal key (can_revoke?/2) and remove scopes from it (update_scopes/3,:scopes_not_narrowingfor anything else), never rotate it: a new token acting as a person goes to nobody but that person; - is created and rotated only outside a borrowed session: both take
impersonated: booleanwith no default (:impersonation_unknownwithout it,:impersonatedwhen true), which the page reads from the session (PhoenixKitWeb.Users.MultiSession.impersonated?/1).
A rotation of a personal key gives the new key the latest expiry allowed now
(PersonalKeysConfig.latest_expiry/0), not the old key's.
Token
pk_<8 hex>_<43 base64url> (see PhoenixKit.API.Keys.Token). The token is
returned once, by create_key/3, create_personal_key/3, rotate/3 and rotate/4;
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/4 (rotate/3 is its form without options) 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 for keys made on Settings -> API. A
personal key is never rotated by anyone but its owner (see "Personal keys").
revoke/2 is different: it takes a bare actor uuid and does not authorize.
The caller decides with can_revoke?/2 first (can_manage?/2, plus an Owner for a
personal key; the Settings page finds 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, and {:error, :personal_keys_not_allowed} for a personal key whose owner
holds neither api_personal_keys nor api right now. Behind active?/1 and
Confirm.live_actor/1. verify/2 goes through the same private check
(checked_owner/1), so the two cannot disagree.
The Actor of key held by its (already loaded, active) owner, with the effective
scopes (none for a personal key whose owner lacks the permission). It and verify/2
share one private builder, so a field added to the actor reaches the request path and
an approved operation (Confirm.live_actor/1) 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, rotate or edit the scopes of key: they
hold api and are an Owner or created the key. A personal key is managed by its
owner alone, while they hold api_personal_keys or api (an Owner included: see
can_revoke?/2).
Whether the person behind scope may revoke key: can_manage?/2, and an Owner for
anyone's personal key too (revoking hands out no token). Callers of revoke/2 check
this first.
Creates a key for owner on behalf of the person behind creator_scope.
Creates a personal key for the person behind creator_scope, who is also its
owner (see "Personal keys" in the moduledoc).
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.
The personal keys of the person behind scope (personal = true and owned by them),
newest first; [] unless they hold api_personal_keys or api. An Owner sees only
their own here (list_keys/1 lists everyone's).
Whether the person behind scope already has PersonalKeysConfig.max_count/0 live
personal keys (a key in a grace period included): then a new key, and a rotation with a
grace period, are refused with :personal_key_limit. For the pages, which offer a grace
period of 0 in that case; the decision itself is made again under the lock.
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 | :personal_keys_not_allowed}
The active owner of a key, {:error, :owner_inactive} when the user is gone, blocked or
not confirmed, and {:error, :personal_keys_not_allowed} for a personal key whose owner
holds neither api_personal_keys nor api right now. Behind active?/1 and
Confirm.live_actor/1. verify/2 goes through the same private check
(checked_owner/1), so the two cannot disagree.
@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 (none for a personal key whose owner lacks the permission). It and verify/2
share one private builder, so a field added to the actor reaches the request path and
an approved operation (Confirm.live_actor/1) 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, rotate or edit the scopes of key: they
hold api and are an Owner or created the key. A personal key is managed by its
owner alone, while they hold api_personal_keys or api (an Owner included: see
can_revoke?/2).
@spec can_revoke?(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.API.Keys.Key.t()) :: boolean()
Whether the person behind scope may revoke key: can_manage?/2, and an Owner for
anyone's personal key too (revoking hands out no token). Callers of revoke/2 check
this first.
@spec create_key( PhoenixKit.Users.Auth.User.t(), PhoenixKit.Users.Auth.Scope.t(), map(), keyword() ) :: {: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.
opts[:impersonated]: a caller that knows the session is borrowed
(PhoenixKitWeb.Users.MultiSession.impersonated?/1) passes true and gets
{:error, :impersonated} — no token is minted in the name of an account someone
acts in. Omitted, nothing is assumed (a mix task, a seed, a test).
@spec create_personal_key(PhoenixKit.Users.Auth.Scope.t() | nil, map(), keyword()) :: {:ok, PhoenixKit.API.Keys.Key.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
Creates a personal key for the person behind creator_scope, who is also its
owner (see "Personal keys" in the moduledoc).
creator_scope should be the person's full scope read again for this event
(PhoenixKit.API.Access.fresh_scope/2); the person is read again here as well and must
hold api_personal_keys or api in both. attrs takes name (required), scopes,
allowed_ips and expires_at (required, at most PersonalKeysConfig.latest_expiry/0),
as create_key/3; requires_approval: true is a changeset error, not dropped.
opts must say impersonated: true | false (no default): a missing or non-boolean value
is {:error, :impersonation_unknown}, true is {:error, :impersonated}. Other
failures: :forbidden (neither permission), :owner_inactive, :owner_not_allowed
(a service user), :invalid_scopes, :personal_key_limit (the person already has
PersonalKeysConfig.max_count/0 live personal keys), or a changeset.
@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, and for a personal key (or the actor of
one) whose owner holds neither api_personal_keys nor api. 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 list_personal_keys(PhoenixKit.Users.Auth.Scope.t() | nil) :: [ PhoenixKit.API.Keys.Key.t() ]
The personal keys of the person behind scope (personal = true and owned by them),
newest first; [] unless they hold api_personal_keys or api. An Owner sees only
their own here (list_keys/1 lists everyone's).
@spec personal_keys_full?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()
Whether the person behind scope already has PersonalKeysConfig.max_count/0 live
personal keys (a key in a grace period included): then a new key, and a rotation with a
grace period, are refused with :personal_key_limit. For the pages, which offer a grace
period of 0 in that case; the decision itself is made again under the lock.
@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_revoke?/2 first (the
Settings page does, on the key it found through list_keys/1; "My API keys" on one it
found through list_personal_keys/1).
@spec rotate( PhoenixKit.API.Keys.Key.t(), 0..24, PhoenixKit.Users.Auth.Scope.t(), keyword() ) :: {: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).
A personal key is rotated by its owner only (can_manage?/2), with
opts[:impersonated] given and false (:impersonation_unknown / :impersonated, as
in create_personal_key/3; rotate/3 passes no opts, so it refuses a personal key).
Any other key is refused with :impersonated when opts[:impersonated] is true
(the new token would act as the borrowed account); omitted, nothing is assumed.
The new key stays personal, never needs approval and expires at
PersonalKeysConfig.latest_expiry/0, not at the old key's expiry.
A rotation of a personal key takes the same per-person advisory lock as a creation,
before the row lock. With a grace period (grace_hours > 0) the old key stays alive
next to the new one, so it is refused with {:error, :personal_key_limit} while the
person already has PersonalKeysConfig.max_count/0 live personal keys (a key in a
grace period included), checked under that lock. Without one (0) the swap is one for
one and always allowed.
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 | :scopes_not_narrowing | :revoked | :not_found}
Replaces the scopes of a live key.
editor_scopemust be able to manage the key (can_manage?/2), else{:error, :forbidden}; an Owner who may not manage someone else's personal key may still remove scopes from it: every scope ofnew_scopesmust already be on the stored row, else{:error, :scopes_not_narrowing}(no token changes hands, and the key can only lose rights). In that case the saved list is exactlynew_scopes: inactive scopes are not kept (the rule below does not apply), so any scope can go;- 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 | :personal_keys_not_allowed}
Verifies a bearer token presented from ip.
Checks, in order: the token's shape and secret, revocation, expiry, the IP
allow-list, that the owner exists and is active, and, for a personal key, that the
owner holds api_personal_keys or api (:personal_keys_not_allowed; the API answers
the same 401 for every reason). 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.