api-foundry-query-engine 0.8.73__tar.gz → 0.8.75__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 (37) hide show
  1. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/PKG-INFO +1 -1
  2. api_foundry_query_engine-0.8.75/api_foundry_query_engine/__init__.py +1 -0
  3. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/batch_operation_handler.py +33 -20
  4. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/operation_dao.py +17 -38
  5. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_delete_query_handler.py +79 -10
  6. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_query_handler.py +17 -0
  7. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_restore_query_handler.py +77 -21
  8. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_subselect_query_handler.py +14 -9
  9. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/operation.py +38 -2
  10. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/token_decoder.py +20 -67
  11. api_foundry_query_engine-0.8.73/api_foundry_query_engine/__init__.py +0 -1
  12. api_foundry_query_engine-0.8.73/api_foundry_query_engine/adapters/case_change_adapter.py +0 -79
  13. api_foundry_query_engine-0.8.73/api_foundry_query_engine/adapters/security_adapter.py +0 -106
  14. api_foundry_query_engine-0.8.73/api_foundry_query_engine/connectors/oracle_connector.py +0 -29
  15. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/.gitignore +0 -0
  16. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/LICENSE +0 -0
  17. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/.pre-commit-config.yaml +0 -0
  18. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/adapters/adapter.py +0 -0
  19. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/adapters/gateway_adapter.py +0 -0
  20. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/connectors/connection.py +0 -0
  21. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/connectors/connection_factory.py +0 -0
  22. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/connectors/postgres_connection.py +0 -0
  23. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/dao.py +0 -0
  24. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_custom_query_handler.py +0 -0
  25. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_insert_query_handler.py +0 -0
  26. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_select_query_handler.py +0 -0
  27. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/sql_update_query_handler.py +0 -0
  28. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/lambda_handler.py +0 -0
  29. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/services/service.py +0 -0
  30. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/services/transactional_service.py +0 -0
  31. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/api_model.py +0 -0
  32. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/app_exception.py +0 -0
  33. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/claims_check.py +0 -0
  34. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/dependency_resolver.py +0 -0
  35. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/logger.py +0 -0
  36. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/reference_resolver.py +0 -0
  37. {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/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.73
3
+ Version: 0.8.75
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.75"
@@ -19,7 +19,13 @@ log = logger(__name__)
19
19
  class BatchOperationHandler:
20
20
  """Handles execution of batch operations with dependencies."""
21
21
 
22
- def __init__(self, batch_request: Dict[str, Any], connection, engine: str):
22
+ def __init__(
23
+ self,
24
+ batch_request: Dict[str, Any],
25
+ connection,
26
+ engine: str,
27
+ claims: Optional[Dict[str, Any]] = None,
28
+ ):
23
29
  """
24
30
  Initialize the batch operation handler.
25
31
 
@@ -27,11 +33,16 @@ class BatchOperationHandler:
27
33
  batch_request: Batch request with 'operations' and 'options'
28
34
  connection: Database connection for executing operations
29
35
  engine: Database engine type (postgres, mysql, etc.)
36
+ claims: The authenticated caller's claims (roles, sub, etc.),
37
+ applied to every sub-operation in the batch. This must come
38
+ from the token-validated top-level request, never from the
39
+ batch request body.
30
40
  """
31
41
  self.operations = batch_request.get("operations", [])
32
42
  self.options = batch_request.get("options", {})
33
43
  self.connection = connection
34
44
  self.engine = engine
45
+ self.claims = claims or {}
35
46
  self.results: Dict[str, Dict[str, Any]] = {}
36
47
  self.failed_operations: List[str] = []
37
48
 
@@ -45,15 +56,12 @@ class BatchOperationHandler:
45
56
  def _validate_batch_request(self):
46
57
  """Validate the batch request structure."""
47
58
  if not self.operations:
48
- raise ApplicationException(
49
- 400, "Batch request must contain at least one operation"
50
- )
59
+ raise ApplicationException(400, "Batch request must contain at least one operation")
51
60
 
52
61
  if len(self.operations) > 100:
53
62
  raise ApplicationException(
54
63
  400,
55
- f"Batch size exceeds maximum (100). "
56
- f"Requested: {len(self.operations)}",
64
+ f"Batch size exceeds maximum (100). " f"Requested: {len(self.operations)}",
57
65
  )
58
66
 
59
67
  # Auto-generate IDs for operations that don't have them
@@ -88,6 +96,18 @@ class BatchOperationHandler:
88
96
  f"Operation '{op['id']}' has invalid action " f"'{op['action']}'",
89
97
  )
