api-dock 0.9.1__tar.gz → 0.11.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.11.0}/PKG-INFO +13 -16
- {api_dock-0.9.1 → api_dock-0.11.0}/README.md +8 -12
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/auth.py +186 -58
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/cli.py +149 -98
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/config.py +222 -127
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/config_discovery.py +30 -47
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/database_backends.py +25 -2
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/database_config.py +48 -128
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/encryption.py +49 -23
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/example_api_dock_config/config.yaml +8 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +3 -1
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/fast_api.py +39 -11
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/flask_api.py +16 -18
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/listings.py +16 -30
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/lookups.py +22 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/postgres_backend.py +8 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/route_mapper.py +597 -187
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/sql_builder.py +162 -85
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/storage_auth.py +37 -75
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/types.py +5 -1
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock.egg-info/PKG-INFO +13 -16
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock.egg-info/SOURCES.txt +10 -1
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock.egg-info/requires.txt +3 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/pyproject.toml +6 -4
- api_dock-0.11.0/tests/test_auth.py +249 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_bound_parameters.py +8 -8
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_database_requests.py +5 -3
- api_dock-0.11.0/tests/test_discovery.py +93 -0
- api_dock-0.11.0/tests/test_encryption_and_discovery.py +137 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_lookup_requests.py +1 -1
- api_dock-0.11.0/tests/test_nested_names.py +233 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_postgres_requests.py +2 -1
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_proxy_pipeline.py +4 -4
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_remote_config.py +13 -0
- api_dock-0.11.0/tests/test_remote_cookies.py +151 -0
- api_dock-0.11.0/tests/test_review_fixes.py +209 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_runtime_settings.py +1 -1
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_sql_builder.py +3 -3
- api_dock-0.11.0/tests/test_sql_placement.py +176 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_sql_selector.py +2 -2
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_startup_check.py +48 -4
- api_dock-0.11.0/tests/test_storage_auth.py +77 -0
- api_dock-0.11.0/tests/test_value_errors.py +116 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/LICENSE.md +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/__init__.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/example_api_dock_config/databases/config.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/example_api_dock_config/databases/example_db.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/example_api_dock_config/remotes/config.yaml +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/postgres_config.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock/sql_template_check.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock.egg-info/dependency_links.txt +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock.egg-info/entry_points.txt +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/api_dock.egg-info/top_level.txt +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/setup.cfg +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_inject_cookies.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_json_conversion.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_listings.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_lookup_cli.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_lookup_sources.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_lookups.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_postgres_config.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_postgres_mixed.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_schema_unions.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_shared_database_config.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.0}/tests/test_sql_template_check.py +0 -0
- {api_dock-0.9.1 → api_dock-0.11.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.11.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.11.0
|
|
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='nested names, auto-discovery, 400 for invalid values'
|
|
294
295
|
git add -A
|
|
295
296
|
git commit -m "v$VERSION: $COMMIT_MESSAGE"
|
|
296
297
|
git tag "v$VERSION"
|
|
@@ -302,18 +303,14 @@ gh release create "v$VERSION" \
|
|
|
302
303
|
--title "v$VERSION" \
|
|
303
304
|
--notes "$(cat <<'EOF'
|
|
304
305
|
* new features
|
|
305
|
-
-
|
|
306
|
-
-
|
|
307
|
-
-
|
|
308
|
-
- Slugs can define their schema inline: `schema: {name, tables}`
|
|
309
|
-
- Database values convert to JSON recursively, including UUIDs, network addresses, intervals and arrays
|
|
306
|
+
- Nested names: remote and database names may contain `/` (e.g. `birdnet/2.4/bullfrog`, served at `/birdnet/2.4/bullfrog/0.5/...`). A request goes to the longest name its path starts with, so `/birdnet/2.4/...` still reaches `birdnet`. `settings.allow_nested_names` (default `true`) can turn them off. Startup refuses names with empty or `latest` segments, and nested names that would hide part of the name they sit under (nested under a remote, covering a whole version, or making one of its routes unreachable)
|
|
307
|
+
- Auto-discovery: without `remotes:` / `databases:` in the main config, every remote/database in the config folder is served (top-level files, version folders at any depth, slugs, `remotes/config.yaml`, lookup-generated names); `excluded_remotes:` / `excluded_databases:` leave names out (and also apply to explicit lists). Configs with explicit lists work unchanged
|
|
308
|
+
- Include/exclude entries and lookup-generated names work with nested names (`'birdnet/2.4'` means birdnet version 2.4, not `birdnet/2.4/bullfrog`)
|
|
310
309
|
* bug fixes
|
|
311
|
-
-
|
|
310
|
+
- A request value that can't be converted to its column's type (e.g. a timestamp for a number column) returns `400 Invalid value for a query parameter` with the database's message, instead of `500 Database query error` (DuckDB and PostgreSQL; bad data in a table is still a 500)
|
|
312
311
|
* cleanup / other improvements
|
|
313
|
-
-
|
|
314
|
-
-
|
|
315
|
-
- Slim README; detailed docs moved to the wiki (https://github.com/SchmidtDSE/api_dock/wiki), including new Concepts, PostgreSQL, Lookups and Developer Guide pages
|
|
316
|
-
- Test suite grew from 373 to 626 tests (a throwaway PostgreSQL server runs the PostgreSQL tests when available)
|
|
312
|
+
- Bundled example config explains optional lists, exclusions and nested names
|
|
313
|
+
- Tests grew from 760 to 789
|
|
317
314
|
EOF
|
|
318
315
|
)"
|
|
319
316
|
|
|
@@ -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.11.0
|
|
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='nested names, auto-discovery, 400 for invalid values'
|
|
252
252
|
git add -A
|
|
253
253
|
git commit -m "v$VERSION: $COMMIT_MESSAGE"
|
|
254
254
|
git tag "v$VERSION"
|
|
@@ -260,18 +260,14 @@ gh release create "v$VERSION" \
|
|
|
260
260
|
--title "v$VERSION" \
|
|
261
261
|
--notes "$(cat <<'EOF'
|
|
262
262
|
* new features
|
|
263
|
-
-
|
|
264
|
-
-
|
|
265
|
-
-
|
|
266
|
-
- Slugs can define their schema inline: `schema: {name, tables}`
|
|
267
|
-
- Database values convert to JSON recursively, including UUIDs, network addresses, intervals and arrays
|
|
263
|
+
- Nested names: remote and database names may contain `/` (e.g. `birdnet/2.4/bullfrog`, served at `/birdnet/2.4/bullfrog/0.5/...`). A request goes to the longest name its path starts with, so `/birdnet/2.4/...` still reaches `birdnet`. `settings.allow_nested_names` (default `true`) can turn them off. Startup refuses names with empty or `latest` segments, and nested names that would hide part of the name they sit under (nested under a remote, covering a whole version, or making one of its routes unreachable)
|
|
264
|
+
- Auto-discovery: without `remotes:` / `databases:` in the main config, every remote/database in the config folder is served (top-level files, version folders at any depth, slugs, `remotes/config.yaml`, lookup-generated names); `excluded_remotes:` / `excluded_databases:` leave names out (and also apply to explicit lists). Configs with explicit lists work unchanged
|
|
265
|
+
- Include/exclude entries and lookup-generated names work with nested names (`'birdnet/2.4'` means birdnet version 2.4, not `birdnet/2.4/bullfrog`)
|
|
268
266
|
* bug fixes
|
|
269
|
-
-
|
|
267
|
+
- A request value that can't be converted to its column's type (e.g. a timestamp for a number column) returns `400 Invalid value for a query parameter` with the database's message, instead of `500 Database query error` (DuckDB and PostgreSQL; bad data in a table is still a 500)
|
|
270
268
|
* cleanup / other improvements
|
|
271
|
-
-
|
|
272
|
-
-
|
|
273
|
-
- Slim README; detailed docs moved to the wiki (https://github.com/SchmidtDSE/api_dock/wiki), including new Concepts, PostgreSQL, Lookups and Developer Guide pages
|
|
274
|
-
- Test suite grew from 373 to 626 tests (a throwaway PostgreSQL server runs the PostgreSQL tests when available)
|
|
269
|
+
- Bundled example config explains optional lists, exclusions and nested names
|
|
270
|
+
- Tests grew from 760 to 789
|
|
275
271
|
EOF
|
|
276
272
|
)"
|
|
277
273
|
|
|
@@ -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)
|