2828import aioshutil
2929import asfquart
3030import asfquart .base as base
31- import asfquart .session
31+ import asfquart .session as session
3232import httpx
3333import quart
3434import sqlalchemy .orm as orm
4949_LOGGER : Final = logging .getLogger (__name__ )
5050
5151
52+ class BrowseAsUserForm (util .QuartFormTyped ):
53+ """Form for browsing as another user."""
54+
55+ uid = wtforms .StringField (
56+ "ASF UID" ,
57+ validators = [wtforms .validators .InputRequired ()],
58+ render_kw = {"placeholder" : "Enter the ASF UID to browse as" },
59+ )
60+ submit = wtforms .SubmitField ("Browse as this user" )
61+
62+
5263class DeleteCommitteeKeysForm (util .QuartFormTyped ):
5364 committee_name = wtforms .SelectField ("Committee" , validators = [wtforms .validators .InputRequired ()])
5465 confirm_delete = wtforms .StringField (
@@ -378,7 +389,7 @@ async def admin_toggle_admin_view_page() -> str:
378389async def admin_toggle_view () -> response .Response :
379390 await util .validate_empty_form ()
380391
381- web_session = await asfquart . session .read ()
392+ web_session = await session .read ()
382393 if web_session is None :
383394 # For the type checker
384395 # We should pass this as an argument, then it's guaranteed
@@ -402,12 +413,54 @@ async def admin_toggle_view() -> response.Response:
402413 return quart .redirect (referrer or quart .url_for ("admin.admin_data" ))
403414
404415
416+ @admin .BLUEPRINT .route ("/browse-as" , methods = ["GET" , "POST" ])
417+ async def browse_as () -> str | response .Response :
418+ """Allows an admin to browse as another user."""
419+ # TODO: Enable this in debugging mode only?
420+ from atr .routes import root
421+
422+ form = await BrowseAsUserForm .create_form ()
423+ if not (await form .validate_on_submit ()):
424+ return await template .render ("browse-as.html" , form = form )
425+
426+ new_uid = str (util .unwrap (form .uid .data ))
427+ if not (current_session := await session .read ()):
428+ raise base .ASFQuartException ("Not authenticated" , 401 )
429+
430+ bind_dn = quart .current_app .config .get ("LDAP_BIND_DN" )
431+ bind_password = quart .current_app .config .get ("LDAP_BIND_PASSWORD" )
432+ ldap_params = ldap .SearchParameters (
433+ uid_query = new_uid ,
434+ bind_dn_from_config = bind_dn ,
435+ bind_password_from_config = bind_password ,
436+ )
437+ await asyncio .to_thread (ldap .search , ldap_params )
438+
439+ if not ldap_params .results_list :
440+ await quart .flash (f"User '{ new_uid } ' not found in LDAP." , "error" )
441+ return quart .redirect (quart .url_for ("admin.browse_as" ))
442+
443+ ldap_projects_data = await apache .get_ldap_projects_data ()
444+ committee_data = await apache .get_active_committee_data ()
445+ ldap_data = ldap_params .results_list [0 ]
446+ _LOGGER .info ("Current session data: %s" , current_session )
447+ new_session_data = _session_data (ldap_data , new_uid , current_session , ldap_projects_data , committee_data )
448+ _LOGGER .info ("New session data: %s" , new_session_data )
449+ session .write (new_session_data )
450+
451+ await quart .flash (
452+ f"You are now browsing as '{ new_uid } '. To return to your own account, please log out and log back in." ,
453+ "success" ,
454+ )
455+ return quart .redirect (util .as_url (root .index ))
456+
457+
405458@admin .BLUEPRINT .route ("/ldap/" , methods = ["GET" ])
406459async def ldap_search () -> str :
407460 form = await LdapLookupForm .create_form (data = quart .request .args )
408461 asf_id_for_template : str | None = None
409462
410- web_session = await asfquart . session .read ()
463+ web_session = await session .read ()
411464 if web_session and web_session .uid :
412465 asf_id_for_template = web_session .uid
413466
@@ -513,6 +566,42 @@ async def _process_undiscovered(data: db.Session) -> tuple[int, int]:
513566 return added_count , updated_count
514567
515568
569+ def _session_data (
570+ ldap_data : dict [str , Any ],
571+ new_uid : str ,
572+ current_session : session .ClientSession ,
573+ ldap_projects : apache .LDAPProjectsData ,
574+ committee_data : apache .CommitteeData ,
575+ ) -> dict [str , Any ]:
576+ # This is not quite accurate
577+ # For example, this misses "tooling" for tooling members
578+ projects = {p .name for p in ldap_projects .projects if (new_uid in p .members ) or (new_uid in p .owners )}
579+ # And this adds "incubator", which is not in the OAuth data
580+ committees = {p .name for p in ldap_projects .projects if (p .pmc and (new_uid in p .members )) or (new_uid in p .owners )}
581+
582+ # Or asf-member-status?
583+ is_member = bool (projects or committees )
584+ is_root = False
585+ is_chair = any (new_uid in (user .id for user in c .chair ) for c in committee_data .committees )
586+
587+ return {
588+ "uid" : ldap_data .get ("uid" , [new_uid ])[0 ],
589+ "dn" : None ,
590+ "fullname" : ldap_data .get ("cn" , [new_uid ])[0 ],
591+ # "email": ldap_user.get("mail", [""])[0],
592+ # Or asf-committer-email?
593+ "email" : f"{ new_uid } @apache.org" ,
594+ "isMember" : is_member ,
595+ "isChair" : is_chair ,
596+ "isRoot" : is_root ,
597+ "committees" : sorted (list (committees )),
598+ "projects" : sorted (list (projects )),
599+ "mfa" : current_session .mfa ,
600+ "isRole" : False ,
601+ "metadata" : {},
602+ }
603+
604+
516605async def _update_committees (
517606 data : db .Session , ldap_projects : apache .LDAPProjectsData , committees_by_name : Mapping [str , apache .Committee ]
518607) -> tuple [int , int ]:
0 commit comments