90
98
 
99
+ # Sub-operations always run with the authenticated caller's
100
+ # claims (see __init__). A client-supplied 'claims' field would
101
+ # let a request forge its own roles/subject/tenant for a
102
+ # sub-operation, so reject it outright rather than silently
103
+ # ignoring it.
104
+ if "claims" in op:
105
+ raise ApplicationException(
106
+ 400,
107
+ f"Operation '{op['id']}' must not specify 'claims'; "
108
+ "the authenticated caller's claims are always used",
109
+ )
110
+
91
111
  def execute(self) -> Dict[str, Any]:
92
112
  """
93
113
  Execute all operations in dependency order.
@@ -238,27 +258,20 @@ class BatchOperationHandler:
238
258
 
239
259
  # Resolve references in parameters
240
260
  ref_resolver = ReferenceResolver(self.results)
241
- query_params = ref_resolver.resolve_parameters(
242
- op_def.get("query_params", {}), op_id
243
- )
244
- store_params = ref_resolver.resolve_parameters(
245
- op_def.get("store_params", {}), op_id
246
- )
247
- metadata_params = ref_resolver.resolve_parameters(
248
- op_def.get("metadata_params", {}), op_id
249
- )
250
-
251
- # Get claims from operation or use empty dict
252
- claims = op_def.get("claims", {})
261
+ query_params = ref_resolver.resolve_parameters(op_def.get("query_params", {}), op_id)
262
+ store_params = ref_resolver.resolve_parameters(op_def.get("store_params", {}), op_id)
263
+ metadata_params = ref_resolver.resolve_parameters(op_def.get("metadata_params", {}), op_id)
253
264
 
254
- # Create Operation object
265
+ # Create Operation object. Always use the authenticated batch
266
+ # caller's claims (self.claims) - never anything from the request
267
+ # body - so a sub-operation cannot run with forged roles/subject.
255
268
  operation = Operation(
256
269
  entity=op_def["entity"],
257
270
  action=op_def["action"],
258
271
  query_params=query_params,
259
272
  store_params=store_params,
260
273
  metadata_params=metadata_params,
261
- claims=claims,
274
+ claims=dict(self.claims),
262
275
  )
263
276
 
264
277
  log.debug(
@@ -53,44 +53,26 @@ class OperationDAO(DAO):
53
53
  @property
54
54
  def query_handler(self) -> SQLQueryHandler:
55
55
  if not hasattr(self, "_query_handler"):
56
- path_operation = get_path_operation(
57
- self.operation.entity, self.operation.action
58
- )
56
+ path_operation = get_path_operation(self.operation.entity, self.operation.action)
59
57
  if path_operation:
60
- self._query_handler = SQLCustomQueryHandler(
61
- self.operation, path_operation, self.engine
62
- )
58
+ self._query_handler = SQLCustomQueryHandler(self.operation, path_operation, self.engine)
63
59
  return self._query_handler
64
60
 
65
61
  schema_object = get_schema_object(self.operation.entity)
66
62
  if not schema_object:
67
- raise ApplicationException(
68
- 500, f"Unknown operation: {self.operation.entity}"
69
- )
63
+ raise ApplicationException(500, f"Unknown operation: {self.operation.entity}")
70
64
  if self.operation.action == "read":
71
- self._query_handler = SQLSelectSchemaQueryHandler(
72
- self.operation, schema_object, self.engine
73
- )
65
+ self._query_handler = SQLSelectSchemaQueryHandler(self.operation, schema_object, self.engine)
74
66
  elif self.operation.action == "create":
75
- self._query_handler = SQLInsertSchemaQueryHandler(
76
- self.operation, schema_object, self.engine
77
- )
67
+ self._query_handler = SQLInsertSchemaQueryHandler(self.operation, schema_object, self.engine)
78
68
  elif self.operation.action == "update":
79
- self._query_handler = SQLUpdateSchemaQueryHandler(
80
- self.operation, schema_object, self.engine
81
- )
69
+ self._query_handler = SQLUpdateSchemaQueryHandler(self.operation, schema_object, self.engine)
82
70
  elif self.operation.action == "delete":
83
- self._query_handler = SQLDeleteSchemaQueryHandler(
84
- self.operation, schema_object, self.engine
85
- )
71
+ self._query_handler = SQLDeleteSchemaQueryHandler(self.operation, schema_object, self.engine)
86
72
  elif self.operation.action == "restore":
87
- self._query_handler = SQLRestoreSchemaQueryHandler(
88
- self.operation, schema_object, self.engine
89
- )
73
+ self._query_handler = SQLRestoreSchemaQueryHandler(self.operation, schema_object, self.engine)
90
74
  else:
91
- raise ApplicationException(
92
- 400, f"Invalid operation action: {self.operation.action}"
93
- )
75
+ raise ApplicationException(400, f"Invalid operation action: {self.operation.action}")
94
76
  return self._query_handler
95
77
 
96
78
  def execute(self, connector, operation=None) -> Union[list[dict], dict]:
@@ -118,8 +100,11 @@ class OperationDAO(DAO):
118
100
  # Extract batch request from store_params
119
101
  batch_request = op.store_params
120
102
 
121
- # Execute batch
122
- handler = BatchOperationHandler(batch_request, connector, self.engine)
103
+ # Execute batch. The authenticated caller's claims (op.claims)
104
+ # are passed explicitly so every sub-operation runs with the
105
+ # real caller's identity/roles, never anything from the batch
106
+ # request body.
107
+ handler = BatchOperationHandler(batch_request, connector, self.engine, claims=op.claims)
123
108
  return handler.execute()
124
109
 
125
110
  # Standard operation handling
@@ -147,9 +132,7 @@ class OperationDAO(DAO):
147
132
  continue
148
133
 
149
134
  child_set = self.__fetch_record_set(
150
- SQLSubselectSchemaQueryHandler(
151
- self.operation, relation, self.query_handler # type: ignore
152
- ),
135
+ SQLSubselectSchemaQueryHandler(self.operation, relation, self.query_handler), # type: ignore
153
136
  cursor,
154
137
  )
155
138
  if len(child_set) == 0:
@@ -168,16 +151,12 @@ class OperationDAO(DAO):
168
151
  if parent:
169
152
  parent[name].append(child)
170
153
 
171
- def __fetch_record_set(
172
- self, query_handler: SQLQueryHandler, cursor: Cursor
173
- ) -> list[dict]:
154
+ def __fetch_record_set(self, query_handler: SQLQueryHandler, cursor: Cursor) -> list[dict]:
174
155
  sql = query_handler.sql
175
156
  if not sql:
176
157
  return []
177
158
 
178
- record_set = cursor.execute(
179
- sql, query_handler.placeholders, query_handler.selection_results
180
- )
159
+ record_set = cursor.execute(sql, query_handler.placeholders, query_handler.selection_results)
181
160
  result = []
182
161
  for record in record_set:
183
162
  object = query_handler.marshal_record(record)
@@ -1,3 +1,5 @@
1
+ import re
2
+ from typing import Match
1
3
  from api_foundry_query_engine.dao.sql_query_handler import SQLSchemaQueryHandler
2
4
  from api_foundry_query_engine.operation import Operation
3
5
  from api_foundry_query_engine.utils.app_exception import ApplicationException
@@ -8,11 +10,83 @@ log = logger(__name__)
8
10
 
9
11
 
10
12
  class SQLDeleteSchemaQueryHandler(SQLSchemaQueryHandler):
11
- def __init__(
12
- self, operation: Operation, schema_object: SchemaObject, engine: str
13
- ) -> None:
13
+ def __init__(self, operation: Operation, schema_object: SchemaObject, engine: str) -> None:
14
14
  super().__init__(operation, schema_object, engine)
15
15
 
16
+ def _template_where(self, expr: str) -> str:
17
+ """Template substitution for WHERE clause expressions with claim values."""
18
+ if not expr:
19
+ return expr
20
+
21
+ def _quote(val: object) -> str:
22
+ if val is None:
23
+ return "NULL"
24
+ if isinstance(val, (int, float)):
25
+ return str(val)
26
+ s = str(val).replace("'", "''")
27
+ return f"'{s}'"
28
+
29
+ def _replace(m: Match[str]) -> str:
30
+ key = m.group(1)
31
+ return _quote(self.extract_injected_value(f"claim:{key}"))
32
+
33
+ return re.sub(r"\$\{claims\.([A-Za-z0-9_]+)\}", _replace, expr)
34
+
35
+ def _row_where_clause(self) -> str:
36
+ """
37
+ Generate row-level WHERE clause based on delete permissions, so a
38
+ role's delete grant is scoped to the rows the permission model
39
+ allows (e.g. tenant/ownership), the same way select and update
40
+ already are.
41
+ """
42
+ perms = getattr(self.schema_object, "permissions", None) or {}
43
+ if "default" in perms:
44
+ provider = perms.get("default", {}) or {}
45
+ delete_map = provider.get("delete", {}) or {}
46
+ role_permissions = perms.get("default", {})
47
+ else:
48
+ delete_map = {}
49
+ role_permissions = perms
50
+ for role, role_perms in perms.items():
51
+ if isinstance(role_perms, dict):
52
+ delete_map[role] = role_perms.get("delete")
53
+
54
+ filters = []
55
+ for role in self.operation.roles or []:
56
+ role_where = None
57
+ if isinstance(role_permissions.get(role), dict):
58
+ role_where = role_permissions[role].get("where")
59
+
60
+ operation_where = None
61
+ rule = delete_map.get(role)
62
+ if isinstance(rule, dict):
63
+ operation_where = rule.get("where")
64
+
65
+ where_clause = operation_where if operation_where else role_where
66
+
67
+ if isinstance(where_clause, str) and where_clause.strip():
68
+ filters.append(self._template_where(where_clause))
69
+
70
+ if not filters:
71
+ return ""
72
+ return "(" + ") OR (".join(filters) + ")"
73
+
74
+ @property
75
+ def search_condition(self) -> str:
76
+ """
77
+ Add permission-based row-level WHERE clause on top of the base
78
+ query-param search condition, matching SQLUpdateSchemaQueryHandler.
79
+ """
80
+ base_condition = super().search_condition
81
+ row_filter = self._row_where_clause()
82
+
83
+ if base_condition and row_filter:
84
+ return f"{base_condition} AND ({row_filter})"
85
+ elif row_filter:
86
+ return f" WHERE ({row_filter})"
87
+ else:
88
+ return base_condition
89
+
16
90
  def check_permission(self) -> bool:
17
91
  """
