api-foundry-query-engine 0.8.74__tar.gz → 0.8.77__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 (34) hide show
  1. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/PKG-INFO +1 -1
  2. api_foundry_query_engine-0.8.77/api_foundry_query_engine/__init__.py +1 -0
  3. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/connectors/connection_factory.py +20 -10
  4. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_query_handler.py +18 -1
  5. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/operation.py +38 -2
  6. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/token_decoder.py +20 -67
  7. api_foundry_query_engine-0.8.74/api_foundry_query_engine/__init__.py +0 -1
  8. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/.gitignore +0 -0
  9. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/LICENSE +0 -0
  10. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/.pre-commit-config.yaml +0 -0
  11. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/adapters/adapter.py +0 -0
  12. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/adapters/gateway_adapter.py +0 -0
  13. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/connectors/connection.py +0 -0
  14. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/connectors/postgres_connection.py +0 -0
  15. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/batch_operation_handler.py +0 -0
  16. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/dao.py +0 -0
  17. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/operation_dao.py +0 -0
  18. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_custom_query_handler.py +0 -0
  19. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_delete_query_handler.py +0 -0
  20. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_insert_query_handler.py +0 -0
  21. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_restore_query_handler.py +0 -0
  22. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_select_query_handler.py +0 -0
  23. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_subselect_query_handler.py +0 -0
  24. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/dao/sql_update_query_handler.py +0 -0
  25. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/lambda_handler.py +0 -0
  26. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/services/service.py +0 -0
  27. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/services/transactional_service.py +0 -0
  28. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/api_model.py +0 -0
  29. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/app_exception.py +0 -0
  30. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/claims_check.py +0 -0
  31. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/dependency_resolver.py +0 -0
  32. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/logger.py +0 -0
  33. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/api_foundry_query_engine/utils/reference_resolver.py +0 -0
  34. {api_foundry_query_engine-0.8.74 → api_foundry_query_engine-0.8.77}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-foundry-query-engine
3
- Version: 0.8.74
3
+ Version: 0.8.77
4
4
  Summary: The AWS lambda service handler use by the `api_foundry` project is a powerful tool designed to automate the deployment of REST APIs on AWS using Lambda services to access and interact with relational databases (RDBMS). This project leverages the OpenAPI specification to define and manage the APIs
5
5
  Project-URL: Documentation, https://github.com/DanRepik/api-foundry
6
6
  Project-URL: Source, https://github.com/DanRepik/api-foundry
@@ -0,0 +1 @@
1
+ __version__ = "0.8.77"
@@ -35,17 +35,27 @@ class ConnectionFactory:
35
35
  log.info("database: %s", database)
36
36
  db_config = self.db_config_map.get(database)
37
37
  if not db_config:
38
- # Use config dict for secrets
39
- secrets_map = self.config.get("SECRETS", {})
40
- if isinstance(secrets_map, str):
41
- secrets_map = json.loads(secrets_map)
42
- secret_name = secrets_map.get(database)
43
- log.debug("secret_name: %s", secret_name)
44
-
45
- if secret_name:
46
- db_config = self.__get_secret(secret_name)
38
+ # A <DATABASE>_DSN config value (e.g. CONTRACT_DB_DSN) skips
39
+ # Secrets Manager entirely. This matters for a Lambda that's
40
+ # VPC-attached for private database access but has no route
41
+ # to the Secrets Manager API (no NAT gateway, no interface
42
+ # endpoint) -- the SECRETS/get_secret_value path would just
43
+ # hang. PostgresConnection already accepts a raw "dsn" key.
44
+ dsn = self.config.get(f"{database.upper()}_DSN")
45
+ if dsn:
46
+ db_config = {"engine": self.config.get(f"{database.upper()}_ENGINE", "postgres"), "dsn": dsn}
47
47
  else:
