Skip to content

Commit 218bedd

Browse files
Harden result server admin controls
Signed-off-by: Yoshifumi Nakamura <nakamura@riken.jp>
1 parent 93e2b95 commit 218bedd

9 files changed

Lines changed: 413 additions & 14 deletions

File tree

docs/cx/BENCHKIT_GAP_ANALYSIS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Continuous estimation has now moved beyond a mere entry point: a common estimati
5858
However, estimation is still not yet broadly deployed across multiple applications, and AI-driven optimization integration remains mostly at the integration-point stage.
5959

6060
As of the current repository survey, BenchKit has six benchmark applications with `build.sh`/`run.sh`, but only `qws` has an `estimate.sh`.
61-
The result portal also already has a meaningful test base (`result_server/tests`: 32 `test_*.py` modules), and the repository now has a repo-local Python dependency manifest, a standard portal test entrypoint under `result_server/tests`, and a lightweight GitHub Actions verification path for portal-oriented changes.
61+
The result portal also already has a meaningful test base (`result_server/tests`: 33 `test_*.py` modules), and the repository now has a repo-local Python dependency manifest, a standard portal test entrypoint under `result_server/tests`, and a lightweight GitHub Actions verification path for portal-oriented changes.
6262
The main GitLab pipeline still intentionally skips heavy benchmark execution when a direct or manually triggered GitLab pipeline sees changes limited to `result_server/**/*` or portal display metadata such as `config/system_info.csv`. Protected-branch synchronization itself uses `ci.skip`, so the dedicated lightweight GitHub Actions path should continue to be kept in sync as portal-side files evolve.
6363

6464
## 2.1 現時点で明示しておく設計負債 / Explicit Design Debts to Keep Visible
@@ -296,7 +296,7 @@ Once the estimation specification is clarified, many other design decisions beco
296296

297297
今回のコードベース調査では、性能推定に次ぐ実務上の詰まりどころとして、`result_server` の検証導線が見えた。
298298

299-
- `result_server/tests` には 32 個の `test_*.py` モジュールがあり、portal 側はすでに「検証すべき対象」になっている
299+
- `result_server/tests` には 33 個の `test_*.py` モジュールがあり、portal 側はすでに「検証すべき対象」になっている
300300
- repo-local な依存関係定義として `requirements-result-server.txt` があり、`result_server/tests/run_result_server_tests.py` が標準 test entrypoint として使える
301301
- portal-oriented 変更向けの lightweight GitHub Actions として `.github/workflows/result-server-tests.yml` が用意されている
302302
- `.gitlab-ci.yml` は直接または手動起動されたGitLab pipelineで `result_server/**/*``config/system_info.csv` 変更時に重い benchmark pipeline を skip する。保護ブランチ同期自体は `ci.skip` を使うため、GitHub Actions 側の path filter を portal 周辺の実ファイルに追従させ続ける必要がある

docs/guides/developer-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ For production portal deployments:
221221
- See `docs/deploy/key-management.md` for generation and rotation guidance.
222222
- `REDIS_URL` must point to a monitored Redis instance; production authentication refuses login when Redis is unavailable.
223223
- API ingest and query endpoints use Redis-backed rate limits by default; set `RESULT_SERVER_MAX_UPLOAD_MB` and `RESULT_SERVER_MAX_ARCHIVE_MEMBER_MB` when deployment-specific upload limits are needed.
224+
- Admin-managed affiliations are only rejected when they contain unsafe path/control characters or the comma delimiter used by the form; set `RESULT_SERVER_ALLOWED_AFFILIATIONS` only when a deployment wants to enforce a fixed comma-separated allowlist.
224225
- `app_dev.py` is localhost-only, uses ephemeral development secrets when none are provided, and enables the Werkzeug debugger only with `RESULT_SERVER_DEV_DEBUG=1`.
225226

226227
### Result Quality Visibility

result_server/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from routes.estimated import estimated_bp
1010
from routes.home import register_home_routes
1111
from routes.results import results_bp
12+
from utils.admin_policy import parse_allowed_affiliations
1213
from utils.auth import parse_ingest_keys
1314
from utils.csrf import init_csrf
1415
from utils.preflight import validate_production_config
@@ -94,6 +95,13 @@ def payload_too_large(_error):
9495
), 413
9596

