Skip to content

Commit 249db5f

Browse files
committed
Add filter server
1 parent b3091a6 commit 249db5f

12 files changed

Lines changed: 350 additions & 1 deletion

File tree

server/example_configurations/access_control/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This folder describes the access-control concept for the [BaSyx Python SDK HTTP servers](../README.md). The main advantage of this approach is that it is not limited to the BaSyx Python SDK servers: it can also be used with other HTTP servers or applications.
44

5-
The general concept is described in [`access_control_general`](./access_control_general).
5+
The general concept is described in [`access_control_general`](general).
66

77
A proof of concept using the BaSyx Python SDK servers together with the [BaSyx AAS Web UI](https://github.com/eclipse-basyx/basyx-aas-web-ui) is provided in [`Basyx-python-sdk_servers_with_access_control`](./Basyx-python-sdk_servers_with_access_control).
88

server/example_configurations/access_control/access_control_general/README.md renamed to server/example_configurations/access_control/general/README.md

File renamed without changes.

server/example_configurations/access_control/access_control_general/compose.yaml renamed to server/example_configurations/access_control/general/compose.yaml

File renamed without changes.

server/example_configurations/access_control/access_control_general/envoy.yaml renamed to server/example_configurations/access_control/general/envoy.yaml

File renamed without changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM python:3.12-slim
2+
3+
RUN pip install --no-cache-dir flask requests
4+
5+
WORKDIR /app
6+
COPY app.py .
7+
8+
ENV UPSTREAM_REPOSITORY_URL="http://repository:80"
9+
ENV POLICY_DATA_PATH="/policies/data.json"
10+
ENV PORT="8080"
11+
12+
EXPOSE 8080
13+
14+
CMD ["python", "app.py"]
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import base64
2+
import json
3+
import os
4+
from copy import deepcopy
5+
from typing import Any
6+
from urllib.parse import urlencode
7+
8+
import requests
9+
from flask import Flask, Response, jsonify, request
10+
11+
app = Flask(__name__)
12+
13+
UPSTREAM_REPOSITORY_URL = os.getenv("UPSTREAM_REPOSITORY_URL", "http://repository:80").rstrip("/")
14+
POLICY_DATA_PATH = os.getenv("POLICY_DATA_PATH", "/policies/data.json")
15+
PORT = int(os.getenv("PORT", "8080"))
16+
UPSTREAM_PAGE_LIMIT = int(os.getenv("UPSTREAM_PAGE_LIMIT", "500"))
17+
MAX_UPSTREAM_PAGES = int(os.getenv("MAX_UPSTREAM_PAGES", "100"))
18+
19+
HOP_BY_HOP_HEADERS = {
20+
"connection",
21+
"keep-alive",
22+
"proxy-authenticate",
23+
"proxy-authorization",
24+
"te",
25+
"trailer",
26+
"transfer-encoding",
27+
"upgrade",
28+
}
29+
30+
31+
def _load_access_control() -> dict[str, Any]:
32+
with open(POLICY_DATA_PATH, encoding="utf-8") as policy_file:
33+
return json.load(policy_file).get("access_control", {})
34+
35+
36+
def _decode_unverified_jwt(token: str) -> dict[str, Any]:
37+
parts = token.split(".")
38+
if len(parts) < 2:
39+
return {}
40+
41+
payload = parts[1]
42+
payload += "=" * (-len(payload) % 4)
43+
try:
44+
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
45+
return json.loads(decoded.decode("utf-8"))
46+
except (ValueError, json.JSONDecodeError):
47+
return {}
48+
49+
50+
def _token_claims() -> dict[str, Any]:
51+
auth_header = request.headers.get("Authorization", "")
52+
prefix = "Bearer "
53+
if not auth_header.startswith(prefix):
54+
return {}
55+
return _decode_unverified_jwt(auth_header[len(prefix) :])
56+
57+
58+
def _token_roles() -> set[str]:
59+
claims = _token_claims()
60+
roles: set[str] = set()
61+
62+
roles.update(claims.get("realm_access", {}).get("roles", []))
63+
roles.update(claims.get("roles", []))
64+
65+
for group in claims.get("groups", []):
66+
roles.add(group)
67+
if isinstance(group, str) and group.startswith("/"):
68+
roles.add(group[1:])
69+
70+
roles.update(scope for scope in claims.get("scope", "").split(" ") if scope)
71+
return roles
72+
73+
74+
def _method_matches(rule: dict[str, Any], method: str) -> bool:
75+
return method.upper() in {item.upper() for item in rule.get("methods", [])}
76+
77+
78+
def _role_matches(rule: dict[str, Any], roles: set[str]) -> bool:
79+
return any(role in roles for role in rule.get("roles", []))
80+
81+
82+
def _filtered_collection(path: str, access_control: dict[str, Any]) -> dict[str, Any] | None:
83+
normalized_path = path.rstrip("/") or "/"
84+
for collection in access_control.get("filtered_collections", []):
85+
collection_path = collection.get("path", "").rstrip("/") or "/"
86+
if normalized_path == collection_path:
87+
return collection
88+
return None
89+
90+
91+
def _allowed_ids_for_template(
92+
access_control: dict[str, Any],
93+
item_path_template: str,
94+
roles: set[str],
95+
) -> tuple[bool, set[str]]:
96+
allowed_ids: set[str] = set()
97+
allow_all = False
98+
99+
for rule in access_control.get("resource_rules", []):
100+
if not _method_matches(rule, "GET") or not _role_matches(rule, roles):
101+
continue
102+
if item_path_template not in rule.get("path_templates", []):
103+
continue
104+
105+
ids = set(rule.get("ids", []))
106+
if "*" in ids:
107+
allow_all = True
108+
allowed_ids.update(ids)
109+
110+
return allow_all, allowed_ids
111+
112+
113+
def _to_path_id(identifier: str) -> str:
114+
return base64.urlsafe_b64encode(identifier.encode("utf-8")).decode("ascii").rstrip("=")
115+
116+
117+
def _item_allowed(item: Any, allow_all: bool, allowed_ids: set[str]) -> bool:
118+
if allow_all:
119+
return True
120+
if not isinstance(item, dict):
121+
return False
122+
123+
item_id = item.get("id")
124+
if not isinstance(item_id, str) or not item_id:
125+
return False
126+
127+
return item_id in allowed_ids or _to_path_id(item_id) in allowed_ids
128+
129+
130+
def _request_query_without_paging() -> list[tuple[str, str]]:
131+
query_items: list[tuple[str, str]] = []
132+
for key in request.args:
133+
if key in {"limit", "cursor"}:
134+
continue
135+
for value in request.args.getlist(key):
136+
query_items.append((key, value))
137+
return query_items
138+
139+
140+
def _upstream_url(path: str, query_items: list[tuple[str, str]] | None = None) -> str:
141+
url = f"{UPSTREAM_REPOSITORY_URL}/{path.lstrip('/')}"
142+
if query_items:
143+
return f"{url}?{urlencode(query_items)}"
144+
return url
145+
146+
147+
def _forward_headers() -> dict[str, str]:
148+
headers = {}
149+
for name, value in request.headers.items():
150+
lower_name = name.lower()
151+
if lower_name in HOP_BY_HOP_HEADERS or lower_name == "host":
152+
continue
153+
headers[name] = value
154+
return headers
155+
156+
157+
def _response_headers(upstream_response: requests.Response) -> dict[str, str]:
158+
headers = {}
159+
for name, value in upstream_response.headers.items():
160+
lower_name = name.lower()
161+
if lower_name in HOP_BY_HOP_HEADERS:
162+
continue
163+
if lower_name in {"content-length", "content-encoding"}:
164+
continue
165+
headers[name] = value
166+
return headers
167+
168+
169+
def _extract_items(payload: Any) -> list[Any]:
170+
if isinstance(payload, dict) and isinstance(payload.get("result"), list):
171+
return payload["result"]
172+
if isinstance(payload, list):
173+
return payload
174+
return []
175+
176+
177+
def _next_upstream_cursor(payload: Any) -> str | None:
178+
if not isinstance(payload, dict):
179+
return None
180+
paging_metadata = payload.get("paging_metadata") or payload.get("pagingMetadata") or {}
181+
cursor = paging_metadata.get("cursor")
182+
return cursor if isinstance(cursor, str) and cursor else None
183+
184+
185+
def _fetch_collection(path: str) -> tuple[Any, list[Any], int]:
186+
query_items = _request_query_without_paging()
187+
cursor = None
188+
first_payload: Any = None
189+
items: list[Any] = []
190+
status_code = 200
191+
192+
for _ in range(MAX_UPSTREAM_PAGES):
193+
page_query = list(query_items)
194+
page_query.append(("limit", str(UPSTREAM_PAGE_LIMIT)))
195+
if cursor:
196+
page_query.append(("cursor", cursor))
197+
198+
upstream_response = requests.get(
199+
_upstream_url(path, page_query),
200+
headers=_forward_headers(),
201+
timeout=30,
202+
)
203+
status_code = upstream_response.status_code
204+
upstream_response.raise_for_status()
205+
206+
payload = upstream_response.json()
207+
if first_payload is None:
208+
first_payload = payload
209+
210+
page_items = _extract_items(payload)
211+
items.extend(page_items)
212+
213+
cursor = _next_upstream_cursor(payload)
214+
if not cursor or not page_items:
215+
break
216+
217+
return first_payload, items, status_code
218+
219+
220+
def _requested_limit(default_size: int) -> int:
221+
raw_limit = request.args.get("limit")
222+
if raw_limit is None:
223+
return default_size
224+
try:
225+
return max(0, int(raw_limit))
226+
except ValueError:
227+
return default_size
228+
229+
230+
def _encode_cursor(offset: int) -> str:
231+
raw = json.dumps({"offset": offset}, separators=(",", ":")).encode("utf-8")
232+
token = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
233+
return f"filtered:{token}"
234+
235+
236+
def _decode_cursor() -> int:
237+
raw_cursor = request.args.get("cursor", "")
238+
prefix = "filtered:"
239+
if not raw_cursor.startswith(prefix):
240+
return 0
241+
242+
token = raw_cursor[len(prefix) :]
243+
token += "=" * (-len(token) % 4)
244+
try:
245+
decoded = base64.urlsafe_b64decode(token.encode("ascii"))
246+
payload = json.loads(decoded.decode("utf-8"))
247+
return max(0, int(payload.get("offset", 0)))
248+
except (ValueError, json.JSONDecodeError):
249+
return 0
250+
251+
252+
def _paginate_items(items: list[Any]) -> tuple[list[Any], str | None]:
253+
offset = _decode_cursor()
254+
limit = _requested_limit(len(items))
255+
if limit == 0:
256+
return [], None
257+
258+
page = items[offset : offset + limit]
259+
next_offset = offset + len(page)
260+
next_cursor = _encode_cursor(next_offset) if next_offset < len(items) else None
261+
return page, next_cursor
262+
263+
264+
def _filtered_payload(original_payload: Any, filtered_items: list[Any]) -> Any:
265+
page_items, cursor = _paginate_items(filtered_items)
266+
267+
if isinstance(original_payload, dict):
268+
payload = deepcopy(original_payload)
269+
payload["result"] = page_items
270+
payload.pop("pagingMetadata", None)
271+
if cursor:
272+
payload["paging_metadata"] = {"cursor": cursor}
273+
else:
274+
payload["paging_metadata"] = {}
275+
return payload
276+
277+
return page_items
278+
279+
280+
def _handle_filtered_collection(path: str, collection: dict[str, Any], access_control: dict[str, Any]) -> Response:
281+
roles = _token_roles()
282+
original_payload, items, status_code = _fetch_collection(path)
283+
allow_all, allowed_ids = _allowed_ids_for_template(
284+
access_control,
285+
collection.get("item_path_template", ""),
286+
roles,
287+
)
288+
filtered_items = [
289+
item
290+
for item in items
291+
if _item_allowed(item, allow_all, allowed_ids)
292+
]
293+
294+
payload = _filtered_payload(original_payload, filtered_items)
295+
return jsonify(payload), status_code
296+
297+
298+
def _proxy_request(path: str) -> Response:
299+
upstream_response = requests.request(
300+
request.method,
301+
_upstream_url(path, list(request.args.items(multi=True))),
302+
headers=_forward_headers(),
303+
data=request.get_data(),
304+
timeout=30,
305+
)
306+
return Response(
307+
upstream_response.content,
308+
status=upstream_response.status_code,
309+
headers=_response_headers(upstream_response),
310+
)
311+
312+
313+
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"])
314+
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"])
315+
def repository_proxy(path: str) -> Response:
316+
access_control = _load_access_control()
317+
normalized_path = f"/{path.strip('/')}"
318+
collection = _filtered_collection(normalized_path, access_control)
319+
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)
332+
333+
334+
if __name__ == "__main__":
335+
app.run(host="0.0.0.0", port=PORT, debug=False)

server/example_configurations/access_control/access_control_general/img.png renamed to server/example_configurations/access_control/general/img.png

File renamed without changes.

server/example_configurations/access_control/access_control_general/img.png:Zone.Identifier renamed to server/example_configurations/access_control/general/img.png:Zone.Identifier

File renamed without changes.

server/example_configurations/access_control/access_control_general/keycloak/realm.json renamed to server/example_configurations/access_control/general/keycloak/realm.json

File renamed without changes.

server/example_configurations/access_control/access_control_general/opa.yaml renamed to server/example_configurations/access_control/general/opa.yaml

File renamed without changes.

0 commit comments

Comments
 (0)