Skip to content

Commit d18e897

Browse files
committed
Add an admin page to browse with an approximation of the state of another user
1 parent c9c2178 commit d18e897

File tree

4 files changed

+146
-3
lines changed

4 files changed

+146
-3
lines changed

atr/blueprints/admin/admin.py

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import aioshutil
2929
import asfquart
3030
import asfquart.base as base
31-
import asfquart.session
31+
import asfquart.session as session
3232
import httpx
3333
import quart
3434
import sqlalchemy.orm as orm
@@ -49,6 +49,17 @@
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+
5263
class 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:
378389
async 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"])
406459
async 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+
516605
async def _update_committees(
517606
data: db.Session, ldap_projects: apache.LDAPProjectsData, committees_by_name: Mapping[str, apache.Committee]
518607
) -> tuple[int, int]:
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{% extends "layouts/base-admin.html" %}
2+
3+
{% block title %}Browse as user{% endblock title %}
4+
5+
{% block description %}
6+
Enter the ASF UID of the user to browse as.
7+
{% endblock description %}
8+
9+
{% block content %}
10+
<h1>Browse as user</h1>
11+
12+
<p class="mb-4">
13+
Enter the ASF UID of the user to browse as.
14+
This will allow you to view the site as that user.
15+
</p>
16+
17+
<form method="post" class="form">
18+
{{ form.csrf_token }}
19+
<div class="field mb-3">
20+
<label class="label" for="uid">{{ form.uid.label }}</label>
21+
<div class="control">
22+
{{ form.uid(class="form-control") }}
23+
</div>
24+
{% if form.uid.errors %}
25+
<div class="invalid-feedback d-block">
26+
{% for error in form.uid.errors %}
27+
<span>{{ error }}</span>
28+
{% endfor %}
29+
</div>
30+
{% endif %}
31+
</div>
32+
<div class="field">
33+
<div class="control">
34+
{{ form.submit(class="btn btn-primary") }}
35+
</div>
36+
</div>
37+
</form>
38+
{% endblock %}

atr/ldap.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717

18+
import collections
1819
import dataclasses
1920
from typing import Final
2021

2122
import ldap3
2223
import ldap3.utils.conv as conv
24+
import ldap3.utils.dn as dn
2325

2426
LDAP_SEARCH_BASE: Final[str] = "ou=people,dc=apache,dc=org"
2527
LDAP_SERVER_HOST: Final[str] = "ldap-eu.apache.org"
@@ -39,6 +41,15 @@ class SearchParameters:
3941
connection: ldap3.Connection | None = None
4042

4143

44+
def parse_dn(dn_string: str) -> dict[str, list[str]]:
45+
parsed = collections.defaultdict(list)
46+
parts = dn.parse_dn(dn_string)
47+
for part in parts:
48+
for attr, value in part:
49+
parsed[attr].append(value)
50+
return dict(parsed)
51+
52+
4253
def search(params: SearchParameters) -> None:
4354
try:
4455
_search_core(params)

atr/templates/includes/sidebar.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ <h3>Administration</h3>
136136
<a href="{{ url_for('admin.admin_toggle_admin_view_page') }}"
137137
{% if request.endpoint == 'admin.admin_toggle_admin_view_page' %}class="active"{% endif %}>Toggle admin view</a>
138138
</li>
139+
<li>
140+
<i class="bi bi-person-plus"></i>
141+
<a href="{{ url_for('admin.browse_as') }}"
142+
{% if request.endpoint == 'admin.browse_as' %}class="active"{% endif %}>Browse as user</a>
143+
</li>
139144
</ul>
140145
{% endif %}
141146
{% endif %}

0 commit comments

Comments
 (0)