9697

98+
def _configure_admin_policy(app):
99+
"""Configure admin-route policy settings."""
100+
app.config["ALLOWED_AFFILIATIONS"] = parse_allowed_affiliations(
101+
os.environ.get("RESULT_SERVER_ALLOWED_AFFILIATIONS")
102+
)
103+
104+
97105
def _register_portal_blueprints(app, prefix):
98106
"""Register all portal blueprints using the given URL prefix."""
99107
from routes.admin import admin_bp
@@ -127,6 +135,7 @@ def create_app(prefix="", base_dir=None):
127135
_configure_totp_issuer(app, prefix)
128136
_configure_result_directories(app, base_dir)
129137
_configure_upload_limits(app)
138+
_configure_admin_policy(app)
130139
init_csrf(app, exempt_blueprints=(api_bp,))
131140

132141
register_home_routes(app, prefix=prefix)

result_server/app_dev.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ def create_dev_app(base_dir):
162162

163163
from routes.home import register_home_routes
164164
from routes.security_metadata import register_security_metadata_routes
165+
from utils.admin_policy import parse_allowed_affiliations
165166
from utils.auth import parse_ingest_keys
166167
from utils.csrf import init_csrf
167168
from utils.system_info import get_all_systems_info, summarize_systems_info
@@ -185,6 +186,9 @@ def create_dev_app(base_dir):
185186
* 1024
186187
* 1024
187188
),
189+
ALLOWED_AFFILIATIONS=parse_allowed_affiliations(
190+
os.environ.get("RESULT_SERVER_ALLOWED_AFFILIATIONS")
191+
),
188192
)
189193
Session(app)
190194