18
92
  Checks the user's permissions for the specified permission type.
@@ -108,9 +182,7 @@ class SQLDeleteSchemaQueryHandler(SQLSchemaQueryHandler):
108
182
  @property
109
183
  def sql(self) -> str:
110
184
  if not self.check_permission():
111
- raise ApplicationException(
112
- 403, f"Subject is not allowed to delete {self.schema_object.api_name}"
113
- )
185
+ raise ApplicationException(403, f"Subject is not allowed to delete {self.schema_object.api_name}")
114
186
 
115
187
  concurrency_property = self.schema_object.concurrency_property
116
188
  if concurrency_property:
@@ -139,7 +211,4 @@ class SQLDeleteSchemaQueryHandler(SQLSchemaQueryHandler):
139
211
  )
140
212
  else:
141
213
  # Fall back to hard delete for tables without soft delete support
142
- return (
143
- f"DELETE FROM {self.table_expression}{self.search_condition} "
144
- + f"RETURNING {self.select_list}"
145
- )
214
+ return f"DELETE FROM {self.table_expression}{self.search_condition} " + f"RETURNING {self.select_list}"
@@ -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
 
@@ -1,3 +1,5 @@
1
+ import re
2
+ from typing import Match
1
3
  from api_foundry_query_engine.dao.sql_query_handler import (
2
4
  SQLSchemaQueryHandler,
3
5
  )
