Skip to content

Commit 16918b3

Browse files
committed
Extend filter_server to also filter submodel_references
1 parent 871fc77 commit 16918b3

1 file changed

Lines changed: 178 additions & 16 deletions

File tree

  • server/example_configurations/access_control/general/filter_server

server/example_configurations/access_control/general/filter_server/app.py

Lines changed: 178 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,29 @@ def _role_matches(rule: dict[str, Any], roles: set[str]) -> bool:
7979
return any(role in roles for role in rule.get("roles", []))
8080

8181

82+
def _path_prefix_matches(prefix: str, path: str) -> bool:
83+
normalized_prefix = prefix.rstrip("/") or "/"
84+
normalized_path = path.rstrip("/") or "/"
85+
return (
86+
normalized_path == normalized_prefix
87+
or normalized_path.startswith(f"{normalized_prefix}/")
88+
)
89+
90+
91+
def _path_allowed_by_rule(access_control: dict[str, Any], path: str, roles: set[str]) -> bool:
92+
for rule in access_control.get("rules", []):
93+
if not _method_matches(rule, "GET") or not _role_matches(rule, roles):
94+
continue
95+
path_prefix = rule.get("path_prefix")
96+
if (
97+
isinstance(path_prefix, str)
98+
and path_prefix
99+
and _path_prefix_matches(path_prefix, path)
100+
):
101+
return True
102+
return False
103+
104+
82105
def _filtered_collection(path: str, access_control: dict[str, Any]) -> dict[str, Any] | None:
83106
normalized_path = path.rstrip("/") or "/"
84107
for collection in access_control.get("filtered_collections", []):
@@ -95,7 +118,6 @@ def _allowed_ids_for_template(
95118
) -> tuple[bool, set[str]]:
96119
allowed_ids: set[str] = set()
97120
allow_all = False
98-
99121
for rule in access_control.get("resource_rules", []):
100122
if not _method_matches(rule, "GET") or not _role_matches(rule, roles):
101123
continue
@@ -114,6 +136,10 @@ def _to_path_id(identifier: str) -> str:
114136
return base64.urlsafe_b64encode(identifier.encode("utf-8")).decode("ascii").rstrip("=")
115137

116138

139+
def _id_allowed(identifier: str, allow_all: bool, allowed_ids: set[str]) -> bool:
140+
return allow_all or identifier in allowed_ids or _to_path_id(identifier) in allowed_ids
141+
142+
117143
def _item_allowed(item: Any, allow_all: bool, allowed_ids: set[str]) -> bool:
118144
if allow_all:
119145
return True
@@ -124,7 +150,114 @@ def _item_allowed(item: Any, allow_all: bool, allowed_ids: set[str]) -> bool:
124150
if not isinstance(item_id, str) or not item_id:
125151
return False
126152

127-
return item_id in allowed_ids or _to_path_id(item_id) in allowed_ids
153+
return _id_allowed(item_id, allow_all, allowed_ids)
154+
155+
156+
def _submodel_reference_id(reference: Any) -> str | None:
157+
if not isinstance(reference, dict):
158+
return None
159+
160+
keys = reference.get("keys")
161+
if not isinstance(keys, list):
162+
return None
163+
164+
for key in reversed(keys):
165+
if not isinstance(key, dict):
166+
continue
167+
key_type = key.get("type")
168+
value = key.get("value")
169+
if (
170+
isinstance(key_type, str)
171+
and key_type.lower() == "submodel"
172+
and isinstance(value, str)
173+
and value
174+
):
175+
return value
176+
177+
return None
178+
179+
180+
def _submodel_reference_allowed(reference: Any, allow_all: bool, allowed_ids: set[str]) -> bool:
181+
if allow_all:
182+
return True
183+
184+
submodel_id = _submodel_reference_id(reference)
185+
if not submodel_id:
186+
return False
187+
188+
return _id_allowed(submodel_id, allow_all, allowed_ids)
189+
190+
191+
def _configured_submodel_path_templates(access_control: dict[str, Any]) -> set[str]:
192+
path_templates = access_control.get("submodel_reference_path_templates", [])
193+
if not isinstance(path_templates, list):
194+
return set()
195+
return {template for template in path_templates if isinstance(template, str)}
196+
197+
198+
def _template_targets_submodel(path_template: Any) -> bool:
199+
return isinstance(path_template, str) and "{submodel" in path_template.lower()
200+
201+
202+
def _rule_targets_submodel(rule: dict[str, Any], configured_templates: set[str]) -> bool:
203+
path_templates = rule.get("path_templates", [])
204+
if not isinstance(path_templates, list):
205+
return False
206+
if configured_templates:
207+
return any(template in configured_templates for template in path_templates)
208+
return any(_template_targets_submodel(template) for template in path_templates)
209+
210+
211+
def _resource_rule_submodel_access(
212+
access_control: dict[str, Any],
213+
roles: set[str],
214+
) -> tuple[bool, set[str]]:
215+
allowed_ids: set[str] = set()
216+
allow_all = False
217+
configured_templates = _configured_submodel_path_templates(access_control)
218+
219+
for rule in access_control.get("resource_rules", []):
220+
if not _method_matches(rule, "GET") or not _role_matches(rule, roles):
221+
continue
222+
if not _rule_targets_submodel(rule, configured_templates):
223+
continue
224+
225+
ids = set(rule.get("ids", []))
226+
if "*" in ids:
227+
allow_all = True
228+
allowed_ids.update(ids)
229+
230+
return allow_all, allowed_ids
231+
232+
233+
def _submodel_reference_access(
234+
access_control: dict[str, Any],
235+
roles: set[str],
236+
request_path: str,
237+
) -> tuple[bool, set[str]]:
238+
allow_all, allowed_ids = _resource_rule_submodel_access(access_control, roles)
239+
if allow_all or allowed_ids:
240+
return allow_all, allowed_ids
241+
if _path_allowed_by_rule(access_control, request_path, roles):
242+
return True, set()
243+
return False, set()
244+
245+
246+
def _has_submodel_references(item: Any) -> bool:
247+
return isinstance(item, dict) and isinstance(item.get("submodels"), list)
248+
249+
250+
def _filter_aas_submodel_references(item: Any, allow_all: bool, allowed_ids: set[str]) -> Any:
251+
if not _has_submodel_references(item):
252+
return item
253+
254+
filtered_item = deepcopy(item)
255+
filtered_item["submodels"] = [
256+
reference
257+
for reference in item["submodels"]
258+
if _submodel_reference_allowed(reference, allow_all, allowed_ids)
259+
]
260+
return filtered_item
128261

