elody 0.0.62__py3-none-any.whl → 0.0.162__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.
- elody/client.py +70 -23
- elody/csv.py +118 -21
- elody/error_codes.py +112 -0
- elody/exceptions.py +14 -0
- elody/job.py +95 -0
- elody/loader.py +33 -5
- elody/migration/__init__.py +0 -0
- elody/migration/base_object_migrator.py +18 -0
- elody/object_configurations/__init__.py +0 -0
- elody/object_configurations/base_object_configuration.py +174 -0
- elody/object_configurations/elody_configuration.py +144 -0
- elody/object_configurations/job_configuration.py +65 -0
- elody/policies/authentication/base_user_tenant_validation_policy.py +48 -15
- elody/policies/authorization/filter_generic_objects_policy.py +68 -22
- elody/policies/authorization/filter_generic_objects_policy_v2.py +166 -0
- elody/policies/authorization/generic_object_detail_policy.py +10 -27
- elody/policies/authorization/generic_object_mediafiles_policy.py +82 -0
- elody/policies/authorization/generic_object_metadata_policy.py +8 -27
- elody/policies/authorization/generic_object_relations_policy.py +12 -29
- elody/policies/authorization/generic_object_request_policy.py +56 -55
- elody/policies/authorization/generic_object_request_policy_v2.py +133 -0
- elody/policies/authorization/mediafile_derivatives_policy.py +92 -0
- elody/policies/authorization/mediafile_download_policy.py +71 -0
- elody/policies/authorization/multi_tenant_policy.py +14 -6
- elody/policies/authorization/tenant_request_policy.py +3 -1
- elody/policies/helpers.py +37 -0
- elody/policies/permission_handler.py +217 -211
- elody/policies/tenant_id_resolver.py +375 -0
- elody/schemas.py +0 -3
- elody/util.py +165 -11
- {elody-0.0.62.dist-info → elody-0.0.162.dist-info}/METADATA +16 -11
- elody-0.0.162.dist-info/RECORD +47 -0
- {elody-0.0.62.dist-info → elody-0.0.162.dist-info}/WHEEL +1 -1
- {elody-0.0.62.dist-info → elody-0.0.162.dist-info}/top_level.txt +1 -0
- tests/__init_.py +0 -0
- tests/data.py +74 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_csv.py +410 -0
- tests/unit/test_utils.py +293 -0
- elody-0.0.62.dist-info/RECORD +0 -27
- {elody-0.0.62.dist-info → elody-0.0.162.dist-info}/LICENSE +0 -0
|
@@ -1,61 +1,34 @@
|
|
|
1
|
+
import re as regex
|
|
2
|
+
|
|
3
|
+
from configuration import get_object_configuration_mapper # pyright: ignore
|
|
1
4
|
from copy import deepcopy
|
|
2
|
-
from elody.
|
|
5
|
+
from elody.error_codes import ErrorCode, get_error_code, get_read
|
|
6
|
+
from elody.util import flatten_dict, interpret_flat_key
|
|
3
7
|
from inuits_policy_based_auth.contexts.user_context import UserContext
|
|
8
|
+
from logging_elody.log import log # pyright: ignore
|
|
4
9
|
|
|
5
10
|
|
|
6
11
|
_permissions = {}
|
|
12
|
+
_placeholders = ["X_TENANT_ID", "TENANT_DEFINING_ENTITY_ID"]
|
|
7
13
|
|
|
8
14
|
|
|
9
|
-
def set_permissions(permissions: dict):
|
|
15
|
+
def set_permissions(permissions: dict, placeholders: list[str] = []):
|
|
10
16
|
global _permissions
|
|
11
17
|
_permissions = permissions
|
|
18
|
+
_placeholders.extend(placeholders)
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
def get_permissions(role: str, user_context: UserContext):
|
|
15
22
|
permissions = deepcopy(_permissions)
|
|
16
|
-
placeholders = ["X_TENANT_ID", "TENANT_DEFINING_ENTITY_ID"]
|
|
17
23
|
|
|
18
|
-
for
|
|
24
|
+
for placeholder_key in _placeholders:
|
|
25
|
+
placeholder_value = user_context.bag.get(placeholder_key.lower())
|
|
19
26
|
permissions = __replace_permission_placeholders(
|
|
20
|
-
permissions,
|
|
27
|
+
permissions, placeholder_key, placeholder_value
|
|
21
28
|
)
|
|
22
29
|
return permissions.get(role, {}) # pyright: ignore
|
|
23
30
|
|
|
24
31
|
|
|
25
|
-
def handle_single_item_request(
|
|
26
|
-
user_context: UserContext, item, permissions, crud, request_body={}
|
|
27
|
-
):
|
|
28
|
-
is_allowed_to_crud_item = __is_allowed_to_crud_item(item, permissions, crud)
|
|
29
|
-
if not is_allowed_to_crud_item:
|
|
30
|
-
return is_allowed_to_crud_item
|
|
31
|
-
|
|
32
|
-
return __is_allowed_to_crud_item_keys(
|
|
33
|
-
user_context, item, permissions, crud, request_body
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def get_mask_protected_content_post_request_hook(
|
|
38
|
-
user_context: UserContext, permissions, item_type=None
|
|
39
|
-
):
|
|
40
|
-
def __post_request_hook(
|
|
41
|
-
response, *, is_single_item_response=False, single_item_sub_route_key=""
|
|
42
|
-
):
|
|
43
|
-
if is_single_item_response:
|
|
44
|
-
if single_item_sub_route_key in ["metadata", "relations"]:
|
|
45
|
-
items = [{single_item_sub_route_key: response[0], "type": item_type}]
|
|
46
|
-
else:
|
|
47
|
-
items = [response[0]]
|
|
48
|
-
else:
|
|
49
|
-
items = response[0]["results"]
|
|
50
|
-
|
|
51
|
-
for item in items:
|
|
52
|
-
__is_allowed_to_crud_item_keys(user_context, item, permissions, "read")
|
|
53
|
-
|
|
54
|
-
return response
|
|
55
|
-
|
|
56
|
-
return __post_request_hook
|
|
57
|
-
|
|
58
|
-
|
|
59
32
|
def __replace_permission_placeholders(data, placeholder_key, placeholder_value):
|
|
60
33
|
if isinstance(data, dict):
|
|
61
34
|
for key, value in data.items():
|
|
@@ -68,195 +41,228 @@ def __replace_permission_placeholders(data, placeholder_key, placeholder_value):
|
|
|
68
41
|
for item in data
|
|
69
42
|
]
|
|
70
43
|
elif isinstance(data, str):
|
|
71
|
-
|
|
44
|
+
if isinstance(placeholder_value, str):
|
|
45
|
+
data = data.replace(placeholder_key, placeholder_value)
|
|
46
|
+
elif isinstance(placeholder_value, list) and data == placeholder_key:
|
|
47
|
+
data = placeholder_value
|
|
72
48
|
return data
|
|
73
49
|
|
|
74
50
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
51
|
+
def handle_single_item_request(
|
|
52
|
+
user_context: UserContext, item, permissions, crud, request_body: dict = {}
|
|
53
|
+
):
|
|
54
|
+
try:
|
|
55
|
+
item_in_storage_format, flat_item, object_lists, restrictions_schema = (
|
|
56
|
+
__prepare_item_for_permission_check(item, permissions, crud)
|
|
57
|
+
)
|
|
80
58
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
59
|
+
is_allowed_to_crud_item = (
|
|
60
|
+
__is_allowed_to_crud_item(flat_item, restrictions_schema)
|
|
61
|
+
if flat_item
|
|
62
|
+
else None
|
|
63
|
+
)
|
|
64
|
+
if not is_allowed_to_crud_item:
|
|
65
|
+
return is_allowed_to_crud_item
|
|
66
|
+
|
|
67
|
+
return __is_allowed_to_crud_item_keys(
|
|
68
|
+
user_context,
|
|
69
|
+
item_in_storage_format,
|
|
70
|
+
flat_item,
|
|
71
|
+
restrictions_schema,
|
|
72
|
+
crud,
|
|
73
|
+
object_lists,
|
|
74
|
+
flatten_dict(object_lists, request_body),
|
|
75
|
+
)
|
|
76
|
+
except Exception as exception:
|
|
77
|
+
log.debug(
|
|
78
|
+
f"{exception.__class__.__name__}: {str(exception)}",
|
|
79
|
+
item.get("storage_format", item),
|
|
80
|
+
)
|
|
81
|
+
if crud != "read":
|
|
82
|
+
log.debug(f"Request body: {request_body}", {})
|
|
83
|
+
raise exception
|
|
91
84
|
|
|
92
|
-
for relation in restrictions.get("relations", []):
|
|
93
|
-
keys = _get_relation_keys(item, relation["key"])
|
|
94
|
-
for expected_value in relation["value"]:
|
|
95
|
-
if expected_value in keys:
|
|
96
|
-
return True
|
|
97
|
-
return None
|
|
98
|
-
|
|
99
|
-
for root in restrictions.get("root", []):
|
|
100
|
-
value = item[root["key"]]
|
|
101
|
-
if isinstance(value, str):
|
|
102
|
-
if value not in root["value"]:
|
|
103
|
-
return None
|
|
104
|
-
elif isinstance(value, list):
|
|
105
|
-
for expected_value in root["value"]:
|
|
106
|
-
if expected_value in value:
|
|
107
|
-
return True
|
|
108
|
-
return None
|
|
109
85
|
|
|
86
|
+
def mask_protected_content_post_request_hook(user_context: UserContext, permissions):
|
|
87
|
+
def __post_request_hook(response):
|
|
88
|
+
items = []
|
|
89
|
+
for item in response["results"]:
|
|
90
|
+
try:
|
|
91
|
+
(
|
|
92
|
+
item_in_storage_format,
|
|
93
|
+
flat_item,
|
|
94
|
+
object_lists,
|
|
95
|
+
restrictions_schema,
|
|
96
|
+
) = __prepare_item_for_permission_check(item, permissions, "read")
|
|
97
|
+
if not flat_item:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
__is_allowed_to_crud_item_keys(
|
|
101
|
+
user_context,
|
|
102
|
+
item_in_storage_format,
|
|
103
|
+
flat_item,
|
|
104
|
+
restrictions_schema,
|
|
105
|
+
"read",
|
|
106
|
+
object_lists,
|
|
107
|
+
)
|
|
108
|
+
items.append(user_context.bag["requested_item"])
|
|
109
|
+
except Exception as exception:
|
|
110
|
+
log.debug(
|
|
111
|
+
f"{exception.__class__.__name__}: {str(exception)}",
|
|
112
|
+
item.get("storage_format", item),
|
|
113
|
+
)
|
|
114
|
+
raise exception
|
|
115
|
+
|
|
116
|
+
response["results"] = items
|
|
117
|
+
return response
|
|
110
118
|
|
|
111
|
-
return
|
|
119
|
+
return __post_request_hook
|
|
112
120
|
|
|
113
121
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if relation["type"] == relation_type
|
|
119
|
-
]
|
|
122
|
+
def __prepare_item_for_permission_check(item, permissions, crud):
|
|
123
|
+
item = deepcopy(item.get("storage_format", item))
|
|
124
|
+
if item.get("type", "") not in permissions[crud].keys():
|
|
125
|
+
return item, None, None, None
|
|
120
126
|
|
|
127
|
+
config = get_object_configuration_mapper().get(item["type"])
|
|
128
|
+
object_lists = config.document_info().get("object_lists", {})
|
|
129
|
+
flat_item = flatten_dict(object_lists, item)
|
|
121
130
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
permissions[crud][item["type"]]
|
|
131
|
+
return (
|
|
132
|
+
item,
|
|
133
|
+
flat_item,
|
|
134
|
+
object_lists,
|
|
135
|
+
__get_restrictions_schema(flat_item, permissions, crud),
|
|
128
136
|
)
|
|
129
137
|
|
|
130
|
-
if keys_permissions:
|
|
131
|
-
initial_item = deepcopy(item)
|
|
132
|
-
for key in item.keys():
|
|
133
|
-
data_key = ""
|
|
134
|
-
if key == "metadata":
|
|
135
|
-
data_key = "key"
|
|
136
|
-
(
|
|
137
|
-
permission_key_data_map,
|
|
138
|
-
data_value_key,
|
|
139
|
-
) = __determine_data_per_permission_key(item, key, data_key, "value")
|
|
140
|
-
elif key == "relations":
|
|
141
|
-
data_key = "type"
|
|
142
|
-
(
|
|
143
|
-
permission_key_data_map,
|
|
144
|
-
data_value_key,
|
|
145
|
-
) = __determine_data_per_permission_key(item, key, data_key, "key")
|
|
146
|
-
else:
|
|
147
|
-
(
|
|
148
|
-
permission_key_data_map,
|
|
149
|
-
data_value_key,
|
|
150
|
-
) = __determine_data_per_permission_key(item, key)
|
|
151
|
-
|
|
152
|
-
for permission_key, data in permission_key_data_map.items():
|
|
153
|
-
if __is_not_valid_request_on_key(
|
|
154
|
-
initial_item,
|
|
155
|
-
keys_permissions,
|
|
156
|
-
negate_condition,
|
|
157
|
-
key,
|
|
158
|
-
permission_key,
|
|
159
|
-
data_value_key,
|
|
160
|
-
):
|
|
161
|
-
if crud == "read":
|
|
162
|
-
data[data_value_key] = "[protected content]"
|
|
163
|
-
else:
|
|
164
|
-
if key == data_value_key:
|
|
165
|
-
if request_body.get(key):
|
|
166
|
-
user_context.bag["soft_call_response_body"].append(key)
|
|
167
|
-
else:
|
|
168
|
-
for element in request_body.get(key, []):
|
|
169
|
-
if f"{key}.{element[data_key]}" == permission_key:
|
|
170
|
-
user_context.bag["soft_call_response_body"].append(
|
|
171
|
-
permission_key
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
return len(user_context.bag["soft_call_response_body"]) == 0
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def __get_keys_permissions(item_permissions):
|
|
178
|
-
if item_permissions.get("keys"):
|
|
179
|
-
negate_condition = False
|
|
180
|
-
keys_permissions = item_permissions["keys"].get("allowed_only", {})
|
|
181
|
-
if not keys_permissions:
|
|
182
|
-
negate_condition = True
|
|
183
|
-
keys_permissions = item_permissions["keys"].get("disallowed_only", {})
|
|
184
|
-
|
|
185
|
-
return keys_permissions, negate_condition
|
|
186
|
-
|
|
187
|
-
return None, None
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def __determine_data_per_permission_key(item, root_key, data_key="", data_value_key=""):
|
|
191
|
-
key_data_map = {}
|
|
192
|
-
|
|
193
|
-
if data_key and data_value_key:
|
|
194
|
-
for data in item[root_key]:
|
|
195
|
-
key_data_map.update({f"{root_key}.{data[data_key]}": data})
|
|
196
|
-
value_key = data_value_key
|
|
197
|
-
else:
|
|
198
|
-
key_data_map.update({root_key: item})
|
|
199
|
-
value_key = root_key
|
|
200
|
-
|
|
201
|
-
return key_data_map, value_key
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def __is_not_valid_request_on_key(
|
|
205
|
-
initial_item,
|
|
206
|
-
keys_permissions,
|
|
207
|
-
negate_condition,
|
|
208
|
-
root_key,
|
|
209
|
-
key,
|
|
210
|
-
data_value_key,
|
|
211
|
-
):
|
|
212
|
-
if root_key == data_value_key:
|
|
213
|
-
is_in_keys_permissions = lambda: key in keys_permissions.keys()
|
|
214
|
-
else:
|
|
215
|
-
is_in_keys_permissions = (
|
|
216
|
-
lambda: key in keys_permissions.keys()
|
|
217
|
-
or f"{root_key}.*" in keys_permissions.keys()
|
|
218
|
-
)
|
|
219
138
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
139
|
+
def __get_restrictions_schema(flat_item, permissions, crud):
|
|
140
|
+
schema_type = flat_item.get("schema.type", "elody")
|
|
141
|
+
schema_version = flat_item.get("schema.version", "1")
|
|
142
|
+
schema = f"{schema_type}:{schema_version}"
|
|
143
|
+
|
|
144
|
+
schemas = permissions[crud][flat_item["type"]]
|
|
145
|
+
if restrictions_schema := schemas.get(schema):
|
|
146
|
+
return restrictions_schema
|
|
147
|
+
|
|
148
|
+
for schema in reversed(schemas.keys()):
|
|
149
|
+
if regex.match(f"^{schema_type}:[0-9]{1,3}?$", schema):
|
|
150
|
+
break
|
|
151
|
+
schema = None
|
|
152
|
+
return schemas[schema] if schemas and schema else {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def __is_allowed_to_crud_item(flat_item, restrictions_schema):
|
|
156
|
+
restrictions = restrictions_schema.get("object_restrictions", {})
|
|
229
157
|
|
|
230
|
-
|
|
158
|
+
for restricted_key, restricting_values in restrictions.items():
|
|
159
|
+
restricted_key = restricted_key.split(":")[1]
|
|
160
|
+
item_value_in_restricting_values = __item_value_in_values(
|
|
161
|
+
flat_item, restricted_key, restricting_values
|
|
162
|
+
)
|
|
163
|
+
if not item_value_in_restricting_values:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
return True
|
|
231
167
|
|
|
232
168
|
|
|
233
|
-
def
|
|
234
|
-
|
|
169
|
+
def __is_allowed_to_crud_item_keys(
|
|
170
|
+
user_context: UserContext,
|
|
171
|
+
item_in_storage_format,
|
|
172
|
+
flat_item,
|
|
173
|
+
restrictions_schema,
|
|
174
|
+
crud,
|
|
175
|
+
object_lists,
|
|
176
|
+
flat_request_body: dict = {},
|
|
235
177
|
):
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
178
|
+
user_context.bag["restricted_keys"] = []
|
|
179
|
+
restrictions = restrictions_schema.get("key_restrictions", {})
|
|
180
|
+
|
|
181
|
+
for restricted_key, restricting_conditions in restrictions.items():
|
|
182
|
+
restricted_key = restricted_key.split(":")[1]
|
|
183
|
+
condition_match = True
|
|
184
|
+
for condition_key, condition_values in restricting_conditions.items():
|
|
185
|
+
condition_match = __item_value_in_values(
|
|
186
|
+
flat_item, condition_key, condition_values, flat_request_body
|
|
187
|
+
)
|
|
188
|
+
if not condition_match:
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
if condition_match:
|
|
192
|
+
if crud == "read":
|
|
193
|
+
keys_info = interpret_flat_key(restricted_key, object_lists)
|
|
194
|
+
for info in keys_info:
|
|
195
|
+
if info["object_list"]:
|
|
196
|
+
element = __get_element_from_object_list_of_item(
|
|
197
|
+
item_in_storage_format,
|
|
198
|
+
info["key"],
|
|
199
|
+
info["object_key"],
|
|
200
|
+
object_lists,
|
|
201
|
+
)
|
|
202
|
+
item_in_storage_format[info["key"]].remove(element)
|
|
203
|
+
break
|
|
204
|
+
else:
|
|
205
|
+
try:
|
|
206
|
+
del item_in_storage_format[keys_info[0]["key"]][
|
|
207
|
+
keys_info[1]["key"]
|
|
208
|
+
]
|
|
209
|
+
except KeyError:
|
|
210
|
+
pass
|
|
211
|
+
else:
|
|
212
|
+
if flat_request_body.get(restricted_key):
|
|
213
|
+
user_context.bag["restricted_keys"].append(restricted_key)
|
|
214
|
+
|
|
215
|
+
user_context.bag["requested_item"] = item_in_storage_format
|
|
216
|
+
return len(user_context.bag["restricted_keys"]) == 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def __item_value_in_values(flat_item, key, values: list, flat_request_body: dict = {}):
|
|
220
|
+
negate_condition = False
|
|
221
|
+
is_optional = False
|
|
222
|
+
|
|
223
|
+
if key[0] == "!":
|
|
224
|
+
key = key[1:]
|
|
225
|
+
negate_condition = True
|
|
226
|
+
if key[0] == "?":
|
|
227
|
+
key = key[1:]
|
|
228
|
+
is_optional = True
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
item_value = flat_request_body.get(key, flat_item[key])
|
|
232
|
+
except KeyError:
|
|
233
|
+
if not is_optional:
|
|
234
|
+
raise Exception(
|
|
235
|
+
f"{get_error_code(ErrorCode.METADATA_KEY_UNDEFINED, get_read())} | key:{key} | document:{flat_item.get('_id', flat_item["type"])} - Key {key} not found in document {flat_item.get('_id', flat_item["type"])}. Either prefix the key with '?' in your permission configuration to make it an optional restriction, or patch the document to include the key. '?' will allow access if key does not exist, '!?' will deny access if key does not exist."
|
|
248
236
|
)
|
|
237
|
+
return not negate_condition
|
|
238
|
+
|
|
239
|
+
expected_values = []
|
|
240
|
+
for value in values:
|
|
241
|
+
if flat_item_key_value := flat_item.get(value):
|
|
242
|
+
value = flat_item_key_value
|
|
243
|
+
if isinstance(value, list):
|
|
244
|
+
expected_values.extend(value)
|
|
245
|
+
else:
|
|
246
|
+
expected_values.append(value)
|
|
247
|
+
|
|
248
|
+
if isinstance(item_value, (str, int, float, bool)):
|
|
249
|
+
if negate_condition:
|
|
250
|
+
return item_value not in expected_values
|
|
251
|
+
else:
|
|
252
|
+
return item_value in expected_values
|
|
253
|
+
elif isinstance(item_value, list):
|
|
254
|
+
for expected_value in expected_values:
|
|
255
|
+
if expected_value in item_value:
|
|
256
|
+
return True != negate_condition
|
|
257
|
+
return False != negate_condition
|
|
249
258
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
return False
|
|
261
|
-
|
|
262
|
-
return not return_value
|
|
259
|
+
raise Exception(f"Invalid item_value: {item_value}")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def __get_element_from_object_list_of_item(
|
|
263
|
+
item: dict, object_list: str, key: str, object_lists: dict
|
|
264
|
+
):
|
|
265
|
+
for element in item[object_list]:
|
|
266
|
+
if element[object_lists[object_list]] == key:
|
|
267
|
+
return element
|
|
268
|
+
return {}
|