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.
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/PKG-INFO +1 -1
- api_foundry_query_engine-0.8.75/api_foundry_query_engine/__init__.py +1 -0
- {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
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/operation_dao.py +17 -38
- {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
- {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
- {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
- {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
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/operation.py +38 -2
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/token_decoder.py +20 -67
- api_foundry_query_engine-0.8.73/api_foundry_query_engine/__init__.py +0 -1
- api_foundry_query_engine-0.8.73/api_foundry_query_engine/adapters/case_change_adapter.py +0 -79
- api_foundry_query_engine-0.8.73/api_foundry_query_engine/adapters/security_adapter.py +0 -106
- api_foundry_query_engine-0.8.73/api_foundry_query_engine/connectors/oracle_connector.py +0 -29
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/.gitignore +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/LICENSE +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/.pre-commit-config.yaml +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/adapters/adapter.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/adapters/gateway_adapter.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/connectors/connection.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/connectors/connection_factory.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/connectors/postgres_connection.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/dao/dao.py +0 -0
- {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
- {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
- {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
- {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
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/lambda_handler.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/services/service.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/services/transactional_service.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/api_model.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/app_exception.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/claims_check.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/dependency_resolver.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/logger.py +0 -0
- {api_foundry_query_engine-0.8.73 → api_foundry_query_engine-0.8.75}/api_foundry_query_engine/utils/reference_resolver.py +0 -0
- {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.
|
|
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__(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|