nui-lambda-shared-utils 1.2.0__tar.gz → 1.2.2__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 (71) hide show
  1. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/CLAUDE.md +25 -7
  2. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/PKG-INFO +1 -1
  3. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/getting-started/configuration.md +79 -8
  4. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/__init__.py +3 -0
  5. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/base_client.py +83 -14
  6. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/db_client.py +59 -41
  7. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/es_client.py +26 -4
  8. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/jwt_auth.py +60 -1
  9. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/slack_client.py +24 -7
  10. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_base_client.py +137 -23
  11. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_db_client.py +110 -0
  12. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_es_client.py +64 -0
  13. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_jwt_auth.py +112 -0
  14. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_slack_client.py +65 -0
  15. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/.editorconfig +0 -0
  16. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/.github/workflows/ci.yml +0 -0
  17. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/.github/workflows/publish.yml +0 -0
  18. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/.github/workflows/test.yml +0 -0
  19. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/.markdownlint-cli2.yaml +0 -0
  20. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/CONTRIBUTING.md +0 -0
  21. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/LICENSE +0 -0
  22. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/MANIFEST.in +0 -0
  23. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/README.md +0 -0
  24. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/README.md +0 -0
  25. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/development/testing.md +0 -0
  26. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/getting-started/installation.md +0 -0
  27. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/getting-started/quickstart.md +0 -0
  28. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/cli-tools.md +0 -0
  29. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/elasticsearch-integration.md +0 -0
  30. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/jwt-authentication.md +0 -0
  31. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/lambda-utilities.md +0 -0
  32. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/log-processing.md +0 -0
  33. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/powertools-integration.md +0 -0
  34. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/shared-types.md +0 -0
  35. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/docs/guides/slack-integration.md +0 -0
  36. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/mypy.ini +0 -0
  37. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/cli.py +0 -0
  38. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/cloudwatch_metrics.py +0 -0
  39. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/config.py +0 -0
  40. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/error_handler.py +0 -0
  41. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/es_query_builder.py +0 -0
  42. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/lambda_helpers.py +0 -0
  43. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/log_processors.py +0 -0
  44. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/powertools_helpers.py +0 -0
  45. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/secrets_helper.py +0 -0
  46. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/slack_formatter.py +0 -0
  47. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/slack_setup/__init__.py +0 -0
  48. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/slack_setup/channel_creator.py +0 -0
  49. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/slack_setup/channel_definitions.py +0 -0
  50. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/slack_setup/setup_helpers.py +0 -0
  51. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/timezone.py +0 -0
  52. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils/utils.py +0 -0
  53. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/nui_lambda_shared_utils.egg-info/SOURCES.txt +0 -0
  54. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/pyproject.toml +0 -0
  55. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/pytest.ini +0 -0
  56. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/requirements-test.txt +0 -0
  57. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/setup.cfg +0 -0
  58. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/setup.py +0 -0
  59. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/__init__.py +0 -0
  60. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_aws_utils.py +0 -0
  61. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_cloudwatch_metrics.py +0 -0
  62. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_config.py +0 -0
  63. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_error_handler.py +0 -0
  64. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_es_query_builder.py +0 -0
  65. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_lambda_helpers.py +0 -0
  66. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_log_processors.py +0 -0
  67. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_powertools_helpers.py +0 -0
  68. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_secrets_helper.py +0 -0
  69. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_slack_formatter.py +0 -0
  70. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_timezone.py +0 -0
  71. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.2}/tests/test_utils.py +0 -0