48
- raise ValueError(f"Secret not found for database: {database}")
48
+ # Use config dict for secrets
49
+ secrets_map = self.config.get("SECRETS", {})
50
+ if isinstance(secrets_map, str):
51
+ secrets_map = json.loads(secrets_map)
52
+ secret_name = secrets_map.get(database)
53
+ log.debug("secret_name: %s", secret_name)
54
+
55
+ if secret_name:
56
+ db_config = self.__get_secret(secret_name)
57
+ else:
58
+ raise ValueError(f"Secret not found for database: {database}")
49
59
 
50
60
  engine = db_config.get("engine")
51
61
  if not engine:
@@ -208,7 +208,24 @@ class SQLQueryHandler:
208
208
  action_permissions,
209
209
  )
210
210
 
211
+ # A `public` rule means "world-readable regardless of who's
212
+ # calling" -- apply it unconditionally rather than only when the
213
+ # caller's own role list happens to literally contain the string
214
+ # "public" (which previously only happened by accident, via the
215
+ # anonymous-role fallback for token-less requests; an
216
+ # authenticated caller hitting a nominally-public resource would
217
+ # otherwise be filtered down to zero properties).
218
+ public_permissions = action_permissions.get("public", {})
219
+ if public_permissions:
220
+ regex_pattern = self._extract_permission_pattern(public_permissions)
221
+ if regex_pattern:
222
+ public_allowed = self._filter_properties_by_regex(properties, regex_pattern)
223
+ log.info("public rule, pattern: %s, matched: %s", regex_pattern, list(public_allowed.keys()))
224
+ allowed_properties.update(public_allowed)
225
+
211
226
  for role in self.operation.roles:
227
+ if role == "public":
228
+ continue # already applied unconditionally above
212
229
  role_permissions = action_permissions.get(role, {})
213
230
  log.info("role: %s, role_permissions: %s", role, role_permissions)
214
231
 
@@ -359,7 +376,7 @@ class SQLQueryHandler:
359
376
  ]
360
377
  sql = f"{column} {'NOT ' if operand == 'not-in' else ''}IN ({', '.join(assignments)})" # noqa E501
361
378
  elif operand in ["like", "not-like"]:
362
- sql = f"{column} {'NOT ' if operand == 'not-like' else ''}LIKE {self.placeholder(property, str(placeholder_name))}"
379
+ sql = f"{column} {'NOT ' if operand == 'not-like' else ''}LIKE {self.placeholder(property, str(placeholder_name))}" # noqa E501
363
380
  else:
364
381
  sql = f"{column} {operand} {self.placeholder(property, str(placeholder_name))}"
365
382
  return sql
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from typing import Any, Dict, List, Optional
2
3
 
3
4
  from api_foundry_query_engine.utils.logger import logger
@@ -95,8 +96,43 @@ class Operation:
95
96
 
96
97
  @property
97
98
  def roles(self) -> List[str]:
98
- """Get the roles from claims."""
99
- return self.claims.get("roles", []) if self.claims else []
99
+ """Get the roles from claims.
100
+
101
+ Falls back to a singular `role` claim plus `groups` when the
102
+ plural `roles` key itself isn't present -- some authorizers (e.g.
103
+ a custom Lambda TOKEN authorizer following AWS's own convention
104
+ of a `role` string and a `groups` list) never populate `roles`,
105
+ which previously made every such caller look role-less even with
106
+ a perfectly valid, correctly-scoped token. `groups` may arrive as
107
+ a real list or, when it passed through an API Gateway authorizer
108
+ context (whose values must be strings), a JSON-encoded string.
109
+ """
110
+ if not self.claims:
111
+ return []
112
+ roles = self.claims.get("roles")
113
+ if roles:
114
+ return list(roles) if isinstance(roles, list) else [str(roles)]
115
+
116
+ fallback: List[str] = []
117
+ role = self.claims.get("role")
118
+ if role:
119
+ fallback.append(str(role))
120
+
121
+ groups_raw = self.claims.get("groups")
122
+ groups: List[str] = []
123
+ if isinstance(groups_raw, str) and groups_raw.strip():
124
+ try:
125
+ parsed = json.loads(groups_raw)
126
+ groups = [str(item) for item in parsed] if isinstance(parsed, list) else [groups_raw]
127
+ except (TypeError, ValueError):
128
+ groups = [groups_raw]
129
+ elif isinstance(groups_raw, list):
130
+ groups = [str(item) for item in groups_raw]
131
+
132
+ for group in groups:
133
+ if group not in fallback:
134
+ fallback.append(group)
135
+ return fallback
100
136
 
