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
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ does not take any request parameters.
### `/metrics`

This endpoint exposes counters describing the traffic this process has served and the work it did to serve it. It takes
no request parameters other than the format selector below.
no request parameters other than the format selector below. It is open by default and can be put behind a bearer token
β€” see [Protecting the endpoint](#protecting-the-endpoint).

#### Query Parameters

Expand All @@ -187,8 +188,7 @@ curl http://localhost:5000/metrics?fmt=json | jq # the same numbers, for a hu

Everything is counted **in this process, in memory**. There is no database and no external dependency, and the counters
**reset to zero when the process restarts** β€” which on the hosted environments is often. `processStartTimeSeconds` is
exposed so a dashboard can tell a restart apart from a drop in traffic; in PromQL, `rate()` already handles counter
resets, and `pesu_auth_process_start_time_seconds` makes the restart itself visible.
exposed so a dashboard can tell a restart apart from a drop in traffic.

Collection happens at three layers, and which layer records what is deliberate:

Expand Down Expand Up @@ -546,6 +546,46 @@ which is `null` rather than absent when nothing has been recorded yet, so the sh

</details>

#### Protecting the endpoint

`/metrics` is **open by default**, which is what a local run and the Docker instructions above
expect. Set the `METRICS_TOKEN` environment variable on the server to require a bearer token
instead:

```bash
TOKEN=$(openssl rand -hex 32) # keep it: whatever scrapes the endpoint needs the same value
docker run --name pesu-auth -d -p 5000:5000 -e METRICS_TOKEN="$TOKEN" pesu-auth
```

With it set, a request must carry that token or the endpoint answers `401` with
`WWW-Authenticate: Bearer` and the same error body as every other failure. Both formats are
covered, so `?fmt=json` is not a way around it.

```bash
curl http://localhost:5000/metrics # 401
curl -H "Authorization: Bearer <token>" http://localhost:5000/metrics # 200
```

The variable is read once at startup, so changing it needs a restart. Leaving it blank counts as
unset. No other endpoint is affected β€” `/health` in particular stays open, since uptime monitors
and the hosting platform's own health check send no credentials.

#### Scraping the endpoint

The default format is the Prometheus text exposition format precisely so that a scraper pointed at
this path needs no configuration. Any Prometheus-compatible collector works:

```yaml
scrape_configs:
- job_name: pesu-auth
metrics_path: /metrics
scheme: https
static_configs:
- targets: [ "pesu-auth.onrender.com" ]
authorization:
credentials: <token> # omit when METRICS_TOKEN is unset
```

### `/readme`

This endpoint redirects to the project's official GitHub repository. This endpoint does not take any request parameters.
Expand Down
7 changes: 6 additions & 1 deletion app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from zoneinfo import ZoneInfo

import uvicorn
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse

Expand All @@ -27,6 +27,7 @@

from app.docs import authenticate_docs, health_docs, metrics_docs, readme_docs
from app.exceptions.base import PESUAcademyError
from app.metrics.auth import require_metrics_token
from app.metrics.collector import (
AUTHENTICATION_REQUESTS,
AUTHENTICATION_RESULTS,
Expand Down Expand Up @@ -223,6 +224,7 @@ async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSO
"message": exc.message,
"timestamp": datetime.datetime.now(IST).isoformat(),
},
headers=exc.headers,
)


