Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
# Copy this file to `.env`, or set the same variables in your process environment.
# FORMS_DATABASE_URL is required. Everything below it is optional and shown with
# its built-in default; uncomment the lines you want to change.
#
# Management API keys are not configured here and there is no variable for one.
# They live in the database so that creating and revoking a key needs no restart.
# Create the first one with:
#
# python -m hymical_forms.cli create-key --name local-admin

# SQLAlchemy database URL. PostgreSQL is the intended production database.
# Alembic reads this too, so `alembic upgrade head` needs nothing else set.
Expand Down
232 changes: 205 additions & 27 deletions README.md

Large diffs are not rendered by default.

27 changes: 26 additions & 1 deletion src/hymical_forms/api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@

from __future__ import annotations

import logging
from datetime import datetime
from http import HTTPStatus

from fastapi import APIRouter, Request
from pydantic import BaseModel, Field

from hymical_forms import storage, webhooks
from hymical_forms.api.security import ManagementKeyDep
from hymical_forms.config import Settings
from hymical_forms.db import SessionDep
from hymical_forms.errors import ApiError, ErrorResponse
from hymical_forms.ingestion import ENDPOINT_ID_RULE, is_valid_endpoint_id
from hymical_forms.models import ENDPOINT_NAME_MAX_LENGTH
from hymical_forms.webhooks import WEBHOOK_URL_MAX_LENGTH

logger = logging.getLogger(__name__)

router = APIRouter(tags=["endpoints"])


Expand Down Expand Up @@ -124,6 +128,7 @@ class EndpointResponse(BaseModel):
status_code=HTTPStatus.CREATED,
summary="Create a form endpoint",
responses={
401: {"model": ErrorResponse, "description": "Missing or invalid management API key"},
409: {"model": ErrorResponse, "description": "Endpoint ID already taken"},
422: {
"model": ErrorResponse,
Expand All @@ -133,17 +138,26 @@ class EndpointResponse(BaseModel):
},
)
def create_endpoint(
payload: CreateEndpointRequest, request: Request, session: SessionDep
payload: CreateEndpointRequest,
request: Request,
session: SessionDep,
principal: ManagementKeyDep,
) -> EndpointResponse:
"""
create an endpoint that submissions may then be addressed to
:param payload: the endpoint identifier, label, active state and optional webhook
:param request: the incoming request, read for the active configuration
:param session: the session this request does its database work through
:param principal: the management key this request authenticated as
:returns: the endpoint as persisted, including its signing secret if one was made
"""
# A plain ``def`` route, so FastAPI runs it in a worker thread and the
# synchronous database calls never block the event loop.
#
# The authenticated key is not recorded on the endpoint. A management key
# administers the service rather than owning a slice of it, and an
# ``api_key_id`` column here would be the beginning of a tenancy model
# nothing in this build has a use for.
if not is_valid_endpoint_id(payload.id):
raise InvalidEndpointId()

Expand Down Expand Up @@ -175,6 +189,17 @@ def create_endpoint(

session.commit()

# Configuring a webhook destination is the most consequential thing this API
# does, so which credential did it is worth having in the log. Only the key's
# non-secret identity is available to log, which is the point of the
# principal carrying nothing else.
logger.info(
"endpoint %s created by management key %s (%s)",
endpoint.id,
principal.key_id,
principal.name,
)

# The secret leaves the service exactly once, in this response. There is no
# route that reads it back, so a caller that loses it has to make a new
# endpoint rather than being handed the old secret again.
Expand Down
170 changes: 170 additions & 0 deletions src/hymical_forms/api/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
the management authentication boundary

One dependency resolves a management API key, and every route that administers
the service declares it. A future management route gets these rules by asking
for the same dependency rather than by reading the ``Authorization`` header
itself, which is what stops the rules from drifting apart route by route.

Public routes deliberately do not appear here. Form ingestion and the health
check are reachable without a credential, because an ingestion URL is meant to
sit in the ``action`` attribute of somebody's HTML form.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from http import HTTPStatus
from typing import Annotated

from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session

from hymical_forms import apikeys, storage
from hymical_forms.db import SessionDep
from hymical_forms.errors import ApiError
from hymical_forms.models import utcnow

logger = logging.getLogger(__name__)

# RFC 9110 says a 401 carries a challenge, and a client library that looks for
# one to decide how to authenticate should find it.
WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}