129262

130263
def _request_query_without_paging() -> list[tuple[str, str]]:
@@ -285,8 +418,14 @@ def _handle_filtered_collection(path: str, collection: dict[str, Any], access_co
285418
collection.get("item_path_template", ""),
286419
roles,
287420
)
421+
request_path = "/" + path.strip("/")
422+
submodel_allow_all, submodel_allowed_ids = _submodel_reference_access(
423+
access_control,
424+
roles,
425+
request_path,
426+
)
288427
filtered_items = [
289-
item
428+
_filter_aas_submodel_references(item, submodel_allow_all, submodel_allowed_ids)
290429
for item in items
291430
if _item_allowed(item, allow_all, allowed_ids)
292431
]
@@ -295,14 +434,36 @@ def _handle_filtered_collection(path: str, collection: dict[str, Any], access_co
295434
return jsonify(payload), status_code
296435

297436

298-
def _proxy_request(path: str) -> Response:
437+
def _proxy_request(path: str, access_control: dict[str, Any]) -> Response:
299438
upstream_response = requests.request(
300439
request.method,
301440
_upstream_url(path, list(request.args.items(multi=True))),
302441
headers=_forward_headers(),
303442
data=request.get_data(),
304443
timeout=30,
305444
)
445+
446+
if request.method == "GET" and upstream_response.status_code < 400:
447+
try:
448+
payload = upstream_response.json()
449+
except ValueError:
450+
pass
451+
else:
452+
if _has_submodel_references(payload):
453+
roles = _token_roles()
454+
request_path = "/" + path.strip("/")
455+
submodel_allow_all, submodel_allowed_ids = _submodel_reference_access(
456+
access_control,
457+
roles,
458+
request_path,
459+
)
460+
filtered_payload = _filter_aas_submodel_references(
461+
payload,
462+
submodel_allow_all,
463+
submodel_allowed_ids,
464+
)
465+
return jsonify(filtered_payload), upstream_response.status_code
466+
306467
return Response(
307468
upstream_response.content,
308469
status=upstream_response.status_code,
@@ -317,18 +478,19 @@ def repository_proxy(path: str) -> Response:
317478
normalized_path = f"/{path.strip('/')}"
318479
collection = _filtered_collection(normalized_path, access_control)
319480

320-
if request.method == "GET" and collection:
321-
try:
322-
return _handle_filtered_collection(path, collection, access_control)
323-
except requests.HTTPError as exc:
324-
response = exc.response
325-
return Response(
326-
response.content,
327-
status=response.status_code,
328-
headers=_response_headers(response),
329-
)
330-
331-
return _proxy_request(path)
481+
if request.method == "GET":
482+
if collection:
483+
try:
484+
return _handle_filtered_collection(path, collection, access_control)
485+
except requests.HTTPError as exc:
486+
response = exc.response
487+
return Response(
488+
response.content,
489+
status=response.status_code,
490+
headers=_response_headers(response),
491+
)
492+
493+
return _proxy_request(path, access_control)
332494

333495

334496
if __name__ == "__main__":

0 commit comments

Comments
 (0)