Expand Down Expand Up @@ -267,6 +269,9 @@ async def health() -> JSONResponse:
response_model=None,
responses=metrics_docs.response_examples,
tags=["Monitoring"],
# Enforced only when METRICS_TOKEN is set in the environment; open otherwise, which is what
# every existing caller and the local Docker instructions expect.
dependencies=[Depends(require_metrics_token)],
)
async def metrics_endpoint(fmt: MetricsFormat = MetricsFormat.PROMETHEUS) -> Response:
"""Expose the collected metrics.
Expand Down
17 changes: 17 additions & 0 deletions app/docs/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,23 @@
}
},
},
401: {
"description": (
"The server has `METRICS_TOKEN` set and the request did not present it. The "
"response carries `WWW-Authenticate: Bearer`. While `METRICS_TOKEN` is unset this "
"cannot occur and the endpoint needs no credentials."
),
"model": ResponseModel,
"content": {
"application/json": {
"example": {
"status": False,
"message": "Invalid or missing metrics token.",
"timestamp": "2024-07-28T22:30:10.103368+05:30",
}
}
},
},
500: _INTERNAL_SERVER_ERROR,
},
)
7 changes: 5 additions & 2 deletions app/exceptions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
class PESUAcademyError(Exception):
"""Base class for all PESU Academy-related errors."""

def __init__(self, message: str, status_code: int) -> None:
"""Initialize the PESUAcademyError with a custom message and status code."""
def __init__(self, message: str, status_code: int, headers: dict[str, str] | None = None) -> None:
"""Initialize the PESUAcademyError with a custom message, status code and response headers."""
self.message = message
self.status_code = status_code
# Only a 401 needs these today, to carry WWW-Authenticate. None for every other error, and
# JSONResponse accepts None, so the handler passes it through unconditionally.
self.headers = headers
super().__init__(self.message)

def __str__(self) -> str:
Expand Down
13 changes: 13 additions & 0 deletions app/exceptions/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Custom exception classes for the metrics endpoint. All errors inherit from PESUAcademyError."""

from app.exceptions.base import PESUAcademyError


class MetricsAuthorizationError(PESUAcademyError):
"""Raised when the metrics endpoint is token-protected and the request did not present it."""

def __init__(self, message: str = "Invalid or missing metrics token.") -> None:
"""Initialize the MetricsAuthorizationError with a custom message."""
# A 401 is required to say how to authenticate, so a scraper can tell "your credentials
# are wrong" apart from "this endpoint wants no credentials at all".
super().__init__(message, status_code=401, headers={"WWW-Authenticate": "Bearer"})
6 changes: 3 additions & 3 deletions app/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
PESUAcademy client, so importing the package has no side effects and a test can swap the collector
out by patching one module attribute.

Import from the modules directly -- `app.metrics.collector` for the families and the collector,
`app.metrics.prometheus` for exposition -- following the same convention as `app.exceptions`. A
re-export list here would be one more place to remember when a metric family is added.
Import from the modules directly rather than from this package, following the same convention as
`app.exceptions`. A re-export list here would be one more place to remember when a metric family or
a module is added -- and it had already fallen out of date once.
"""
86 changes: 86 additions & 0 deletions app/metrics/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Optional bearer-token protection for the metrics endpoint."""

from __future__ import annotations

import os
import secrets
from typing import Annotated

from fastapi import Security

# Imported at runtime on purpose, not under TYPE_CHECKING. FastAPI resolves a dependency's
# annotations with get_type_hints() when the route is built, and this module uses
# `from __future__ import annotations`, so a name that exists only for type checkers would be a
# NameError at import time rather than a typing nicety.
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.exceptions.metrics import MetricsAuthorizationError


def _configured_token() -> str | None:
"""Read the configured metrics token from the environment.

Returns:
str | None: The token, or None when it is unset or blank -- a variable declared and left
empty means "no token", not "the empty token".
"""
return os.environ.get("METRICS_TOKEN") or None


# Read once at import, so whether this process enforces a token is fixed for its lifetime and
# cannot start or stop halfway through.
METRICS_TOKEN: str | None = _configured_token()

# auto_error=False so this never raises by itself: every failure path -- no header, no scheme, no
# credentials, or a scheme that is not Bearer -- returns None, and the 401 is raised below as a
# PESUAcademyError. That is what keeps the body in this API's `{status, message, timestamp}` shape
# and gets the failure counted in errors_total. HTTPBasic cannot be used the same way; it raises
# HTTPException on a malformed credential regardless of auto_error, which would answer in
# Starlette's `{"detail": ...}` shape and bypass the error metric entirely.
_bearer = HTTPBearer(
auto_error=False,
scheme_name="MetricsToken",
description=(
"Set the METRICS_TOKEN environment variable on the server to require this token. While it "
"is unset the endpoint is open and any credential here is ignored."
),
)