class AuthenticationRequired(ApiError):
"""
raised when a management request arrived without a usable bearer credential
"""

status_code = HTTPStatus.UNAUTHORIZED
code = "authentication_required"
headers = WWW_AUTHENTICATE

def __init__(self) -> None:
"""
state how a management request is meant to authenticate
"""
# Separate from ``invalid_api_key`` because the two are genuinely
# different problems for an integrator to fix, and because whether a
# request carried an Authorization header is something its sender
# already knows. Nothing is disclosed by saying so.
super().__init__(
f"This endpoint requires a management API key. {apikeys.MANAGEMENT_KEY_RULE}"
)


class InvalidApiKey(ApiError):
"""
raised when a bearer credential was supplied but does not authenticate
"""

# 401 rather than 403. A 403 says "I know who you are and you may not do
# this", which needs a permission model, and this build has none: every valid
# management key may do everything a management key can do.
status_code = HTTPStatus.UNAUTHORIZED
code = "invalid_api_key"
headers = WWW_AUTHENTICATE

def __init__(self) -> None:
"""
refuse a credential without describing what was wrong with it
"""
# Malformed, unknown and revoked all arrive here and all leave with these
# exact words. Telling them apart would let somebody sort guesses into
# "nearly right" and "wrong", which is the signal enumeration runs on.
# The supplied credential is never echoed.
super().__init__("The management API key is not valid.")


@dataclass(frozen=True, slots=True)
class ManagementPrincipal:
"""
the identity a management request authenticated as
"""

# Only non-secret identity crosses this boundary. Routes are handed no
# credential material, so no handler downstream is in a position to log it,
# store it, or forward it to somebody else's server.
key_id: str
name: str
display_prefix: str


_bearer = HTTPBearer(
scheme_name="ManagementApiKey",
description="A management API key, created with `python -m hymical_forms.cli create-key`.",
# Errors are raised by this module so that they reach the shared JSON
# envelope. Left on, the scheme would answer with FastAPI's own error body
# and a caller would meet two different error shapes from one API.
auto_error=False,
)


def require_management_key(
session: SessionDep,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
) -> ManagementPrincipal:
"""
resolve the management key a request authenticated with
:param session: the session this request does its database work through
:param credentials: the parsed bearer credential, or None if there was no usable one
:returns: the non-secret identity of the key that authenticated
:raises AuthenticationRequired: if no bearer credential was supplied
:raises InvalidApiKey: if the credential is malformed, unknown or revoked
"""
if credentials is None:
# No Authorization header, an unparseable one, a scheme other than
# Bearer, or Bearer with nothing after it.
raise AuthenticationRequired()

supplied = credentials.credentials
if not apikeys.is_valid_management_key(supplied):
# A syntax gate in front of the database, so a sweep of unrelated tokens
# costs no query. It is not a security decision: a well-formed key that
# is not in the table is refused in exactly the same words.
raise InvalidApiKey()

digest = apikeys.digest_key(supplied)
record = storage.find_management_key_by_digest(session, digest)
if record is None or not apikeys.digests_match(digest, record.key_digest):
raise InvalidApiKey()
if record.revoked_at is not None:
# Read from the row on every request. Nothing caches a key anywhere, so a
# revocation takes effect on the next request rather than whenever some
# cache would have expired.
raise InvalidApiKey()

_record_use(session, record.id)
return ManagementPrincipal(
key_id=record.id,
name=record.name,
display_prefix=record.display_prefix,
)


ManagementKeyDep = Annotated[ManagementPrincipal, Depends(require_management_key)]


def _record_use(session: Session, key_id: str) -> None:
"""
note that a key authenticated a request, without letting that failure matter
:param session: the session to write through
:param key_id: the key that authenticated
"""
# This is telemetry, not authentication, and it is written after the decision
# has already been made. One small write per authenticated management request
# is affordable because management traffic is rare by nature; it would not be
# if this ran on the ingestion path, which is exactly why it does not.
#
# The failure is swallowed on purpose. A database that cannot record a
# timestamp must not be able to turn a valid credential into a 401, and the
# rollback is what hands the route a usable session afterwards.
try:
storage.record_management_key_use(session, key_id, now=utcnow())
except SQLAlchemyError:
session.rollback()
logger.warning("could not record last use of management key %s", key_id)
Loading
Loading