@@ -12,9 +14,7 @@ log = logger(__name__)
12
14
  class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
13
15
  """Handler for restoring soft-deleted records."""
14
16
 
15
- def __init__(
16
- self, operation: Operation, schema_object: SchemaObject, engine: str
17
- ) -> None:
17
+ def __init__(self, operation: Operation, schema_object: SchemaObject, engine: str) -> None:
18
18
  super().__init__(operation, schema_object, engine)
19
19
 
20
20
  def check_permission(self) -> bool:
@@ -42,9 +42,7 @@ class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
42
42
 
43
43
  # If still not found, check wildcard "*"
44
44
  if role_permissions is None:
45
- role_permissions = restore_permissions.get(
46
- "*"
47
- ) or write_permissions.get("*")
45
+ role_permissions = restore_permissions.get("*") or write_permissions.get("*")
48
46
  log.info("Fallback to wildcard role '*': %s", role_permissions)
49
47
  if role_permissions is None:
50
48
  continue
@@ -61,6 +59,67 @@ class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
61
59
 
62
60
  return False
63
61
 
62
+ def _template_where(self, expr: str) -> str:
63
+ """Template substitution for WHERE clause expressions with claim values."""
64
+ if not expr:
65
+ return expr
66
+
67
+ def _quote(val: object) -> str:
68
+ if val is None:
69
+ return "NULL"
70
+ if isinstance(val, (int, float)):
71
+ return str(val)
72
+ s = str(val).replace("'", "''")
73
+ return f"'{s}'"
74
+
75
+ def _replace(m: Match[str]) -> str:
76
+ key = m.group(1)
77
+ return _quote(self.extract_injected_value(f"claim:{key}"))
78
+
79
+ return re.sub(r"\$\{claims\.([A-Za-z0-9_]+)\}", _replace, expr)
80
+
81
+ def _row_where_clause(self) -> str:
82
+ """
83
+ Generate row-level WHERE clause based on restore permissions
84
+ (falling back to write permissions, matching check_permission's own
85
+ restore->write fallback), so a role's restore grant is scoped to
86
+ the rows the permission model allows.
87
+ """
88
+ perms = getattr(self.schema_object, "permissions", None) or {}
89
+ if "default" in perms:
90
+ provider = perms.get("default", {}) or {}
91
+ restore_map = provider.get("restore", {}) or {}
92
+ write_map = provider.get("write", {}) or {}
93
+ role_permissions = perms.get("default", {})
94
+ else:
95
+ restore_map = {}
96
+ write_map = {}
97
+ role_permissions = perms
98
+ for role, role_perms in perms.items():
99
+ if isinstance(role_perms, dict):
100
+ restore_map[role] = role_perms.get("restore")
101
+ write_map[role] = role_perms.get("write")
102
+
103
+ filters = []
104
+ for role in self.operation.roles or []:
105
+ role_where = None
106
+ if isinstance(role_permissions.get(role), dict):
107
+ role_where = role_permissions[role].get("where")
108
+
109
+ operation_where = None
110
+ rule = restore_map.get(role) or write_map.get(role)
111
+ if isinstance(rule, dict):
112
+ operation_where = rule.get("where")
113
+
114
+ where_clause = operation_where if operation_where else role_where
115
+
116
+ if isinstance(where_clause, str) and where_clause.strip():
117
+ filters.append(self._template_where(where_clause))
118
+
119
+ if not filters:
120
+ return ""
121
+ return "(" + ") OR (".join(filters) + ")"
122
+
64
123
  def _get_restore_update_values(self) -> str:
65
124
  """Generate SET clause for restore operation."""
66
125
  self.store_placeholders = {}
@@ -114,14 +173,10 @@ class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
114
173
 
115
174
  for name, value in self.operation.query_params.items():
116
175
  if "." in name:
117
- raise ApplicationException(
118
- 400, "Selection on relations is not supported"
119
- )
176
+ raise ApplicationException(400, "Selection on relations is not supported")
120
177
  prop = self.schema_object.properties.get(name)
121
178
  if not prop:
122
- raise ApplicationException(
123
- 500, f"Search condition column not found {name}"
124
- )
179
+ raise ApplicationException(500, f"Search condition column not found {name}")
125
180
 
126
181
  assignment, holders = self.search_value_assignment(prop, value)
127
182
  conditions.append(assignment)
@@ -132,6 +187,12 @@ class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
132
187
  if soft_delete_conditions:
133
188
  conditions.append(f"({soft_delete_conditions})")
134
189
 
190
+ # Add row-level permission filter, scoping which rows a role may
191
+ # restore (e.g. tenant/ownership), matching update/delete.
192
+ row_filter = self._row_where_clause()
193
+ if row_filter:
194
+ conditions.append(f"({row_filter})")
195
+
135
196
  return f" WHERE {' AND '.join(conditions)}" if conditions else ""
