Files
Spoolman/spoolman/database/auth_api_key.py
T
Donkie 07be532f00 auth: API keys, machine credentials that cannot outrank their owner
Phase 1 created auth_api_key and left PrincipalKind.APIKEY unproduced. This
fills both in, so a printer or a script has a credential that is not somebody's
password.

A key is presented as spoolman_<prefix>.<secret>. The marker exists so a leaked
key is recognisable in a log or a paste, and the separated prefix is what makes
authentication one indexed lookup instead of a scan that verifies every stored
hash in turn. The separator is a full stop because both halves are url-safe
base64, whose alphabet already contains '-' and '_'. Secrets are hashed with
the same plain SHA-256 as session tokens and for the same reason: 256 bits from
a CSPRNG has no dictionary to attack, so scrypt on the hot path of every
printer poll would cost 50 ms per request to buy nothing.

Three restrictions are the substance of this, and each has a failure mode it
exists to prevent:

A key's level is capped against its owner's *current* level on every request
rather than baked in at creation, so demoting a user weakens their outstanding
keys immediately instead of leaving credentials in the wild that outrank the
account they belong to. Verified against a running server: a key issued at
manage drops to read the moment its owner is demoted, both in enforcement and
in what the listing reports.

A key never carries administrative rights, however senior its owner, so
everything behind require_admin needs a session.

require_user now refuses keys outright, which is what gates the key endpoints
themselves. A credential that can mint another credential can never really be
revoked -- whoever held a leaked key would issue themselves a fresh one ahead
of the withdrawal.

Keys skip CSRF, as cookies.py anticipated in phase 1. The check in
require_level was already conditioned on PrincipalKind.USER, so this needed no
code change; what it needed was the comment saying why a header that a
cross-site page cannot set is not an ambient credential and has nothing to
defend against.

/metrics accepts a key in addition to SPOOLMAN_METRICS_TOKEN rather than
instead of it. The shared token needs no database and no account, which is what
an operator scraping their own instance wants and what keeps working when the
database is down; a key is attributable and revocable through the interface,
which is what you want when the scraper belongs to somebody else. Only keys are
honoured there, never session cookies, so a signed-in browser tab does not
quietly make the endpoint readable.

Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
2026-07-28 13:40:28 +02:00

230 lines
7.3 KiB
Python

