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+ )
4246from 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-
194176def _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
524506async 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
710692async 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 :
0 commit comments