136
197
 
137
198
  def _get_soft_delete_restore_conditions(self) -> str:
@@ -157,8 +218,7 @@ class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
157
218
  if excluded_values:
158
219
  # For restore, we want records that ARE in the excluded values
159
220
  formatted_values = ", ".join(
160
- f"'{val}'" if isinstance(val, str) else str(val)
161
- for val in excluded_values
221
+ f"'{val}'" if isinstance(val, str) else str(val) for val in excluded_values
162
222
  )
163
223
  conditions.append(f"{column_name} IN ({formatted_values})")
164
224
 
@@ -177,17 +237,13 @@ class SQLRestoreSchemaQueryHandler(SQLSchemaQueryHandler):
177
237
  if not self.schema_object.has_soft_delete_support():
178
238
  raise ApplicationException(
179
239
  400,
180
- f"Schema object {self.schema_object.api_name} does not support "
181
- + "soft delete operations",
240
+ f"Schema object {self.schema_object.api_name} does not support " + "soft delete operations",
182
241
  )
183
242
 
184
243
  update_clause = self._get_restore_update_values()
185
244
  if not update_clause:
186
- raise ApplicationException(
187
- 400, f"No restore fields available for {self.schema_object.api_name}"
188
- )
245
+ raise ApplicationException(400, f"No restore fields available for {self.schema_object.api_name}")
189
246
 
190
247
  return (
191
- f"UPDATE {self.table_expression}{update_clause}"
192
- + f"{self.search_condition} RETURNING {self.select_list}"
248
+ f"UPDATE {self.table_expression}{update_clause}" + f"{self.search_condition} RETURNING {self.select_list}"
193
249
  )
@@ -15,9 +15,7 @@ class SQLSubselectSchemaQueryHandler(SQLSelectSchemaQueryHandler):
15
15
  relation: SchemaObjectAssociation,
16
16
  parent_generator: SQLSchemaQueryHandler,
17
17
  ) -> None:
18
- super().__init__(
19
- operation, relation.child_schema_object, parent_generator.engine
20
- )
18
+ super().__init__(operation, relation.child_schema_object, parent_generator.engine)
21
19
  self.relation = relation
22
20
  self.parent_generator = parent_generator
23
21
 
@@ -32,11 +30,20 @@ class SQLSubselectSchemaQueryHandler(SQLSelectSchemaQueryHandler):
32
30
 
33
31
  schema_object = self.relation.child_schema_object
34
32
 