"""Helper functions for interacting with API key database objects.
As with :mod:`spoolman.database.auth_user`, nothing here emits a websocket event.
``websocket_manager.send`` fans out to every subscriber, so broadcasting key activity
would tell every connected client when and how often each machine credential is used,
and the creation event would carry the prefix that identifies it. Do not add a notifier
here.
"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth import apikey
from spoolman.auth.hashing import hash_token, tokens_equal
from spoolman.auth.levels import Level
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
logger = logging.getLogger(__name__)
# How stale last_used must be before a request bothers to update it.
#
# The same reasoning as auth_session.RENEW_THRESHOLD, and it matters more here: an API
# key is what a printer polls with, several times a minute, forever. Recording every one
# of those would turn a read-only request into a write on a database whose default is a
# single SQLite file behind one write lock. "Last used" to the nearest five minutes is
# all this field is ever read for.
TOUCH_THRESHOLD = timedelta(minutes=5)
# Longest a key may be issued for, in days. Not a security boundary -- an expiry is a
# convenience, and a key can always be revoked -- but an unbounded value would let a
# typo produce a date SQLite stores and other backends reject.
MAX_EXPIRY_DAYS = 3650
async def create(
*,
db: AsyncSession,
user_id: int,
name: str,
level: Level,
expires_days: int | None = None,
) -> tuple[models.AuthApiKey, str]:
"""Issue a new API key.
Unlike passwords, the secret is generated here rather than passed in. There is
nothing for a caller to supply -- a key is not chosen, it is minted -- and hashing
it is a single SHA-256 rather than the tens of milliseconds a password costs, so
none of the offloading that :mod:`spoolman.auth.hashing` exists for applies. This
mirrors :func:`spoolman.database.auth_session.create`.
Args:
db: The database session.
user_id: Who the key belongs to.
name: A human label, so the owner can tell their keys apart.
level: The level to issue at. The caller is responsible for refusing to issue
above its own; the cap against the owner's current level is applied at
every request instead, in :func:`spoolman.auth.apikey.effective_level`.
expires_days: How long the key should live, or None for no expiry.
Returns:
tuple: The stored key and the complete plaintext key, which is the only time
the secret exists in a readable form.
"""
prefix, plaintext = apikey.generate()
now = datetime.utcnow().replace(microsecond=0)
expires = now + timedelta(days=expires_days) if expires_days else None
key = models.AuthApiKey(
user_id=user_id,
name=name,
level=str(level),
prefix=prefix,
key_hash=hash_token(plaintext),
created=now,
expires=expires,
last_used=None,
revoked=False,
)
db.add(key)
await db.commit()
return key, plaintext
async def resolve(db: AsyncSession, presented: str) -> models.AuthApiKey | None:
"""Look up a live key by the string a caller presented.
Args:
db: The database session.
presented: The complete key from the request header.
Returns:
Optional[models.AuthApiKey]: The key, or None if it is unknown, revoked or
expired.
"""
prefix = apikey.prefix_of(presented)
if prefix is None:
return None
stmt = select(models.AuthApiKey).where(models.AuthApiKey.prefix == prefix)
key = (await db.execute(stmt)).scalar_one_or_none()
if key is None:
return None
# Constant time even though both sides are hex digests of the same length: the
# comparison is cheap and a timing oracle on the stored hash is not worth leaving
# open for the sake of an early exit.
if not tokens_equal(hash_token(presented), key.key_hash):
return None
if key.revoked:
return None
now = datetime.utcnow().replace(microsecond=0)
if key.expires is not None and key.expires <= now:
# Left in place rather than deleted, unlike an expired session. A key is
# something a person made and named, and having it disappear from the list
# instead of showing as expired reads as data loss.
return None
if key.last_used is None or now - key.last_used >= TOUCH_THRESHOLD:
key.last_used = now
await db.commit()
return key
async def get_by_id(db: AsyncSession, key_id: int) -> models.AuthApiKey:
"""Get an API key by its unique ID.
Args:
db: The database session.
key_id: The key ID.
Raises:
ItemNotFoundError: If no such key exists.
Returns:
models.AuthApiKey: The key.
"""
key = await db.get(models.AuthApiKey, key_id)
if key is None:
raise ItemNotFoundError(f"No API key with ID {key_id} found.")
return key
async def find_for_user(*, db: AsyncSession, user_id: int) -> list[models.AuthApiKey]:
"""List a user's keys, newest first.
Revoked and expired keys are included. The owner needs to see that a key they
revoked is in fact revoked, and a key that stopped working needs to be
distinguishable from one that was never created.
Args:
db: The database session.
user_id: Whose keys to list.
Returns:
list[models.AuthApiKey]: The keys.
"""
stmt = (
select(models.AuthApiKey)
.where(models.AuthApiKey.user_id == user_id)
.order_by(models.AuthApiKey.created.desc(), models.AuthApiKey.id.desc())
)
return list((await db.execute(stmt)).scalars().all())
async def count_for_user(db: AsyncSession, user_id: int) -> int:
"""Count the keys a user holds, revoked ones included.
Args:
db: The database session.
user_id: Whose keys to count.
Returns:
int: The number of keys.
"""
stmt = select(func.count()).select_from(models.AuthApiKey).where(models.AuthApiKey.user_id == user_id)
return (await db.execute(stmt)).scalar_one()
async def revoke(*, db: AsyncSession, key: models.AuthApiKey) -> None:
"""Stop a key from authenticating, keeping the row.
Args:
db: The database session.
key: The key to revoke.
"""
key.revoked = True
await db.commit()
async def delete_key(*, db: AsyncSession, key: models.AuthApiKey) -> None:
"""Remove a key entirely.
Args:
db: The database session.
key: The key to delete.
"""
await db.delete(key)
await db.commit()
async def delete_all_for_user(db: AsyncSession, user_id: int) -> int:
"""Remove every key belonging to a user.
Used when the user itself is deleted: the foreign key would otherwise leave rows
pointing at an account that no longer exists.
Args:
db: The database session.
user_id: Whose keys to remove.
Returns:
int: How many keys were removed.
"""
result = await db.execute(delete(models.AuthApiKey).where(models.AuthApiKey.user_id == user_id))
await db.commit()
return result.rowcount or 0