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]

Reply via email to