Skip to content

Commit a7d4be5

Browse files
authored
fix(wizard): admin/owner-rolcontrole en bescherming privileged velden bij wizard-bewerken (#69)
refactor(security): centralize project-edit guards + immutable field check
1 parent c8bcc4a commit a7d4be5

7 files changed

Lines changed: 622 additions & 66 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Cluster editing (future feature)
2+
3+
Status: **niet ondersteund**. `clusters` op een bestaand project is op dit
4+
moment niet wijzigbaar; de keuze wordt gemaakt bij projectaanmaak en blijft
5+
daarna staan.
6+
7+
Beperkingen vandaag:
8+
9+
- Geen edit-mode form exposes het veld (`IDENTITY_EDIT_SECTION` in
10+
`forms/visualizers/wizard_sections.py` toont alleen display-name +
11+
description).
12+
- Geen add/remove-logica voor cluster aan een bestaand project.
13+
- `clusters` zit in `IMMUTABLE_PROJECT_FIELDS`
14+
(`opi/web/project_edit_security.py`): een form die het toch zou
15+
submitten krijgt 400 (geen edit-form exposed het, dus zien we het =
16+
bug of tamper).
17+
18+
Bij implementatie: form-veld toevoegen, `clusters` uit
19+
`IMMUTABLE_PROJECT_FIELDS` halen, en de master-OPI propagatie testen voor
20+
zowel add (nieuwe cluster pickt project op) als remove (vertrekkende OPI
21+
ruimt op).
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Form field-level RBAC (future refactor)
2+
3+
Status: **niet geïmplementeerd**. Vandaag past elke save-handler
4+
zelf `require_project_edit_access` (rol-gate) toe via
5+
`opi/web/project_edit_security.py`. Vergeet één save-pad en de
6+
gate is daar afwezig.
7+
8+
Het `Editable`/`FormSection`-systeem
9+
(`opi/forms/editables/editable.py`) heeft geen `requires_role`-
10+
attribuut. Er is een `AdminEnforcer` voor business-rules ("≥1 admin
11+
moet blijven bestaan") maar geen field-level "wie mag dit veld editen".
12+
13+
Wenselijke eindstaat:
14+
15+
- `requires_role: str | None` op `Editable` of `FormSection`
16+
- Centrale `form_submission_guard` matcht request-user-role tegen
17+
field-metadata vóór de processor draait — niet-toegestane waardes
18+
leveren een `FieldError`
19+
- Form-renderer disablet velden voor users zonder permission (UI-feedback
20+
in plaats van stille rejection achteraf)
21+
- Eén bron van waarheid voor RBAC: definitie naast de field-definitie,
22+
automatisch geldend op alle save-paden
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Shared guards for project-editing save handlers.
2+
3+
- ``require_project_edit_access``: admin/owner role gate, re-check on every
4+
mutating request (TOCTOU).
5+
- ``apply_form_data_to_project``: merge submitted form data into the stored
6+
project; reject submissions that include immutable top-level fields.
7+
8+
Role-based protection (e.g. only admin/owner may edit ``users``) lives in
9+
the role gate; this module does not duplicate that. See
10+
``features/futures/form-field-rbac.md`` for the proper field-level RBAC
11+
that would replace today's per-handler glue.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import Any
17+
18+
from fastapi import HTTPException, Request
19+
20+
from opi.core.auth_decorators import get_current_user
21+
from opi.services.project_service import get_project_service
22+
23+
24+
def require_project_edit_access(request: Request, project_name: str):
25+
"""Require admin/owner role on the project. Returns (project, user_email).
26+
27+
Raises HTTPException 404 if the project does not exist, 403 if the user
28+
is not authorized for the project or lacks the required role.
29+
"""
30+
user = get_current_user(request)
31+
project_service = get_project_service()
32+
project = project_service.get_project(project_name)
33+
34+
if not project:
35+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' niet gevonden")
36+
37+
user_email = user.get("email", "").lower()
38+
if not project_service.is_user_authorized_for_project(project_name, user_email):
39+
raise HTTPException(status_code=403, detail="Geen toegang tot dit project")
40+
41+
user_role = project_service.get_user_role_for_project(project_name, user_email)
42+
if user_role not in ("admin", "owner"):
43+
raise HTTPException(status_code=403, detail="Onvoldoende rechten om dit project te bewerken")
44+
45+
return project, user_email
46+
47+
48+
# Top-level fields no form should ever submit. name=on-disk filename;
49+
# clusters=not yet editable (see features/futures/cluster-editing.md).
50+
IMMUTABLE_PROJECT_FIELDS: tuple[str, ...] = ("name", "clusters")
51+
52+
53+
def apply_form_data_to_project(
54+
existing_data: dict[str, Any],
55+
submitted_data: dict[str, Any],
56+
) -> dict[str, Any]:
57+
"""Merge submitted form data into the stored project data. Returns a new dict.
58+
59+
Raises HTTPException 400 if the submission tries to set any
60+
``IMMUTABLE_PROJECT_FIELDS``. That is a structural-integrity violation:
61+
no form should expose these fields, so receiving one means either a
62+
form bug or a tampered submission. Fail loud rather than silently
63+
dropping the value.
64+
"""
65+
offending = [field for field in IMMUTABLE_PROJECT_FIELDS if field in submitted_data]
66+
if offending:
67+
raise HTTPException(
68+
status_code=400,
69+
detail=f"Submitted form data contains immutable project fields: {', '.join(offending)}",
70+
)
71+
return {**existing_data, **submitted_data}

operations-manager/python/opi/web/router_detail_edit.py

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
init_modal_state_tokenized,
4040
save_modal_state_by_token,
4141
)
42+
from opi.web.project_edit_security import (
43+
apply_form_data_to_project,
44+
require_project_edit_access,
45+
)
4246
from opi.web.router_wizard import (
4347
_empty_sequence_item,
4448
_extract_section_data,
@@ -169,28 +173,6 @@ def _render_section_html(
169173
return html
170174

171175

172-
def _require_project_edit_access(request: Request, project_name: str):
173-
"""Check auth and return (project, user_email). Raises on failure."""
174-
from opi.services.project_service import get_project_service
175-
176-
user = get_current_user(request)
177-
project_service = get_project_service()
178-
project = project_service.get_project(project_name)
179-
180-
if not project:
181-
raise HTTPException(status_code=404, detail=f"Project '{project_name}' niet gevonden")
182-
183-
user_email = user.get("email", "").lower()
184-
if not project_service.is_user_authorized_for_project(project_name, user_email):
185-
raise HTTPException(status_code=403, detail="Geen toegang tot dit project")
186-
187-
user_role = project_service.get_user_role_for_project(project_name, user_email)
188-
if user_role not in ("admin", "owner"):
189-
raise HTTPException(status_code=403, detail="Onvoldoende rechten om dit project te bewerken")
190-
191-
return project, user_email
192-
193-
194176
def _require_project_member_access(request: Request, project_name: str):
195177
"""Check auth for project member access (any role). Returns (project, user_email)."""
196178
from opi.services.project_service import get_project_service
@@ -523,7 +505,7 @@ async def _start_deployment(
523505
@requires_sso
524506
async def sequence_action(request: Request, project_name: str, section_id: str) -> HTMLResponse:
525507
"""Handle add/remove sequence item and re-render the section form."""
526-
project, _user_email = _require_project_edit_access(request, project_name)
508+
project, _user_email = require_project_edit_access(request, project_name)
527509

528510
section = _get_edit_section(section_id)
529511
project_data = project.data or {}
@@ -580,7 +562,7 @@ async def modal_wizard_init(request: Request, project_name: str, flow_id: str) -
580562
if _is_backup_restore_flow(flow_id):
581563
project, _user_email = _require_project_member_access(request, project_name)
582564
else:
583-
project, _user_email = _require_project_edit_access(request, project_name)
565+
project, _user_email = require_project_edit_access(request, project_name)
584566

585567
project_data = project.data or {}
586568

@@ -709,7 +691,7 @@ async def modal_wizard_init(request: Request, project_name: str, flow_id: str) -
709691
@requires_sso
710692
async def modal_wizard_load_step(request: Request, project_name: str, flow_id: str, section_id: str) -> HTMLResponse:
711693
"""Load a step (for back-navigation)."""
712-
_require_project_edit_access(request, project_name)
694+
require_project_edit_access(request, project_name)
713695

714696
wizard_token = _get_wizard_token(request)
715697
state = get_modal_state_by_token(wizard_token)
@@ -737,7 +719,7 @@ async def modal_wizard_submit_step(request: Request, project_name: str, flow_id:
737719
if _is_backup_restore_flow(flow_id):
738720
_require_project_member_access(request, project_name)
739721
else:
740-
_require_project_edit_access(request, project_name)
722+
require_project_edit_access(request, project_name)
741723

742724
wizard_token = _get_wizard_token(request)
743725
state = get_modal_state_by_token(wizard_token)
@@ -939,7 +921,7 @@ async def modal_wizard_skip(request: Request, project_name: str, flow_id: str) -
939921
if _is_backup_restore_flow(flow_id):
940922
_require_project_member_access(request, project_name)
941923
else:
942-
_require_project_edit_access(request, project_name)
924+
require_project_edit_access(request, project_name)
943925

944926
wizard_token = await _get_wizard_token_with_body(request)
945927
state = get_modal_state_by_token(wizard_token)
@@ -957,7 +939,7 @@ async def modal_wizard_confirm(request: Request, project_name: str, flow_id: str
957939
if _is_backup_restore_flow(flow_id):
958940
_require_project_member_access(request, project_name)
959941
else:
960-
_require_project_edit_access(request, project_name)
942+
require_project_edit_access(request, project_name)
961943

962944
wizard_token = await _get_wizard_token_with_body(request)
963945
state = get_modal_state_by_token(wizard_token)
@@ -1105,6 +1087,11 @@ async def _modal_do_submit(
11051087
from opi.handlers.project_file_handler import save_project_file
11061088
from opi.services.project_service import get_project_service
11071089

1090+
# TOCTOU recheck on the mutating request. Backup/restore has its own
1091+
# member-level gate further down.
1092+
if not _is_backup_restore_flow(flow_id):
1093+
require_project_edit_access(request, project_name)
1094+
11081095
state = get_modal_state_by_token(wizard_token)
11091096
if not state:
11101097
raise HTTPException(status_code=400, detail=_SESSION_EXPIRED)
@@ -1179,7 +1166,7 @@ async def _modal_do_submit(
11791166
if field in existing_dep and field not in new_dep:
11801167
new_dep[field] = existing_dep[field]
11811168

1182-
existing_data.update(merged_data)
1169+
existing_data = apply_form_data_to_project(existing_data, merged_data)
11831170

11841171
# Run post_merge hooks (e.g. distribute component refs to deployments)
11851172
for section in active_sections:

operations-manager/python/opi/web/router_wizard.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -413,20 +413,16 @@ async def wizard_page(request: Request, flow_id: str) -> HTMLResponse:
413413
@requires_sso
414414
async def wizard_edit_page(request: Request, flow_id: str, project_name: str) -> HTMLResponse:
415415
"""Render the wizard page pre-filled from an existing project."""
416-
from opi.services.project_service import get_project_service
416+
from opi.web.project_edit_security import require_project_edit_access
417417

418418
flow = get_flow(flow_id)
419419
user = get_current_user(request)
420420
templates = get_templates()
421421

422-
project_service = get_project_service()
423-
project = project_service.get_project(project_name)
424-
if not project:
425-
raise HTTPException(status_code=404, detail=f"Project '{project_name}' niet gevonden")
426-
427-
user_email = user.get("email", "").lower()
428-
if not project_service.is_user_authorized_for_project(project_name, user_email):
429-
raise HTTPException(status_code=403, detail="Geen toegang tot dit project")
422+
# Enforce admin/owner role: the wizard edit flow exposes users/role and
423+
# config fields as editable, so a plain member must not be able to enter
424+
# it (project takeover).
425+
project, _user_email = require_project_edit_access(request, project_name)
430426

431427
project_data = project.data
432428
if not project_data:
@@ -740,6 +736,13 @@ async def submit_step(request: Request, flow_id: str, section_id: str) -> HTMLRe
740736
if not state or state.flow_id != flow_id:
741737
return RedirectResponse(url=f"/forms/wizard/{flow_id}", status_code=303)
742738

739+
# Edit-mode only: fail fast per step instead of stripping data at final
740+
# save. Also covers any future step-driven side-effects (e.g. auto-save).
741+
if state.project_name:
742+
from opi.web.project_edit_security import require_project_edit_access
743+
744+
require_project_edit_access(request, state.project_name)
745+
743746
section = _get_section_from_flow(flow_id, section_id)
744747
flow = get_flow(flow_id)
745748
templates = get_templates()
@@ -1877,15 +1880,14 @@ async def _save_existing_project(
18771880
"""Save updated data to an existing project."""
18781881
from opi.handlers.project_file_handler import save_project_file
18791882
from opi.services.project_service import get_project_service
1883+
from opi.web.project_edit_security import apply_form_data_to_project, require_project_edit_access
1884+
1885+
# TOCTOU recheck on the mutating request.
1886+
project, _user_email = require_project_edit_access(request, project_name)
18801887

18811888
project_service = get_project_service()
1882-
project = project_service.get_project(project_name)
1883-
if not project:
1884-
raise HTTPException(status_code=404, detail=f"Project '{project_name}' niet gevonden")
18851889

1886-
# Merge with existing data (preserve system-managed fields)
1887-
existing_data = project.data or {}
1888-
existing_data.update(data)
1890+
existing_data = apply_form_data_to_project(project.data or {}, data)
18891891

18901892
save_project_file(project.filename, existing_data)
18911893
project_service.load_project_from_data(existing_data, project.filename)

0 commit comments

Comments
 (0)