asf-tooling commented on issue #1112:
URL:
https://github.com/apache/tooling-trusted-releases/issues/1112#issuecomment-4409790689
<!-- gofannon-issue-triage-bot v2 -->
**Automated triage** — analyzed at `main@2da7807a`
**Type:** `new_feature` • **Classification:** `actionable` •
**Confidence:** `high`
**Application domain(s):** `authentication_authorization`, `admin_operations`
### Summary
The issue requests a global token revocation capability for administrators.
Currently, `FoundationAdmin.revoke_all_user_tokens()` in
`atr/storage/writers/tokens.py` only handles single-user revocation. The admin
UI at `/revoke-user-tokens` requires specifying a target UID. In a mass
security incident (e.g., PAT hash algorithm weakness), there's no single action
to revoke all PATs across all users. The code and architecture clearly support
this addition — a new method on FoundationAdmin plus a new admin route with a
strong confirmation form.
### Where this lives in the code today
#### `atr/storage/writers/tokens.py` —
`FoundationAdmin.revoke_all_user_tokens` (lines 201-225)
_extension point_
This is the single-user revocation method that the new global method will be
modeled after.
```python
async def revoke_all_user_tokens(self, target_asf_uid: str) -> int:
"""Revoke all PATs for a specified user. Returns count of revoked
tokens."""
tokens = await self.__data.query_all(
sqlmodel.select(sql.PersonalAccessToken).where(sql.PersonalAccessToken.asfuid
== target_asf_uid)
)
count = len(tokens)
for token in tokens:
await self.__data.delete(token)
if count > 0:
await self.__data.commit()
self.__write_as.append_to_audit_log(
target_asf_uid=target_asf_uid,
tokens_revoked=count,
)
log.auth_event("pat_bulk_revoke", target_asf_uid,
by=self.__asf_uid)
message = mail.Message(
email_sender=mail.NOREPLY_EMAIL_ADDRESS,
email_to=f"{target_asf_uid}@apache.org",
subject="ATR - Security alert: API tokens revoked by
administrator",
body=f"An administrator has revoked all API tokens ({count})
for your ATR account. "
"If you did not expect this action, please contact ASF
Tooling.",
)
await self.__write_as.mail.send(message,
mail.MailFooterCategory.AUTO)
return count
```
#### `atr/admin/__init__.py` — `RevokeUserTokensForm` (lines 108-110)
_extension point_
Existing per-user form pattern that the new global form should follow.
```python
class RevokeUserTokensForm(form.Form):
asf_uid: str = form.label("ASF UID", "Enter the ASF UID whose tokens
should be revoked.")
confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type
REVOKE to confirm.")
```
#### `atr/admin/__init__.py` — `revoke_user_tokens_post` (lines 145-167)
_extension point_
Existing per-user POST route that the global route handler should follow in
pattern.
```python
@admin.typed
async def revoke_user_tokens_post(
session: web.Committer, _revoke_user_tokens:
Literal["revoke-user-tokens"], revoke_form: RevokeUserTokensForm
) -> str | web.WerkzeugResponse:
"""
URL: POST /revoke-user-tokens
Revoke all Personal Access Tokens for a specified user.
"""
# audit_guidance PAT revocation does not terminate web sessions
# audit_guidance PATs and OAuth sessions are independent auth paths
target_uid = revoke_form.asf_uid
async with storage.write(session) as write:
wafa = write.as_foundation_admin()
count = await wafa.tokens.revoke_all_user_tokens(target_uid)
if count > 0:
await quart.flash(f"Revoked {util.plural(count, 'token')} for
{target_uid}.", "success")
else:
await quart.flash(f"No tokens found for {target_uid}.", "info")
return await session.redirect(revoke_user_tokens_get)
```
#### `atr/storage/writers/tokens.py` — `FoundationAdmin` (lines 185-199)
_extension point_
The FoundationAdmin class where the new global revocation method should be
added.
```python
class FoundationAdmin(FoundationCommitter):
def __init__(
self,
write: storage.Write,
write_as: storage.WriteAsFoundationAdmin,
data: db.Session,
):
super().__init__(write, write_as, data)
self.__write = write
self.__write_as = write_as
self.__data = data
asf_uid = write.authorisation.asf_uid
if asf_uid is None:
raise storage.AccessError("Not authorized", status=403)
self.__asf_uid = asf_uid
```
#### `atr/tasks/maintenance.py` — `_expired_pats_maintenance` (lines 68-77)
_currently does this_
Shows how bulk PAT deletion is done efficiently using sqlmodel.delete — the
global revocation method can use a similar pattern for performance.
```python
async def _expired_pats_maintenance() -> None:
via = sql.validate_instrumented_attribute
cutoff = datetime.datetime.now(datetime.UTC) -
datetime.timedelta(days=_EXPIRED_TOKEN_RETENTION_DAYS)
async with db.session() as data:
statement =
sqlmodel.delete(sql.PersonalAccessToken).where(via(sql.PersonalAccessToken.expires)
< cutoff)
result = await data.execute(statement)
await data.commit()
deleted = getattr(result, "rowcount", 0) or 0
if deleted > 0:
log.info(f"Purged {deleted} expired personal access tokens")
```
### Where new code would go
- `atr/storage/writers/tokens.py` — after symbol
FoundationAdmin.revoke_all_user_tokens
Add revoke_all_tokens_globally() method to the FoundationAdmin class,
after the existing per-user method.
- `atr/admin/__init__.py` — after symbol RevokeUserTokensForm
Add RevokeAllTokensGloballyForm with a strong confirmation literal.
- `atr/admin/__init__.py` — after symbol revoke_user_tokens_post
Add revoke_all_tokens_globally_get and revoke_all_tokens_globally_post
route handlers.
- `atr/admin/templates/revoke-all-tokens-globally.html` — new file
Template for the global token revocation admin page with warning message.
### Proposed approach
Add a `revoke_all_tokens_globally()` method to the `FoundationAdmin` class
in `atr/storage/writers/tokens.py`. This method should query all
`PersonalAccessToken` records (without any user filter), delete them in bulk
using `sqlmodel.delete` for efficiency, log the global revocation event to
audit, and return the count of revoked tokens. Since this affects all users, it
should not send individual emails (impractical for hundreds of users) but
should log prominently.
In `atr/admin/__init__.py`, add a `RevokeAllTokensGloballyForm` with a
stronger confirmation string (`REVOKE ALL TOKENS`) and corresponding GET/POST
route handlers. The GET handler should display total token counts and a
prominent warning about the impact. The POST handler should call the new
storage method and flash the result. A new template
`revoke-all-tokens-globally.html` should include a clear warning about the
irreversible nature of the action.
### Suggested patches
#### `atr/storage/writers/tokens.py`
Add global revocation method to FoundationAdmin class, after the existing
per-user method.
````diff
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -185,6 +185,27 @@
await self.__write_as.mail.send(message,
mail.MailFooterCategory.AUTO)
return count
+ async def revoke_all_tokens_globally(self) -> int:
+ """Revoke all PATs for all users. Returns count of revoked tokens.
+
+ This is an emergency action for mass security incidents.
+ """
+ statement = sqlmodel.delete(sql.PersonalAccessToken)
+ result = await self.__data.execute(statement)
+ await self.__data.commit()
+ count = getattr(result, "rowcount", 0) or 0
+
+ if count > 0:
+ self.__write_as.append_to_audit_log(
+ action="global_token_revocation",
+ tokens_revoked=count,
+ initiated_by=self.__asf_uid,
+ )
+ log.auth_event("pat_global_revoke", self.__asf_uid,
tokens_revoked=count)
+ else:
+ log.info("Global token revocation requested but no tokens
existed")
+ return count
+
async def rotate_jwt_signing_key(self) -> None:
key = await asyncio.to_thread(jwtoken.write_new_signing_key)
log.auth_event("jwt_key_rotation", self.__asf_uid)
````
#### `atr/admin/__init__.py`
Add the RevokeAllTokensGloballyForm class after RevokeUserTokensForm.
````diff
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -103,6 +103,10 @@
confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type
REVOKE to confirm.")
+class RevokeAllTokensGloballyForm(form.Form):
+ confirm_revoke: Literal["REVOKE ALL TOKENS"] =
form.label("Confirmation", "Type REVOKE ALL TOKENS to confirm.")
+
+
class RevokeUserSSHKeysForm(form.Form):
asf_uid: str = form.label("ASF UID", "Enter the ASF UID whose SSH keys
should be revoked.")
confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type
REVOKE to confirm.")
````
#### `atr/admin/__init__.py`
Add GET and POST route handlers for global token revocation after the
per-user revoke_user_tokens_post handler.
````diff
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -462,6 +462,55 @@
return await session.redirect(revoke_user_tokens_get)
[email protected]
+async def revoke_all_tokens_globally_get(
+ _session: web.Committer, _revoke_all_tokens:
Literal["revoke-all-tokens-globally"]
+) -> str:
+ """
+ URL: GET /revoke-all-tokens-globally
+
+ Revoke all Personal Access Tokens for ALL users (emergency action).
+ """
+ total_token_count = 0
+ async with db.session() as data:
+ stmt =
sqlmodel.select(sqlmodel.func.count()).select_from(sql.PersonalAccessToken)
+ rows = await data.execute_query(stmt)
+ result = list(rows)
+ if result:
+ total_token_count = result[0][0] or 0
+
+ rendered_form = await form.render(
+ model_cls=RevokeAllTokensGloballyForm,
+ submit_label="Revoke ALL tokens globally",
+ submit_classes="btn-danger",
+ )
+ return await template.render(
+ "revoke-all-tokens-globally.html",
+ form=rendered_form,
+ total_token_count=total_token_count,
+ )
+
+
[email protected]
+async def revoke_all_tokens_globally_post(
+ session: web.Committer,
+ _revoke_all_tokens: Literal["revoke-all-tokens-globally"],
+ revoke_form: RevokeAllTokensGloballyForm,
+) -> str | web.WerkzeugResponse:
+ """
+ URL: POST /revoke-all-tokens-globally
+
+ Revoke all Personal Access Tokens for ALL users (emergency action).
+ """
+ async with storage.write(session) as write:
+ wafa = write.as_foundation_admin()
+ count = await wafa.tokens.revoke_all_tokens_globally()
+
+ if count > 0:
+ await quart.flash(
+ f"GLOBAL REVOCATION: Revoked {util.plural(count, 'token')}
across all users.", "success"
+ )
+ else:
+ await quart.flash("No tokens found to revoke.", "info")
+
+ return await session.redirect(revoke_all_tokens_globally_get)
+
+
@admin.typed
async def revoke_user_ssh_keys_get(
````
#### `atr/admin/templates/revoke-all-tokens-globally.html`
New template for the global revocation page with strong warning.
````diff
--- /dev/null
+++ b/atr/admin/templates/revoke-all-tokens-globally.html
@@ -0,0 +1,35 @@
+{% extends "layouts/base-admin.html" %}
+
+{%- block title -%}Revoke ALL tokens globally ~ ATR{%- endblock title -%}
+
+{%- block description -%}Emergency: Revoke all Personal Access Tokens for
all users.{%- endblock description -%}
+
+{% block content %}
+ <h1>Revoke ALL tokens globally</h1>
+
+ <div class="alert alert-danger" role="alert">
+ <h5 class="alert-heading">⚠️ Emergency action</h5>
+ <p class="mb-1">This will immediately revoke <strong>all</strong>
Personal Access Tokens for
+ <strong>every user</strong> in the system. All API integrations
relying on PATs will stop
+ working. Users will need to generate new tokens.</p>
+ <p class="mb-0">Use this only during a confirmed security incident
affecting all users
+ (e.g., PAT hash algorithm compromise). Consider also rotating the JWT
signing key.</p>
+ </div>
+
+ {% if total_token_count > 0 %}
+ <div class="alert alert-warning" role="alert">
+ There are currently <strong>{{ total_token_count }}</strong> active
token(s) across all users.
+ </div>
+ {% else %}
+ <div class="alert alert-info" role="alert">
+ There are currently no active tokens in the system.
+ </div>
+ {% endif %}
+
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">Confirm global revocation</h5>
+ </div>
+ <div class="card-body">
+ {{ form }}
+ </div>
+ </div>
+{% endblock content %}
````
### Open questions
- Should the global revocation method send a bulk notification email (e.g.,
to [email protected]) rather than individual emails to all affected
users?
- Does the admin navigation/sidebar need to be updated to include a link to
the new route?
- Should the `execute_query` method in the db session be used for the count
query, or is there a more idiomatic way in this codebase?
- Should the global revocation also trigger session termination for all
users (similar to how _delete_token calls
sessions.terminate_current_users_sessions)?
- Is there an admin template that lists all admin operations to link to from?
### Files examined
- `atr/storage/writers/tokens.py`
- `atr/admin/__init__.py`
- `atr/admin/templates/revoke-user-tokens.html`
- `atr/storage/readers/tokens.py`
- `atr/post/tokens.py`
- `atr/get/tokens.py`
- `atr/tasks/maintenance.py`
- `atr/jwtoken.py`
---
*Draft from a triage agent. A human reviewer should validate before merging
any change. The agent did not run tests or verify diffs apply.*
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]