api-dock 0.9.1__tar.gz → 0.10.0__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.
- {api_dock-0.9.1 → api_dock-0.10.0}/PKG-INFO +30 -16
- {api_dock-0.9.1 → api_dock-0.10.0}/README.md +25 -12
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/auth.py +186 -58
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/cli.py +149 -98
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/config.py +119 -123
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/config_discovery.py +30 -47
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/database_backends.py +0 -1
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/database_config.py +12 -125
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/encryption.py +49 -23
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +3 -1
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/fast_api.py +27 -7
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/flask_api.py +2 -1
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/listings.py +2 -24
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/route_mapper.py +449 -187
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/sql_builder.py +162 -85
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/storage_auth.py +37 -75
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/types.py +5 -1
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/PKG-INFO +30 -16
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/SOURCES.txt +6 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/requires.txt +3 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/pyproject.toml +6 -4
- api_dock-0.10.0/tests/test_auth.py +249 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_bound_parameters.py +8 -8
- api_dock-0.10.0/tests/test_encryption_and_discovery.py +137 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookup_requests.py +1 -1
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_proxy_pipeline.py +4 -4
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_remote_config.py +13 -0
- api_dock-0.10.0/tests/test_remote_cookies.py +151 -0
- api_dock-0.10.0/tests/test_review_fixes.py +209 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_runtime_settings.py +1 -1
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_sql_builder.py +3 -3
- api_dock-0.10.0/tests/test_sql_placement.py +176 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_sql_selector.py +2 -2
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_startup_check.py +48 -4
- api_dock-0.10.0/tests/test_storage_auth.py +77 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/LICENSE.md +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/__init__.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/config.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/databases/config.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/databases/example_db.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/remotes/config.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/lookups.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/postgres_backend.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/postgres_config.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/sql_template_check.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/dependency_links.txt +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/entry_points.txt +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/top_level.txt +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/setup.cfg +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_database_requests.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_inject_cookies.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_json_conversion.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_listings.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookup_cli.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookup_sources.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookups.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_postgres_config.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_postgres_mixed.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_postgres_requests.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_schema_unions.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_shared_database_config.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_sql_template_check.py +0 -0
- {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_types.py +0 -0
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api_dock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases
|
|
5
5
|
Author-email: Brookie Guzder-Williams <bguzder-williams@berkeley.edu>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
7
7
|
Classifier: Development Status :: 4 - Beta
|
|
8
8
|
Classifier: Intended Audience :: Developers
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
13
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
14
|
Classifier: Topic :: Database
|
|
16
15
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
16
|
Classifier: Topic :: Software Development :: Libraries
|
|
@@ -38,6 +37,8 @@ Requires-Dist: pytest<10,>=9.0.2; extra == "dev"
|
|
|
38
37
|
Provides-Extra: postgres
|
|
39
38
|
Requires-Dist: psycopg[binary]<4,>=3.3.6; extra == "postgres"
|
|
40
39
|
Requires-Dist: psycopg_pool<4,>=3.3.2; extra == "postgres"
|
|
40
|
+
Provides-Extra: gcp
|
|
41
|
+
Requires-Dist: google-cloud-secret-manager<3,>=2; extra == "gcp"
|
|
41
42
|
Dynamic: license-file
|
|
42
43
|
|
|
43
44
|
# API Dock
|
|
@@ -279,7 +280,7 @@ Publishing a GitHub Release is what publishes to PyPI: `.github/workflows/publis
|
|
|
279
280
|
|
|
280
281
|
```bash
|
|
281
282
|
# 0. Start from a clean, up-to-date main
|
|
282
|
-
export VERSION=0.
|
|
283
|
+
export VERSION=0.10.0 # the NEW version, no leading "v"
|
|
283
284
|
git checkout main
|
|
284
285
|
git pull origin main
|
|
285
286
|
git status
|
|
@@ -290,7 +291,7 @@ git status
|
|
|
290
291
|
pixi run -e dev pytest -q
|
|
291
292
|
|
|
292
293
|
# 3. Commit, tag, push (the commit command adds the "v$VERSION: " prefix)
|
|
293
|
-
export COMMIT_MESSAGE='
|
|
294
|
+
export COMMIT_MESSAGE='code review fixes: remote cookies, safer SQL building, auth caching'
|
|
294
295
|
git add -A
|
|
295
296
|
git commit -m "v$VERSION: $COMMIT_MESSAGE"
|
|
296
297
|
git tag "v$VERSION"
|
|
@@ -301,19 +302,32 @@ git push origin main "v$VERSION"
|
|
|
301
302
|
gh release create "v$VERSION" \
|
|
302
303
|
--title "v$VERSION" \
|
|
303
304
|
--notes "$(cat <<'EOF'
|
|
305
|
+
**Upgrading:** a remote now receives only the cookies its `cookies:` setting allows (none without it). A remote that relied on the browser's cookies being passed through needs `cookies: [<name>]`, or `cookies: true` for all of them. Configs that set `action`, an unknown version `schema:`, non-boolean `add_trailing_slash`/`follow_redirects`, or a bad `timeout` now stop at startup.
|
|
306
|
+
|
|
304
307
|
* new features
|
|
305
|
-
-
|
|
306
|
-
-
|
|
307
|
-
- `
|
|
308
|
-
-
|
|
309
|
-
- Database values convert to JSON recursively, including UUIDs, network addresses, intervals and arrays
|
|
308
|
+
- `api-dock describe` builds the API like `start` (lookups, startup checks) and prints every database/version with expanded route SQL, plus each remote's url
|
|
309
|
+
- `api-dock init --force` replaces existing files; the whole example tree is copied
|
|
310
|
+
- `gcp` extra (`pip install 'api_dock[gcp]'`) for GCP Secret Manager authentication
|
|
311
|
+
- Startup warning when an `authentication` block would be ignored by remote routes
|
|
310
312
|
* bug fixes
|
|
311
|
-
- `
|
|
313
|
+
- Remote cookies: the client's raw `Cookie` header is no longer forwarded; the upstream `Cookie` header is built from the remote's `cookies` setting, so filtering and injected cookies work even when the client sends cookies
|
|
314
|
+
- Several upstream `Set-Cookie` headers reach the client separately instead of merged into one (new `ProxyResponse.set_cookies`)
|
|
315
|
+
- Query-param filters join the base query's top-level `WHERE` (strings, comments, CTEs and subqueries ignored), parenthesized and before a trailing `GROUP BY`/`ORDER BY`/`LIMIT`: fixes `WHERE a OR b` letting rows past filters, CTE-only `WHERE`s and base queries ending in `ORDER BY`
|
|
316
|
+
- `[[table]]` is a table source only right after `FROM`/`JOIN` or a `FROM`-list comma (was any `FROM`/`JOIN` in the previous 20 characters, which broke `ON [[a]].id = [[b]].id`)
|
|
317
|
+
- `sql_append` values are limited to integers and column names (with `ASC`/`DESC`, `NULLS FIRST/LAST`); the unimplemented `action` (which echoed request values, including cookies) is refused
|
|
318
|
+
- Authentication providers are built once per config instead of on every request (no secret-store/KMS call per request); `refresh_interval` now refreshes, keeping the last values if a refresh fails; tokens compared in constant time; `aws_tokens_file` works (`aws_key_id` optional)
|
|
319
|
+
- Remote route mapping fills `{{route_name}}` and `{{cookies.x}}` in `remote_route`, and keeps a query string written there
|
|
320
|
+
- Include/exclude and listing filters compare versions part by part (`1.10` no longer matches `1.1`)
|
|
321
|
+
- Values in DuckDB secret SQL (S3 region, GCS keys, HTTP headers) are quoted; a second GCS `service_account` no longer overwrites the process-wide one
|
|
322
|
+
- A main config that is missing (explicit path), invalid YAML or not a mapping stops startup instead of serving an empty API; settings are validated at startup
|
|
323
|
+
- An explicit encryption `key_file` must exist (only the default key file falls back to `API_DOCK_ENCRYPTION_KEY`); `env_key` reads only its variable
|
|
324
|
+
- Remote error responses no longer include internal error text (it's logged); `map_route_sync` no longer leaks event loops
|
|
312
325
|
* cleanup / other improvements
|
|
313
|
-
-
|
|
314
|
-
-
|
|
315
|
-
-
|
|
316
|
-
-
|
|
326
|
+
- `map_database_route` split into named steps; one version-resolution helper for remotes and databases
|
|
327
|
+
- A remote request reads only that remote's config file (was every remote file, twice)
|
|
328
|
+
- Removed unused internal functions; shared YAML-loading and version-matching helpers; `follow_protocol_downgrades` (never implemented) removed
|
|
329
|
+
- FastAPI app reports the package version; classifiers match Python 3.11+; style fixes
|
|
330
|
+
- Tests grew from 626 to 760, including the first tests for authentication, encryption, config discovery and storage credentials
|
|
317
331
|
EOF
|
|
318
332
|
)"
|
|
319
333
|
|
|
@@ -237,7 +237,7 @@ Publishing a GitHub Release is what publishes to PyPI: `.github/workflows/publis
|
|
|
237
237
|
|
|
238
238
|
```bash
|
|
239
239
|
# 0. Start from a clean, up-to-date main
|
|
240
|
-
export VERSION=0.
|
|
240
|
+
export VERSION=0.10.0 # the NEW version, no leading "v"
|
|
241
241
|
git checkout main
|
|
242
242
|
git pull origin main
|
|
243
243
|
git status
|
|
@@ -248,7 +248,7 @@ git status
|
|
|
248
248
|
pixi run -e dev pytest -q
|
|
249
249
|
|
|
250
250
|
# 3. Commit, tag, push (the commit command adds the "v$VERSION: " prefix)
|
|
251
|
-
export COMMIT_MESSAGE='
|
|
251
|
+
export COMMIT_MESSAGE='code review fixes: remote cookies, safer SQL building, auth caching'
|
|
252
252
|
git add -A
|
|
253
253
|
git commit -m "v$VERSION: $COMMIT_MESSAGE"
|
|
254
254
|
git tag "v$VERSION"
|
|
@@ -259,19 +259,32 @@ git push origin main "v$VERSION"
|
|
|
259
259
|
gh release create "v$VERSION" \
|
|
260
260
|
--title "v$VERSION" \
|
|
261
261
|
--notes "$(cat <<'EOF'
|
|
262
|
+
**Upgrading:** a remote now receives only the cookies its `cookies:` setting allows (none without it). A remote that relied on the browser's cookies being passed through needs `cookies: [<name>]`, or `cookies: true` for all of them. Configs that set `action`, an unknown version `schema:`, non-boolean `add_trailing_slash`/`follow_redirects`, or a bad `timeout` now stop at startup.
|
|
263
|
+
|
|
262
264
|
* new features
|
|
263
|
-
-
|
|
264
|
-
-
|
|
265
|
-
- `
|
|
266
|
-
-
|
|
267
|
-
- Database values convert to JSON recursively, including UUIDs, network addresses, intervals and arrays
|
|
265
|
+
- `api-dock describe` builds the API like `start` (lookups, startup checks) and prints every database/version with expanded route SQL, plus each remote's url
|
|
266
|
+
- `api-dock init --force` replaces existing files; the whole example tree is copied
|
|
267
|
+
- `gcp` extra (`pip install 'api_dock[gcp]'`) for GCP Secret Manager authentication
|
|
268
|
+
- Startup warning when an `authentication` block would be ignored by remote routes
|
|
268
269
|
* bug fixes
|
|
269
|
-
- `
|
|
270
|
+
- Remote cookies: the client's raw `Cookie` header is no longer forwarded; the upstream `Cookie` header is built from the remote's `cookies` setting, so filtering and injected cookies work even when the client sends cookies
|
|
271
|
+
- Several upstream `Set-Cookie` headers reach the client separately instead of merged into one (new `ProxyResponse.set_cookies`)
|
|
272
|
+
- Query-param filters join the base query's top-level `WHERE` (strings, comments, CTEs and subqueries ignored), parenthesized and before a trailing `GROUP BY`/`ORDER BY`/`LIMIT`: fixes `WHERE a OR b` letting rows past filters, CTE-only `WHERE`s and base queries ending in `ORDER BY`
|
|
273
|
+
- `[[table]]` is a table source only right after `FROM`/`JOIN` or a `FROM`-list comma (was any `FROM`/`JOIN` in the previous 20 characters, which broke `ON [[a]].id = [[b]].id`)
|
|
274
|
+
- `sql_append` values are limited to integers and column names (with `ASC`/`DESC`, `NULLS FIRST/LAST`); the unimplemented `action` (which echoed request values, including cookies) is refused
|
|
275
|
+
- Authentication providers are built once per config instead of on every request (no secret-store/KMS call per request); `refresh_interval` now refreshes, keeping the last values if a refresh fails; tokens compared in constant time; `aws_tokens_file` works (`aws_key_id` optional)
|
|
276
|
+
- Remote route mapping fills `{{route_name}}` and `{{cookies.x}}` in `remote_route`, and keeps a query string written there
|
|
277
|
+
- Include/exclude and listing filters compare versions part by part (`1.10` no longer matches `1.1`)
|
|
278
|
+
- Values in DuckDB secret SQL (S3 region, GCS keys, HTTP headers) are quoted; a second GCS `service_account` no longer overwrites the process-wide one
|
|
279
|
+
- A main config that is missing (explicit path), invalid YAML or not a mapping stops startup instead of serving an empty API; settings are validated at startup
|
|
280
|
+
- An explicit encryption `key_file` must exist (only the default key file falls back to `API_DOCK_ENCRYPTION_KEY`); `env_key` reads only its variable
|
|
281
|
+
- Remote error responses no longer include internal error text (it's logged); `map_route_sync` no longer leaks event loops
|
|
270
282
|
* cleanup / other improvements
|
|
271
|
-
-
|
|
272
|
-
-
|
|
273
|
-
-
|
|
274
|
-
-
|
|
283
|
+
- `map_database_route` split into named steps; one version-resolution helper for remotes and databases
|
|
284
|
+
- A remote request reads only that remote's config file (was every remote file, twice)
|
|
285
|
+
- Removed unused internal functions; shared YAML-loading and version-matching helpers; `follow_protocol_downgrades` (never implemented) removed
|
|
286
|
+
- FastAPI app reports the package version; classifiers match Python 3.11+; style fixes
|
|
287
|
+
- Tests grew from 626 to 760, including the first tests for authentication, encryption, config discovery and storage credentials
|
|
275
288
|
EOF
|
|
276
289
|
)"
|
|
277
290
|
|
|
@@ -11,12 +11,16 @@ License: BSD 3-Clause
|
|
|
11
11
|
#
|
|
12
12
|
# IMPORTS
|
|
13
13
|
#
|
|
14
|
+
import hmac
|
|
14
15
|
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
15
19
|
import time
|
|
16
|
-
from abc import ABC
|
|
20
|
+
from abc import ABC
|
|
17
21
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
18
22
|
|
|
19
|
-
from api_dock.encryption import
|
|
23
|
+
from api_dock.encryption import decrypt_value_if_needed, EncryptionError
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
#
|
|
@@ -25,6 +29,24 @@ from api_dock.encryption import create_encryption_provider, decrypt_value_if_nee
|
|
|
25
29
|
DEFAULT_CACHE_TTL: int = 300 # 5 minutes
|
|
26
30
|
DEFAULT_STATUS_CODE: int = 401
|
|
27
31
|
|
|
32
|
+
# Keys that choose the authentication method; exactly one must be present.
|
|
33
|
+
# (`aws_key_id` is an optional detail of the KMS methods, not a method.)
|
|
34
|
+
METHOD_KEYS: Tuple[str, ...] = (
|
|
35
|
+
"value", "values", "filepath", "aws_secret_name", "aws_tokens", "aws_tokens_file",
|
|
36
|
+
"gcp_project_id",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Settings that name a file whose contents a provider reads at creation.
|
|
40
|
+
FILE_KEYS: Tuple[str, ...] = ("filepath", "aws_tokens_file")
|
|
41
|
+
|
|
42
|
+
# At most this many providers are cached (one per distinct auth config).
|
|
43
|
+
PROVIDER_CACHE_SIZE: int = 64
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
_PROVIDERS: Dict[str, "AuthenticationProvider"] = {}
|
|
48
|
+
_PROVIDERS_LOCK = threading.Lock()
|
|
49
|
+
|
|
28
50
|
|
|
29
51
|
#
|
|
30
52
|
# PUBLIC
|
|
@@ -40,20 +62,28 @@ class AuthenticationProvider(ABC):
|
|
|
40
62
|
"""
|
|
41
63
|
self.failed_response_config = failed_response or {}
|
|
42
64
|
|
|
43
|
-
|
|
44
65
|
def validate(self, token: str) -> bool:
|
|
45
|
-
"""
|
|
66
|
+
"""Check a token against the expected values, in constant time per value.
|
|
46
67
|
|
|
47
68
|
Args:
|
|
48
69
|
token: Token to validate.
|
|
49
|
-
valid_tokens: Set of valid tokens.
|
|
50
70
|
|
|
51
71
|
Returns:
|
|
52
72
|
True if token is valid, False otherwise.
|
|
53
73
|
"""
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
candidate = str(token).encode()
|
|
75
|
+
matched = False
|
|
76
|
+
for expected in self._normalize_token_set(self.current_values()):
|
|
77
|
+
matched |= hmac.compare_digest(candidate, expected.encode())
|
|
78
|
+
return matched
|
|
56
79
|
|
|
80
|
+
def current_values(self) -> set:
|
|
81
|
+
"""The values tokens are checked against (secret stores refresh them).
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The expected values.
|
|
85
|
+
"""
|
|
86
|
+
return set(self.expected_values)
|
|
57
87
|
|
|
58
88
|
def get_failed_response(self) -> Tuple[int, Any]:
|
|
59
89
|
"""Get the response to return when authentication fails.
|
|
@@ -85,7 +115,9 @@ class AuthenticationProvider(ABC):
|
|
|
85
115
|
class FixedValueAuth(AuthenticationProvider):
|
|
86
116
|
"""Authentication using a single fixed value."""
|
|
87
117
|
|
|
88
|
-
def __init__(self, value: str, encrypted: bool = True,
|
|
118
|
+
def __init__(self, value: str, encrypted: bool = True,
|
|
119
|
+
encryption_config: Optional[Dict[str, Any]] = None,
|
|
120
|
+
failed_response: Optional[Dict[str, Any]] = None):
|
|
89
121
|
"""Initialize fixed value authentication.
|
|
90
122
|
|
|
91
123
|
Args:
|
|
@@ -108,7 +140,9 @@ class FixedValueAuth(AuthenticationProvider):
|
|
|
108
140
|
class ListValueAuth(AuthenticationProvider):
|
|
109
141
|
"""Authentication using a list of allowed values."""
|
|
110
142
|
|
|
111
|
-
def __init__(self, values: List[Union[str, Dict[str, Any]]], encrypted: bool = True,
|
|
143
|
+
def __init__(self, values: List[Union[str, Dict[str, Any]]], encrypted: bool = True,
|
|
144
|
+
encryption_config: Optional[Dict[str, Any]] = None,
|
|
145
|
+
failed_response: Optional[Dict[str, Any]] = None):
|
|
112
146
|
"""Initialize list value authentication.
|
|
113
147
|
|
|
114
148
|
Args:
|
|
@@ -134,7 +168,9 @@ class ListValueAuth(AuthenticationProvider):
|
|
|
134
168
|
# Dict format with individual encryption setting
|
|
135
169
|
value_str = value_item.get("value", "")
|
|
136
170
|
value_encrypted = value_item.get("encrypted", encrypted)
|
|
137
|
-
decrypted = decrypt_value_if_needed(
|
|
171
|
+
decrypted = decrypt_value_if_needed(
|
|
172
|
+
value_str, value_encrypted, encryption_config
|
|
173
|
+
)
|
|
138
174
|
self.expected_values.add(str(decrypted))
|
|
139
175
|
else:
|
|
140
176
|
raise AuthenticationError(f"Invalid value format: {type(value_item)}")
|
|
@@ -145,7 +181,9 @@ class ListValueAuth(AuthenticationProvider):
|
|
|
145
181
|
class FileAuth(AuthenticationProvider):
|
|
146
182
|
"""Authentication using values from a text file (one value per line)."""
|
|
147
183
|
|
|
148
|
-
def __init__(self, filepath: str, encrypted: bool = True,
|
|
184
|
+
def __init__(self, filepath: str, encrypted: bool = True,
|
|
185
|
+
encryption_config: Optional[Dict[str, Any]] = None,
|
|
186
|
+
failed_response: Optional[Dict[str, Any]] = None):
|
|
149
187
|
"""Initialize file-based authentication.
|
|
150
188
|
|
|
151
189
|
Args:
|
|
@@ -171,7 +209,9 @@ class FileAuth(AuthenticationProvider):
|
|
|
171
209
|
decrypted = decrypt_value_if_needed(line, encrypted, encryption_config)
|
|
172
210
|
self.expected_values.add(str(decrypted))
|
|
173
211
|
except EncryptionError as e:
|
|
174
|
-
raise AuthenticationError(
|
|
212
|
+
raise AuthenticationError(
|
|
213
|
+
f"Failed to decrypt value on line {line_num} in '{filepath}': {e}"
|
|
214
|
+
)
|
|
175
215
|
except FileNotFoundError:
|
|
176
216
|
raise AuthenticationError(f"Authentication file not found: {filepath}")
|
|
177
217
|
except IOError as e:
|
|
@@ -181,7 +221,9 @@ class FileAuth(AuthenticationProvider):
|
|
|
181
221
|
class AWSSecretsAuth(AuthenticationProvider):
|
|
182
222
|
"""Authentication using AWS Secrets Manager."""
|
|
183
223
|
|
|
184
|
-
def __init__(self, secret_name: str, region: str = "us-west-2",
|
|
224
|
+
def __init__(self, secret_name: str, region: str = "us-west-2",
|
|
225
|
+
cache_ttl: int = DEFAULT_CACHE_TTL,
|
|
226
|
+
failed_response: Optional[Dict[str, Any]] = None):
|
|
185
227
|
"""Initialize AWS Secrets authentication.
|
|
186
228
|
|
|
187
229
|
Args:
|
|
@@ -218,6 +260,22 @@ class AWSSecretsAuth(AuthenticationProvider):
|
|
|
218
260
|
self._cache_time = 0
|
|
219
261
|
self.expected_values = self._get_cached_tokens()
|
|
220
262
|
|
|
263
|
+
def current_values(self) -> set:
|
|
264
|
+
"""The secret's values, re-read once ``refresh_interval`` has passed.
|
|
265
|
+
|
|
266
|
+
A failed refresh keeps the last values (and is logged), so a secret
|
|
267
|
+
store outage doesn't lock everyone out.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The expected values.
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
self.expected_values = self._get_cached_tokens()
|
|
274
|
+
except AuthenticationError as error:
|
|
275
|
+
logger.warning("Refreshing authentication values failed; using the last ones: %s",
|
|
276
|
+
error)
|
|
277
|
+
self._cache_time = time.time()
|
|
278
|
+
return set(self.expected_values)
|
|
221
279
|
|
|
222
280
|
def _get_cached_tokens(self) -> set:
|
|
223
281
|
"""Get authentication tokens from cache or refresh from AWS."""
|
|
@@ -268,13 +326,17 @@ class AWSSecretsAuth(AuthenticationProvider):
|
|
|
268
326
|
class AWSKMSAuth(AuthenticationProvider):
|
|
269
327
|
"""Authentication using AWS KMS for encrypted tokens."""
|
|
270
328
|
|
|
271
|
-
def __init__(self, tokens: Optional[List[str]] = None,
|
|
329
|
+
def __init__(self, tokens: Optional[List[str]] = None,
|
|
330
|
+
aws_tokens_file: Optional[str] = None, aws_key_id: Optional[str] = None,
|
|
331
|
+
aws_region: str = "us-west-2",
|
|
332
|
+
failed_response: Optional[Dict[str, Any]] = None):
|
|
272
333
|
"""Initialize AWS KMS authentication.
|
|
273
334
|
|
|
274
335
|
Args:
|
|
275
336
|
tokens: List of encrypted tokens (for inline configuration).
|
|
276
337
|
aws_tokens_file: Path to file containing encrypted tokens (one per line).
|
|
277
|
-
aws_key_id: AWS KMS key ID or ARN
|
|
338
|
+
aws_key_id: AWS KMS key ID or ARN (optional: KMS finds the key from
|
|
339
|
+
the ciphertext; kept for reference).
|
|
278
340
|
aws_region: AWS region.
|
|
279
341
|
failed_response: Custom response for failed authentication.
|
|
280
342
|
|
|
@@ -342,7 +404,6 @@ class AWSKMSAuth(AuthenticationProvider):
|
|
|
342
404
|
except IOError as e:
|
|
343
405
|
raise AuthenticationError(f"Failed to read AWS KMS tokens file '{filepath}': {str(e)}")
|
|
344
406
|
|
|
345
|
-
|
|
346
407
|
def _decrypt_token(self, encrypted_token: str) -> str:
|
|
347
408
|
"""Decrypt a single token using AWS KMS."""
|
|
348
409
|
try:
|
|
@@ -363,7 +424,9 @@ class AWSKMSAuth(AuthenticationProvider):
|
|
|
363
424
|
class GCPSecretsAuth(AuthenticationProvider):
|
|
364
425
|
"""Authentication using GCP Secret Manager."""
|
|
365
426
|
|
|
366
|
-
def __init__(self, project_id: str, secret_name: str, version: str = "latest",
|
|
427
|
+
def __init__(self, project_id: str, secret_name: str, version: str = "latest",
|
|
428
|
+
cache_ttl: int = DEFAULT_CACHE_TTL,
|
|
429
|
+
failed_response: Optional[Dict[str, Any]] = None):
|
|
367
430
|
"""Initialize GCP Secrets authentication.
|
|
368
431
|
|
|
369
432
|
Args:
|
|
@@ -380,7 +443,10 @@ class GCPSecretsAuth(AuthenticationProvider):
|
|
|
380
443
|
from google.cloud import secretmanager
|
|
381
444
|
from google.auth.exceptions import DefaultCredentialsError
|
|
382
445
|
except ImportError:
|
|
383
|
-
raise AuthenticationError(
|
|
446
|
+
raise AuthenticationError(
|
|
447
|
+
"GCP Secrets authentication needs google-cloud-secret-manager: "
|
|
448
|
+
"pip install 'api_dock[gcp]'"
|
|
449
|
+
)
|
|
384
450
|
|
|
385
451
|
super().__init__(failed_response)
|
|
386
452
|
|
|
@@ -405,6 +471,22 @@ class GCPSecretsAuth(AuthenticationProvider):
|
|
|
405
471
|
self._cache_time = 0
|
|
406
472
|
self.expected_values = self._get_cached_tokens()
|
|
407
473
|
|
|
474
|
+
def current_values(self) -> set:
|
|
475
|
+
"""The secret's values, re-read once ``refresh_interval`` has passed.
|
|
476
|
+
|
|
477
|
+
A failed refresh keeps the last values (and is logged), so a secret
|
|
478
|
+
store outage doesn't lock everyone out.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
The expected values.
|
|
482
|
+
"""
|
|
483
|
+
try:
|
|
484
|
+
self.expected_values = self._get_cached_tokens()
|
|
485
|
+
except AuthenticationError as error:
|
|
486
|
+
logger.warning("Refreshing authentication values failed; using the last ones: %s",
|
|
487
|
+
error)
|
|
488
|
+
self._cache_time = time.time()
|
|
489
|
+
return set(self.expected_values)
|
|
408
490
|
|
|
409
491
|
def _get_cached_tokens(self) -> set:
|
|
410
492
|
"""Get authentication tokens from cache or refresh from GCP."""
|
|
@@ -442,7 +524,9 @@ class GCPSecretsAuth(AuthenticationProvider):
|
|
|
442
524
|
return self._cached_values
|
|
443
525
|
|
|
444
526
|
except Exception as e:
|
|
445
|
-
raise AuthenticationError(
|
|
527
|
+
raise AuthenticationError(
|
|
528
|
+
f"Failed to retrieve GCP secret '{self.secret_name}': {e}"
|
|
529
|
+
)
|
|
446
530
|
|
|
447
531
|
|
|
448
532
|
class AuthenticationError(Exception):
|
|
@@ -467,28 +551,21 @@ def create_authentication_provider(config: Dict[str, Any]) -> AuthenticationProv
|
|
|
467
551
|
encryption_config = config.get("encryption")
|
|
468
552
|
encrypted = config.get("encrypted", True)
|
|
469
553
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
method_keys.append("gcp_project_id")
|
|
486
|
-
|
|
487
|
-
# Validate exactly one method key is present
|
|
488
|
-
if len(method_keys) == 0:
|
|
489
|
-
raise AuthenticationError("Authentication configuration must specify exactly one of: value, values, filepath, aws_secret_name, aws_key_id, aws_tokens_file, or gcp_project_id")
|
|
490
|
-
elif len(method_keys) > 1:
|
|
491
|
-
raise AuthenticationError(f"Authentication configuration has conflicting method keys: {', '.join(method_keys)}. Only one is allowed.")
|
|
554
|
+
method_keys = [key for key in METHOD_KEYS if key in config]
|
|
555
|
+
if not method_keys:
|
|
556
|
+
if "aws_key_id" in config:
|
|
557
|
+
raise AuthenticationError(
|
|
558
|
+
"AWS KMS authentication needs 'aws_tokens' (a list) or 'aws_tokens_file' "
|
|
559
|
+
"alongside 'aws_key_id'"
|
|
560
|
+
)
|
|
561
|
+
raise AuthenticationError(
|
|
562
|
+
f"Authentication configuration must specify exactly one of: {', '.join(METHOD_KEYS)}"
|
|
563
|
+
)
|
|
564
|
+
if len(method_keys) > 1:
|
|
565
|
+
raise AuthenticationError(
|
|
566
|
+
f"Authentication configuration has conflicting method keys: "
|
|
567
|
+
f"{', '.join(method_keys)}. Only one is allowed."
|
|
568
|
+
)
|
|
492
569
|
|
|
493
570
|
method_key = method_keys[0]
|
|
494
571
|
|
|
@@ -513,29 +590,28 @@ def create_authentication_provider(config: Dict[str, Any]) -> AuthenticationProv
|
|
|
513
590
|
cache_ttl = config.get("refresh_interval", DEFAULT_CACHE_TTL)
|
|
514
591
|
return AWSSecretsAuth(secret_name, region, cache_ttl, failed_response)
|
|
515
592
|
|
|
516
|
-
elif method_key == "
|
|
517
|
-
aws_key_id = config.get("aws_key_id")
|
|
593
|
+
elif method_key == "aws_tokens":
|
|
518
594
|
tokens = config.get("aws_tokens")
|
|
519
595
|
if not tokens or not isinstance(tokens, list):
|
|
520
|
-
raise AuthenticationError("
|
|
521
|
-
|
|
596
|
+
raise AuthenticationError("'aws_tokens' must be a non-empty list of encrypted tokens")
|
|
522
597
|
region = config.get("aws_region", "us-west-2")
|
|
523
|
-
return AWSKMSAuth(tokens=tokens, aws_key_id=aws_key_id, aws_region=region,
|
|
598
|
+
return AWSKMSAuth(tokens=tokens, aws_key_id=config.get("aws_key_id"), aws_region=region,
|
|
599
|
+
failed_response=failed_response)
|
|
524
600
|
|
|
525
601
|
elif method_key == "aws_tokens_file":
|
|
526
602
|
aws_key_id = config.get("aws_key_id")
|
|
527
|
-
if not aws_key_id:
|
|
528
|
-
raise AuthenticationError("AWS KMS file authentication requires both 'aws_tokens_file' and 'aws_key_id'")
|
|
529
|
-
|
|
530
603
|
aws_tokens_file = config.get("aws_tokens_file")
|
|
531
604
|
region = config.get("aws_region", "us-west-2")
|
|
532
|
-
return AWSKMSAuth(aws_tokens_file=aws_tokens_file, aws_key_id=aws_key_id,
|
|
605
|
+
return AWSKMSAuth(aws_tokens_file=aws_tokens_file, aws_key_id=aws_key_id,
|
|
606
|
+
aws_region=region, failed_response=failed_response)
|
|
533
607
|
|
|
534
608
|
elif method_key == "gcp_project_id":
|
|
535
609
|
project_id = config.get("gcp_project_id")
|
|
536
610
|
secret_name = config.get("gcp_secret_name")
|
|
537
611
|
if not secret_name:
|
|
538
|
-
raise AuthenticationError(
|
|
612
|
+
raise AuthenticationError(
|
|
613
|
+
"GCP Secrets authentication requires both 'gcp_project_id' and 'gcp_secret_name'"
|
|
614
|
+
)
|
|
539
615
|
|
|
540
616
|
version = config.get("gcp_version", "latest")
|
|
541
617
|
cache_ttl = config.get("refresh_interval", DEFAULT_CACHE_TTL)
|
|
@@ -545,7 +621,40 @@ def create_authentication_provider(config: Dict[str, Any]) -> AuthenticationProv
|
|
|
545
621
|
raise AuthenticationError(f"Unknown authentication method key: {method_key}")
|
|
546
622
|
|
|
547
623
|
|
|
548
|
-
def
|
|
624
|
+
def get_authentication_provider(config: Dict[str, Any]) -> AuthenticationProvider:
|
|
625
|
+
"""The provider for an auth config, built once and then reused.
|
|
626
|
+
|
|
627
|
+
Providers are cached by the config's contents (and the modification time
|
|
628
|
+
of a token file it names), so secret-store and KMS lookups happen when a
|
|
629
|
+
provider is built (and on its refresh interval), not on every request. A
|
|
630
|
+
changed config or token file builds a new provider.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
config: Authentication configuration dictionary.
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
The provider.
|
|
637
|
+
|
|
638
|
+
Raises:
|
|
639
|
+
AuthenticationError: If the configuration is invalid or the provider
|
|
640
|
+
can't be built.
|
|
641
|
+
"""
|
|
642
|
+
key = _provider_cache_key(config)
|
|
643
|
+
with _PROVIDERS_LOCK:
|
|
644
|
+
provider = _PROVIDERS.get(key)
|
|
645
|
+
if provider is not None:
|
|
646
|
+
return provider
|
|
647
|
+
provider = create_authentication_provider(config)
|
|
648
|
+
with _PROVIDERS_LOCK:
|
|
649
|
+
if len(_PROVIDERS) >= PROVIDER_CACHE_SIZE:
|
|
650
|
+
_PROVIDERS.clear()
|
|
651
|
+
_PROVIDERS[key] = provider
|
|
652
|
+
return provider
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def validate_authentication(
|
|
656
|
+
cookies: Dict[str, str],
|
|
657
|
+
auth_config: Dict[str, Any]) -> Tuple[bool, Optional[int], Optional[Any]]:
|
|
549
658
|
"""Validate authentication using cookies and configuration.
|
|
550
659
|
|
|
551
660
|
Args:
|
|
@@ -565,16 +674,12 @@ def validate_authentication(cookies: Dict[str, str], auth_config: Dict[str, Any]
|
|
|
565
674
|
auth_key = auth_config.get("key")
|
|
566
675
|
if not auth_key:
|
|
567
676
|
raise AuthenticationError("Authentication configuration missing 'key' field")
|
|
568
|
-
|
|
677
|
+
provider = get_authentication_provider(auth_config)
|
|
569
678
|
auth_token = cookies.get(auth_key)
|
|
570
679
|
if not auth_token:
|
|
571
|
-
# Token not provided - create provider to get proper error response
|
|
572
|
-
provider = create_authentication_provider(auth_config)
|
|
573
680
|
status_code, response_body = provider.get_failed_response()
|
|
574
681
|
return (False, status_code, response_body)
|
|
575
682
|
|
|
576
|
-
# Create authentication provider and validate
|
|
577
|
-
provider = create_authentication_provider(auth_config)
|
|
578
683
|
is_valid = provider.validate(auth_token)
|
|
579
684
|
|
|
580
685
|
if is_valid:
|
|
@@ -585,4 +690,27 @@ def validate_authentication(cookies: Dict[str, str], auth_config: Dict[str, Any]
|
|
|
585
690
|
except AuthenticationError:
|
|
586
691
|
raise
|
|
587
692
|
except Exception as e:
|
|
588
|
-
raise AuthenticationError(f"Authentication validation failed: {str(e)}")
|
|
693
|
+
raise AuthenticationError(f"Authentication validation failed: {str(e)}")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
#
|
|
697
|
+
# INTERNAL
|
|
698
|
+
#
|
|
699
|
+
def _provider_cache_key(config: Dict[str, Any]) -> str:
|
|
700
|
+
"""A cache key for an auth config: its contents plus named files' mtimes.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
config: Authentication configuration dictionary.
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
The key.
|
|
707
|
+
"""
|
|
708
|
+
files = {}
|
|
709
|
+
for key in FILE_KEYS:
|
|
710
|
+
path = config.get(key)
|
|
711
|
+
if isinstance(path, str):
|
|
712
|
+
try:
|
|
713
|
+
files[key] = os.stat(path).st_mtime_ns
|
|
714
|
+
except OSError:
|
|
715
|
+
files[key] = None
|
|
716
|
+
return json.dumps({"config": config, "files": files}, sort_keys=True, default=str)
|