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.
- coffeehouse_common-0.15.1/LICENSE +21 -0
- coffeehouse_common-0.15.1/PKG-INFO +170 -0
- coffeehouse_common-0.15.1/README.md +114 -0
- coffeehouse_common-0.15.1/coffeehouse_common/__init__.py +6 -0
- coffeehouse_common-0.15.1/coffeehouse_common/auth_client.py +187 -0
- coffeehouse_common-0.15.1/coffeehouse_common/csrf.py +162 -0
- coffeehouse_common-0.15.1/coffeehouse_common/db.py +87 -0
- coffeehouse_common-0.15.1/coffeehouse_common/flash.py +81 -0
- coffeehouse_common-0.15.1/coffeehouse_common/jwt.py +69 -0
- coffeehouse_common-0.15.1/coffeehouse_common/logging_config.py +62 -0
- coffeehouse_common-0.15.1/coffeehouse_common/observability.py +178 -0
- coffeehouse_common-0.15.1/coffeehouse_common/paths.py +30 -0
- coffeehouse_common-0.15.1/coffeehouse_common/role_cleanup.py +90 -0
- coffeehouse_common-0.15.1/coffeehouse_common/security_middleware.py +25 -0
- coffeehouse_common-0.15.1/coffeehouse_common.egg-info/PKG-INFO +170 -0
- coffeehouse_common-0.15.1/coffeehouse_common.egg-info/SOURCES.txt +41 -0
- coffeehouse_common-0.15.1/coffeehouse_common.egg-info/dependency_links.txt +1 -0
- coffeehouse_common-0.15.1/coffeehouse_common.egg-info/entry_points.txt +2 -0
- coffeehouse_common-0.15.1/coffeehouse_common.egg-info/requires.txt +12 -0
- coffeehouse_common-0.15.1/coffeehouse_common.egg-info/top_level.txt +2 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/__init__.py +44 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/_canonicalize.py +215 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/auth_stub.py +109 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/client.py +56 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/cors_smoke.py +81 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/db.py +203 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/jwt_factory.py +72 -0
- coffeehouse_common-0.15.1/coffeehouse_testing/migration_roundtrip.py +84 -0
- coffeehouse_common-0.15.1/pyproject.toml +179 -0
- coffeehouse_common-0.15.1/setup.cfg +4 -0
- coffeehouse_common-0.15.1/tests/test_auth_client.py +380 -0
- coffeehouse_common-0.15.1/tests/test_cors_smoke_helper.py +135 -0
- coffeehouse_common-0.15.1/tests/test_csrf_middleware.py +284 -0
- coffeehouse_common-0.15.1/tests/test_db.py +136 -0
- coffeehouse_common-0.15.1/tests/test_flash.py +143 -0
- coffeehouse_common-0.15.1/tests/test_imports.py +43 -0
- coffeehouse_common-0.15.1/tests/test_jwt_helper.py +84 -0
- coffeehouse_common-0.15.1/tests/test_logging_config.py +72 -0
- coffeehouse_common-0.15.1/tests/test_migration_roundtrip_canonicalize.py +218 -0
- coffeehouse_common-0.15.1/tests/test_observability.py +377 -0
- coffeehouse_common-0.15.1/tests/test_request_id_middleware.py +78 -0
- coffeehouse_common-0.15.1/tests/test_role_cleanup.py +159 -0
- 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
|