This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 8fdb8c21 Manual PAT removal; fixes #598
8fdb8c21 is described below
commit 8fdb8c2166d5e0dadfc3294c205976c34c9207e3
Author: Andrew K. Musselman <[email protected]>
AuthorDate: Fri Feb 20 12:32:51 2026 -0800
Manual PAT removal; fixes #598
---
atr/admin/__init__.py | 52 +++++++++++
atr/admin/templates/revoke-user-tokens.html | 48 ++++++++++
atr/docs/authentication-security.md | 7 +-
atr/docs/authorization-security.md | 6 ++
atr/storage/__init__.py | 1 +
atr/storage/writers/tokens.py | 31 +++++++
atr/templates/includes/topnav.html | 5 +
tests/e2e/admin/__init__.py | 16 ++++
tests/e2e/admin/conftest.py | 53 +++++++++++
tests/e2e/admin/helpers.py | 41 +++++++++
tests/e2e/admin/test_revoke_tokens.py | 137 ++++++++++++++++++++++++++++
11 files changed, 394 insertions(+), 3 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 8cb1ae8f..fe0ec2e2 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -88,6 +88,11 @@ class LdapLookupForm(form.Form):
email: str = form.label("Email address (optional)", "Enter email address,
e.g. [email protected]")
+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.")
+
+
class SessionDataCommon(NamedTuple):
uid: str
fullname: str
@@ -701,6 +706,53 @@ async def projects_update_post(session: web.Committer) ->
str | web.WerkzeugResp
}, 200
[email protected]("/revoke-user-tokens")
+async def revoke_user_tokens_get(session: web.Committer) -> str:
+ """Revoke all Personal Access Tokens for a specified user."""
+ token_counts: list[tuple[str, int]] = []
+ async with db.session() as data:
+ stmt = (
+ sqlmodel.select(
+ sql.PersonalAccessToken.asfuid,
+ sqlmodel.func.count(),
+ )
+ .group_by(sql.PersonalAccessToken.asfuid)
+ .order_by(sql.PersonalAccessToken.asfuid)
+ )
+ rows = await data.execute_query(stmt)
+ token_counts = [(row[0], row[1]) for row in rows]
+
+ rendered_form = form.render(
+ model_cls=RevokeUserTokensForm,
+ submit_label="Revoke all tokens",
+ )
+ return await template.render(
+ "revoke-user-tokens.html",
+ form=rendered_form,
+ token_counts=token_counts,
+ )
+
+
[email protected]("/revoke-user-tokens")
[email protected](RevokeUserTokensForm)
+async def revoke_user_tokens_post(
+ session: web.Committer, revoke_form: RevokeUserTokensForm
+) -> str | web.WerkzeugResponse:
+ """Revoke all Personal Access Tokens for a specified user."""
+ target_uid = revoke_form.asf_uid.strip()
+
+ async with storage.write(session) as write:
+ wafa = write.as_foundation_admin("infrastructure")
+ count = await wafa.tokens.revoke_all_user_tokens(target_uid)
+
+ if count > 0:
+ await quart.flash(f"Revoked {count} token(s) for {target_uid}.",
"success")
+ else:
+ await quart.flash(f"No tokens found for {target_uid}.", "info")
+
+ return await session.redirect(revoke_user_tokens_get)
+
+
@admin.get("/task-times/<project_name>/<version_name>/<revision_number>")
async def task_times(
session: web.Committer, project_name: str, version_name: str,
revision_number: str
diff --git a/atr/admin/templates/revoke-user-tokens.html
b/atr/admin/templates/revoke-user-tokens.html
new file mode 100644
index 00000000..441789f8
--- /dev/null
+++ b/atr/admin/templates/revoke-user-tokens.html
@@ -0,0 +1,48 @@
+{% extends "layouts/base-admin.html" %}
+
+{%- block title -%}Revoke user tokens ~ ATR{%- endblock title -%}
+
+{%- block description -%}Revoke all Personal Access Tokens for a user.{%-
endblock description -%}
+
+{% block content %}
+ <h1>Revoke user tokens</h1>
+ <p>Revoke all Personal Access Tokens (PATs) for a user account. Use this
when an account
+ is being disabled or when immediate token revocation is needed.</p>
+
+ <div class="card mb-4">
+ <div class="card-header">
+ <h5 class="mb-0">Revoke tokens</h5>
+ </div>
+ <div class="card-body">
+ {{ form }}
+ </div>
+ </div>
+
+ {% if token_counts %}
+ <div class="card">
+ <div class="card-header">
+ <h5 class="mb-0">Users with active tokens</h5>
+ </div>
+ <div class="card-body">
+ <table class="table table-sm table-striped table-bordered">
+ <thead>
+ <tr>
+ <th>ASF UID</th>
+ <th>Token count</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for uid, count in token_counts %}
+ <tr>
+ <td><code>{{ uid }}</code></td>
+ <td>{{ count }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ {% else %}
+ <div class="alert alert-info" role="alert">No users currently have active
tokens.</div>
+ {% endif %}
+{% endblock content %}
diff --git a/atr/docs/authentication-security.md
b/atr/docs/authentication-security.md
index 79827c95..fb0cf117 100644
--- a/atr/docs/authentication-security.md
+++ b/atr/docs/authentication-security.md
@@ -69,12 +69,12 @@ Committers can obtain PATs from the `/tokens` page on the
ATR website. PATs have
* **Validity**: 180 days from creation, while LDAP account is still active
* **Storage**: ATR stores only SHA3-256 hashes, never the plaintext PAT
-* **Revocation**: Users can revoke their own PATs at any time; admins can
revoke any PAT
+* **Revocation**: Users can revoke their own PATs at any time; admins can
revoke all PATs for any user via the admin "Revoke user tokens" page
* **Purpose**: PATs are used solely to obtain JWTs; they cannot be used
directly for API access
Only authenticated committers (signed in via ASF OAuth) can create PATs. Each
user can have multiple active PATs.
-PATs are rejected if the user who created them has been removed from LDAP.
+PATs are rejected if the user who created them has been banned in or removed
from LDAP.
### JSON Web Tokens (JWTs)
@@ -139,7 +139,7 @@ For web users, authentication happens once via ASF OAuth,
and the session persis
### Personal Access Tokens
* Stored as SHA3-256 hashes
-* Can be revoked immediately by the user
+* Can be revoked immediately by the user or in bulk by administrators
* Limited purpose (only for JWT issuance) reduces impact of compromise
* Long validity (180 days) balanced by easy revocation
@@ -162,3 +162,4 @@ Tokens must be protected by the user at all times:
* [`principal.py`](/ref/atr/principal.py) - Session caching and authorization
data
* [`jwtoken.py`](/ref/atr/jwtoken.py) - JWT creation, verification, and
decorators
+* [`storage/writers/tokens.py`](/ref/atr/storage/writers/tokens.py) - Token
creation, deletion, and admin revocation
diff --git a/atr/docs/authorization-security.md
b/atr/docs/authorization-security.md
index 617ae3f4..41029ebd 100644
--- a/atr/docs/authorization-security.md
+++ b/atr/docs/authorization-security.md
@@ -122,6 +122,12 @@ Token operations apply to the authenticated user:
* Allowed for: The token owner, or administrators
* Constraint: Users can only revoke their own tokens (unless admin)
+**Revoke all tokens for a user (admin)**:
+
+* Allowed for: ATR administrators only
+* Interface: Admin "Revoke user tokens" page
+* Constraint: Requires typing "REVOKE" as confirmation
+
**Exchange PAT for JWT**:
* Allowed for: Anyone with a valid PAT
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index cf159412..7c758a94 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -247,6 +247,7 @@ class WriteAsFoundationAdmin(WriteAsCommitteeMember):
self.__committee_name = committee_name
self.keys = writers.keys.FoundationAdmin(write, self, data,
committee_name)
self.release = writers.release.FoundationAdmin(write, self, data,
committee_name)
+ self.tokens = writers.tokens.FoundationAdmin(write, self, data,
committee_name)
@property
def asf_uid(self) -> str:
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index d47cefa5..67679c3c 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -169,3 +169,34 @@ class CommitteeMember(CommitteeParticipant):
raise storage.AccessError("Not authorized")
self.__asf_uid = asf_uid
self.__committee_name = committee_name
+
+
+class FoundationAdmin(CommitteeMember):
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsFoundationAdmin,
+ data: db.Session,
+ committee_name: str,
+ ):
+ super().__init__(write, write_as, data, committee_name)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+
+ 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,
+ )
+ return count
diff --git a/atr/templates/includes/topnav.html
b/atr/templates/includes/topnav.html
index 5422f9f5..6689dc35 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -216,6 +216,11 @@
href="{{ as_url(admin.ldap_get) }}"
{% if request.endpoint == 'atr_admin_ldap_get'
%}class="active"{% endif %}><i class="bi bi-person-plus"></i> LDAP search</a>
</li>
+ <li>
+ <a class="dropdown-item"
+ href="{{ as_url(admin.revoke_user_tokens_get) }}"
+ {% if request.endpoint ==
'atr_admin_revoke_user_tokens_get' %}class="active"{% endif %}><i class="bi
bi-shield-x"></i> Revoke user tokens</a>
+ </li>
<li>
<a class="dropdown-item"
href="{{ as_url(admin.toggle_view_get) }}"
diff --git a/tests/e2e/admin/__init__.py b/tests/e2e/admin/__init__.py
new file mode 100644
index 00000000..13a83393
--- /dev/null
+++ b/tests/e2e/admin/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tests/e2e/admin/conftest.py b/tests/e2e/admin/conftest.py
new file mode 100644
index 00000000..7eecb75d
--- /dev/null
+++ b/tests/e2e/admin/conftest.py
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import e2e.admin.helpers as admin_helpers
+import e2e.helpers as helpers
+import pytest
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+ from playwright.sync_api import Page
+
+
[email protected]
+def page_revoke_tokens(page: Page) -> Generator[Page]:
+ helpers.log_in(page)
+ helpers.visit(page, admin_helpers.REVOKE_TOKENS_PATH)
+ yield page
+
+
[email protected]
+def page_revoke_tokens_with_token(page: Page) -> Generator[Page]:
+ """Log in, create a test token, then navigate to the revoke page."""
+ helpers.log_in(page)
+ # Create a token first
+ helpers.visit(page, admin_helpers.TOKENS_PATH)
+ admin_helpers.create_token(page, admin_helpers.TOKEN_LABEL_FOR_TESTING)
+ # Navigate to admin revoke page
+ helpers.visit(page, admin_helpers.REVOKE_TOKENS_PATH)
+ yield page
+ # Cleanup: delete the test token if it still exists
+ helpers.visit(page, admin_helpers.TOKENS_PATH)
+ from e2e.tokens.helpers import delete_token_by_label
+
+ delete_token_by_label(page, admin_helpers.TOKEN_LABEL_FOR_TESTING)
diff --git a/tests/e2e/admin/helpers.py b/tests/e2e/admin/helpers.py
new file mode 100644
index 00000000..13c17d25
--- /dev/null
+++ b/tests/e2e/admin/helpers.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Final
+
+from playwright.sync_api import Page
+
+REVOKE_TOKENS_PATH: Final[str] = "/admin/revoke-user-tokens"
+TOKENS_PATH: Final[str] = "/tokens"
+TOKEN_LABEL_FOR_TESTING: Final[str] = "e2e-revoke-test-token"
+
+
+def create_token(page: Page, label: str) -> None:
+ """Create a PAT via the tokens page."""
+ label_input = page.locator('input[name="label"]')
+ label_input.fill(label)
+ page.get_by_role("button", name="Generate token").click()
+ page.wait_for_load_state()
+
+
+def get_token_count_for_user(page: Page, uid: str) -> int:
+ """Read the token count for a user from the revoke tokens page table."""
+ row = page.locator(f'tr:has(td code:text-is("{uid}"))')
+ if row.count() == 0:
+ return 0
+ count_cell = row.locator("td").nth(1)
+ return int(count_cell.inner_text())
diff --git a/tests/e2e/admin/test_revoke_tokens.py
b/tests/e2e/admin/test_revoke_tokens.py
new file mode 100644
index 00000000..782c958e
--- /dev/null
+++ b/tests/e2e/admin/test_revoke_tokens.py
@@ -0,0 +1,137 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import e2e.admin.helpers as admin_helpers
+import e2e.helpers as helpers
+from playwright.sync_api import Page, expect
+
+
+def test_revoke_tokens_page_loads(page_revoke_tokens: Page) -> None:
+ expect(page_revoke_tokens).to_have_title("Revoke user tokens ~ ATR")
+
+
+def test_revoke_tokens_page_has_heading(page_revoke_tokens: Page) -> None:
+ heading = page_revoke_tokens.get_by_role("heading", name="Revoke user
tokens")
+ expect(heading).to_be_visible()
+
+
+def test_revoke_tokens_page_has_uid_input(page_revoke_tokens: Page) -> None:
+ uid_input = page_revoke_tokens.locator('input[name="asf_uid"]')
+ expect(uid_input).to_be_visible()
+
+
+def test_revoke_tokens_page_has_confirmation_input(page_revoke_tokens: Page)
-> None:
+ confirm_input = page_revoke_tokens.locator('input[name="confirm_revoke"]')
+ expect(confirm_input).to_be_visible()
+
+
+def test_revoke_tokens_page_has_submit_button(page_revoke_tokens: Page) ->
None:
+ button = page_revoke_tokens.get_by_role("button", name="Revoke all tokens")
+ expect(button).to_be_visible()
+
+
+def test_revoke_tokens_page_shows_token_counts_table(
+ page_revoke_tokens_with_token: Page,
+) -> None:
+ page = page_revoke_tokens_with_token
+ table = page.locator("table")
+ expect(table).to_be_visible()
+ # The test user should appear in the table
+ test_user_row = page.locator('tr:has(td code:text-is("test"))')
+ expect(test_user_row).to_be_visible()
+
+
+def test_revoke_tokens_page_shows_no_tokens_message_when_empty(
+ page_revoke_tokens: Page,
+) -> None:
+ # This test assumes no PATs exist for any user
+ # If the table is not visible, the info alert should be
+ page = page_revoke_tokens
+ table = page.locator("table.table-striped")
+ info_alert = page.locator('.alert-info:has-text("No users currently have
active tokens")')
+ # One of these should be visible
+ expect(table.or_(info_alert)).to_be_visible()
+
+
+def test_revoke_shows_error_for_wrong_confirmation(page_revoke_tokens: Page)
-> None:
+ page = page_revoke_tokens
+ page.locator('input[name="asf_uid"]').fill("test")
+ page.locator('input[name="confirm_revoke"]').fill("WRONG")
+ page.get_by_role("button", name="Revoke all tokens").click()
+ page.wait_for_load_state()
+
+ error_message = page.locator(".flash-message.flash-error")
+ expect(error_message).to_be_visible()
+
+
+def test_revoke_nonexistent_user_shows_info(page_revoke_tokens: Page) -> None:
+ page = page_revoke_tokens
+ page.locator('input[name="asf_uid"]').fill("nonexistent_user_abc123")
+ page.locator('input[name="confirm_revoke"]').fill("REVOKE")
+ page.get_by_role("button", name="Revoke all tokens").click()
+ page.wait_for_load_state()
+
+ info_message = page.locator('.flash-message:has-text("No tokens found")')
+ expect(info_message).to_be_visible()
+
+
+def test_revoke_deletes_tokens_and_shows_success(
+ page_revoke_tokens_with_token: Page,
+) -> None:
+ page = page_revoke_tokens_with_token
+
+ # Verify the test user has tokens before revocation
+ token_count = admin_helpers.get_token_count_for_user(page, "test")
+ assert token_count > 0
+
+ # Revoke all tokens for the test user
+ page.locator('input[name="asf_uid"]').fill("test")
+ page.locator('input[name="confirm_revoke"]').fill("REVOKE")
+ page.get_by_role("button", name="Revoke all tokens").click()
+ page.wait_for_load_state()
+
+ # Should see success message
+ success_message =
page.locator('.flash-message.flash-success:has-text("Revoked")')
+ expect(success_message).to_be_visible()
+
+ # The test user should no longer appear in the table (or have 0 tokens)
+ test_user_row = page.locator('tr:has(td code:text-is("test"))')
+ expect(test_user_row).to_have_count(0)
+
+
+def test_revoke_tokens_removes_tokens_from_user_view(
+ page_revoke_tokens_with_token: Page,
+) -> None:
+ page = page_revoke_tokens_with_token
+
+ # Revoke
+ page.locator('input[name="asf_uid"]').fill("test")
+ page.locator('input[name="confirm_revoke"]').fill("REVOKE")
+ page.get_by_role("button", name="Revoke all tokens").click()
+ page.wait_for_load_state()
+
+ # Navigate to the user tokens page and verify tokens are gone
+ helpers.visit(page, admin_helpers.TOKENS_PATH)
+ token_row =
page.locator(f'tr:has(td:text-is("{admin_helpers.TOKEN_LABEL_FOR_TESTING}"))')
+ expect(token_row).to_have_count(0)
+
+
+def test_revoke_tokens_nav_link_exists(page_revoke_tokens: Page) -> None:
+ """The admin dropdown should contain a 'Revoke user tokens' link."""
+ page = page_revoke_tokens
+ nav_link = page.locator('a.dropdown-item:has-text("Revoke user tokens")')
+ expect(nav_link).to_have_count(1)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]