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.
Files changed (63) hide show
  1. {api_dock-0.9.1 → api_dock-0.10.0}/PKG-INFO +30 -16
  2. {api_dock-0.9.1 → api_dock-0.10.0}/README.md +25 -12
  3. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/auth.py +186 -58
  4. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/cli.py +149 -98
  5. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/config.py +119 -123
  6. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/config_discovery.py +30 -47
  7. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/database_backends.py +0 -1
  8. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/database_config.py +12 -125
  9. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/encryption.py +49 -23
  10. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/remotes/example_remote.yaml +3 -1
  11. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/fast_api.py +27 -7
  12. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/flask_api.py +2 -1
  13. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/listings.py +2 -24
  14. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/route_mapper.py +449 -187
  15. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/sql_builder.py +162 -85
  16. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/storage_auth.py +37 -75
  17. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/types.py +5 -1
  18. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/PKG-INFO +30 -16
  19. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/SOURCES.txt +6 -0
  20. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/requires.txt +3 -0
  21. {api_dock-0.9.1 → api_dock-0.10.0}/pyproject.toml +6 -4
  22. api_dock-0.10.0/tests/test_auth.py +249 -0
  23. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_bound_parameters.py +8 -8
  24. api_dock-0.10.0/tests/test_encryption_and_discovery.py +137 -0
  25. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookup_requests.py +1 -1
  26. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_proxy_pipeline.py +4 -4
  27. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_remote_config.py +13 -0
  28. api_dock-0.10.0/tests/test_remote_cookies.py +151 -0
  29. api_dock-0.10.0/tests/test_review_fixes.py +209 -0
  30. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_runtime_settings.py +1 -1
  31. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_sql_builder.py +3 -3
  32. api_dock-0.10.0/tests/test_sql_placement.py +176 -0
  33. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_sql_selector.py +2 -2
  34. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_startup_check.py +48 -4
  35. api_dock-0.10.0/tests/test_storage_auth.py +77 -0
  36. {api_dock-0.9.1 → api_dock-0.10.0}/LICENSE.md +0 -0
  37. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/__init__.py +0 -0
  38. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/config.yaml +0 -0
  39. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/databases/config.yaml +0 -0
  40. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/databases/example_db.yaml +0 -0
  41. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/example_api_dock_config/remotes/config.yaml +0 -0
  42. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/lookups.py +0 -0
  43. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/postgres_backend.py +0 -0
  44. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/postgres_config.py +0 -0
  45. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock/sql_template_check.py +0 -0
  46. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/dependency_links.txt +0 -0
  47. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/entry_points.txt +0 -0
  48. {api_dock-0.9.1 → api_dock-0.10.0}/api_dock.egg-info/top_level.txt +0 -0
  49. {api_dock-0.9.1 → api_dock-0.10.0}/setup.cfg +0 -0
  50. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_database_requests.py +0 -0
  51. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_inject_cookies.py +0 -0
  52. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_json_conversion.py +0 -0
  53. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_listings.py +0 -0
  54. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookup_cli.py +0 -0
  55. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookup_sources.py +0 -0
  56. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_lookups.py +0 -0
  57. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_postgres_config.py +0 -0
  58. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_postgres_mixed.py +0 -0
  59. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_postgres_requests.py +0 -0
  60. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_schema_unions.py +0 -0
  61. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_shared_database_config.py +0 -0
  62. {api_dock-0.9.1 → api_dock-0.10.0}/tests/test_sql_template_check.py +0 -0
  63. {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.9.1
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.9.1 # the NEW version, no leading "v"
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='PostgreSQL, lookups, remotes/config.yaml, numeric latest'
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
- - PostgreSQL tables (`pip install 'api_dock[postgres]'`): named `connections` in `databases/config.yaml` and `{connection, table}` tables. A route runs natively on PostgreSQL when all its tables are on one connection (one pool per connection, read-only transactions, statement timeout), otherwise on DuckDB with PostgreSQL attached read-only, so unions and joins can mix PostgreSQL and Parquet. Route `engine: postgres|duckdb` checks or forces the choice; 503 when a database is unavailable
306
- - Lookups: named queries (SQL over any configured table, or an HTTP API) run at startup, every `refresh` and on demand. `from:` entries turn rows into database/versions (with inline schemas) or remote versions, `{{lookup.<name>.<column>}}` fills single values, and `databases: [- from: <lookup>]` serves what they generate. New rows are checked before use; values used as URIs must match `allow:` prefixes. Optional token-protected refresh endpoint (`settings.lookups`) and `api-dock lookups` CLI
307
- - `remotes/config.yaml`: define remotes inline (`version`, `versions` or unversioned); mixes with remote files, files win
308
- - Slugs can define their schema inline: `schema: {name, tables}`
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
- - `latest` and version lists compare versions numerically (`0.10` > `0.9`, `0.10.0` > `0.9.0`) for remotes and databases; they used to compare as floats or text
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
- - Database queries go through a `DatabaseBackend` interface (`DuckDBBackend`, `PostgresBackend`); the SQL builder takes the backend's bound-value marker
314
- - Startup checks also cover PostgreSQL connections, every route's engine, `remotes/config.yaml` and lookup-generated config
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)
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.9.1 # the NEW version, no leading "v"
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='PostgreSQL, lookups, remotes/config.yaml, numeric latest'
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
- - PostgreSQL tables (`pip install 'api_dock[postgres]'`): named `connections` in `databases/config.yaml` and `{connection, table}` tables. A route runs natively on PostgreSQL when all its tables are on one connection (one pool per connection, read-only transactions, statement timeout), otherwise on DuckDB with PostgreSQL attached read-only, so unions and joins can mix PostgreSQL and Parquet. Route `engine: postgres|duckdb` checks or forces the choice; 503 when a database is unavailable
264
- - Lookups: named queries (SQL over any configured table, or an HTTP API) run at startup, every `refresh` and on demand. `from:` entries turn rows into database/versions (with inline schemas) or remote versions, `{{lookup.<name>.<column>}}` fills single values, and `databases: [- from: <lookup>]` serves what they generate. New rows are checked before use; values used as URIs must match `allow:` prefixes. Optional token-protected refresh endpoint (`settings.lookups`) and `api-dock lookups` CLI
265
- - `remotes/config.yaml`: define remotes inline (`version`, `versions` or unversioned); mixes with remote files, files win
266
- - Slugs can define their schema inline: `schema: {name, tables}`
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
- - `latest` and version lists compare versions numerically (`0.10` > `0.9`, `0.10.0` > `0.9.0`) for remotes and databases; they used to compare as floats or text
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
- - Database queries go through a `DatabaseBackend` interface (`DuckDBBackend`, `PostgresBackend`); the SQL builder takes the backend's bound-value marker
272
- - Startup checks also cover PostgreSQL connections, every route's engine, `remotes/config.yaml` and lookup-generated config
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)
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, abstractmethod
20
+ from abc import ABC
17
21
  from typing import Any, Dict, List, Optional, Tuple, Union