async def require_metrics_token(
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(_bearer)] = None,
) -> None:
"""Reject the request unless it carries the configured metrics token.

Args:
credentials (HTTPAuthorizationCredentials | None): Parsed bearer credentials, or None when
the request carried no usable `Authorization: Bearer` header.

Raises:
MetricsAuthorizationError: If a token is configured and the request did not present it.
"""
# Looked up on the module at call time rather than captured, so a test can swap it with
# monkeypatch.setattr("app.metrics.auth.METRICS_TOKEN", ...)
expected = METRICS_TOKEN
if expected is None:
return
# compare_digest, not ==, so a wrong token cannot be recovered a character at a time from how
# long the comparison took.
#
# Compared as bytes, not str: compare_digest *raises TypeError* on a str holding any non-ASCII
# character, so `Authorization: Bearer ΓΌ` would turn this 401 into a 500 with a logged
# traceback -- something any caller could do at will, and it would land in
# failures_total{fault="server"}, which is the one metric worth alerting on. A non-ASCII
# METRICS_TOKEN was worse still: every request 500ed, including the correct one.
#
# The two codecs are not interchangeable. A header value reaches us already latin-1 decoded,
# per the HTTP spec and every ASGI server, so encoding it back through latin-1 recovers the
# exact bytes the client sent; the configured token comes from the environment as UTF-8, with
# surrogates standing in for any byte sequence that was not valid UTF-8. Encoding each back the
# way it arrived makes the comparison byte-exact, so a non-ASCII token works rather than
# silently never matching.
if credentials is None or not secrets.compare_digest(
credentials.credentials.encode("latin-1", "replace"),
expected.encode("utf-8", "surrogateescape"),
):
raise MetricsAuthorizationError
4 changes: 2 additions & 2 deletions app/metrics/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ def __init__(self, *, clock: Callable[[], float] | None = None) -> None:
self._start_time = self._clock()
self._values: defaultdict[str, dict[LabelKey, float]] = defaultdict(dict)
# Seed the unlabelled series so a freshly started process still exposes them. Without this
# a Grafana panel has no series at all until the first request, and rate() over a series
# that springs into existence mid-window reads as a spike.
# there is no series at all until the first request, and a rate over a series that springs
# into existence mid-window reads as a spike.
for family in FAMILIES:
if family.labels:
continue
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pesu-auth"
version = "4.2.0"
version = "4.3.0"
description = "A simple API to authenticate PESU credentials using PESU Academy."
readme = "README.md"
requires-python = ">=3.14"
Expand Down
2 changes: 1 addition & 1 deletion scripts/benchmark/benchmark_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
parser.add_argument(
"--route",
type=str,
choices=["authenticate", "health", "readme", "metrics"],
choices=["authenticate", "health", "readme"],
default="authenticate",
help="The route to make the request to (default: authenticate)",
)
Expand Down
6 changes: 3 additions & 3 deletions scripts/benchmark/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ def make_request(
follow_redirects=True,
)
elapsed_time = time.time() - start_time
# Not every route answers with JSON: /readme is a 308 to GitHub, and /metrics is Prometheus
# text. An unconditional .json() crashes the sequential runner outright and, in the parallel
# runner, is swallowed as a failed request -- which silently skews the numbers being measured.
# Not every route answers with JSON: /readme is a 308 to GitHub. An unconditional .json()
# crashes the sequential runner outright and, in the parallel runner, is swallowed as a failed
# request -- which silently skews the numbers being measured.
try:
body = response.json()
except ValueError:
Expand Down
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import pytest
from dotenv import load_dotenv

load_dotenv()


@pytest.fixture(autouse=True)
def _metrics_token_unset(monkeypatch):
"""Keep /metrics open unless a test asks for a token.

METRICS_TOKEN is read from the environment when app.metrics.auth is imported, and the
load_dotenv() above runs before any test module imports the app. So a token left in a local
.env -- or merely exported in the shell -- would make roughly thirty /metrics tests across the
suite fail with a confusing 401 that has nothing to do with what they are testing. Pinning it
here makes the suite independent of the ambient environment; the tests that exercise the token
set it themselves.
"""
monkeypatch.setattr("app.metrics.auth.METRICS_TOKEN", None)


def pytest_collection_modifyitems(config, items):
# Force directory-based test ordering: unit > functional > integration
priority = {
Expand Down
Loading
Loading