result_server/create_admin.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
"""
2323

2424
import argparse
25+
import os
2526

2627
import redis
2728

29+
from utils.admin_policy import parse_affiliations, parse_allowed_affiliations
2830
from utils.user_store import UserStore
2931

3032

@@ -53,7 +55,10 @@ def main():
5355
)
5456
args = parser.parse_args()
5557

56-
affiliations = [item.strip() for item in args.affiliations.split(",") if item.strip()]
58+
allowed = parse_allowed_affiliations(os.environ.get("RESULT_SERVER_ALLOWED_AFFILIATIONS"))
59+
affiliations, invalid = parse_affiliations(args.affiliations, allowed)
60+
if invalid:
61+
parser.error(f"Invalid affiliations: {', '.join(sorted(invalid))}.")
5762

5863
r_conn = redis.from_url(args.redis_url, decode_responses=True)
5964
store = UserStore(r_conn, args.prefix)

result_server/routes/admin.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from flask import (
66
Blueprint,
77
abort,
8+
current_app,
89
flash,
910
make_response,
1011
redirect,
@@ -14,8 +15,9 @@
1415
url_for,
1516
)
1617

17-
from utils.user_store import get_user_store
18+
from utils.admin_policy import parse_affiliations
1819
from utils.rate_limit import rate_limited
20+
from utils.user_store import get_user_store
1921

2022
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
2123

@@ -39,6 +41,40 @@ def _admin_rate_key(_request):
3941
return f"admin:{session.get('user_email', 'anon')}"
4042

4143

44+
def _allowed_affiliations():
45+
"""Return the configured affiliation allowlist, if one is enforced."""
46+
return current_app.config.get("ALLOWED_AFFILIATIONS")
47+
48+
49+
def _parse_requested_affiliations():
50+
"""Parse submitted affiliations and flash an error for invalid values."""
51+
affiliations_raw = request.form.get("affiliations", "").strip()
52+
affiliations, invalid = parse_affiliations(affiliations_raw, _allowed_affiliations())
53+
if invalid:
54+
flash(f"Invalid affiliations: {', '.join(sorted(invalid))}.")
55+
return None
56+
return affiliations
57+
58+
59+
def _user_affiliations(store, email):
60+
"""Return the affiliations for a user, handling missing records uniformly."""
61+
if hasattr(store, "get_user"):
62+
user = store.get_user(email)
63+
else:
64+
user = next(
65+
(item for item in store.list_users() if item.get("email") == email),
66+
None,
67+
)
68+
if not user:
69+
return None
70+
return list(user.get("affiliations", []))
71+
72+
73+
def _admin_user_count(store):
74+
"""Return the number of stored users with the admin affiliation."""
75+
return sum(1 for user in store.list_users() if "admin" in user.get("affiliations", []))
76+
77+
4278
def admin_required(f):
4379
"""Allow access only to authenticated users with the admin affiliation."""
4480

@@ -68,8 +104,9 @@ def add_user():
68104
"""Create a user invitation and show the generated invitation URL."""
69105
store = get_user_store()
70106
email = request.form.get("email", "").strip()
71-
affiliations_raw = request.form.get("affiliations", "").strip()
72-
affiliations = [item.strip() for item in affiliations_raw.split(",") if item.strip()]
107+
affiliations = _parse_requested_affiliations()
108+
if affiliations is None:
109+
return redirect(url_for("admin.users"))
73110

74111
if not email:
75112
flash("Email is required.")
@@ -86,7 +123,7 @@ def add_user():
86123
return _render_users_page(invitation_url)
87124

88125

89-
@admin_bp.route("/users/<path:email>/delete", methods=["POST"])
126+
@admin_bp.route("/users/<email>/delete", methods=["POST"])
90127
@admin_required
91128
@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="admin_write")
92129
def delete_user(email):
@@ -95,25 +132,44 @@ def delete_user(email):
95132
flash("You cannot delete your own account.")
96133
return redirect(url_for("admin.users"))
97134
store = get_user_store()
135+
affiliations = _user_affiliations(store, email)
136+
if affiliations is None:
137+
flash(f"User {email} not found.")
138+
return redirect(url_for("admin.users"))
139+
if "admin" in affiliations and _admin_user_count(store) <= 1:
140+
flash("You cannot delete the only admin user.")
141+
return redirect(url_for("admin.users"))
98142
store.delete_user(email)
99143
flash(f"User {email} has been deleted.")
100144
return redirect(url_for("admin.users"))
101145

102146

103-
@admin_bp.route("/users/<path:email>/affiliations", methods=["POST"])
147+
@admin_bp.route("/users/<email>/affiliations", methods=["POST"])
104148
@admin_required
105149
@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="admin_write")
106150
def update_affiliations(email):
107151
"""Update the affiliations stored for a user."""
108152
store = get_user_store()
109-
affiliations_raw = request.form.get("affiliations", "").strip()
110-
affiliations = [item.strip() for item in affiliations_raw.split(",") if item.strip()]
153+
current_affiliations = _user_affiliations(store, email)
154+
if current_affiliations is None:
155+
flash(f"User {email} not found.")
156+
return redirect(url_for("admin.users"))
157+
affiliations = _parse_requested_affiliations()
158+
if affiliations is None:
159+
return redirect(url_for("admin.users"))
160+
if (
161+
"admin" in current_affiliations
162+
and "admin" not in affiliations
163+
and _admin_user_count(store) <= 1
164+
):
165+
flash("You cannot remove admin from the only admin user.")
166+
return redirect(url_for("admin.users"))
111167
store.update_affiliations(email, affiliations)
112168
flash(f"Affiliations updated for {email}.")
113169
return redirect(url_for("admin.users"))
114170

115171

116-
@admin_bp.route("/users/<path:email>/reinvite", methods=["POST"])
172+
@admin_bp.route("/users/<email>/reinvite", methods=["POST"])
117173
@admin_required
118174
@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="admin_write")
119175
def reinvite_user(email):

result_server/test_support.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,15 @@ def users():
8181
def add_user():
8282
return ""
8383

84-
@admin_bp.route("/users/<path:email>/affiliations", methods=["POST"])
84+
@admin_bp.route("/users/<email>/affiliations", methods=["POST"])
8585
def update_affiliations(email):
8686
return email
8787

88-
@admin_bp.route("/users/<path:email>/reinvite", methods=["POST"])
88+
@admin_bp.route("/users/<email>/reinvite", methods=["POST"])
8989
def reinvite_user(email):
9090
return email
9191

92-
@admin_bp.route("/users/<path:email>/delete", methods=["POST"])
92+
@admin_bp.route("/users/<email>/delete", methods=["POST"])
9393
def delete_user(email):
9494
return email
9595

0 commit comments

Comments
 (0)