18
22
 
19
- from api_dock.encryption import create_encryption_provider, decrypt_value_if_needed, EncryptionError
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
- """Validate token against a set of valid tokens with string normalization.
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
- normalized_tokens = self._normalize_token_set(self.expected_values)
55
- return str(token) in normalized_tokens
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, encryption_config: Optional[Dict[str, Any]] = None, failed_response: Optional[Dict[str, Any]] = None):
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, encryption_config: Optional[Dict[str, Any]] = None, failed_response: Optional[Dict[str, Any]] = None):
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(value_str, value_encrypted, encryption_config)
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, encryption_config: Optional[Dict[str, Any]] = None, failed_response: Optional[Dict[str, Any]] = None):
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(f"Failed to decrypt value on line {line_num} in '{filepath}': {str(e)}")
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", cache_ttl: int = DEFAULT_CACHE_TTL, failed_response: Optional[Dict[str, Any]] = None):
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, aws_tokens_file: Optional[str] = None, aws_key_id: str = None, aws_region: str = "us-west-2", failed_response: Optional[Dict[str, Any]] = 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", cache_ttl: int = DEFAULT_CACHE_TTL, failed_response: Optional[Dict[str, Any]] = None):
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("google-cloud-secret-manager package is required for GCP Secrets authentication")
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(f"Failed to retrieve GCP secret '{self.secret_name}': {str(e)}")
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
- # Identify authentication method keys
471
- method_keys = []
472
- if "value" in config:
473
- method_keys.append("value")
474
- if "values" in config:
475
- method_keys.append("values")
476
- if "filepath" in config:
477
- method_keys.append("filepath")
478
- if "aws_secret_name" in config:
479
- method_keys.append("aws_secret_name")
480
- if "aws_key_id" in config:
481
- method_keys.append("aws_key_id")
482
- if "aws_tokens_file" in config:
483
- method_keys.append("aws_tokens_file")
484
- if "gcp_project_id" in config:
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 == "aws_key_id":
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("AWS KMS authentication requires both 'aws_key_id' and 'aws_tokens' list")
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, failed_response=failed_response)
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, aws_region=region, failed_response=failed_response)
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("GCP Secrets authentication requires both 'gcp_project_id' and 'gcp_secret_name'")
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 validate_authentication(cookies: Dict[str, str], auth_config: Dict[str, Any]) -> Tuple[bool, Optional[int], Optional[Any]]:
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
- # Get the token from cookies
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)