Skip to content

Commit f2a52cc

Browse files
author
Nils Bars
committed
Add get_instance_info() for trusted instance metadata queries
Expose a signed helper that retrieves user and exercise details from the webapp. Response fields originate from the server-side database and cannot be forged by the student, so submission tests can safely branch on the user's role (e.g. admin) without trusting anything inside the container.
1 parent a3bb20c commit f2a52cc

3 files changed

Lines changed: 94 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ref-utils"
3-
version = "0.1.8"
3+
version = "0.1.9"
44
description = "A package containing various utility functions for remote-exercise-framework submission process"
55
authors = [
66
{name = "Nils Bars", email = "nils.bars@rub.de"},
@@ -16,6 +16,8 @@ classifiers = [
1616
]
1717
dependencies = [
1818
"colorama>=0.4.3",
19+
"requests>=2.28",
20+
"itsdangerous>=2.0",
1921
]
2022

2123
[build-system]

ref_utils/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"decorator",
99
"config",
1010
"serialization",
11+
"instance",
1112
# Config
1213
"Config",
1314
"get_config",
@@ -26,6 +27,10 @@
2627
"add_submission_test",
2728
"run_tests",
2829
"suppress_run_tests",
30+
# Instance
31+
"InstanceInfo",
32+
"InstanceInfoError",
33+
"get_instance_info",
2934
# Process
3035
"drop_privileges",
3136
"run",
@@ -60,6 +65,7 @@
6065
submission_test,
6166
suppress_run_tests,
6267
)
68+
from .instance import InstanceInfo, InstanceInfoError, get_instance_info
6369
from .process import (
6470
drop_privileges,
6571
get_payload_from_executable,

ref_utils/instance.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Query the webapp for trusted details about the current instance.
2+
3+
The container holds a per-instance key at ``/etc/key`` (root-readable only).
4+
Requests signed with this key are verified by the webapp, which resolves all
5+
response fields from its own database. The returned info is therefore
6+
authoritative and cannot be spoofed from inside the container.
7+
"""
8+
9+
import typing as ty
10+
from dataclasses import dataclass
11+
from pathlib import Path
12+
13+
import requests
14+
from itsdangerous import TimedSerializer
15+
16+
from .error import RefUtilsError
17+
18+
KEY_PATH = Path("/etc/key")
19+
INSTANCE_ID_PATH = Path("/etc/instance_id")
20+
INSTANCE_INFO_URL = "http://ssh-reverse-proxy:8000/api/instance/info"
21+
22+
23+
class InstanceInfoError(RefUtilsError):
24+
"""Raised when instance info cannot be retrieved."""
25+
26+
27+
@dataclass
28+
class InstanceInfo:
29+
instance_id: int
30+
is_submission: bool
31+
user_full_name: str
32+
user_mat_num: ty.Optional[str]
33+
is_admin: bool
34+
is_grading_assistant: bool
35+
exercise_short_name: str
36+
exercise_version: int
37+
38+
39+
def _sign_request(instance_id: int, key: bytes, payload: ty.Dict[str, ty.Any]) -> str:
40+
signer = TimedSerializer(key, salt="from-container-to-web")
41+
payload = dict(payload)
42+
payload["instance_id"] = instance_id
43+
return signer.dumps(payload) # type: ignore[no-any-return]
44+
45+
46+
def get_instance_info(timeout_s: float = 10.0) -> InstanceInfo:
47+
"""Fetch trusted details about the current instance from the webapp.
48+
49+
All fields originate from the webserver's database; the request is signed
50+
with the instance-specific key at ``/etc/key``, which is not accessible to
51+
the student user. Use this to branch test behavior on user role without
52+
trusting anything the student can tamper with.
53+
54+
Raises:
55+
InstanceInfoError: if credentials are missing, the webapp is
56+
unreachable, or the response cannot be parsed.
57+
"""
58+
try:
59+
key = KEY_PATH.read_bytes()
60+
instance_id = int(INSTANCE_ID_PATH.read_text().strip())
61+
except OSError as e:
62+
raise InstanceInfoError(f"Failed to read instance credentials: {e}") from e
63+
64+
signed = _sign_request(instance_id, key, {})
65+
try:
66+
resp = requests.post(INSTANCE_INFO_URL, json=signed, timeout=timeout_s)
67+
except requests.RequestException as e:
68+
raise InstanceInfoError(f"Failed to reach webapp: {e}") from e
69+
70+
try:
71+
data = resp.json()
72+
except ValueError as e:
73+
raise InstanceInfoError(f"Invalid JSON response (status={resp.status_code}): {e}") from e
74+
75+
if resp.status_code != 200:
76+
detail = data.get("error", data) if isinstance(data, dict) else data
77+
raise InstanceInfoError(f"Info request failed (status={resp.status_code}): {detail}")
78+
79+
if not isinstance(data, dict):
80+
raise InstanceInfoError(f"Unexpected response body: {data!r}")
81+
82+
try:
83+
return InstanceInfo(**data)
84+
except TypeError as e:
85+
raise InstanceInfoError(f"Response missing or extra fields: {e}") from e

0 commit comments

Comments
 (0)