coffeehouse-common 0.15.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. coffeehouse_common-0.15.1/LICENSE +21 -0
  2. coffeehouse_common-0.15.1/PKG-INFO +170 -0
  3. coffeehouse_common-0.15.1/README.md +114 -0
  4. coffeehouse_common-0.15.1/coffeehouse_common/__init__.py +6 -0
  5. coffeehouse_common-0.15.1/coffeehouse_common/auth_client.py +187 -0
  6. coffeehouse_common-0.15.1/coffeehouse_common/csrf.py +162 -0
  7. coffeehouse_common-0.15.1/coffeehouse_common/db.py +87 -0
  8. coffeehouse_common-0.15.1/coffeehouse_common/flash.py +81 -0
  9. coffeehouse_common-0.15.1/coffeehouse_common/jwt.py +69 -0
  10. coffeehouse_common-0.15.1/coffeehouse_common/logging_config.py +62 -0
  11. coffeehouse_common-0.15.1/coffeehouse_common/observability.py +178 -0
  12. coffeehouse_common-0.15.1/coffeehouse_common/paths.py +30 -0
  13. coffeehouse_common-0.15.1/coffeehouse_common/role_cleanup.py +90 -0
  14. coffeehouse_common-0.15.1/coffeehouse_common/security_middleware.py +25 -0
  15. coffeehouse_common-0.15.1/coffeehouse_common.egg-info/PKG-INFO +170 -0
  16. coffeehouse_common-0.15.1/coffeehouse_common.egg-info/SOURCES.txt +41 -0
  17. coffeehouse_common-0.15.1/coffeehouse_common.egg-info/dependency_links.txt +1 -0
  18. coffeehouse_common-0.15.1/coffeehouse_common.egg-info/entry_points.txt +2 -0
  19. coffeehouse_common-0.15.1/coffeehouse_common.egg-info/requires.txt +12 -0
  20. coffeehouse_common-0.15.1/coffeehouse_common.egg-info/top_level.txt +2 -0
  21. coffeehouse_common-0.15.1/coffeehouse_testing/__init__.py +44 -0
  22. coffeehouse_common-0.15.1/coffeehouse_testing/_canonicalize.py +215 -0
  23. coffeehouse_common-0.15.1/coffeehouse_testing/auth_stub.py +109 -0
  24. coffeehouse_common-0.15.1/coffeehouse_testing/client.py +56 -0
  25. coffeehouse_common-0.15.1/coffeehouse_testing/cors_smoke.py +81 -0
  26. coffeehouse_common-0.15.1/coffeehouse_testing/db.py +203 -0
  27. coffeehouse_common-0.15.1/coffeehouse_testing/jwt_factory.py +72 -0
  28. coffeehouse_common-0.15.1/coffeehouse_testing/migration_roundtrip.py +84 -0
  29. coffeehouse_common-0.15.1/pyproject.toml +179 -0
  30. coffeehouse_common-0.15.1/setup.cfg +4 -0
  31. coffeehouse_common-0.15.1/tests/test_auth_client.py +380 -0
  32. coffeehouse_common-0.15.1/tests/test_cors_smoke_helper.py +135 -0
  33. coffeehouse_common-0.15.1/tests/test_csrf_middleware.py +284 -0
  34. coffeehouse_common-0.15.1/tests/test_db.py +136 -0
  35. coffeehouse_common-0.15.1/tests/test_flash.py +143 -0
  36. coffeehouse_common-0.15.1/tests/test_imports.py +43 -0
  37. coffeehouse_common-0.15.1/tests/test_jwt_helper.py +84 -0
  38. coffeehouse_common-0.15.1/tests/test_logging_config.py +72 -0
  39. coffeehouse_common-0.15.1/tests/test_migration_roundtrip_canonicalize.py +218 -0
  40. coffeehouse_common-0.15.1/tests/test_observability.py +377 -0
  41. coffeehouse_common-0.15.1/tests/test_request_id_middleware.py +78 -0
  42. coffeehouse_common-0.15.1/tests/test_role_cleanup.py +159 -0
  43. coffeehouse_common-0.15.1/tests/test_security_headers_middleware.py +73 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ruben Sukiasyan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: coffeehouse-common
