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
63 changes: 63 additions & 0 deletions csp_gateway/server/gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import multiprocessing.pool
import os
import signal
import warnings
from datetime import datetime, timedelta
from socket import gethostname
from time import sleep
Expand Down Expand Up @@ -238,6 +239,11 @@ def start(
self._in_test = _in_test
self._module_shutdown_timeout = module_shutdown_timeout

# Back-compat: in csp-gateway <2.5 auth was configured via
# `Settings.AUTHENTICATE` / `Settings.API_KEY`. Apply those onto the
# `MountAPIKeyMiddleware` instance (if present) with a deprecation warning.
self._apply_legacy_auth_settings()

try:
if show:
# Show graph and return without running
Expand Down Expand Up @@ -316,6 +322,63 @@ def start(
# so re-raise it back to the caller
raise

def _apply_legacy_auth_settings(self) -> None:
"""Bridge old-style `Settings.AUTHENTICATE` / `Settings.API_KEY` onto the middleware.

These used to live on `Settings`; they now live on `MountAPIKeyMiddleware`.
Old configs still parse but do nothing, so we read them off `Settings`
here and apply them with a `DeprecationWarning`. `AUTHENTICATE=False`
drops the middleware. `API_KEY` gets copied onto the middleware's
`api_key`. `AUTHENTICATE=True` with no middleware configured is an
invalid configuration because startup would otherwise continue without
enforcing the requested auth.

Rip out when we stop supporting the old config shape.
"""
authenticate = getattr(self.settings, "AUTHENTICATE", None)
api_key = getattr(self.settings, "API_KEY", None)

if authenticate is None and api_key is None:
# Nothing to migrate — user is on the new layout (or simply didn't
# set these), so don't nag them.
return

# Local import to avoid tightening coupling at module load.
from csp_gateway.server.middleware.api_key import MountAPIKeyMiddleware

middleware = next(
(module for module in self.modules if isinstance(module, MountAPIKeyMiddleware)),
None,
)

if authenticate is False:
warnings.warn(
"`Settings.AUTHENTICATE=False` is deprecated. To disable auth, omit `MountAPIKeyMiddleware` from your `modules` list instead.",
DeprecationWarning,
stacklevel=3,
)
if middleware is not None:
self.modules = [module for module in self.modules if module is not middleware]
# authenticate=False wins — don't also try to set api_key.
return

if api_key is not None:
warnings.warn(
"`Settings.API_KEY` is deprecated. Set `api_key` on your `MountAPIKeyMiddleware` instance directly.",
DeprecationWarning,
stacklevel=3,
)
if middleware is not None:
middleware.api_key = api_key

if authenticate is True and middleware is None:
# User explicitly opted-in via Settings but didn't add the middleware
# -- do not silently start without the auth they asked for.
raise ValueError(
"`Settings.AUTHENTICATE=True` requires a `MountAPIKeyMiddleware` in `modules`. "
"Add one to enforce auth, or omit `Settings.AUTHENTICATE` to use the 2.5+ middleware config shape."
)

def _get_web_app_class(self) -> Any:
# FIXME ugly
from csp_gateway.server import GatewayWebApp
Expand Down
20 changes: 19 additions & 1 deletion csp_gateway/server/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List
from typing import List, Optional

from pydantic import AnyHttpUrl, Field

Expand Down Expand Up @@ -35,6 +35,24 @@ class Settings(BaseSettings):

UI: bool = Field(False, description="Enables ui in the web application")

# --- DEPRECATED auth settings ---
# Historically (csp-gateway <2.5), auth was configured via these two fields
# on Settings. In 2.5+, auth moved onto `MountAPIKeyMiddleware` as module
# fields. Keeping these here (default-None sentinels) lets existing YAML
# configs that set `gateway.settings.AUTHENTICATE` / `gateway.settings.API_KEY`
# continue to validate. The `Gateway` class reads them at `start()` and
# applies them to the middleware with a DeprecationWarning. Remove these
# fields (and the `_apply_legacy_auth_settings()` shim in gateway.py) in a
# future major release.
AUTHENTICATE: Optional[bool] = Field(
default=None,
description="DEPRECATED. Use `MountAPIKeyMiddleware` (set it to None or omit from modules) to disable auth.",
)
API_KEY: Optional[str] = Field(
default=None,
description="DEPRECATED. Set `api_key` on `MountAPIKeyMiddleware` directly.",
)


# Alias
GatewaySettings = Settings
58 changes: 58 additions & 0 deletions csp_gateway/tests/server/gateway/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,64 @@ def test_harness_ordering(harness_first):
csp.run(gateway.graph, starttime=datetime(2020, 1, 1), endtime=timedelta(1))


class TestLegacyAuthCompat:
"""Regression tests for the back-compat shim that forwards
pre-2.5 `Settings.AUTHENTICATE` / `Settings.API_KEY` onto the
`MountAPIKeyMiddleware` instance at `Gateway.start()`.

See `Gateway._apply_legacy_auth_settings`. Remove these tests once
the deprecated fields are removed from `Settings`.
"""

@staticmethod
def _make(modules, **settings_kwargs):
from csp_gateway.server.settings import Settings

return Gateway(modules=list(modules), settings=Settings(**settings_kwargs))

def test_no_legacy_settings_is_quiet_no_op(self):
import warnings

from csp_gateway.server.middleware.api_key import MountAPIKeyMiddleware

mw = MountAPIKeyMiddleware(api_key="untouched")
g = self._make([mw])
with warnings.catch_warnings(record=True) as record:
warnings.simplefilter("always")
g._apply_legacy_auth_settings()
assert not [w for w in record if issubclass(w.category, DeprecationWarning)]
assert mw in g.modules
assert mw.api_key == "untouched"

def test_authenticate_false_strips_middleware(self):
from csp_gateway.server.middleware.api_key import MountAPIKeyMiddleware

mw = MountAPIKeyMiddleware(api_key="x")
g = self._make([mw], AUTHENTICATE=False)
with pytest.warns(DeprecationWarning):
g._apply_legacy_auth_settings()
assert mw not in g.modules

def test_api_key_forwarded_onto_middleware(self):
from csp_gateway.server.middleware.api_key import MountAPIKeyMiddleware

mw = MountAPIKeyMiddleware(api_key="old")
g = self._make([mw], API_KEY="new")
with pytest.warns(DeprecationWarning):
g._apply_legacy_auth_settings()
assert mw.api_key == "new"

def test_authenticate_false_without_middleware_is_harmless(self):
g = self._make([], AUTHENTICATE=False)
with pytest.warns(DeprecationWarning):
g._apply_legacy_auth_settings() # no error

def test_authenticate_true_without_middleware_raises(self):
g = self._make([], AUTHENTICATE=True)
with pytest.raises(ValueError, match="requires a `MountAPIKeyMiddleware`"):
g._apply_legacy_auth_settings()


def test_gateway_channels_fields_classmethod():
assert set(MyGatewayChannels().fields()) == set(MyGatewayChannels.fields())
assert set(MyGatewayChannels.fields()) == {
Expand Down
12 changes: 9 additions & 3 deletions csp_gateway/tests/utils/test_id_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ def test_base_uses_utc_midnight():
_reset_global_counter()
try:
# Simulate a time where UTC hour < local-timezone UTC offset would
# have caused underflow with the old naive-datetime code.
# 2026-04-15 01:00 UTC (9 PM EDT the previous evening)
fake_now = datetime(2026, 4, 15, 1, 0, 0, tzinfo=timezone.utc)
# have caused underflow with the old naive-datetime code
# (01:00 UTC = 9 PM EDT the previous evening).
#
# Derive from today's real UTC date rather than hardcoding one:
# Rust's `Utc::now()` can't be mocked from Python, so
# `counter.current() == real_now_ns - mocked_base_ns`. A hardcoded
# date makes that delta grow without bound as the real clock walks
# forward, blowing past the upper-bound assertion below.
fake_now = datetime.now(timezone.utc).replace(hour=1, minute=0, second=0, microsecond=0)

with patch.object(_id_gen_mod, "datetime") as mock_dt:
mock_dt.now.return_value = fake_now
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,11 @@ packages = [
exclude = [
"docs",
"examples",
"js",
# Leading slash anchors to build root — without it, `js` matches any
# directory named `js` at any depth, including `csp_gateway/server/web/
# templates/js/` where `common.js` lives and is referenced from
# `login.html.j2` / `logout.html.j2`.
"/js",
]

[tool.hatch.build.hooks.hatch-js]
Expand Down
Loading