# `PhoenixKit.API.Keys`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v3.5.0/lib/phoenix_kit/api/keys.ex#L1)

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`; 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/0` days 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/0` live 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_keys` or `api` (`Scope.for_user/2`,
  `narrow: false`). The check is one private owner lookup that `active_owner/1` and
  `verify/2` both use (so `active?/1` and `Confirm.live_actor/1` too), 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_narrowing` for 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: boolean` with no default (`:impersonation_unknown` without it,
  `:impersonated` when 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`).

# `active?`

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

```elixir
@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.

# `actor`

```elixir
@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.

# `allowed_scopes_for`

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

```elixir
@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?`

```elixir
@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`).

# `can_revoke?`

```elixir
@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.

# `create_key`

```elixir
@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).

# `create_personal_key`

```elixir
@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.

# `dead_reason`

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

```elixir
@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.

# `get_key`

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

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

```elixir
@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.

# `list_personal_keys`

```elixir
@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).

# `personal_keys_full?`

```elixir
@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.

# `revoke`

```elixir
@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`).

# `rotate`

```elixir
@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`.

# `token_prefix`

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

```elixir
@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_scope` must 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 of `new_scopes` must 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 exactly `new_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_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`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