101
137
  def subject(self) -> Optional[str]:
102
138
  """Get the subject from claims."""
@@ -33,9 +33,7 @@ def _log_jwt_configuration():
33
33
  log.debug("=== JWT Configuration Debug ===")
34
34
  log.debug("JWKS_HOST: %s", os.getenv("JWKS_HOST", "NOT_SET"))
35
35
  log.debug("JWT_ISSUER: %s", os.getenv("JWT_ISSUER", "NOT_SET"))
36
- log.debug(
37
- "JWT_ALLOWED_AUDIENCES: %s", os.getenv("JWT_ALLOWED_AUDIENCES", "NOT_SET")
38
- )
36
+ log.debug("JWT_ALLOWED_AUDIENCES: %s", os.getenv("JWT_ALLOWED_AUDIENCES", "NOT_SET"))
39
37
  log.debug("ANONYMOUS_ROLE: %s", os.getenv("ANONYMOUS_ROLE", "NOT_SET"))
40
38
  log.debug(
41
39
  "TOKEN_VALIDATOR_LAMBDA_ARN: %s",
@@ -65,9 +63,7 @@ class LambdaTokenValidator:
65
63
  log.error("boto3 not available for Lambda token validation")
66
64
  raise ImportError("boto3 is required for Lambda token validation")
67
65
 
68
- def validate(
69
- self, token: str, method_arn: str = "arn:aws:execute-api:*:*:*"
70
- ) -> Dict[str, Any]:
66
+ def validate(self, token: str, method_arn: str = "arn:aws:execute-api:*:*:*") -> Dict[str, Any]:
71
67
  """
72
68
  Invoke AWS Lambda TOKEN authorizer.
73
69
 
@@ -129,11 +125,7 @@ def _extract_event_entity_and_action(event: Dict[str, Any]) -> tuple[Optional[st
129
125
  resource = event.get("resource") or event.get("path") or ""
130
126
  parts = [p for p in str(resource).split("/") if p]
131
127
  api_prefixes = {"api", "v1", "v2", "v3"}
132
- literal_parts = [
133
- p
134
- for p in parts
135
- if p not in api_prefixes and not (p.startswith("{") and p.endswith("}"))
136
- ]
128
+ literal_parts = [p for p in parts if p not in api_prefixes and not (p.startswith("{") and p.endswith("}"))]
137
129
  entity = "_".join(literal_parts) if literal_parts else None
138
130
  method = str(event.get("httpMethod", "")).upper()
139
131
  action = METHOD_TO_ACTION.get(method, "read")
@@ -194,9 +186,7 @@ def _route_explicitly_allows_role(event: Dict[str, Any], role: Optional[str]) ->
194
186
 
195
187
  _, action = _extract_event_entity_and_action(event)
196
188
  permissions = _normalize_permissions(_get_route_permissions(event))
197
- action_permissions = permissions.get("default", {}).get(
198
- _normalize_permission_action(action), {}
199
- )
189
+ action_permissions = permissions.get("default", {}).get(_normalize_permission_action(action), {})
200
190
  return role in action_permissions
201
191
 
202
192
 
@@ -260,12 +250,7 @@ def token_decoder(
260
250
  config_lambda_arn = lambda_validator_arn or os.getenv("TOKEN_VALIDATOR_LAMBDA_ARN")
261
251
 
262
252
  # If no config provided, allow for runtime configuration
263
- if (
264
- not config_anonymous_role
265
- and not jwks_url
266
- and not os.getenv("JWKS_HOST")
267
- and not config_lambda_arn
268
- ):
253
+ if not config_anonymous_role and not jwks_url and not os.getenv("JWKS_HOST") and not config_lambda_arn:
269
254
  pass
270
255
 
271
256
  def decorator(handler: Callable) -> Callable:
@@ -288,13 +273,9 @@ def token_decoder(
288
273
  return handler(event, context)
289
274
 
290
275
  # Determine configuration sources
291
- config_lambda_arn = lambda_validator_arn or os.getenv(
292
- "TOKEN_VALIDATOR_LAMBDA_ARN"
293
- )
276
+ config_lambda_arn = lambda_validator_arn or os.getenv("TOKEN_VALIDATOR_LAMBDA_ARN")
294
277
  config_jwks_url = (
295
- jwks_url or f"{os.getenv('JWKS_HOST')}/.well-known/jwks.json"
296
- if os.getenv("JWKS_HOST")
297
- else None
278
+ jwks_url or f"{os.getenv('JWKS_HOST')}/.well-known/jwks.json" if os.getenv("JWKS_HOST") else None
298
279
  )
299
280
  config_issuer = issuer or os.getenv("JWT_ISSUER")
300
281
  config_audience = (
@@ -321,14 +302,10 @@ def token_decoder(
321
302
  and os.getenv("SKIP_CLAIMS_CHECK", "").lower() == "true"
322
303
  and _route_explicitly_allows_role(event, config_anonymous_role)
323
304
  ):
324
- log.debug(
325
- "SKIP_CLAIMS_CHECK enabled; using anonymous role without inline token validation"
326
- )
305
+ log.debug("SKIP_CLAIMS_CHECK enabled; using anonymous role without inline token validation")
327
306
  if "requestContext" not in event:
328
307
  event["requestContext"] = {}
329
- event["requestContext"]["authorizer"] = {
330
- "roles": [config_anonymous_role]
331
- }
308
+ event["requestContext"]["authorizer"] = {"roles": [config_anonymous_role]}
332
309
  return handler(event, context)
333
310
 
334
311
  # Skip all validation if no method configured
@@ -338,13 +315,9 @@ def token_decoder(
338
315
  log.debug("Using anonymous role: %s", config_anonymous_role)
339
316
  if "requestContext" not in event:
340
317
  event["requestContext"] = {}
341
- event["requestContext"]["authorizer"] = {
342
- "roles": [config_anonymous_role]
343
- }
318
+ event["requestContext"]["authorizer"] = {"roles": [config_anonymous_role]}
344
319
  elif config_anonymous_role:
345
- log.error(
346
- "Anonymous role configured but route does not explicitly allow it"
347
- )
320
+ log.error("Anonymous role configured but route does not explicitly allow it")
348
321
  return {
349
322
  "statusCode": 401,
350
323
  "headers": {"Content-Type": "application/json"},
@@ -365,9 +338,7 @@ def token_decoder(
365
338
  "Configuring Lambda validator: %s",
366
339
  config_lambda_arn,
367
340
  )
368
- wrapper._lambda_validator = LambdaTokenValidator(
369
- config_lambda_arn
370
- )
341
+ wrapper._lambda_validator = LambdaTokenValidator(config_lambda_arn)
371
342
  else:
372
343
  wrapper._lambda_validator = None
373
344
 
@@ -380,9 +351,7 @@ def token_decoder(
380
351
  wrapper._jwt_decoder = JWTDecoder(
381
352
  jwks_url=config_jwks_url,
382
353
  issuer=config_issuer,
383
- allowed_audiences=set(config_audience)
384
- if config_audience
385
- else None,
354
+ allowed_audiences=set(config_audience) if config_audience else None,
386
355
  algorithms=config_algorithms,
387
356
  anonymous_role=config_anonymous_role,
388
357
  )
@@ -498,9 +467,7 @@ class JWTDecoder:
498
467
  if jwks_url:
499
468
  self.public_key = self.fetch_public_key_from_jwks(jwks_url)
500
469
  if not self.public_key:
501
- raise ValueError(
502
- f"Failed to fetch public key from JWKS URL: {jwks_url}"
503
- )
470
+ raise ValueError(f"Failed to fetch public key from JWKS URL: {jwks_url}")
504
471
  else:
505
472
  self.public_key = None
506
473
 
@@ -510,9 +477,7 @@ class JWTDecoder:
510
477
  self.algorithms,
511
478
  )
512
479
 
513
- def fetch_public_key_from_jwks(
514
- self, jwks_url: str, kid: Optional[str] = None
515
- ) -> Optional[str]:
480
+ def fetch_public_key_from_jwks(self, jwks_url: str, kid: Optional[str] = None) -> Optional[str]:
516
481
  """
517
482
  Fetch the public key from a JWKS endpoint.
518
483
 
@@ -555,9 +520,7 @@ class JWTDecoder:
555
520
  log.warning("Key ID %s not found, using first key", kid)
556
521
  if not key:
557
522
  key = keys[0]
558
- log.debug(
559
- "Using first available key with kid: %s", key.get("kid", "unknown")
560
- )
523
+ log.debug("Using first available key with kid: %s", key.get("kid", "unknown"))
561
524
  # Convert JWK to PEM (requires cryptography)
562
525
 
563
526
  def b64url_decode(val):
@@ -583,15 +546,11 @@ class JWTDecoder:
583
546
  if token:
584
547
  return self.decode_token(token)
585
548
  elif self.anonymous_role:
586
- log.debug(
587
- "No token found, using anonymous role: %s", self.anonymous_role
588
- )
549
+ log.debug("No token found, using anonymous role: %s", self.anonymous_role)
589
550
  return {"roles": [self.anonymous_role]}
590
551
  else:
591
552
  log.error("No token found and no anonymous role configured")
592
- raise ValueError(
593
- "Authentication required: no token provided and no anonymous access configured"
594
- )
553
+ raise ValueError("Authentication required: no token provided and no anonymous access configured")
595
554
  except ValueError as e:
596
555
  if self.anonymous_role and "No authorization header" in str(e):
597
556
  log.debug("Token parsing failed, using anonymous role: %s", str(e))
@@ -618,11 +577,7 @@ class JWTDecoder:
618
577
  auth_token_parts = auth_header.split(" ")
619
578
  log.debug("Authorization header parts: %d", len(auth_token_parts))
620
579
 
621
- if (
622
- len(auth_token_parts) != 2
623
- or auth_token_parts[0].lower() != "bearer"
624
- or not auth_token_parts[1]
625
- ):
580
+ if len(auth_token_parts) != 2 or auth_token_parts[0].lower() != "bearer" or not auth_token_parts[1]:
626
581
  log.error("Invalid authorization header format")
627
582
  raise ValueError("Invalid AuthorizationToken.")
628
583
 
@@ -682,9 +637,7 @@ class JWTDecoder:
682
637
  log.debug("Token audience claim: %s", token_aud)
683
638
 
684
639
  # Normalize token audience to a list for comparison
685
- token_auds = (
686
- [token_aud] if isinstance(token_aud, str) else list(token_aud or [])
687
- )
640
+ token_auds = [token_aud] if isinstance(token_aud, str) else list(token_aud or [])
688
641
  token_auds = [str(a).strip() for a in token_auds if str(a).strip()]
689
642
  log.debug("Normalized token audiences: %s", token_auds)
690
643
 
@@ -1 +0,0 @@
1
- __version__ = "0.8.74"