3
+ Version: 0.15.1
4
+ Summary: Shared CSRF, security headers, logging, observability, flash messages, role-cleanup, and Auth client for Coffee House FastAPI apps
5
+ Author-email: Ruben Sukiasyan <rubsksn@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Ruben Sukiasyan
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/coffeehouse-tools/coffeehouse-common
29
+ Project-URL: Repository, https://github.com/coffeehouse-tools/coffeehouse-common
30
+ Project-URL: Issues, https://github.com/coffeehouse-tools/coffeehouse-common/issues
31
+ Project-URL: Changelog, https://github.com/coffeehouse-tools/coffeehouse-common/blob/master/CHANGELOG.md
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Operating System :: OS Independent
39
+ Classifier: Framework :: FastAPI
40
+ Classifier: Typing :: Typed
41
+ Requires-Python: >=3.11
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: itsdangerous==2.2.0
45
+ Requires-Dist: starlette>=0.38.0
46
+ Requires-Dist: starlette-csrf==3.0.0
47
+ Requires-Dist: SQLAlchemy>=2.0
48
+ Requires-Dist: requests>=2.31
49
+ Requires-Dist: sentry-sdk<3.0,>=2.20
50
+ Provides-Extra: testing
51
+ Requires-Dist: pytest>=8.0; extra == "testing"
52
+ Requires-Dist: PyJWT>=2.8; extra == "testing"
53
+ Requires-Dist: httpx>=0.27; extra == "testing"
54
+ Requires-Dist: python-multipart>=0.0.9; extra == "testing"
55
+ Dynamic: license-file
56
+
57
+ # coffeehouse-common
58
+
59
+ Shared Python package for Coffee House ecosystem FastAPI apps:
60
+
61
+ - `coffeehouse_common.csrf` — `CSRFMiddleware` subclass (form body + header + safe body replay)
62
+ - `coffeehouse_common.security_middleware` — `SecurityHeadersMiddleware` (raw ASGI)
63
+ - `coffeehouse_common.logging_config` — `RequestIdMiddleware` (raw ASGI), `RequestIdFilter`, `configure_logging`, `get_request_id`
64
+ - `coffeehouse_common.observability` — `init_sentry(app_slug)` Sentry SDK bootstrap; reads `SENTRY_DSN` from env (no-op when unset), tags events with `app_slug`, drops 4xx HTTP/CSRF noise, keeps 5xx
65
+
66
+ **v0.3.0+** implements security headers and request ID middleware as **raw ASGI** (not `BaseHTTPMiddleware`) so streaming responses are not fully buffered. **v0.3.1** sets `X-Frame-Options: SAMEORIGIN` (same-origin iframes allowed; third-party framing blocked). Logs include a per-request correlation id when `configure_logging()` + `RequestIdMiddleware` are used.
67
+
68
+ ## Install
69
+
70
+ From GitHub (pinned tag):
71
+
72
+ ```bash
73
+ pip install "coffeehouse-common @ git+https://github.com/rubencfh/coffeehouse-common.git@v0.9.0"
74
+ ```
75
+
76
+ Local editable install:
77
+
78
+ ```bash
79
+ pip install -e ../coffeehouse-common
80
+ ```
81
+
82
+ ## Development
83
+
84
+ Set up the repo for local development + testing:
85
+
86
+ ```bash
87
+ git clone https://github.com/rubencfh/coffeehouse-common.git
88
+ cd coffeehouse-common
89
+ pip install -e ".[testing]"
90
+ pip install -r requirements-dev.txt
91
+ pre-commit install
92
+ ```
93
+
94
+ Run the full test suite (with coverage, fails under 70%):
95
+
96
+ ```bash
97
+ pytest
98
+ ```
99
+
100
+ Run the pre-commit hooks against all files (format, lint, secret scan, syntax check):
101
+
102
+ ```bash
103
+ pre-commit run --all-files
104
+ ```
105
+
106
+ Individual checks:
107
+
108
+ ```bash
109
+ ruff format --check . # formatting
110
+ ruff check . # linting
111
+ mypy coffeehouse_common # type-checking (lenient baseline)
112
+ bandit -c pyproject.toml -r coffeehouse_common coffeehouse_testing # SAST
113
+ pip-audit -r requirements-dev.txt # SCA
114
+ ```
115
+
116
+ CI runs the same checks on every push / PR via `.github/workflows/ci.yml`.
117
+ See `coffeehouse-ecosystem/docs/pipeline.md` for the broader pipeline.
118
+
119
+ ### Shared test fixtures
120
+
121
+ The `coffeehouse_testing` subpackage ships with this repo and provides shared
122
+ pytest fixtures that consumer apps import in their own `tests/conftest.py`:
123
+
124
+ ```python
125
+ from coffeehouse_testing.jwt_factory import jwt_factory
126
+ from coffeehouse_testing.client import authed_client
127
+ from coffeehouse_testing.db import postgres_container, clean_db # noqa: F401
128
+ from coffeehouse_testing.auth_stub import make_auth_stub, build_roles_manifest
129
+ ```
130
+
131
+ All heavy dependencies (PyJWT, testcontainers, starlette) are loaded lazily —
132
+ import the modules you need in tests and the import errors only fire if the
133
+ underlying package is not installed.
134
+
135
+ ## Usage
136
+
137
+ ```python
138
+ from coffeehouse_common.csrf import CSRFMiddleware
139
+ from coffeehouse_common.logging_config import RequestIdMiddleware, configure_logging
140
+ from coffeehouse_common.observability import init_sentry
141
+ from coffeehouse_common.security_middleware import SecurityHeadersMiddleware
142
+
143
+ configure_logging() # once at startup; attaches RequestIdFilter for %(request_id)s
144
+ init_sentry(app_slug="auth") # no-op if SENTRY_DSN is unset
145
+ ```
146
+
147
+ `init_sentry` reads `SENTRY_DSN`, `RAILWAY_ENVIRONMENT_NAME` (or `ENVIRONMENT`),
148
+ and `RAILWAY_GIT_COMMIT_SHA` from the environment. Errors-only sample rate by
149
+ default (`traces_sample_rate=0.0`). 401/403/404/405 responses, 4xx
150
+ `HTTPException`s, and `CSRFError`s are dropped client-side so they don't burn
151
+ the Sentry quota. 5xx HTTPExceptions and any other exception class are kept.
152
+ Pass `extra_ignored_status_codes=` / `extra_ignored_exceptions=` to extend the
153
+ filter per app.
154
+
155
+ Explicit dependency: **`itsdangerous`** (CSRF token signing).
156
+
157
+ See `CONVENTIONS.md` in the `coffeehouse-ecosystem` repo for middleware order and CSRF patterns.
158
+
159
+ ## First-time publish (maintainers)
160
+
161
+ 1. Create an empty repo on GitHub: `rubencfh/coffeehouse-common`
162
+ 2. From this directory:
163
+
164
+ ```bash
165
+ git remote add origin https://github.com/rubencfh/coffeehouse-common.git
166
+ git push -u origin master
167
+ git push origin v0.9.0
168
+ ```
169
+
170
+ Until the repo and tag exist, app Docker builds that `pip install` from GitHub will fail. For local dev, use editable install (above).
@@ -0,0 +1,114 @@
1
+ # coffeehouse-common
2
+
3
+ Shared Python package for Coffee House ecosystem FastAPI apps:
4
+
5
+ - `coffeehouse_common.csrf` — `CSRFMiddleware` subclass (form body + header + safe body replay)
6
+ - `coffeehouse_common.security_middleware` — `SecurityHeadersMiddleware` (raw ASGI)
7
+ - `coffeehouse_common.logging_config` — `RequestIdMiddleware` (raw ASGI), `RequestIdFilter`, `configure_logging`, `get_request_id`
8
+ - `coffeehouse_common.observability` — `init_sentry(app_slug)` Sentry SDK bootstrap; reads `SENTRY_DSN` from env (no-op when unset), tags events with `app_slug`, drops 4xx HTTP/CSRF noise, keeps 5xx
9
+
10
+ **v0.3.0+** implements security headers and request ID middleware as **raw ASGI** (not `BaseHTTPMiddleware`) so streaming responses are not fully buffered. **v0.3.1** sets `X-Frame-Options: SAMEORIGIN` (same-origin iframes allowed; third-party framing blocked). Logs include a per-request correlation id when `configure_logging()` + `RequestIdMiddleware` are used.
11
+
12
+ ## Install
13
+
14
+ From GitHub (pinned tag):
15
+
16
+ ```bash
17
+ pip install "coffeehouse-common @ git+https://github.com/rubencfh/coffeehouse-common.git@v0.9.0"
18
+ ```
19
+
20
+ Local editable install:
21
+
22
+ ```bash
23
+ pip install -e ../coffeehouse-common
24
+ ```
25
+
26
+ ## Development
27
+
28
+ Set up the repo for local development + testing:
29
+
30
+ ```bash
31
+ git clone https://github.com/rubencfh/coffeehouse-common.git
32
+ cd coffeehouse-common
33
+ pip install -e ".[testing]"
34
+ pip install -r requirements-dev.txt
35
+ pre-commit install
36
+ ```
37
+
38
+ Run the full test suite (with coverage, fails under 70%):
39
+
40
+ ```bash
41
+ pytest
42
+ ```
43
+
44
+ Run the pre-commit hooks against all files (format, lint, secret scan, syntax check):
45
+
46
+ ```bash
47
+ pre-commit run --all-files
48
+ ```
49
+
50
+ Individual checks:
51
+
52
+ ```bash
53
+ ruff format --check . # formatting
54
+ ruff check . # linting
55
+ mypy coffeehouse_common # type-checking (lenient baseline)
56
+ bandit -c pyproject.toml -r coffeehouse_common coffeehouse_testing # SAST
57
+ pip-audit -r requirements-dev.txt # SCA
58
+ ```
59
+
60
+ CI runs the same checks on every push / PR via `.github/workflows/ci.yml`.
61
+ See `coffeehouse-ecosystem/docs/pipeline.md` for the broader pipeline.
62
+
63
+ ### Shared test fixtures
64
+
65
+ The `coffeehouse_testing` subpackage ships with this repo and provides shared
66
+ pytest fixtures that consumer apps import in their own `tests/conftest.py`:
67
+
68
+ ```python
69
+ from coffeehouse_testing.jwt_factory import jwt_factory
70
+ from coffeehouse_testing.client import authed_client
71
+ from coffeehouse_testing.db import postgres_container, clean_db # noqa: F401
72
+ from coffeehouse_testing.auth_stub import make_auth_stub, build_roles_manifest
73
+ ```
74
+
75
+ All heavy dependencies (PyJWT, testcontainers, starlette) are loaded lazily —
76
+ import the modules you need in tests and the import errors only fire if the
77
+ underlying package is not installed.
78
+
79
+ ## Usage
80
+
81
+ ```python
82
+ from coffeehouse_common.csrf import CSRFMiddleware
83
+ from coffeehouse_common.logging_config import RequestIdMiddleware, configure_logging
84
+ from coffeehouse_common.observability import init_sentry
85
+ from coffeehouse_common.security_middleware import SecurityHeadersMiddleware
86
+
87
+ configure_logging() # once at startup; attaches RequestIdFilter for %(request_id)s
88
+ init_sentry(app_slug="auth") # no-op if SENTRY_DSN is unset
89
+ ```
90
+
91
+ `init_sentry` reads `SENTRY_DSN`, `RAILWAY_ENVIRONMENT_NAME` (or `ENVIRONMENT`),
92
+ and `RAILWAY_GIT_COMMIT_SHA` from the environment. Errors-only sample rate by
93
+ default (`traces_sample_rate=0.0`). 401/403/404/405 responses, 4xx
94
+ `HTTPException`s, and `CSRFError`s are dropped client-side so they don't burn
95
+ the Sentry quota. 5xx HTTPExceptions and any other exception class are kept.
96
+ Pass `extra_ignored_status_codes=` / `extra_ignored_exceptions=` to extend the
97
+ filter per app.
98
+
99
+ Explicit dependency: **`itsdangerous`** (CSRF token signing).
100
+
101
+ See `CONVENTIONS.md` in the `coffeehouse-ecosystem` repo for middleware order and CSRF patterns.
102
+
103
+ ## First-time publish (maintainers)
104
+
105
+ 1. Create an empty repo on GitHub: `rubencfh/coffeehouse-common`
106
+ 2. From this directory:
107
+
108
+ ```bash
109
+ git remote add origin https://github.com/rubencfh/coffeehouse-common.git
110
+ git push -u origin master
111
+ git push origin v0.9.0
112
+ ```
113
+
114
+ Until the repo and tag exist, app Docker builds that `pip install` from GitHub will fail. For local dev, use editable install (above).
@@ -0,0 +1,6 @@
1
+ """Shared middleware, CSRF, flash messages, observability, and role-cleanup for Coffee House FastAPI apps."""
2
+
3
+ from coffeehouse_common.paths import PathEscapeError as PathEscapeError
4
+ from coffeehouse_common.paths import safe_under as safe_under
5
+
6
+ __version__ = "0.13.0"
@@ -0,0 +1,187 @@
1
+ """Shared Auth Service API client for consumer apps.
2
+
3
+ Consolidated from identical per-repo ``app/auth_client.py`` modules that existed
4
+ in Grind, Digi, and Beany. Each consumer's module is now a one-line re-export
5
+ shim — the logic lives here.
6
+
7
+ Exposes two endpoints that consumer admin UIs need:
8
+
9
+ - :func:`fetch_roles(app_slug)` — live role catalog from
10
+ ``GET /api/apps/{slug}/roles``, so admin-created roles in Auth UI appear in
11
+ the consumer's matrix without a redeploy.
12
+ - :func:`fetch_departments()` — live department vocabulary from
13
+ ``GET /api/departments`` (global, not per-app), for dept-baseline matrix
14
+ rendering. Added after Auth migration 009 moved ``department`` from role
15
+ level to user level.
16
+
17
+ Both endpoints are authenticated with the same ``X-Deploy-Key`` (the consumer's
18
+ ``SYNC_ROLES_API_KEY``). Results are cached in-process for
19
+ ``CACHE_TTL_SECONDS`` seconds; failures raise :class:`AuthRolesUnavailable` so
20
+ admin matrix pages can render an explicit error banner rather than fall back
21
+ to a stale list. Consumer apps set ``AUTH_INTERNAL_URL`` (server-to-server
22
+ URL, e.g. Docker / Railway internal) or ``AUTH_SERVICE_URL``; the former takes
23
+ precedence when set.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+ import time
31
+ from copy import deepcopy
32
+ from typing import Any
33
+
34
+ import requests
35
+
36
+ _log = logging.getLogger(__name__)
37
+
38
+ CACHE_TTL_SECONDS = 60
39
+ _cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
40
+
41
+
42
+ class AuthRolesUnavailable(RuntimeError):
43
+ """Raised when an Auth endpoint cannot be reached or returns non-2xx."""
44
+
45
+
46
+ def _sync_roles_api_key() -> str:
47
+ # Read lazily so tests that monkeypatch os.environ work cleanly.
48
+ return (os.environ.get("SYNC_ROLES_API_KEY") or "").strip()
49
+
50
+
51
+ def _auth_base() -> str:
52
+ # AUTH_INTERNAL_URL preferred for server-to-server (Docker / Railway);
53
+ # AUTH_SERVICE_URL for everything else. Trim each candidate before OR-ing
54
+ # so a whitespace-only AUTH_INTERNAL_URL still falls back to AUTH_SERVICE_URL.
55
+ internal = (os.environ.get("AUTH_INTERNAL_URL") or "").strip()
56
+ service = (os.environ.get("AUTH_SERVICE_URL") or "").strip()
57
+ return (internal or service).rstrip("/")
58
+
59
+
60
+ def fetch_roles(app_slug: str, *, include_inactive: bool = False) -> list[dict[str, Any]]:
61
+ """Return the live role catalog for ``app_slug`` from Auth.
62
+
63
+ Results are cached in-process for ``CACHE_TTL_SECONDS``. Raises
64
+ :class:`AuthRolesUnavailable` on network failure or non-2xx response;
65
+ callers render an error banner rather than fall back to a stale list.
66
+ """
67
+ cache_key = f"roles:{app_slug}:{include_inactive}"
68
+ now = time.monotonic()
69
+ cached = _cache.get(cache_key)
70
+ if cached and now - cached[0] < CACHE_TTL_SECONDS:
71
+ return deepcopy(cached[1])
72
+
73
+ base = _auth_base()
74
+ if not base:
75
+ raise AuthRolesUnavailable("AUTH_SERVICE_URL / AUTH_INTERNAL_URL is not configured")
76
+ api_key = _sync_roles_api_key()
77
+ if not api_key:
78
+ raise AuthRolesUnavailable("SYNC_ROLES_API_KEY is not configured")
79
+
80
+ url = f"{base}/api/apps/{app_slug}/roles"
81
+ params = {"include_inactive": "true"} if include_inactive else None
82
+ try:
83
+ resp = requests.get(
84
+ url,
85
+ params=params,
86
+ headers={"X-Deploy-Key": api_key},
87
+ timeout=5,
88
+ )
89
+ except requests.RequestException as e:
90
+ _log.warning("auth_client.fetch_roles: network failure for %s: %s", app_slug, e)
91
+ raise AuthRolesUnavailable(str(e)) from e
92
+
93
+ if resp.status_code >= 400:
94
+ _log.warning(
95
+ "auth_client.fetch_roles: %s returned %d: %s",
96
+ url,
97
+ resp.status_code,
98
+ resp.text[:200],
99
+ )
100
+ raise AuthRolesUnavailable(f"HTTP {resp.status_code} from Auth")
101
+
102
+ try:
103
+ roles = resp.json()
104
+ except ValueError as e:
105
+ _log.warning("auth_client.fetch_roles: invalid JSON from %s: %s", url, e)
106
+ raise AuthRolesUnavailable("Auth returned invalid JSON") from e
107
+ if not isinstance(roles, list):
108
+ raise AuthRolesUnavailable("Auth returned non-list payload")
109
+ _cache[cache_key] = (now, deepcopy(roles))
110
+ return roles
111
+
112
+
113
+ UNASSIGNED_DEPT_SLUG = "unassigned"
114
+
115
+
116
+ def fetch_departments(*, include_unassigned: bool = False) -> list[dict[str, Any]]:
117
+ """Return the live department catalog from Auth's ``GET /api/departments``.
118
+
119
+ Since Auth migration 009 departments are a user-level attribute; role
120
+ definitions no longer carry ``department``. Consumer admin UIs read the
121
+ vocabulary directly from this endpoint. Shares the same 60s cache slot +
122
+ ``X-Deploy-Key`` credential as :func:`fetch_roles`. Raises
123
+ :class:`AuthRolesUnavailable` on network failure or non-2xx response.
124
+
125
+ The ``unassigned`` sentinel dept is excluded by default because consumer
126
+ runtime code (``get_app_dept`` in each app) maps that slug to ``None`` to
127
+ deny baseline inheritance to users who haven't been assigned a real dept.
128
+ Rendering the sentinel alongside real depts in admin matrices created a
129
+ silent no-op trap: baselines set on the ``unassigned`` row were stored in
130
+ the DB but never applied at request time. Callers that genuinely need the
131
+ full list (Auth's own admin UI) pass ``include_unassigned=True``.
132
+ """
133
+ cache_key = "departments"
134
+ now = time.monotonic()
135
+ cached = _cache.get(cache_key)
136
+ if cached and now - cached[0] < CACHE_TTL_SECONDS:
137
+ return _filter_unassigned(deepcopy(cached[1]), include_unassigned)
138
+
139
+ base = _auth_base()
140
+ if not base:
141
+ raise AuthRolesUnavailable("AUTH_SERVICE_URL / AUTH_INTERNAL_URL is not configured")
142
+ api_key = _sync_roles_api_key()
143
+ if not api_key:
144
+ raise AuthRolesUnavailable("SYNC_ROLES_API_KEY is not configured")
145
+
146
+ url = f"{base}/api/departments"
147
+ try:
148
+ resp = requests.get(
149
+ url,
150
+ headers={"X-Deploy-Key": api_key},
151
+ timeout=5,
152
+ )
153
+ except requests.RequestException as e:
154
+ _log.warning("auth_client.fetch_departments: network failure: %s", e)
155
+ raise AuthRolesUnavailable(str(e)) from e
156
+
157
+ if resp.status_code >= 400:
158
+ _log.warning(
159
+ "auth_client.fetch_departments: %s returned %d: %s",
160
+ url,
161
+ resp.status_code,
162
+ resp.text[:200],
163
+ )
164
+ raise AuthRolesUnavailable(f"HTTP {resp.status_code} from Auth")
165
+
166
+ try:
167
+ depts = resp.json()
168
+ except ValueError as e:
169
+ _log.warning("auth_client.fetch_departments: invalid JSON from %s: %s", url, e)
170
+ raise AuthRolesUnavailable("Auth returned invalid JSON") from e
171
+ if not isinstance(depts, list):
172
+ raise AuthRolesUnavailable("Auth returned non-list payload")
173
+ _cache[cache_key] = (now, deepcopy(depts))
174
+ return _filter_unassigned(depts, include_unassigned)
175
+
176
+
177
+ def _filter_unassigned(
178
+ depts: list[dict[str, Any]], include_unassigned: bool
179
+ ) -> list[dict[str, Any]]:
180
+ if include_unassigned:
181
+ return list(depts)
182
+ return [d for d in depts if d.get("slug") != UNASSIGNED_DEPT_SLUG]
183
+
184
+
185
+ def invalidate_cache() -> None:
186
+ """Drop all cached role/department payloads (e.g. after admin edits Auth)."""
187
+ _cache.clear()
@@ -0,0 +1,162 @@
1
+ """
2
+ CSRF middleware that accepts the token from form body (HTML forms)
3
+ in addition to the x-csrftoken header (AJAX / HTMX).
4
+
5
+ Improvements over starlette-csrf's base middleware:
6
+ 1. Reads the token from form data (not just the x-csrftoken header).
7
+ 2. Replays the request body so downstream handlers can read it.
8
+ 3. Detects stale/invalid cookies (e.g. signed with an old secret after
9
+ a secret-key rotation or environment change) and replaces them.
10
+ 4. Only injects Set-Cookie on http.response.start (not body messages).
11
+ 5. Treats two body-parsing edge cases as expected user-input friction
12
+ rather than 5xx server bugs:
13
+ - ``ClientDisconnect`` (client closed the TCP connection mid-POST):
14
+ abandon the request silently — there is no socket left to reply
15
+ to. Logged at INFO so it is visible without burning Sentry quota.
16
+ - ``MultipartParseError`` (malformed multipart body, almost always
17
+ a scanner / bot probe): respond with 400 instead of bubbling.
18
+ """
19
+
20
+ import functools
21
+ import http.cookies
22
+ import logging
23
+
24
+ from itsdangerous import BadSignature
25
+ from multipart.exceptions import MultipartParseError
26
+ from starlette.datastructures import MutableHeaders
27
+ from starlette.requests import ClientDisconnect, Request
28
+ from starlette.responses import PlainTextResponse
29
+ from starlette.types import Message, Receive, Scope, Send
30
+ from starlette_csrf.middleware import CSRFMiddleware as _Base
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class CSRFMiddleware(_Base):
36
+ def _cookie_is_valid(self, token: str) -> bool:
37
+ try:
38
+ self.serializer.loads(token)
39
+ return True
40
+ except (BadSignature, Exception):
41
+ return False
42
+
43
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
44
+ # CSRF only applies to HTTP requests. WebSocket has its own origin
45
+ # check during the handshake, and lifespan has no client at all.
46
+ # `Request(scope, receive)` below asserts scope["type"] == "http"; if
47
+ # we let any other scope through, we crash the connection (Sentry
48
+ # COFFEEHOUSE-BEANY-1: AssertionError on /ws/socket.io/ in Chainlit).
49
+ if scope["type"] != "http":
50
+ await self.app(scope, receive, send)
51
+ return
52
+
53
+ request = Request(scope, receive)
54
+ csrf_cookie = request.cookies.get(self.cookie_name)
55
+
56
+ if self._url_is_required(request.url) or (
57
+ request.method not in self.safe_methods
58
+ and not self._url_is_exempt(request.url)
59
+ and self._has_sensitive_cookies(request.cookies)
60
+ ):
61
+ try:
62
+ body = await request.body()
63
+ submitted_csrf_token = await self._get_submitted_csrf_token(request)
64
+ except ClientDisconnect:
65
+ # Client closed the connection before the body finished
66
+ # transferring. The socket is gone — there's no client left
67
+ # to send a 400/500 to, so we abandon the request without
68
+ # propagating the exception. Logged at INFO so the event is
69
+ # visible in container logs without filling the Sentry quota
70
+ # (before_send filter cannot see this — it never reaches
71
+ # sentry_sdk because we don't re-raise).
72
+ logger.info(
73
+ "csrf middleware: client disconnected before body read complete "
74
+ "(method=%s url=%s)",
75
+ request.method,
76
+ request.url,
77
+ )
78
+ return
79
+ except MultipartParseError as exc:
80
+ # Malformed multipart body — almost always a scanner probing
81
+ # the public POST surface. Reply 400 rather than bubbling a
82
+ # 500 to the error middleware (and Sentry). Inline the
83
+ # response construction so the local `response` name doesn't
84
+ # leak into the outer scope (mypy would then narrow it to
85
+ # PlainTextResponse and reject the later Response assignment
86
+ # in the token-mismatch branch below).
87
+ logger.info(
88
+ "csrf middleware: malformed multipart body (method=%s url=%s err=%s)",
89
+ request.method,
90
+ request.url,
91
+ exc,
92
+ )
93
+ await PlainTextResponse("Invalid request body", status_code=400)(
94
+ scope, receive, send
95
+ )
96
+ return
97
+
98
+ if (
99
+ not csrf_cookie
100
+ or not submitted_csrf_token
101
+ or not self._csrf_tokens_match(csrf_cookie, submitted_csrf_token)
102
+ ):
103
+ response = self._get_error_response(request)
104
+ error_send = functools.partial(
105
+ self._send_with_refresh,
106
+ send=send,
107
+ scope=scope,
108
+ )
109
+ await response(scope, receive, error_send)
110
+ return
111
+
112
+ async def replay_receive() -> Message:
113
+ return {"type": "http.request", "body": body, "more_body": False}
114
+
115
+ receive = replay_receive
116
+
117
+ send = functools.partial(self._send_with_refresh, send=send, scope=scope)
118
+ await self.app(scope, receive, send)
119
+
120
+ async def _send_with_refresh(
121
+ self,
122
+ message: Message,
123
+ *,
124
+ send: Send,
125
+ scope: Scope,
126
+ ) -> None:
127
+ if message.get("type") == "http.response.start":
128
+ request = Request(scope)
129
+ csrf_cookie = request.cookies.get(self.cookie_name)
130
+
131
+ needs_new = csrf_cookie is None or not self._cookie_is_valid(csrf_cookie)
132
+ if needs_new:
133
+ message.setdefault("headers", [])
134
+ headers = MutableHeaders(scope=message)
135
+ cookie: http.cookies.BaseCookie[str] = http.cookies.SimpleCookie()
136
+ name = self.cookie_name
137
+ cookie[name] = self._generate_csrf_token()
138
+ cookie[name]["path"] = self.cookie_path
139
+ cookie[name]["secure"] = self.cookie_secure
140
+ cookie[name]["httponly"] = self.cookie_httponly
141
+ cookie[name]["samesite"] = self.cookie_samesite
142
+ if self.cookie_domain is not None:
143
+ cookie[name]["domain"] = self.cookie_domain
144
+ headers.append("set-cookie", cookie.output(header="").strip())
145
+
146
+ await send(message)
147
+
148
+ async def _get_submitted_csrf_token(self, request: Request) -> str | None:
149
+ token = request.headers.get(self.header_name)
150
+ if token:
151
+ return token
152
+ content_type = request.headers.get("content-type", "")
153
+ if (
154
+ "application/x-www-form-urlencoded" in content_type
155
+ or "multipart/form-data" in content_type
156
+ ):
157
+ form = await request.form()
158
+ value = form.get(self.cookie_name)
159
+ # form.get() can return UploadFile, str, or None. Only accept strings
160
+ # (an UploadFile "csrftoken" is nonsensical and should be rejected).
161
+ return value if isinstance(value, str) else None
162
+ return None