@@ -182,16 +182,33 @@ python -m twine upload dist/*
182
182
 
183
183
  ## Configuration Patterns
184
184
 
185
+ ### Credential Resolution
186
+
187
+ Clients resolve credentials in this order (first match wins):
188
+
189
+ 1. **Explicit `credentials` dict** — constructor param, skips everything else
190
+ 2. **Environment variables** — per-client patterns, skips Secrets Manager
191
+ 3. **AWS Secrets Manager** — default behavior
192
+
185
193
  ### Environment Variables
186
194
 
187
- The package expects these environment variables for runtime configuration:
195
+ Runtime configuration:
188
196
 
189
197
  - `ES_HOST` - Elasticsearch endpoint
190
198
  - `ES_CREDENTIALS_SECRET` - AWS secret name for ES credentials
191
- - `DB_CREDENTIALS_SECRET` - AWS secret name for database credentials
199
+ - `DB_CREDENTIALS_SECRET` - AWS secret name for database credentials
192
200
  - `SLACK_CREDENTIALS_SECRET` - AWS secret name for Slack token
193
201
  - `AWS_REGION` - AWS region for services
194
202
 
203
+ Direct credential env vars (bypass Secrets Manager when set):
204
+
205
+ | Client | Required | Optional |
206
+ |--------|----------|----------|
207
+ | Slack | `SLACK_BOT_TOKEN` | `SLACK_WEBHOOK_URL` |
208
+ | Elasticsearch | `ES_PASSWORD` | `ES_USERNAME` (default: `elastic`) |
209
+ | Database (MySQL) | `DB_HOST` + `DB_PASSWORD` | `DB_PORT` (3306), `DB_USERNAME` (root), `DB_DATABASE` (app) |
210
+ | Database (PostgreSQL) | `DB_HOST` + `DB_PASSWORD` | `DB_PORT` (5432), `DB_USERNAME` (postgres), `DB_DATABASE` (postgres) |
211
+
195
212
  ### AWS Secrets Format
196
213
 
197
214
  Secrets should follow standardized JSON structures:
@@ -212,15 +229,16 @@ Secrets should follow standardized JSON structures:
212
229
  ```python
213
230
  import nui_lambda_shared_utils as nui
214
231
 
215
- # Configure specific settings
232
+ # Pass credentials directly (no Secrets Manager needed)
233
+ slack = nui.SlackClient(credentials={"bot_token": "xoxb-..."})
234
+ es = nui.ElasticsearchClient(credentials={"username": "elastic", "password": "pass"})
235
+ db = nui.DatabaseClient(credentials={"host": "localhost", "port": 3306, "username": "root", "password": "pass", "database": "app"})
236
+
237
+ # Or configure globally
216
238
  nui.configure(
217
239
  es_host="localhost:9200",
218
240
  slack_credentials_secret="dev/slack-token"
219
241
  )
220
-
221
- # Or use Config object
222
- config = nui.Config(es_host="prod:9200")
223
- nui.set_config(config)
224
242
  ```
225
243
 
226
244
  ## Common Usage Patterns
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-lambda-shared-utils
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Enterprise-grade utilities for AWS Lambda functions with Slack, Elasticsearch, and monitoring integrations
5
5
  Home-page: https://github.com/nuimarkets/nui-lambda-shared-utils
6
6
  Author: NUI Markets
@@ -10,6 +10,14 @@ The package uses a hierarchical configuration system with the following priority
10
10
  2. **Environment Variables** - System environment variables
11
11
  3. **Configuration Defaults** - Package default values
12
12
 
13
+ ### Credential Resolution Precedence
14
+
15
+ All clients (Slack, Elasticsearch, Database) resolve credentials in this order:
16
+
17
+ 1. **Explicit `credentials` dict** - Passed to constructor, skips everything else
18
+ 2. **Credential environment variables** - Per-client env vars, skips Secrets Manager
19
+ 3. **AWS Secrets Manager** - Default behavior (unchanged)
20
+
13
21
  ## Environment Variables
14
22
 
15
23
  ### Core Configuration
@@ -34,19 +42,60 @@ The package supports alternative environment variable names for compatibility:
34
42
  | `DB_CREDENTIALS_SECRET` | `DATABASE_CREDENTIALS_SECRET` |
35
43
  | `AWS_REGION` | `AWS_DEFAULT_REGION` |
36
44
 
45
+ ### Credential Environment Variables
46
+
47
+ These environment variables provide credentials directly, bypassing AWS Secrets Manager:
48
+
49
+ #### Slack
50
+
51
+ | Variable | Required | Default |
52
+ |----------|----------|---------|
53
+ | `SLACK_BOT_TOKEN` | Yes (triggers env var path) | - |
54
+ | `SLACK_WEBHOOK_URL` | No | - |
55
+
56
+ #### Elasticsearch
57
+
58
+ | Variable | Required | Default |
59
+ |----------|----------|---------|
60
+ | `ES_PASSWORD` | Yes (triggers env var path) | - |
61
+ | `ES_USERNAME` | No | `elastic` |
62
+
63
+ #### Database (MySQL)
64
+
65
+ | Variable | Required | Default |
66
+ |----------|----------|---------|
67
+ | `DB_HOST` | Yes (both required) | - |
68
+ | `DB_PASSWORD` | Yes (both required) | - |
69
+ | `DB_PORT` | No | `3306` |
70
+ | `DB_USERNAME` | No | `root` |
71
+ | `DB_DATABASE` | No | `app` |
72
+
73
+ #### Database (PostgreSQL)
74
+
75
+ | Variable | Required | Default |
76
+ |----------|----------|---------|
77
+ | `DB_HOST` | Yes (both required) | - |
78
+ | `DB_PASSWORD` | Yes (both required) | - |
79
+ | `DB_PORT` | No | `5432` |
80
+ | `DB_USERNAME` | No | `postgres` |
81
+ | `DB_DATABASE` | No | `postgres` |
82
+
37
83
  ### Example Environment Setup
38
84
 
39
85
  ```bash
40
- # Basic configuration
86
+ # Secrets Manager configuration (default)
41
87
  export ES_HOST="prod-elasticsearch:9200"
42
88
  export ES_CREDENTIALS_SECRET="prod/elasticsearch-creds"
43
89
  export DB_CREDENTIALS_SECRET="prod/database-creds"
44
90
  export SLACK_CREDENTIALS_SECRET="prod/slack-token"
45
91
  export AWS_REGION="us-west-2"
46
92
 
47
- # Alternative names (equivalent)
48
- export ELASTICSEARCH_HOST="prod-elasticsearch:9200"
49
- export DATABASE_CREDENTIALS_SECRET="prod/database-creds"
93
+ # Direct credentials (bypass Secrets Manager)
94
+ export SLACK_BOT_TOKEN="xoxb-your-token"
95
+ export ES_PASSWORD="your-password"
96
+ export DB_HOST="localhost"
97
+ export DB_PASSWORD="your-password"
98
+ export DB_DATABASE="mydb"
50
99
  ```
51
100
 
52
101
  ## Programmatic Configuration
@@ -186,9 +235,21 @@ aws secretsmanager create-secret \
186
235
  4. Enter the JSON values as key-value pairs
187
236
  5. Name the secret according to your configuration
188
237
 
189
- ### Secret Access Patterns
238
+ ### Credential Access Patterns
190
239
 
191
- #### Automatic Credential Loading
240
+ #### Direct Credentials (No AWS Required)
241
+
242
+ ```python
243
+ # Pass credentials directly — Secrets Manager is never called
244
+ slack = nui.SlackClient(credentials={"bot_token": "xoxb-your-token"})
245
+ es = nui.ElasticsearchClient(credentials={"username": "elastic", "password": "secret"})
246
+ db = nui.DatabaseClient(credentials={
247
+ "host": "localhost", "port": 3306,
248
+ "username": "root", "password": "secret", "database": "mydb"
249
+ })
250
+ ```
251
+
252
+ #### Automatic Credential Loading (Secrets Manager)
192
253
 
193
254
  ```python
194
255
  # Credentials are automatically loaded based on configuration
@@ -197,7 +258,7 @@ db = nui.DatabaseClient() # Uses DB_CREDENTIALS_SECRET
197
258
  slack = nui.SlackClient() # Uses SLACK_CREDENTIALS_SECRET
198
259
  ```
199
260
 
200
- #### Override Credential Sources
261
+ #### Override Secret Names
201
262
 
202
263
  ```python
203
264
  # Override default secret names
@@ -223,7 +284,17 @@ api_keys = nui.get_secret("my-service/api-keys")
223
284
  ### Development Environment
224
285
 
225
286
  ```python
226
- # Development configuration
287
+ # Option 1: Direct credentials (no AWS needed)
288
+ slack = nui.SlackClient(credentials={"bot_token": "xoxb-dev-token"})
289
+ db = nui.DatabaseClient(credentials={
290
+ "host": "localhost", "port": 3306,
291
+ "username": "root", "password": "dev", "database": "app_dev"
292
+ })
293
+
294
+ # Option 2: Environment variables (set SLACK_BOT_TOKEN, DB_HOST, DB_PASSWORD, etc.)
295
+ slack = nui.SlackClient() # picks up SLACK_BOT_TOKEN from env
296
+
297
+ # Option 3: Secrets Manager
227
298
  nui.configure(
228
299
  es_host="localhost:9200",
229
300
  es_credentials_secret="dev/elasticsearch",
@@ -137,6 +137,7 @@ try:
137
137
  from .jwt_auth import (
138
138
  validate_jwt,
139
139
  require_auth,
140
+ check_auth,
140
141
  get_jwt_public_key,
141
142
  JWTValidationError,
142
143
  AuthenticationError,
@@ -144,6 +145,7 @@ try:
144
145
  except ImportError:
145
146
  validate_jwt = None # type: ignore
146
147
  require_auth = None # type: ignore
148
+ check_auth = None # type: ignore
147
149
  get_jwt_public_key = None # type: ignore
148
150
  JWTValidationError = None # type: ignore
149
151
  AuthenticationError = None # type: ignore
@@ -243,6 +245,7 @@ __all__ = [
243
245
  # JWT authentication
244
246
  "validate_jwt",
245
247
  "require_auth",
248
+ "check_auth",
246
249
  "get_jwt_public_key",
247
250
  "JWTValidationError",
248
251
  "AuthenticationError",
@@ -23,25 +23,32 @@ class BaseClient(ABC):
23
23
  """
24
24
 
25
25
  def __init__(
26
- self,
26
+ self,
27
27
  secret_name: Optional[str] = None,
28
28
  config_key_prefix: Optional[str] = None,
29
+ credentials: Optional[Dict[str, Any]] = None,
29
30
  **kwargs
30
31
  ):
31
32
  """
32
33
  Initialize base client with standardized credential and config resolution.
33
-
34
+
35
+ Credential resolution precedence:
36
+ 1. Explicit ``credentials`` dict — skip everything else
37
+ 2. Environment variables (per-client patterns) — skip Secrets Manager
38
+ 3. Secrets Manager (existing behavior) — unchanged
39
+
34
40
  Args:
35
41
  secret_name: Override secret name for credentials
36
42
  config_key_prefix: Prefix for config keys (e.g., 'slack', 'es', 'db')
43
+ credentials: Direct credentials dict, bypasses Secrets Manager entirely
37
44
  **kwargs: Additional client-specific parameters
38
45
  """
39
46
  self.config = get_config()
40
47
  self.config_key_prefix = config_key_prefix or self._get_default_config_prefix()
41
-
48
+
42
49
  # Resolve and store credentials
43
- self.credentials = self._resolve_credentials(secret_name)
44
-
50
+ self.credentials = self._resolve_credentials(secret_name, credentials)
51
+
45
52
  # Store additional configuration
46
53
  self.client_config = kwargs
47
54
 
@@ -72,18 +79,66 @@ class BaseClient(ABC):
72
79
  """Return the default secret name for this client type."""
73
80
  pass
74
81
 
75
- def _resolve_credentials(self, secret_name: Optional[str]) -> Dict[str, Any]:
82
+ def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
83
+ """
84
+ Resolve credentials from environment variables.
85
+
86
+ Subclasses override this to check client-specific env vars
87
+ (e.g. SLACK_BOT_TOKEN, ES_PASSWORD). Return a credentials dict
88
+ if the required env vars are present, or ``None`` to fall through
89
+ to Secrets Manager.
90
+
91
+ Returns:
92
+ Credentials dict or None
93
+ """
94
+ return None
95
+
96
+ def _resolve_credentials(
97
+ self,
98
+ secret_name: Optional[str],
99
+ explicit_credentials: Optional[Dict[str, Any]] = None,
100
+ ) -> Dict[str, Any]:
76
101
  """
77
102
  Resolve credentials using standardized precedence.
78
-
103
+
104
+ Precedence:
105
+ 1. Explicit credentials dict (constructor param)
106
+ 2. Environment variables (per-client ``_resolve_credentials_from_env``)
107
+ 3. AWS Secrets Manager (via ``_fetch_credentials_from_sm``)
108
+
79
109
  Args:
80
110
  secret_name: Optional override for secret name
81
-
111
+ explicit_credentials: Direct credentials dict from constructor
112
+
82
113
  Returns:
83
114
  Dictionary containing resolved credentials
84
115
  """
85
- # Determine secret name with precedence
86
- resolved_secret_name = resolve_config_value(
116
+ # 1. Explicit credentials dict — skip everything
117
+ if explicit_credentials is not None:
118
+ log.debug("Using explicitly provided credentials")
119
+ return explicit_credentials
120
+
121
+ # 2. Environment variables — skip Secrets Manager
122
+ env_credentials = self._resolve_credentials_from_env()
123
+ if env_credentials is not None:
124
+ log.debug("Using credentials from environment variables")
125
+ return env_credentials
126
+
127
+ # 3. Secrets Manager
128
+ return self._fetch_credentials_from_sm(secret_name)
129
+
130
+ def _resolve_secret_name(self, secret_name: Optional[str]) -> str:
131
+ """
132
+ Resolve the Secrets Manager secret name using standard precedence:
133
+ explicit param > env var > config default.
134
+
135
+ Args:
136
+ secret_name: Optional explicit override
137
+
138
+ Returns:
139
+ Resolved secret name string
140
+ """
141
+ resolved = resolve_config_value(
87
142
  secret_name,
88
143
  [
89
144
  f"{self.config_key_prefix.upper()}_CREDENTIALS_SECRET",
@@ -91,10 +146,24 @@ class BaseClient(ABC):
91
146
  ],
92
147
  getattr(self.config, f"{self.config_key_prefix}_credentials_secret", self._get_default_secret_name())
93
148
  )
94
-
95
- validate_required_param(resolved_secret_name, "secret_name")
96
-
97
- # Retrieve and return credentials
149
+ validate_required_param(resolved, "secret_name")
150
+ return resolved
151
+
152
+ def _fetch_credentials_from_sm(self, secret_name: Optional[str]) -> Dict[str, Any]:
153
+ """
154
+ Fetch credentials from AWS Secrets Manager.
155
+
156
+ Subclasses override this to customize the SM fetch (e.g. DB clients
157
+ use ``get_database_credentials`` for field normalization).
158
+
159
+ Args:
160
+ secret_name: Optional override for secret name
161
+
162
+ Returns:
163
+ Dictionary containing resolved credentials
164
+ """
165
+ resolved_secret_name = self._resolve_secret_name(secret_name)
166
+
98
167
  try:
99
168
  credentials = get_secret(resolved_secret_name)
100
169
  log.debug(f"Retrieved credentials from secret: {resolved_secret_name}")
@@ -2,6 +2,7 @@
2
2
  Refactored Database client using BaseClient for DRY code patterns.
3
3
  """
4
4
 
5
+ import os
5
6
  import time
6
7
  import logging
7
8
  from typing import Dict, List, Optional, Any
@@ -120,23 +121,26 @@ class DatabaseClient(BaseClient, ServiceHealthMixin):
120
121
  use_pool: bool = True,
121
122
  pool_size: int = 5,
122
123
  pool_recycle: int = 3600,
124
+ credentials: Optional[Dict[str, Any]] = None,
123
125
  **kwargs
124
126
  ):
125
127
  """
126
128
  Initialize database client.
127
-
129
+
128
130
  Args:
129
131
  secret_name: Override secret name
130
132
  use_pool: Enable connection pooling
131
133
  pool_size: Maximum pooled connections
132
134
  pool_recycle: Recycle connections after seconds
135
+ credentials: Direct credentials dict (keys: host, port, username, password, database),
136
+ bypasses Secrets Manager
133
137
  **kwargs: Additional configuration
134
138
  """
135
139
  self.use_pool = use_pool
136
140
  self.pool_size = pool_size
137
141
  self.pool_recycle = pool_recycle
138
-
139
- super().__init__(secret_name=secret_name, **kwargs)
142
+
143
+ super().__init__(secret_name=secret_name, credentials=credentials, **kwargs)
140
144
 
141
145
  # Build pool key for connection management
142
146
  self._pool_key = f"{self.credentials['host']}:{self.credentials['port']}"
@@ -153,32 +157,31 @@ class DatabaseClient(BaseClient, ServiceHealthMixin):
153
157
  """Database client doesn't have a single service client - uses connections."""
154
158
  return None
155
159
 
156
- def _resolve_credentials(self, secret_name: Optional[str]) -> Dict[str, Any]:
157
- """
158
- Override to use get_database_credentials for normalized field names.
159
-
160
- Args:
161
- secret_name: Optional secret name override
160
+ def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
161
+ """Resolve MySQL credentials from environment variables.
162
162
 
163
- Returns:
164
- Normalized database credentials
163
+ Requires both DB_HOST and DB_PASSWORD to trigger.
165
164
  """
166
- # Use BaseClient's resolution logic to determine the secret name
167
- from .utils import resolve_config_value, validate_required_param
168
-
169
- resolved_secret_name = resolve_config_value(
170
- secret_name,
171
- [
172
- f"{self.config_key_prefix.upper()}_CREDENTIALS_SECRET",
173
- f"{self.config_key_prefix.upper()}CREDENTIALS_SECRET" # Alternative format
174
- ],
175
- getattr(self.config, f"{self.config_key_prefix}_credentials_secret", self._get_default_secret_name())
176
- )
177
-
178
- validate_required_param(resolved_secret_name, "secret_name")
165
+ host = os.environ.get("DB_HOST")
166
+ password = os.environ.get("DB_PASSWORD")
167
+ if not host or not password:
168
+ return None
169
+ port_str = os.environ.get("DB_PORT", "3306")
170
+ try:
171
+ port = int(port_str)
172
+ except ValueError:
173
+ raise ValueError(f"DB_PORT must be an integer, got: {port_str!r}")
174
+ return {
175
+ "host": host,
176
+ "port": port,
177
+ "username": os.environ.get("DB_USERNAME", "root"),
178
+ "password": password,
179
+ "database": os.environ.get("DB_DATABASE", "app"),
180
+ }
179
181
 
180
- # Now use the resolved secret name with get_database_credentials
181
- return get_database_credentials(resolved_secret_name)
182
+ def _fetch_credentials_from_sm(self, secret_name: Optional[str]) -> Dict[str, Any]:
183
+ """Override to use get_database_credentials for normalized field names."""
184
+ return get_database_credentials(self._resolve_secret_name(secret_name))
182
185
 
183
186
  def _clean_expired_connections(self, pool_key: str) -> None:
184
187
  """
@@ -476,21 +479,24 @@ class PostgreSQLClient(BaseClient, ServiceHealthMixin):
476
479
  self,
477
480
  secret_name: Optional[str] = None,
478
481
  use_auth_credentials: bool = True,
482
+ credentials: Optional[Dict[str, Any]] = None,
479
483
  **kwargs
480
484
  ):
481
485
  """
482
486
  Initialize PostgreSQL client.
483
-
487
+
484
488
  Args:
485
489
  secret_name: Override secret name
486
490
  use_auth_credentials: Use auth-specific credentials
491
+ credentials: Direct credentials dict (keys: host, port, username, password, database),
492
+ bypasses Secrets Manager
487
493
  **kwargs: Additional configuration
488
494
  """
489
495
  if not HAS_POSTGRESQL:
490
496
  raise ImportError("psycopg2 is not installed. Install with: pip install psycopg2-binary")
491
497
 
492
498
  self.use_auth_credentials = use_auth_credentials
493
- super().__init__(secret_name=secret_name, **kwargs)
499
+ super().__init__(secret_name=secret_name, credentials=credentials, **kwargs)
494
500
 
495
501
  def _get_default_config_prefix(self) -> str:
496
502
  """Return configuration prefix for PostgreSQL."""
@@ -504,20 +510,33 @@ class PostgreSQLClient(BaseClient, ServiceHealthMixin):
504
510
  """PostgreSQL client doesn't have a single service client - uses connections."""
505
511
  return None
506
512
 
507
- def _resolve_credentials(self, secret_name: Optional[str]) -> Dict[str, Any]:
508
- """
509
- Resolve PostgreSQL credentials with auth-specific handling.
510
-
511
- Args:
512
- secret_name: Optional secret name override
513
-
514
- Returns:
515
- PostgreSQL credentials
513
+ def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
514
+ """Resolve PostgreSQL credentials from environment variables.
515
+
516
+ Requires both DB_HOST and DB_PASSWORD to trigger.
516
517
  """
518
+ host = os.environ.get("DB_HOST")
519
+ password = os.environ.get("DB_PASSWORD")
520
+ if not host or not password:
521
+ return None
522
+ port_str = os.environ.get("DB_PORT", "5432")
523
+ try:
524
+ port = int(port_str)
525
+ except ValueError:
526
+ raise ValueError(f"DB_PORT must be an integer, got: {port_str!r}")
527
+ return {
528
+ "host": host,
529
+ "port": port,
530
+ "username": os.environ.get("DB_USERNAME", "postgres"),
531
+ "password": password,
532
+ "database": os.environ.get("DB_DATABASE", "postgres"),
533
+ }
534
+
535
+ def _fetch_credentials_from_sm(self, secret_name: Optional[str]) -> Dict[str, Any]:
536
+ """Override with auth-specific credential handling."""
517
537
  from .secrets_helper import get_secret
518
-
519
- # Get raw secret to access auth-specific fields
520
- resolved_secret_name = secret_name or self._get_default_secret_name()
538
+
539
+ resolved_secret_name = self._resolve_secret_name(secret_name)
521
540
  raw_creds = get_secret(resolved_secret_name)
522
541
 
523
542
  # Use auth-specific credentials if available and requested
@@ -530,7 +549,6 @@ class PostgreSQLClient(BaseClient, ServiceHealthMixin):
530
549
  "database": raw_creds.get("auth_database", "auth-service-db"),
531
550
  }
532
551
  else:
533
- # Fall back to normalized credentials
534
552
  return get_database_credentials(resolved_secret_name)
535
553
 
536
554
  @contextmanager
@@ -2,6 +2,7 @@
2
2
  Refactored Elasticsearch client using BaseClient for DRY code patterns.
3
3
  """
4
4
 
5
+ import os
5
6
  import logging
6
7
  from datetime import datetime, timedelta
7
8
  from typing import Dict, Iterator, List, Optional, Any, Tuple
@@ -20,18 +21,25 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
20
21
  Refactored Elasticsearch client with standardized patterns.
21
22
  """
22
23
 
23
- def __init__(self, host: Optional[str] = None, secret_name: Optional[str] = None, **kwargs):
24
+ def __init__(
25
+ self,
26
+ host: Optional[str] = None,
27
+ secret_name: Optional[str] = None,
28
+ credentials: Optional[Dict[str, Any]] = None,
29
+ **kwargs,
30
+ ):
24
31
  """
25
32
  Initialize Elasticsearch client.
26
-
33
+
27
34
  Args:
28
35
  host: Override ES host
29
36
  secret_name: Override secret name
37
+ credentials: Direct credentials dict (keys: username, password), bypasses Secrets Manager
30
38
  **kwargs: Additional ES client configuration
31
39
  """
32
40
  # Store host for later use in service client creation
33
41
  self._host_override = host
34
- super().__init__(secret_name=secret_name, **kwargs)
42
+ super().__init__(secret_name=secret_name, credentials=credentials, **kwargs)
35
43
 
36
44
  def _get_default_config_prefix(self) -> str:
37
45
  """Return configuration prefix for Elasticsearch."""
@@ -41,6 +49,20 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
41
49
  """Return default secret name for ES credentials."""
42
50
  return "elasticsearch-credentials"
43
51
 
52
+ def _resolve_credentials_from_env(self) -> Optional[Dict[str, Any]]:
53
+ """Resolve Elasticsearch credentials from environment variables.
54
+
55
+ Checks for ES_PASSWORD (required to trigger).
56
+ ES_USERNAME defaults to "elastic" if not set.
57
+ """
58
+ password = os.environ.get("ES_PASSWORD")
59
+ if not password:
60
+ return None
61
+ return {
62
+ "username": os.environ.get("ES_USERNAME", "elastic"),
63
+ "password": password,
64
+ }
65
+
44
66
  def _create_service_client(self) -> Elasticsearch:
45
67
  """Create Elasticsearch client with resolved configuration."""
46
68
  # Resolve host using utility
@@ -132,7 +154,7 @@ class ElasticsearchClient(BaseClient, ServiceHealthMixin):
132
154
  )
133
155
 
134
156
  @handle_client_errors(default_return=0)
135
- def count(self, index: str, body: Optional[Dict] = None) -> int:
157
+ def count(self, index: str, body: Optional[Dict[str, Any]] = None) -> int:
136
158
  """
137
159
  Count documents with error handling.
138
160
 
@@ -9,10 +9,12 @@ Install: pip install nui-lambda-shared-utils[jwt]
9
9
 
10
10
  import os
11
11
  import json
12
+ import re
12
13
  import base64
13
14
  import time
14
15
  import logging
15
- from typing import TYPE_CHECKING, Optional
16
+ from typing import TYPE_CHECKING, AbstractSet, Any, Dict, Optional, Tuple
17
+ from urllib.parse import unquote
16
18
 
17
19
  from .secrets_helper import get_secret
18
20
 
@@ -216,3 +218,60 @@ def require_auth(event: dict, secret_name: Optional[str] = None) -> dict:
216
218
  return validate_jwt(token, public_key)
217
219
  except JWTValidationError as e:
218
220
  raise AuthenticationError(f"Authentication failed: {e}") from e
221
+
222
+
223
+ def _normalize_path(path: str) -> str:
224
+ """Normalize a URL path for safe comparison.
225
+
226
+ URL-decodes, collapses duplicate slashes, ensures a single leading slash,
227
+ and strips any trailing slash (except for root "/").
228
+ """
229
+ path = unquote(path)
230
+ path = re.sub(r"/+", "/", path)
231
+ return "/" + path.strip("/") if path.strip("/") else "/"
232
+
233
+
234
+ def check_auth(
235
+ event: dict,
236
+ public_paths: AbstractSet[str] = frozenset(),
237
+ secret_name: Optional[str] = None,
238
+ ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
239
+ """Check JWT authentication on an API Gateway event, skipping public paths.
240
+
241
+ Combines path normalization, public-path bypass, JWT validation,
242
+ and a standard JSON:API 401 error response in one call.
243
+
244
+ Args:
245
+ event: API Gateway Lambda proxy integration event.
246
+ public_paths: Set of normalized paths that skip auth (e.g. {"/health"}).
247
+ secret_name: Optional Secrets Manager secret name for the public key.
248
+
249
+ Returns:
250
+ (claims, None) on success — claims is the decoded JWT dict,
251
+ or None if the path is public.
252
+ (None, response) on auth failure — response is a 401 dict
253
+ ready to return from your Lambda handler.
254
+ """
255
+ raw_path = event.get("path") or event.get("rawPath") or ""
256
+ if _normalize_path(raw_path) in public_paths:
257
+ return None, None
258
+
259
+ try:
260
+ claims = require_auth(event, secret_name=secret_name)
261
+ return claims, None
262
+ except AuthenticationError as e:
263
+ log.warning("Authentication failed: %s", e)
264
+ return None, {
265
+ "statusCode": 401,
266
+ "headers": {
267
+ "Content-Type": "application/json",
268
+ "Access-Control-Allow-Origin": "*",
269
+ },
270
+ "body": json.dumps({
271
+ "errors": [{
272
+ "status": "401",
273
+ "title": "Unauthorized",
274
+ "detail": "Authentication required",
275
+ }]
276
+ }),
277
+ }