api-foundry-query-engine 0.8.39__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_foundry_query_engine/.pre-commit-config.yaml +22 -0
- api_foundry_query_engine/__init__.py +1 -0
- api_foundry_query_engine/adapters/adapter.py +73 -0
- api_foundry_query_engine/adapters/case_change_adapter.py +79 -0
- api_foundry_query_engine/adapters/gateway_adapter.py +191 -0
- api_foundry_query_engine/adapters/security_adapter.py +106 -0
- api_foundry_query_engine/connectors/connection.py +32 -0
- api_foundry_query_engine/connectors/connection_factory.py +115 -0
- api_foundry_query_engine/connectors/oracle_connector.py +29 -0
- api_foundry_query_engine/connectors/postgres_connection.py +142 -0
- api_foundry_query_engine/dao/batch_operation_handler.py +295 -0
- api_foundry_query_engine/dao/dao.py +23 -0
- api_foundry_query_engine/dao/operation_dao.py +186 -0
- api_foundry_query_engine/dao/sql_custom_query_handler.py +68 -0
- api_foundry_query_engine/dao/sql_delete_query_handler.py +146 -0
- api_foundry_query_engine/dao/sql_insert_query_handler.py +187 -0
- api_foundry_query_engine/dao/sql_query_handler.py +713 -0
- api_foundry_query_engine/dao/sql_restore_query_handler.py +195 -0
- api_foundry_query_engine/dao/sql_select_query_handler.py +433 -0
- api_foundry_query_engine/dao/sql_subselect_query_handler.py +66 -0
- api_foundry_query_engine/dao/sql_update_query_handler.py +198 -0
- api_foundry_query_engine/lambda_handler.py +63 -0
- api_foundry_query_engine/operation.py +104 -0
- api_foundry_query_engine/services/service.py +75 -0
- api_foundry_query_engine/services/transactional_service.py +52 -0
- api_foundry_query_engine/utils/api_model.py +380 -0
- api_foundry_query_engine/utils/app_exception.py +22 -0
- api_foundry_query_engine/utils/claims_check.py +471 -0
- api_foundry_query_engine/utils/dependency_resolver.py +157 -0
- api_foundry_query_engine/utils/gateway_operation.py +279 -0
- api_foundry_query_engine/utils/logger.py +60 -0
- api_foundry_query_engine/utils/reference_resolver.py +222 -0
- api_foundry_query_engine/utils/token_decoder.py +624 -0
- api_foundry_query_engine-0.8.39.dist-info/METADATA +21 -0
- api_foundry_query_engine-0.8.39.dist-info/RECORD +37 -0
- api_foundry_query_engine-0.8.39.dist-info/WHEEL +4 -0
- api_foundry_query_engine-0.8.39.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claims Check Decorator for Lambda Functions
|
|
3
|
+
|
|
4
|
+
This decorator validates JWT scopes and permissions after token decoding.
|
|
5
|
+
It works in conjunction with the @token_decoder() decorator to ensure
|
|
6
|
+
that the authenticated user has the required permissions for the requested
|
|
7
|
+
operation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from typing import Optional, Dict, Any, Callable
|
|
15
|
+
from api_foundry_query_engine.utils.app_exception import ApplicationException
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=logging.INFO)
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def claims_check(
|
|
22
|
+
require_authentication: Optional[bool] = None,
|
|
23
|
+
validate_scope_format: bool = True,
|
|
24
|
+
validate_path_scope: bool = True,
|
|
25
|
+
min_scope_level: Optional[str] = None,
|
|
26
|
+
required_scopes: Optional[list] = None,
|
|
27
|
+
required_permissions: Optional[list] = None,
|
|
28
|
+
operation_type: Optional[str] = None,
|
|
29
|
+
entity_name: Optional[str] = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Decorator for API Foundry Query Engine authentication validation.
|
|
33
|
+
|
|
34
|
+
This decorator validates that the user has valid authentication for the
|
|
35
|
+
Query Engine. The Query Engine acts as a SQL translation engine that
|
|
36
|
+
handles all operations, so granular permissions are enforced at the
|
|
37
|
+
SQL level by the existing permissions system.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
require_authentication: Require valid JWT token claims (default: True)
|
|
41
|
+
validate_scope_format: Validate that scopes follow expected format
|
|
42
|
+
validate_path_scope: Auto-validate scope matches request path/method
|
|
43
|
+
(default: True). E.g. GET /employee needs
|
|
44
|
+
read:employee scope
|
|
45
|
+
min_scope_level: Minimum scope level required
|
|
46
|
+
('read', 'write', 'delete')
|
|
47
|
+
If None, any valid scope is accepted
|
|
48
|
+
required_scopes: List of specific scopes required (optional)
|
|
49
|
+
required_permissions: List of specific permissions required (optional)
|
|
50
|
+
operation_type: Override operation type detection (optional)
|
|
51
|
+
entity_name: Override entity name detection (optional)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Decorated function that validates authentication before executing
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ApplicationException: If authentication validation fails
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
@token_decoder()
|
|
61
|
+
@claims_check() # Auto-validate scope matches path
|
|
62
|
+
def handler(event, context):
|
|
63
|
+
# GET /employee needs read:employee scope OR employee.read perm
|
|
64
|
+
# Additional SQL-level permissions enforced by query engine
|
|
65
|
+
return query_engine.process(event)
|
|
66
|
+
|
|
67
|
+
@claims_check(validate_path_scope=False) # Skip path validation
|
|
68
|
+
def handler_no_scope_check(event, context):
|
|
69
|
+
# Only SQL-level permissions will be enforced
|
|
70
|
+
return query_engine.process(event)
|
|
71
|
+
|
|
72
|
+
@claims_check(min_scope_level="write") # Path + minimum write level
|
|
73
|
+
def admin_handler(event, context):
|
|
74
|
+
return query_engine.process(event)
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def decorator(func: Callable) -> Callable:
|
|
78
|
+
@functools.wraps(func)
|
|
79
|
+
def wrapper(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
80
|
+
try:
|
|
81
|
+
log.debug("Claims check decorator starting")
|
|
82
|
+
|
|
83
|
+
config_require_authentication = require_authentication or os.getenv(
|
|
84
|
+
"REQUIRE_AUTHENTICATION", ""
|
|
85
|
+
).lower() in ("true", "1", "yes")
|
|
86
|
+
log.debug(
|
|
87
|
+
"config_require_authentication: %s", config_require_authentication
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Extract claims from the event (set by token_decoder)
|
|
91
|
+
claims = _extract_claims(event)
|
|
92
|
+
if not claims:
|
|
93
|
+
if config_require_authentication:
|
|
94
|
+
raise ApplicationException(
|
|
95
|
+
status_code=401, message="No authentication claims found"
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
log.debug("Found claims: %s", list(claims.keys()))
|
|
99
|
+
|
|
100
|
+
# Validate scope format if requested
|
|
101
|
+
if validate_scope_format:
|
|
102
|
+
_validate_scope_format(claims)
|
|
103
|
+
|
|
104
|
+
# Check minimum scope level if specified
|
|
105
|
+
if min_scope_level:
|
|
106
|
+
_validate_min_scope_level(claims, min_scope_level)
|
|
107
|
+
|
|
108
|
+
# Auto-validate path-based scope if enabled
|
|
109
|
+
if validate_path_scope:
|
|
110
|
+
_validate_path_scope(claims, event, operation_type, entity_name)
|
|
111
|
+
|
|
112
|
+
# Check specific scopes if required
|
|
113
|
+
if required_scopes:
|
|
114
|
+
_validate_required_scopes(
|
|
115
|
+
claims, required_scopes, event, operation_type, entity_name
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Check specific permissions if required
|
|
119
|
+
if required_permissions:
|
|
120
|
+
_validate_required_permissions(claims, required_permissions)
|
|
121
|
+
|
|
122
|
+
log.debug("Authentication validation passed")
|
|
123
|
+
|
|
124
|
+
# Execute the original function
|
|
125
|
+
return func(event, context)
|
|
126
|
+
|
|
127
|
+
except ApplicationException:
|
|
128
|
+
raise
|
|
129
|
+
except Exception as e:
|
|
130
|
+
log.error("Claims check error: %s", str(e))
|
|
131
|
+
raise ApplicationException(
|
|
132
|
+
status_code=500,
|
|
133
|
+
message="Internal server error during claims validation",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return wrapper
|
|
137
|
+
|
|
138
|
+
return decorator
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_claims(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
142
|
+
"""Extract JWT claims from the event context.
|
|
143
|
+
|
|
144
|
+
Checks multiple locations where claims might be stored:
|
|
145
|
+
- requestContext.authorizer (API Gateway TOKEN authorizer)
|
|
146
|
+
- requestContext.authorizer.claims (nested claims)
|
|
147
|
+
- requestContext.authorizer.iam (IAM authorizer context)
|
|
148
|
+
- requestContext.authorizer.lambda (Lambda authorizer context)
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
request_context = event.get("requestContext", {})
|
|
152
|
+
authorizer = request_context.get("authorizer")
|
|
153
|
+
|
|
154
|
+
if authorizer is None:
|
|
155
|
+
log.debug("No authorizer found in requestContext")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Check direct authorizer context (most common for TOKEN authorizer)
|
|
159
|
+
claim_keys = ["sub", "scope", "permissions", "roles"]
|
|
160
|
+
if isinstance(authorizer, dict) and any(
|
|
161
|
+
key in authorizer for key in claim_keys
|
|
162
|
+
):
|
|
163
|
+
log.debug("Found claims in requestContext.authorizer")
|
|
164
|
+
return authorizer
|
|
165
|
+
|
|
166
|
+
# Check nested claims object
|
|
167
|
+
claims = authorizer.get("claims")
|
|
168
|
+
if claims and isinstance(claims, dict):
|
|
169
|
+
log.debug("Found claims in requestContext.authorizer.claims")
|
|
170
|
+
return claims
|
|
171
|
+
|
|
172
|
+
# Check IAM authorizer context
|
|
173
|
+
iam_context = authorizer.get("iam")
|
|
174
|
+
if iam_context and isinstance(iam_context, dict):
|
|
175
|
+
log.debug("Found claims in requestContext.authorizer.iam")
|
|
176
|
+
return iam_context
|
|
177
|
+
|
|
178
|
+
# Check Lambda authorizer context
|
|
179
|
+
lambda_context = authorizer.get("lambda")
|
|
180
|
+
if lambda_context and isinstance(lambda_context, dict):
|
|
181
|
+
log.debug("Found claims in requestContext.authorizer.lambda")
|
|
182
|
+
return lambda_context
|
|
183
|
+
|
|
184
|
+
# If authorizer exists but no recognized claims format, return it
|
|
185
|
+
# This handles empty authorizers or custom claim structures
|
|
186
|
+
log.debug("Authorizer exists but no standard claims structure found")
|
|
187
|
+
return authorizer
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
log.warning("Failed to extract claims: %s", str(e))
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Operation types and resources are handled by the SQL Query Engine
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate_scope_format(claims: Dict[str, Any]) -> None:
|
|
198
|
+
"""Validate that scopes follow expected format."""
|
|
199
|
+
scopes = claims.get("scope", "")
|
|
200
|
+
if isinstance(scopes, str):
|
|
201
|
+
scopes = scopes.split()
|
|
202
|
+
elif not isinstance(scopes, list):
|
|
203
|
+
scopes = []
|
|
204
|
+
|
|
205
|
+
# Allow empty scopes if permissions exist
|
|
206
|
+
permissions = claims.get("permissions", [])
|
|
207
|
+
if not scopes and not permissions:
|
|
208
|
+
raise ApplicationException(
|
|
209
|
+
status_code=403, message="No scopes or permissions found in token"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Validate scope format: should be operation:resource or operation:*
|
|
213
|
+
for scope in scopes:
|
|
214
|
+
if not re.match(r"^[a-zA-Z]+:[a-zA-Z*_-]+$", scope):
|
|
215
|
+
log.warning("Invalid scope format: %s", scope)
|
|
216
|
+
# Don't fail on invalid format, just log warning
|
|
217
|
+
# The SQL layer will handle actual permission enforcement
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _validate_min_scope_level(claims: Dict[str, Any], min_level: str) -> None:
|
|
221
|
+
"""Validate that user has at least the minimum scope level."""
|
|
222
|
+
scopes = claims.get("scope", "")
|
|
223
|
+
if isinstance(scopes, str):
|
|
224
|
+
scopes = scopes.split()
|
|
225
|
+
elif not isinstance(scopes, list):
|
|
226
|
+
scopes = []
|
|
227
|
+
|
|
228
|
+
# Define scope hierarchy
|
|
229
|
+
scope_hierarchy = {"read": 1, "write": 2, "delete": 3, "admin": 4}
|
|
230
|
+
|
|
231
|
+
min_level_value = scope_hierarchy.get(min_level, 0)
|
|
232
|
+
user_max_level = 0
|
|
233
|
+
|
|
234
|
+
for scope in scopes:
|
|
235
|
+
if ":" in scope:
|
|
236
|
+
operation = scope.split(":")[0].lower()
|
|
237
|
+
scope_value = scope_hierarchy.get(operation, 0)
|
|
238
|
+
user_max_level = max(user_max_level, scope_value)
|
|
239
|
+
|
|
240
|
+
if user_max_level < min_level_value:
|
|
241
|
+
raise ApplicationException(
|
|
242
|
+
status_code=403, message=f"Insufficient scope level. Required: {min_level}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _validate_path_scope(
|
|
247
|
+
claims: Dict[str, Any],
|
|
248
|
+
event: Dict[str, Any],
|
|
249
|
+
operation_type: Optional[str],
|
|
250
|
+
entity_name: Optional[str],
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Validate that user has scope matching the request path and method."""
|
|
253
|
+
user_scopes = claims.get("scope", "")
|
|
254
|
+
if isinstance(user_scopes, str):
|
|
255
|
+
user_scopes = user_scopes.split()
|
|
256
|
+
elif not isinstance(user_scopes, list):
|
|
257
|
+
user_scopes = []
|
|
258
|
+
|
|
259
|
+
# Get user permissions as well
|
|
260
|
+
user_permissions = claims.get("permissions", [])
|
|
261
|
+
if isinstance(user_permissions, str):
|
|
262
|
+
try:
|
|
263
|
+
import json
|
|
264
|
+
|
|
265
|
+
user_permissions = json.loads(user_permissions)
|
|
266
|
+
except (json.JSONDecodeError, ValueError):
|
|
267
|
+
user_permissions = []
|
|
268
|
+
elif not isinstance(user_permissions, list):
|
|
269
|
+
user_permissions = []
|
|
270
|
+
|
|
271
|
+
# Skip path scope validation if user has no scopes or permissions
|
|
272
|
+
if not user_scopes and not user_permissions:
|
|
273
|
+
log.debug("No scopes or permissions found, skipping path validation")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
# Determine operation and entity from request
|
|
277
|
+
operation = operation_type or _extract_operation_type(event)
|
|
278
|
+
entity = entity_name or _extract_entity_from_path(event)
|
|
279
|
+
|
|
280
|
+
if not entity:
|
|
281
|
+
# Can't validate without entity - skip validation
|
|
282
|
+
log.debug("No entity found in path, skipping path scope validation")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Construct required scope for this request
|
|
286
|
+
required_scope = f"{operation}:{entity}"
|
|
287
|
+
|
|
288
|
+
# Check if user has the required scope OR equivalent permission
|
|
289
|
+
has_scope = _scope_matches(user_scopes, required_scope, operation, entity)
|
|
290
|
+
required_permission = f"{entity}.{operation}"
|
|
291
|
+
has_permission = _permission_matches(user_permissions, required_permission)
|
|
292
|
+
|
|
293
|
+
if not has_scope and not has_permission:
|
|
294
|
+
raise ApplicationException(
|
|
295
|
+
status_code=403,
|
|
296
|
+
message=f"Access denied. Required scope: {required_scope} "
|
|
297
|
+
f"or permission: {required_permission}",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
log.debug("Path scope validation passed for %s", required_scope)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _validate_required_scopes(
|
|
304
|
+
claims: Dict[str, Any],
|
|
305
|
+
required_scopes: list,
|
|
306
|
+
event: Dict[str, Any],
|
|
307
|
+
operation_type: Optional[str],
|
|
308
|
+
entity_name: Optional[str],
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Validate that user has required scopes."""
|
|
311
|
+
user_scopes = claims.get("scope", "")
|
|
312
|
+
if isinstance(user_scopes, str):
|
|
313
|
+
user_scopes = user_scopes.split()
|
|
314
|
+
elif not isinstance(user_scopes, list):
|
|
315
|
+
user_scopes = []
|
|
316
|
+
|
|
317
|
+
# Determine operation and entity
|
|
318
|
+
operation = operation_type or _extract_operation_type(event)
|
|
319
|
+
entity = entity_name or _extract_entity_from_path(event)
|
|
320
|
+
|
|
321
|
+
# Check each required scope
|
|
322
|
+
for required_scope in required_scopes:
|
|
323
|
+
if not _scope_matches(user_scopes, required_scope, operation, entity):
|
|
324
|
+
raise ApplicationException(
|
|
325
|
+
status_code=403, message=f"Required scope not found: {required_scope}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_required_permissions(
|
|
330
|
+
claims: Dict[str, Any], required_permissions: list
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Validate that user has required permissions."""
|
|
333
|
+
user_permissions = claims.get("permissions", [])
|
|
334
|
+
if isinstance(user_permissions, str):
|
|
335
|
+
# Handle JSON string format
|
|
336
|
+
try:
|
|
337
|
+
import json
|
|
338
|
+
|
|
339
|
+
user_permissions = json.loads(user_permissions)
|
|
340
|
+
except (json.JSONDecodeError, ValueError):
|
|
341
|
+
user_permissions = []
|
|
342
|
+
elif not isinstance(user_permissions, list):
|
|
343
|
+
user_permissions = []
|
|
344
|
+
|
|
345
|
+
# Check each required permission
|
|
346
|
+
for required_permission in required_permissions:
|
|
347
|
+
if not _permission_matches(user_permissions, required_permission):
|
|
348
|
+
raise ApplicationException(
|
|
349
|
+
status_code=403,
|
|
350
|
+
message=f"Required permission not found: {required_permission}",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# The Query Engine handles granular permissions at the SQL level
|
|
355
|
+
# This decorator only validates basic authentication and scope levels
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Convenience decorators for Query Engine access levels
|
|
359
|
+
def requires_authentication():
|
|
360
|
+
"""Require valid authentication but allow any scopes."""
|
|
361
|
+
return claims_check(require_authentication=True)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def requires_read_access():
|
|
365
|
+
"""Require at least read-level access."""
|
|
366
|
+
return claims_check(min_scope_level="read")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def requires_write_access():
|
|
370
|
+
"""Require at least write-level access."""
|
|
371
|
+
return claims_check(min_scope_level="write")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def requires_delete_access():
|
|
375
|
+
"""Require at least delete-level access."""
|
|
376
|
+
return claims_check(min_scope_level="delete")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def requires_admin_access():
|
|
380
|
+
"""Require admin-level access."""
|
|
381
|
+
return claims_check(min_scope_level="admin")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Additional utility functions for testing and convenience
|
|
385
|
+
def requires_read_scope(entity: Optional[str] = None, extract_from_path: bool = False):
|
|
386
|
+
"""Convenience decorator for read operations."""
|
|
387
|
+
# Parameters kept for compatibility but not used in simple implementation
|
|
388
|
+
_ = entity, extract_from_path # Silence unused warnings
|
|
389
|
+
return claims_check(min_scope_level="read")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def requires_write_scope(entity: Optional[str] = None, extract_from_path: bool = False):
|
|
393
|
+
"""Convenience decorator for write operations."""
|
|
394
|
+
# Parameters kept for compatibility but not used in simple implementation
|
|
395
|
+
_ = entity, extract_from_path # Silence unused warnings
|
|
396
|
+
return claims_check(min_scope_level="write")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _extract_operation_type(event: Dict[str, Any]) -> str:
|
|
400
|
+
"""Extract operation type from HTTP method."""
|
|
401
|
+
method = event.get("httpMethod", "GET").upper()
|
|
402
|
+
if method == "GET":
|
|
403
|
+
return "read"
|
|
404
|
+
elif method in ["POST", "PUT", "PATCH"]:
|
|
405
|
+
return "write"
|
|
406
|
+
elif method == "DELETE":
|
|
407
|
+
return "delete"
|
|
408
|
+
else:
|
|
409
|
+
return "read" # default
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _extract_entity_from_path(event: Dict[str, Any]) -> Optional[str]:
|
|
413
|
+
"""Extract entity name from request path."""
|
|
414
|
+
path = event.get("path") or event.get("resource", "")
|
|
415
|
+
if not path:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
# Remove leading slash and split by slash
|
|
419
|
+
path_parts = path.lstrip("/").split("/")
|
|
420
|
+
|
|
421
|
+
# Skip API prefixes and return the last non-parameter part
|
|
422
|
+
# For paths like "/chinook-api/album" or "/api/v1/customer/123"
|
|
423
|
+
for part in reversed(path_parts):
|
|
424
|
+
if part and not part.startswith("{") and not part.isdigit():
|
|
425
|
+
return part
|
|
426
|
+
|
|
427
|
+
# If no suitable part found, return the last non-parameter part
|
|
428
|
+
for part in reversed(path_parts):
|
|
429
|
+
if part and not part.startswith("{"):
|
|
430
|
+
return part
|
|
431
|
+
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _scope_matches(
|
|
436
|
+
user_scopes: list, required_scope: str, operation: str, entity: str
|
|
437
|
+
) -> bool:
|
|
438
|
+
"""Check if user scopes match the required scope."""
|
|
439
|
+
_ = entity # Silence unused warning
|
|
440
|
+
# Check for exact match
|
|
441
|
+
if required_scope in user_scopes:
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
# Check for wildcard matches
|
|
445
|
+
wildcard_patterns = [
|
|
446
|
+
f"{operation}:*", # operation wildcard
|
|
447
|
+
"*:*", # global wildcard
|
|
448
|
+
"*", # simple wildcard
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
for pattern in wildcard_patterns:
|
|
452
|
+
if pattern in user_scopes:
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _permission_matches(user_permissions: list, required_permission: str) -> bool:
|
|
459
|
+
"""Check if user permissions match the required permission."""
|
|
460
|
+
# Direct match
|
|
461
|
+
if required_permission in user_permissions:
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
# Wildcard match (e.g., "customer.*" matches "customer.read")
|
|
465
|
+
for perm in user_permissions:
|
|
466
|
+
if perm.endswith(".*"):
|
|
467
|
+
prefix = perm[:-2] # Remove ".*"
|
|
468
|
+
if required_permission.startswith(prefix + "."):
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
return False
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency Resolver for Batch Operations
|
|
3
|
+
|
|
4
|
+
Performs topological sorting of operations based on their dependency relationships.
|
|
5
|
+
Detects circular dependencies and provides clear error messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Dict, Set
|
|
9
|
+
from api_foundry_query_engine.utils.app_exception import ApplicationException
|
|
10
|
+
from api_foundry_query_engine.utils.logger import logger
|
|
11
|
+
|
|
12
|
+
log = logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DependencyResolver:
|
|
16
|
+
"""Resolves operation dependencies and determines execution order."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, operations: List[Dict]):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the dependency resolver.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
operations: List of operation dictionaries with 'id' and optional 'depends_on'
|
|
24
|
+
"""
|
|
25
|
+
self.operations = operations
|
|
26
|
+
self.op_map = {op["id"]: op for op in operations}
|
|
27
|
+
self._validate_operations()
|
|
28
|
+
|
|
29
|
+
def _validate_operations(self):
|
|
30
|
+
"""Validate that operations have unique IDs and valid dependencies."""
|
|
31
|
+
# Check for unique IDs
|
|
32
|
+
ids = [op["id"] for op in self.operations]
|
|
33
|
+
if len(ids) != len(set(ids)):
|
|
34
|
+
duplicates = [id for id in ids if ids.count(id) > 1]
|
|
35
|
+
raise ApplicationException(
|
|
36
|
+
400, f"Duplicate operation IDs found: {set(duplicates)}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Check that all dependencies reference valid operation IDs
|
|
40
|
+
for op in self.operations:
|
|
41
|
+
depends_on = op.get("depends_on", [])
|
|
42
|
+
for dep_id in depends_on:
|
|
43
|
+
if dep_id not in self.op_map:
|
|
44
|
+
raise ApplicationException(
|
|
45
|
+
400,
|
|
46
|
+
f"Operation '{op['id']}' depends on unknown operation '{dep_id}'",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def get_execution_order(self) -> List[str]:
|
|
50
|
+
"""
|
|
51
|
+
Determine execution order using topological sort (Kahn's algorithm).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of operation IDs in execution order
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ApplicationException: If circular dependencies detected
|
|
58
|
+
"""
|
|
59
|
+
# Build adjacency list and in-degree map
|
|
60
|
+
# graph maps: operation_id -> list of operations it depends on
|
|
61
|
+
graph = {op["id"]: op.get("depends_on", []) for op in self.operations}
|
|
62
|
+
in_degree = {op_id: 0 for op_id in graph}
|
|
63
|
+
|
|
64
|
+
# Calculate in-degrees - how many dependencies each operation has
|
|
65
|
+
for op_id, dependencies in graph.items():
|
|
66
|
+
in_degree[op_id] = len(dependencies)
|
|
67
|
+
|
|
68
|
+
# Find all nodes with no dependencies (in-degree = 0)
|
|
69
|
+
queue = [op_id for op_id, degree in in_degree.items() if degree == 0]
|
|
70
|
+
result = []
|
|
71
|
+
|
|
72
|
+
while queue:
|
|
73
|
+
# Remove node from queue
|
|
74
|
+
node = queue.pop(0)
|
|
75
|
+
result.append(node)
|
|
76
|
+
|
|
77
|
+
# For each operation that depends on this node, reduce in-degree
|
|
78
|
+
for op_id, dependencies in graph.items():
|
|
79
|
+
if node in dependencies:
|
|
80
|
+
in_degree[op_id] -= 1
|
|
81
|
+
if in_degree[op_id] == 0:
|
|
82
|
+
queue.append(op_id)
|
|
83
|
+
|
|
84
|
+
# If result doesn't contain all nodes, there's a cycle
|
|
85
|
+
if len(result) != len(graph):
|
|
86
|
+
cycle = self._find_cycle(graph)
|
|
87
|
+
raise ApplicationException(
|
|
88
|
+
400, f"Circular dependency detected: {' -> '.join(cycle)}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
log.info(f"Execution order determined: {result}")
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def _find_cycle(self, graph: Dict[str, List[str]]) -> List[str]:
|
|
95
|
+
"""
|
|
96
|
+
Find and return a cycle in the dependency graph for error reporting.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
graph: Adjacency list representation of dependencies
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of operation IDs forming a cycle
|
|
103
|
+
"""
|
|
104
|
+
visited = set()
|
|
105
|
+
rec_stack = set()
|
|
106
|
+
|
|
107
|
+
def dfs(node: str, path: List[str]) -> List[str]:
|
|
108
|
+
visited.add(node)
|
|
109
|
+
rec_stack.add(node)
|
|
110
|
+
path.append(node)
|
|
111
|
+
|
|
112
|
+
# Check all dependencies
|
|
113
|
+
for dependency in graph.get(node, []):
|
|
114
|
+
if dependency not in visited:
|
|
115
|
+
cycle = dfs(dependency, path[:])
|
|
116
|
+
if cycle:
|
|
117
|
+
return cycle
|
|
118
|
+
elif dependency in rec_stack:
|
|
119
|
+
# Found cycle - return the cycle path
|
|
120
|
+
cycle_start = path.index(dependency)
|
|
121
|
+
return path[cycle_start:] + [dependency]
|
|
122
|
+
|
|
123
|
+
rec_stack.remove(node)
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
for node in graph:
|
|
127
|
+
if node not in visited:
|
|
128
|
+
cycle = dfs(node, [])
|
|
129
|
+
if cycle:
|
|
130
|
+
return cycle
|
|
131
|
+
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
def get_independent_operations(self) -> Set[str]:
|
|
135
|
+
"""
|
|
136
|
+
Get operations that have no dependencies (can execute immediately).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Set of operation IDs with no dependencies
|
|
140
|
+
"""
|
|
141
|
+
return {op["id"] for op in self.operations if not op.get("depends_on", [])}
|
|
142
|
+
|
|
143
|
+
def get_dependents(self, op_id: str) -> List[str]:
|
|
144
|
+
"""
|
|
145
|
+
Get all operations that depend on the given operation.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
op_id: Operation ID to check
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of operation IDs that depend on op_id
|
|
152
|
+
"""
|
|
153
|
+
dependents = []
|
|
154
|
+
for op in self.operations:
|
|
155
|
+
if op_id in op.get("depends_on", []):
|
|
156
|
+
dependents.append(op["id"])
|
|
157
|
+
return dependents
|