mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 12:38:32 +00:00
Row 8 of the plan in Discussion #1536 is the auth middleware. The API key `before_request` hook, the proxy auth hook, the hook that strips the session cookie from API key requests and `_proxy_default_is_admin` move from `main.py` to `core/auth_middleware.py`, registered with `register_auth_middleware(app, resolve_auth_mode=..., get_user_db=...)`. This is the code that decides who a request is, so the first commit adds 41 tests before anything moves. They cover the hook order, the admin group mapping (comma and pipe delimiters, a custom group header, the WSGI fallback), proxy header handling in each auth mode, provisioning once per session, the API key scope (exempt paths, a read only key only on GET `/api/stats`, a wrong key) and the cookie stripping. They pass on current `main`. The second commit is the move. The call is made right after `register_security_headers`, so the `before_request` and `after_request` order is the same as before. The existing tests called `proxy_auth_middleware()` and `api_key_auth_middleware()` on `shelfmark.main` directly, and those names are gone, so 18 call lines in three test files now find the registered hook by name through a small helper. No patch targets changed since the auth mode and user database are still read from `main` at request time. The URL map and hook order are identical, and the full suite, ruff and basedpyright are clean. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Authentication** * API-key authentication supports admin and read-only access, with read-only keys limited to statistics requests. * Reverse-proxy authentication recognizes configured identity and group information, and applies admin access according to the configured role settings. * Requests with invalid credentials, missing proxy identities, or authentication errors receive appropriate error responses. * API-key requests no longer retain session cookies. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""Pin the API-key and proxy-auth request hooks registered on the Flask app."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask import Response, g, session
|
|
|
|
from shelfmark.core import api_key
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def main_module():
|
|
with patch("shelfmark.download.orchestrator.start"):
|
|
import shelfmark.main as main
|
|
|
|
importlib.reload(main)
|
|
return main
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _empty_user_table(main_module):
|
|
def _clear() -> None:
|
|
for user in main_module.user_db.list_users():
|
|
main_module.user_db.delete_user(user["id"])
|
|
|
|
_clear()
|
|
yield
|
|
_clear()
|
|
|
|
|
|
def _hook(app, kind: str, name: str):
|
|
funcs = app.before_request_funcs if kind == "before" else app.after_request_funcs
|
|
for func in funcs[None]:
|
|
if func.__name__ == name:
|
|
return func
|
|
raise AssertionError(f"{kind}_request hook {name} is not registered")
|
|
|
|
|
|
def _config_getter(values: dict[str, Any]):
|
|
def _get(key: str, default: Any = None, user_id: Any = None):
|
|
return values.get(key, default)
|
|
|
|
return _get
|
|
|
|
|
|
def _proxy(main_module, path="/api/releases", *, headers=None, environ=None, config=None):
|
|
"""Run the proxy hook in a request context and return (result, session dict)."""
|
|
values = {"PROXY_AUTH_USER_HEADER": "X-Auth-User", **(config or {})}
|
|
with (
|
|
patch.object(main_module, "get_auth_mode", return_value="proxy"),
|
|
patch.object(main_module.app_config, "get", side_effect=_config_getter(values)),
|
|
main_module.app.test_request_context(path, headers=headers, environ_base=environ),
|
|
):
|
|
result = _hook(main_module.app, "before", "proxy_auth_middleware")()
|
|
return result, dict(session), session.permanent
|
|
|
|
|
|
class TestHookOrder:
|
|
def test_before_request_order(self, main_module):
|
|
names = [f.__name__ for f in main_module.app.before_request_funcs[None]]
|
|
assert names.index("api_key_auth_middleware") < names.index("proxy_auth_middleware")
|
|
|
|
def test_after_request_order(self, main_module):
|
|
names = [f.__name__ for f in main_module.app.after_request_funcs[None]]
|
|
assert names.index("set_security_headers") < names.index(
|
|
"strip_cookie_for_api_key_requests"
|
|
)
|
|
|
|
|
|
class TestProxyGroups:
|
|
@pytest.mark.parametrize(
|
|
("groups", "expected"),
|
|
[
|
|
("admins", True),
|
|
("users,admins", True),
|
|
("users, admins ,other", True),
|
|
("users|admins", True),
|
|
("users", False),
|
|
("", False),
|
|
("administrators", False),
|
|
],
|
|
)
|
|
def test_group_header_maps_to_admin(self, main_module, groups, expected):
|
|
headers = {"X-Auth-User": "gina"}
|
|
if groups:
|
|
headers["X-Auth-Groups"] = groups
|
|
result, sess, permanent = _proxy(
|
|
main_module, headers=headers, config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"}
|
|
)
|
|
assert result is None
|
|
assert sess["is_admin"] is expected
|
|
assert permanent is False
|
|
db_user = main_module.user_db.get_user(user_id=sess["db_user_id"])
|
|
assert db_user["role"] == ("admin" if expected else "user")
|
|
|
|
def test_custom_group_header(self, main_module):
|
|
result, sess, _ = _proxy(
|
|
main_module,
|
|
headers={"X-Auth-User": "gina", "Remote-Groups": "ops"},
|
|
config={
|
|
"PROXY_AUTH_ADMIN_GROUP_NAME": "ops",
|
|
"PROXY_AUTH_ADMIN_GROUP_HEADER": "Remote-Groups",
|
|
},
|
|
)
|
|
assert result is None
|
|
assert sess["is_admin"] is True
|
|
|
|
def test_group_header_wsgi_environ_fallback(self, main_module):
|
|
result, sess, _ = _proxy(
|
|
main_module,
|
|
headers={"X-Auth-User": "gina"},
|
|
environ={"HTTP_X_AUTH_GROUPS": "admins"},
|
|
config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"},
|
|
)
|
|
assert result is None
|
|
assert sess["is_admin"] is True
|
|
|
|
def test_configured_group_overrides_stored_role(self, main_module):
|
|
main_module.user_db.create_user(username="gina", role="admin", auth_source="proxy")
|
|
result, sess, _ = _proxy(
|
|
main_module,
|
|
headers={"X-Auth-User": "gina", "X-Auth-Groups": "users"},
|
|
config={"PROXY_AUTH_ADMIN_GROUP_NAME": "admins"},
|
|
)
|
|
assert result is None
|
|
assert sess["is_admin"] is False
|
|
|
|
def test_blank_user_header_setting_falls_back_to_default(self, main_module):
|
|
result, sess, _ = _proxy(
|
|
main_module,
|
|
headers={"X-Auth-User": "gina"},
|
|
config={"PROXY_AUTH_USER_HEADER": " "},
|
|
)
|
|
assert result is None
|
|
assert sess["user_id"] == "gina"
|
|
|
|
|
|
class TestProxyHeaderHandling:
|
|
def test_missing_header_on_protected_path_is_401(self, main_module):
|
|
result, sess, _ = _proxy(main_module)
|
|
resp, status = result
|
|
assert status == 401
|
|
assert resp.get_json() == {"error": "Authentication required. Proxy header not set."}
|
|
assert "user_id" not in sess
|
|
|
|
def test_missing_header_on_auth_path_passes(self, main_module):
|
|
result, sess, _ = _proxy(main_module, "/api/auth/login")
|
|
assert result is None
|
|
assert "user_id" not in sess
|
|
|
|
def test_non_api_path_without_header_is_401(self, main_module):
|
|
result, _, _ = _proxy(main_module, "/")
|
|
assert result[1] == 401
|
|
|
|
def test_header_ignored_outside_proxy_mode(self, main_module):
|
|
for mode in ("builtin", "none", "cwa"):
|
|
with (
|
|
patch.object(main_module, "get_auth_mode", return_value=mode),
|
|
main_module.app.test_request_context(
|
|
"/api/releases", headers={"X-Auth-User": "mallory"}
|
|
),
|
|
):
|
|
assert _hook(main_module.app, "before", "proxy_auth_middleware")() is None
|
|
assert "user_id" not in session
|
|
assert main_module.user_db.get_user(username="mallory") is None
|
|
|
|
def test_custom_user_header_is_case_insensitive(self, main_module):
|
|
result, sess, _ = _proxy(
|
|
main_module,
|
|
headers={"x-forwarded-user": "gina"},
|
|
config={"PROXY_AUTH_USER_HEADER": "X-Forwarded-User"},
|
|
)
|
|
assert result is None
|
|
assert sess["user_id"] == "gina"
|
|
|
|
def test_provisioned_once_per_session(self, main_module):
|
|
_, sess, _ = _proxy(main_module, headers={"X-Auth-User": "gina"})
|
|
first_id = sess["db_user_id"]
|
|
with (
|
|
patch.object(main_module, "get_auth_mode", return_value="proxy"),
|
|
main_module.app.test_request_context("/api/releases", headers={"X-Auth-User": "gina"}),
|
|
):
|
|
session["user_id"] = "gina"
|
|
session["db_user_id"] = first_id
|
|
assert _hook(main_module.app, "before", "proxy_auth_middleware")() is None
|
|
assert session["db_user_id"] == first_id
|
|
assert len(main_module.user_db.list_users()) == 1
|
|
|
|
def test_store_error_is_500(self, main_module):
|
|
with patch.object(main_module.user_db, "get_user_cached", side_effect=RuntimeError("boom")):
|
|
result, _, _ = _proxy(main_module, headers={"X-Auth-User": "gina"})
|
|
resp, status = result
|
|
assert status == 500
|
|
assert resp.get_json() == {"error": "Authentication error"}
|
|
|
|
|
|
class TestApiKeyScope:
|
|
@pytest.fixture
|
|
def keyed(self, main_module, monkeypatch):
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY_READONLY", "ro-key")
|
|
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
|
yield main_module
|
|
|
|
def _run(self, main_module, path, key="s3cret", method="GET"):
|
|
with main_module.app.test_request_context(path, method=method, headers={"X-Api-Key": key}):
|
|
result = _hook(main_module.app, "before", "api_key_auth_middleware")()
|
|
return result, dict(session), g.get("api_key_auth"), g.get("api_key_scope")
|
|
|
|
@pytest.mark.parametrize("path", ["/", "/index.html", "/static/app.js", "/apix", "/api"])
|
|
def test_non_api_paths_ignore_key(self, keyed, path):
|
|
result, sess, flag, scope = self._run(keyed, path)
|
|
assert result is None
|
|
assert sess == {}
|
|
assert flag is None
|
|
assert scope is None
|
|
|
|
@pytest.mark.parametrize("path", ["/api/health", "/api/auth/check", "/api/auth/login"])
|
|
def test_exempt_paths_ignore_key(self, keyed, path):
|
|
result, sess, flag, _ = self._run(keyed, path)
|
|
assert result is None
|
|
assert sess == {}
|
|
assert flag is None
|
|
|
|
def test_admin_key_marks_request_and_sets_admin_session(self, keyed):
|
|
admin = keyed.user_db.create_user(username="root", role="admin")
|
|
result, sess, flag, scope = self._run(keyed, "/api/downloads/active")
|
|
assert result is None
|
|
assert flag is True
|
|
assert scope == "admin"
|
|
assert sess["user_id"] == "root"
|
|
assert sess["is_admin"] is True
|
|
assert sess["db_user_id"] == admin["id"]
|
|
|
|
def test_admin_key_without_admin_account_uses_api_identity(self, keyed):
|
|
_, sess, _, _ = self._run(keyed, "/api/downloads/active")
|
|
assert sess["user_id"] == "api"
|
|
assert sess["is_admin"] is True
|
|
assert "db_user_id" not in sess
|
|
|
|
def test_admin_key_session_is_not_persisted(self, keyed):
|
|
with keyed.app.test_request_context(
|
|
"/api/downloads/active", headers={"X-Api-Key": "s3cret"}
|
|
):
|
|
_hook(keyed.app, "before", "api_key_auth_middleware")()
|
|
assert session.permanent is False
|
|
assert session.modified is False
|
|
|
|
def test_wrong_key_falls_through_untouched(self, keyed):
|
|
result, sess, flag, scope = self._run(keyed, "/api/downloads/active", key="nope")
|
|
assert result is None
|
|
assert sess == {}
|
|
assert flag is None
|
|
assert scope is None
|
|
|
|
def test_readonly_key_allowed_on_stats_get_only(self, keyed):
|
|
result, sess, flag, scope = self._run(keyed, "/api/stats", key="ro-key")
|
|
assert result is None
|
|
assert (sess, flag, scope) == ({}, True, "readonly")
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "method"),
|
|
[("/api/stats", "POST"), ("/api/downloads/active", "GET"), ("/api/stats/x", "GET")],
|
|
)
|
|
def test_readonly_key_refused_elsewhere(self, keyed, path, method):
|
|
result, _, flag, scope = self._run(keyed, path, key="ro-key", method=method)
|
|
resp, status = result
|
|
assert status == 403
|
|
assert resp.get_json() == {"error": "This API key is read-only"}
|
|
assert flag is True
|
|
assert scope == "readonly"
|
|
|
|
def test_none_mode_ignores_key(self, main_module, monkeypatch):
|
|
monkeypatch.setattr(api_key, "SHELFMARK_API_KEY", "s3cret")
|
|
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
|
result, sess, flag, _ = self._run(main_module, "/api/downloads/active")
|
|
assert (result, sess, flag) == (None, {}, None)
|
|
|
|
def test_store_error_is_500_and_still_marked_keyed(self, keyed):
|
|
with patch.object(keyed.user_db, "get_first_admin", side_effect=RuntimeError("boom")):
|
|
result, _, flag, _ = self._run(keyed, "/api/downloads/active")
|
|
resp, status = result
|
|
assert status == 500
|
|
assert resp.get_json() == {"error": "Authentication error"}
|
|
assert flag is True
|
|
|
|
def test_proxy_mode_keyed_request_skips_proxy_hook(self, keyed):
|
|
with (
|
|
patch.object(keyed, "get_auth_mode", return_value="proxy"),
|
|
keyed.app.test_request_context(
|
|
"/api/downloads/active", headers={"X-Api-Key": "s3cret"}
|
|
),
|
|
):
|
|
assert _hook(keyed.app, "before", "api_key_auth_middleware")() is None
|
|
assert _hook(keyed.app, "before", "proxy_auth_middleware")() is None
|
|
assert session["is_admin"] is True
|
|
|
|
|
|
class TestCookieStripping:
|
|
def _strip(self, main_module, *, keyed: bool):
|
|
with main_module.app.test_request_context("/api/downloads/active"):
|
|
if keyed:
|
|
g.api_key_auth = True
|
|
session["dirty"] = True
|
|
session.permanent = True
|
|
resp = _hook(main_module.app, "after", "strip_cookie_for_api_key_requests")(
|
|
Response("ok")
|
|
)
|
|
return resp, session.modified, session.permanent
|
|
|
|
def test_keyed_request_resets_session_flags(self, main_module):
|
|
resp, modified, permanent = self._strip(main_module, keyed=True)
|
|
assert resp.get_data(as_text=True) == "ok"
|
|
assert modified is False
|
|
assert permanent is False
|
|
|
|
def test_unkeyed_request_is_untouched(self, main_module):
|
|
_, modified, permanent = self._strip(main_module, keyed=False)
|
|
assert modified is True
|
|
assert permanent is True
|