35
- # Filter and prefix keys for the current entity and regular expressions
36
- filtered_keys = self.filter_and_prefix_keys(
37
- reg_exs, schema_object.properties
33
+ # Enforce read permissions on the associated entity's
34
+ # properties the same way the top-level select handler does.
35
+ # Without this, a property restricted at the top level (e.g.
36
+ # a field limited to a privileged role) would leak through
37
+ # when fetched as a nested one-to-many association.
38
+ allowed_properties = self.check_permissions(
39
+ "read",
40
+ schema_object.permissions,
41
+ schema_object.properties,
38
42
  )
39
43
 
44
+ # Filter and prefix keys for the current entity and regular expressions
45
+ filtered_keys = self.filter_and_prefix_keys(reg_exs, allowed_properties)
46
+
40
47
  # Extend the result map with the filtered keys
41
48
  result.update(filtered_keys)
42
49
 
@@ -51,9 +58,7 @@ class SQLSubselectSchemaQueryHandler(SQLSelectSchemaQueryHandler):
51
58
  if len(self.select_list_columns) == 1: # then it only contains the key
52
59
  return None
53
60
 
54
- parent_prefix = self.parent_generator.prefix_map[
55
- str(self.parent_generator.schema_object.api_name)
56
- ]
61
+ parent_prefix = self.parent_generator.prefix_map[str(self.parent_generator.schema_object.api_name)]
57
62
  sql = (
58
63
  f"SELECT {self.select_list} "
59
64
  + f"FROM {self.relation.child_schema_object.qualified_name} "
@@ -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.73"
@@ -1,79 +0,0 @@
1
- from humps import camelize, decamelize
2
-
3
- from api_foundry_query_engine.adapters.adapter import Adapter
4
- from api_foundry_query_engine.utils.logger import logger
5
- from api_foundry_query_engine.operation import Operation
6
-
7
- log = logger(__name__)
8
-
9
-
10
- class CaseChangeAdapter(Adapter):
11
- """
12
- Handles changing case from snake to camel and back
13
- """
14
-
15
- def unmarshal(self, event) -> Operation:
16
- """
17
- Unmarshal the event in a tuple for processing
18
-
19
- Parameters:
20
- - event (dict): Lambda event object.
21
-
22
- Returns:
23
- - tuple: Tuple containing entity operation, store_params, query_params and metadata params.
24
- """
25
- operation = super().unmarshal(event)
26
-
27
- # determine case
28
- self.camel_case = (
29
- operation.metadata_params.get("_case", "snake") == "camel"
30
- or self.__check_camel_case(operation.store_params)
31
- or self.__check_camel_case(operation.query_params)
32
- )
33
- log.info("camel_case: %s", self.camel_case)
34
-
35
- if self.camel_case:
36
- return Operation(
37
- path=operation.entity,
38
- action=operation.action,
39
- store_params=decamelize(operation.store_params),
40
- query_params=decamelize(operation.query_params),
41
- metadata_params=operation.metadata_params,
42
- )
43
-
44
- return operation
45
-
46
- def marshal(self, result: list[dict]):
47
- """
48
- Marshal the result into a event response
49
-
50
- Parameters:
51
- - result (list): the data set to return in the response
52
-
53
- Returns:
54
- - the event response
55
- """
56
- super().marshal(result)
57
-
58
- if not self.camel_case:
59
- return result
60
-
61
- converted_result = []
62
- for item in result:
63
- converted_result.append(camelize(item))
64
-
65
- # convert back to camel case if needed
66
- return converted_result
67
-
68
- def __check_camel_case(self, params: dict) -> bool:
69
- if params is not None:
70
- # check the keys for an upper case character
71
- for param in params:
72
- if (
73
- param != param.lower()
74
- and param != param.upper()
75
- and "_" not in param
76
- ):
77
- return True
78
-
79
- return False
@@ -1,106 +0,0 @@
1
- from typing import Optional
2
-
3
- from api_foundry_query_engine.operation import Operation
4
- from api_foundry_query_engine.services.service import Service
5
- from api_foundry_query_engine.adapters.adapter import Adapter
6
- from api_foundry_query_engine.utils.logger import logger
7
-
8
- log = logger(__name__)
9
-
10
-
11
- class SecurityAdapter(Adapter):
12
- def __init__(self, service: Optional[Service] = None, permissions: dict = None):
13
- """
14
- Initialize SecurityAdapter with a service and permissions.
15
-
16
- Parameters:
17
- - service (Service): The service used to process operations.
18
- - permissions (dict): A dictionary containing the user's permissions,
19
- structured as:
20
- {
21
- "read": ["field1", "field2", ...],
22
- "write": ["field3", "field4", ...]
23
- }
24
- """
25
- super().__init__(service)
26
- self.permissions = permissions or {"read": [], "write": []}
27
-
28
- def unmarshal(self, event) -> Operation:
29
- """
30
- Unmarshal the event into an Operation object after validating query and store params.
31
-
32
- Parameters:
33
- - event (dict): Lambda event object.
34
-
35
- Returns:
36
- - Operation: The validated operation.
37
- """
38
- entity = event.get("entity")
39
- action = event.get("action")
40
- query_params = event.get("query_params", {})
41
- store_params = event.get("store_params", {})
42
-
43
- # Validate read permissions for query_params
44
- invalid_query_params = [
45
- key for key in query_params if key not in self.permissions["read"]
46
- ]
47
- if invalid_query_params:
48
- raise PermissionError(
49
- f"Query parameters not permitted: {invalid_query_params}"
50
- )
51
-
52
- # Validate write permissions for store_params
53
- invalid_store_params = [
54
- key for key in store_params if key not in self.permissions["write"]
55
- ]
56
- if invalid_store_params:
57
- raise PermissionError(
58
- f"Store parameters not permitted: {invalid_store_params}"
59
- )
60
-
61
- return Operation(
62
- entity=entity,
63
- action=action,
64
- query_params=query_params,
65
- store_params=store_params,
66
- )
67
-
68
- def marshal(self, result: list[dict]):
69
- """
70
- Filter the result based on read permissions before returning.
71
-
72
- Parameters:
73
- - result (list[dict]): The data set to return in the response.
74
-
75
- Returns:
76
- - list[dict]: Filtered response with only permitted fields.
77
- """
78
- filtered_result = []
79
- for record in result:
80
- filtered_record = {
81
- key: value
82
- for key, value in record.items()
83
- if key in self.permissions["read"]
84
- }
85
- filtered_result.append(filtered_record)
86
-
87
- return filtered_result
88
-
89
- def process_event(self, event):
90
- """
91
- Process Lambda event using a domain function.
92
-
93
- Parameters:
94
- - event (dict): Lambda event object.
95
-
96
- Returns:
97
- - any: Result of the domain function.
98
- """
99
- try:
100
- operation = self.unmarshal(event)
101
- result = self.service.execute(operation)
102
- log.debug("adapter result: %s", result)
103
- return self.marshal(result)
104
- except PermissionError as e:
105
- log.error("Permission error: %s", e)
106
- return {"error": str(e), "status": "permission_denied"}
@@ -1,29 +0,0 @@
1
- from api_foundry_query_engine.utils.logger import logger
2
- from api_foundry_query_engine.utils.app_exception import ApplicationException
3
- from api_foundry_query_engine.connectors.connection import Connector
4
-
5
- log = logger(__name__)
6
-
7
-
8
- class OracleConnnector(Connector):
9
- def __init__(self, db_secret_name: str) -> None:
10
- super().__init__(db_secret_name)
11
-
12
- def close(self):
13
- pass
14
-
15
- def execute(self, cursor, sql: str, parameters: dict):
16
- from oracledb import Error, IntegrityError, ProgrammingError
17
-
18
- log.debug("sql: %s, parameters: %s", sql, parameters)
19
- try:
20
- cursor.execute(sql, parameters)
21
- except IntegrityError as err:
22
- (error,) = err.args
23
- raise ApplicationException(409, error.message)
24
- except ProgrammingError as err:
25
- (error,) = err.args
26
- raise ApplicationException(400, error.message)
27
- except Error as err:
28
- (error,) = err.args
29
- raise ApplicationException(500, error.message)