You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ak...@apache.org on 2026/03/19 23:46:23 UTC

(tooling-trusted-releases) branch ssh-invalidate-737 created (now 59f30984)

This is an automated email from the ASF dual-hosted git repository.

akm pushed a change to branch ssh-invalidate-737
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


      at 59f30984 Invalidate SSH keys; fixes #737

This branch includes the following new commits:

     new 59f30984 Invalidate SSH keys; fixes #737

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: Invalidate SSH keys; fixes #737

Posted by ak...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

akm pushed a commit to branch ssh-invalidate-737
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 59f30984fd3eef98475f43e6187b1d44d6505964
Author: Andrew K. Musselman <ak...@apache.org>
AuthorDate: Thu Mar 19 16:45:31 2026 -0700

    Invalidate SSH keys; fixes #737
---
 atr/admin/__init__.py                           | 84 +++++++++++++++++++++++++
 atr/admin/templates/revoke-user-ssh-keys.html   | 76 ++++++++++++++++++++++
 atr/docs/authentication-security.md             |  9 +++
 atr/docs/authorization-security.md              |  6 ++
 atr/models/sql.py                               |  1 +
 atr/ssh.py                                      | 23 ++++++-
 atr/storage/__init__.py                         |  1 +
 atr/storage/writers/ssh.py                      | 52 +++++++++++++++
 atr/templates/includes/topnav.html              |  5 ++
 migrations/versions/0062_2026.03.19_ef59ffaf.py | 33 ++++++++++
 pip-audit.requirements                          | 10 +--
 tests/e2e/admin/conftest.py                     |  7 +++
 tests/e2e/admin/helpers.py                      |  1 +
 tests/e2e/admin/test_revoke_ssh_keys.py         | 69 ++++++++++++++++++++
 uv.lock                                         | 76 +++++++++++-----------
 15 files changed, 409 insertions(+), 44 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 1ce05888..6e8be873 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -100,6 +100,11 @@ class RevokeUserTokensForm(form.Form):
     confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type REVOKE 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.")
+
+
 class RotateJwtKeyForm(form.Form):
     confirm_rotate: Literal["ROTATE"] = form.label("Confirmation", "Type ROTATE to confirm.")
 
@@ -895,6 +900,85 @@ async def revoke_user_tokens_post(
     return await session.redirect(revoke_user_tokens_get)
 
 
+@admin.typed
+async def revoke_user_ssh_keys_get(
+    _session: web.Committer, _revoke_user_ssh_keys: Literal["revoke-user-ssh-keys"]
+) -> str:
+    """
+    URL: GET /revoke-user-ssh-keys
+
+    Revoke all SSH keys for a specified user.
+    """
+    via = sql.validate_instrumented_attribute
+    ssh_key_counts: list[tuple[str, int]] = []
+    workflow_key_counts: list[tuple[str, int]] = []
+    async with db.session() as data:
+        ssh_stmt = (
+            sqlmodel.select(
+                sql.SSHKey.asf_uid,
+                sqlmodel.func.count(),
+            )
+            .group_by(sql.SSHKey.asf_uid)
+            .order_by(sql.SSHKey.asf_uid)
+        )
+        ssh_rows = await data.execute_query(ssh_stmt)
+        ssh_key_counts = [(row[0], row[1]) for row in ssh_rows]
+
+        workflow_stmt = (
+            sqlmodel.select(
+                sql.WorkflowSSHKey.asf_uid,
+                sqlmodel.func.count(),
+            )
+            .where(via(sql.WorkflowSSHKey.revoked).is_(False))
+            .group_by(sql.WorkflowSSHKey.asf_uid)
+            .order_by(sql.WorkflowSSHKey.asf_uid)
+        )
+        workflow_rows = await data.execute_query(workflow_stmt)
+        workflow_key_counts = [(row[0], row[1]) for row in workflow_rows]
+
+    rendered_form = form.render(
+        model_cls=RevokeUserSSHKeysForm,
+        submit_label="Revoke all SSH keys",
+    )
+    return await template.render(
+        "revoke-user-ssh-keys.html",
+        form=rendered_form,
+        ssh_key_counts=ssh_key_counts,
+        workflow_key_counts=workflow_key_counts,
+    )
+
+
+@admin.typed
+async def revoke_user_ssh_keys_post(
+    session: web.Committer,
+    _revoke_user_ssh_keys: Literal["revoke-user-ssh-keys"],
+    revoke_form: RevokeUserSSHKeysForm,
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /revoke-user-ssh-keys
+
+    Revoke all SSH keys for a specified user.
+    """
+    target_uid = revoke_form.asf_uid
+
+    async with storage.write(session) as write:
+        wafa = write.as_foundation_admin()
+        persistent_count, workflow_count = await wafa.ssh.revoke_all_user_keys(target_uid)
+
+    total = persistent_count + workflow_count
+    if total > 0:
+        parts = []
+        if persistent_count > 0:
+            parts.append(util.plural(persistent_count, "persistent key"))
+        if workflow_count > 0:
+            parts.append(util.plural(workflow_count, "workflow key"))
+        await quart.flash(f"Revoked {' and '.join(parts)} for {target_uid}.", "success")
+    else:
+        await quart.flash(f"No SSH keys found for {target_uid}.", "info")
+
+    return await session.redirect(revoke_user_ssh_keys_get)
+
+
 @admin.typed
 async def rotate_jwt_key_get(_session: web.Committer, _rotate_jwt_key: Literal["rotate-jwt-key"]) -> str:
     """
diff --git a/atr/admin/templates/revoke-user-ssh-keys.html b/atr/admin/templates/revoke-user-ssh-keys.html
new file mode 100644
index 00000000..d7b37c23
--- /dev/null
+++ b/atr/admin/templates/revoke-user-ssh-keys.html
@@ -0,0 +1,76 @@
+{% extends "layouts/base-admin.html" %}
+
+{%- block title -%}Revoke user SSH keys ~ ATR{%- endblock title -%}
+
+{%- block description -%}Revoke all SSH keys for a user.{%- endblock description -%}
+
+{% block content %}
+  <h1>Revoke user SSH keys</h1>
+  <p>Revoke all SSH keys (persistent and workflow) for a user account. Use this when an
+    account is being disabled or when immediate SSH key revocation is needed.</p>
+
+  <div class="card mb-4">
+    <div class="card-header">
+      <h5 class="mb-0">Revoke SSH keys</h5>
+    </div>
+    <div class="card-body">
+      {{ form }}
+    </div>
+  </div>
+
+  {% if ssh_key_counts %}
+    <div class="card mb-4">
+      <div class="card-header">
+        <h5 class="mb-0">Users with persistent SSH keys</h5>
+      </div>
+      <div class="card-body">
+        <table class="table table-sm table-striped table-bordered">
+          <thead>
+            <tr>
+              <th>ASF UID</th>
+              <th>Key count</th>
+            </tr>
+          </thead>
+          <tbody>
+            {% for uid, count in ssh_key_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 persistent SSH keys.</div>
+  {% endif %}
+
+  {% if workflow_key_counts %}
+    <div class="card">
+      <div class="card-header">
+        <h5 class="mb-0">Users with active workflow SSH keys</h5>
+      </div>
+      <div class="card-body">
+        <table class="table table-sm table-striped table-bordered">
+          <thead>
+            <tr>
+              <th>ASF UID</th>
+              <th>Key count</th>
+            </tr>
+          </thead>
+          <tbody>
+            {% for uid, count in workflow_key_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 workflow SSH keys.</div>
+  {% endif %}
+{% endblock content %}
diff --git a/atr/docs/authentication-security.md b/atr/docs/authentication-security.md
index 0a1b6efa..555a30ca 100644
--- a/atr/docs/authentication-security.md
+++ b/atr/docs/authentication-security.md
@@ -180,6 +180,13 @@ For web users, authentication happens once via ASF OAuth, and the session persis
 * Signed with a server secret initialized at startup
 * Stateless design means no database lookup required for verification
 
+### SSH keys
+
+* Persistent SSH keys are deleted when an admin revokes keys for a user
+* Workflow SSH keys (20-minute TTL) can be immediately revoked via a `revoked` flag
+* LDAP account status is checked during SSH authentication, rejecting banned or deleted accounts
+* Administrators can revoke all SSH keys for a user via the admin interface
+
 ### Credential protection
 
 Tokens must be protected by the user at all times:
@@ -194,3 +201,5 @@ 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
+* [`ssh.py`](/ref/atr/ssh.py) - SSH server with LDAP account status checks
+* [`storage/writers/ssh.py`](/ref/atr/storage/writers/ssh.py) - SSH key management and admin revocation
diff --git a/atr/docs/authorization-security.md b/atr/docs/authorization-security.md
index fcb51ff3..33484d7f 100644
--- a/atr/docs/authorization-security.md
+++ b/atr/docs/authorization-security.md
@@ -136,6 +136,12 @@ Token operations apply to the authenticated user:
 * Interface: Admin "Revoke user tokens" page
 * Constraint: Requires typing "REVOKE" as confirmation
 
+**Revoke all SSH keys for a user (admin)**:
+
+* Allowed for: ATR administrators only
+* Interface: Admin "Revoke user SSH keys" page
+* Constraint: Requires typing "REVOKE" as confirmation
+
 **Exchange PAT for JWT**:
 
 * Allowed for: Anyone with a valid PAT
diff --git a/atr/models/sql.py b/atr/models/sql.py
index d0a1a817..a19a7a1d 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -492,6 +492,7 @@ class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
         default_factory=dict, sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
     )
     expires: int = sqlmodel.Field()
+    revoked: bool = sqlmodel.Field(default=False)
 
 
 # SQL core models
diff --git a/atr/ssh.py b/atr/ssh.py
index 533fc86f..5cb55678 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -39,6 +39,7 @@ import ssh_audit.builtin_policies as builtin_policies
 import atr.attestable as attestable
 import atr.config as config
 import atr.db as db
+import atr.ldap as ldap
 import atr.log as log
 import atr.models.github as github
 import atr.models.safe as safe
@@ -101,7 +102,14 @@ class SSHServer(asyncssh.SSHServer):
         if username == "github":
             log.info("GitHub authentication will use validate_public_key")
             return True
-
+        try:
+            account = await ldap.account_lookup(username)
+            if account is None or ldap.is_banned(account):
+                log.warning(f"SSH auth rejected: account {username} is banned or deleted")
+                return True
+        except Exception as e:
+            log.error(f"LDAP lookup failed for SSH user {username}: {e}")
+            return True
         try:
             # Load SSH keys for this user from the database
             async with db.session() as data:
@@ -154,10 +162,23 @@ class SSHServer(asyncssh.SSHServer):
             if workflow_key is None:
                 return False
 
+            if workflow_key.revoked:
+                log.failed_authentication("workflow_key_revoked")
+                return False
+
             # In some cases this will be a service account
             self._github_asf_uid = workflow_key.asf_uid
             log.set_asf_uid(self._github_asf_uid)
 
+            try:
+                account = await ldap.account_lookup(self._github_asf_uid)
+                if account is None or ldap.is_banned(account):
+                    log.failed_authentication("account_banned_or_deleted")
+                    return False
+            except Exception as e:
+                log.error(f"LDAP lookup failed for workflow key user {self._github_asf_uid}: {e}")
+                return False
+
             now = int(time.time())
             # audit_guidance this application is not concerned with checking for a not_before flag on the workflow_key
             if workflow_key.expires < now:
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index d9465078..978f4010 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -226,6 +226,7 @@ class WriteAsFoundationAdmin(WriteAsFoundationCommitter):
         self.__asf_uid = write.authorisation.asf_uid
         self.release = writers.release.FoundationAdmin(write, self, data)
         self.tokens = writers.tokens.FoundationAdmin(write, self, data)
+        self.ssh = writers.ssh.FoundationAdmin(write, self, data)
 
     @property
     def asf_uid(self) -> str:
diff --git a/atr/storage/writers/ssh.py b/atr/storage/writers/ssh.py
index fb776e4a..7c7d3eae 100644
--- a/atr/storage/writers/ssh.py
+++ b/atr/storage/writers/ssh.py
@@ -20,6 +20,8 @@ from __future__ import annotations
 
 import time
 
+import sqlmodel
+
 import atr.db as db
 import atr.models.github as github
 import atr.models.safe as safe
@@ -140,3 +142,53 @@ class CommitteeMember(CommitteeParticipant):
             raise storage.AccessError("Not authorized")
         self.__asf_uid = asf_uid
         self.__committee_key = committee_key
+
+
+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")
+        self.__asf_uid = asf_uid
+
+    async def revoke_all_user_keys(self, target_asf_uid: str) -> tuple[int, int]:
+        """Revoke all SSH keys for a specified user.
+
+        Returns (persistent_count, workflow_count) of keys affected.
+        """
+        via = sql.validate_instrumented_attribute
+        persistent_keys = await self.__data.query_all(
+            sqlmodel.select(sql.SSHKey).where(sql.SSHKey.asf_uid == target_asf_uid)
+        )
+        persistent_count = len(persistent_keys)
+        for key in persistent_keys:
+            await self.__data.delete(key)
+
+        workflow_keys = await self.__data.query_all(
+            sqlmodel.select(sql.WorkflowSSHKey).where(
+                sql.WorkflowSSHKey.asf_uid == target_asf_uid,
+                via(sql.WorkflowSSHKey.revoked).is_(False),
+            )
+        )
+        workflow_count = len(workflow_keys)
+        for key in workflow_keys:
+            key.revoked = True
+
+        total = persistent_count + workflow_count
+        if total > 0:
+            await self.__data.commit()
+            self.__write_as.append_to_audit_log(
+                target_asf_uid=target_asf_uid,
+                persistent_keys_deleted=persistent_count,
+                workflow_keys_revoked=workflow_count,
+            )
+        return persistent_count, workflow_count
diff --git a/atr/templates/includes/topnav.html b/atr/templates/includes/topnav.html
index 62e733c3..9865be04 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -222,6 +222,11 @@
                    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.revoke_user_ssh_keys_get) }}"
+                   {% if request.endpoint == 'atr_admin_revoke_user_ssh_keys_get' %}class="active"{% endif %}><i class="bi bi-shield-x"></i> Revoke user SSH keys</a>
+              </li>
               <li>
                 <a class="dropdown-item"
                    href="{{ as_url(admin.rotate_jwt_key_get) }}"
diff --git a/migrations/versions/0062_2026.03.19_ef59ffaf.py b/migrations/versions/0062_2026.03.19_ef59ffaf.py
new file mode 100644
index 00000000..f39897fd
--- /dev/null
+++ b/migrations/versions/0062_2026.03.19_ef59ffaf.py
@@ -0,0 +1,33 @@
+"""add revoked field to workflowsshkey
+
+Revision ID: 0062_2026.03.19_ef59ffaf
+Revises: 0061_2026.03.18_7838cfcc
+Create Date: 2026-03-19 22:57:29.018312+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0062_2026.03.19_ef59ffaf"
+down_revision: str | None = "0061_2026.03.18_7838cfcc"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table("workflowsshkey", schema=None) as batch_op:
+        batch_op.add_column(sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.text("0")))
+
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table("workflowsshkey", schema=None) as batch_op:
+        batch_op.drop_column("revoked")
+
+    # ### end Alembic commands ###
diff --git a/pip-audit.requirements b/pip-audit.requirements
index ee0180c4..aef6ef78 100644
--- a/pip-audit.requirements
+++ b/pip-audit.requirements
@@ -30,11 +30,11 @@ arrow==1.4.0
     # via isoduration
 asfpy==0.58
     # via asfquart
-asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@ae3b020002b63688c65715f4f45bd351d868da3d
+asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@7ff9f50dcfca338bc935646323c1942275b85195
     # via tooling-trusted-releases
 asyncssh==2.22.0
     # via tooling-trusted-releases
-attrs==25.4.0
+attrs==26.1.0
     # via
     #   aiohttp
     #   jsonschema
@@ -150,7 +150,7 @@ hypercorn==0.18.0
     #   tooling-trusted-releases
 hyperframe==6.1.0
     # via h2
-hyperscan==0.8.1
+hyperscan==0.8.2
     # via tooling-trusted-releases
 identify==2.6.18
     # via pre-commit
@@ -271,7 +271,7 @@ python-dateutil==2.9.0.post0
     #   strictyaml
 python-decouple==3.8
     # via tooling-trusted-releases
-python-discovery==1.1.3
+python-discovery==1.2.0
     # via virtualenv
 python-gnupg==0.5.6
     # via tooling-trusted-releases
@@ -317,7 +317,7 @@ rpds-py==0.30.0
     # via
     #   jsonschema
     #   referencing
-ruff==0.15.6
+ruff==0.15.7
 semver==3.0.4
     # via tooling-trusted-releases
 six==1.17.0
diff --git a/tests/e2e/admin/conftest.py b/tests/e2e/admin/conftest.py
index 7eecb75d..666ea544 100644
--- a/tests/e2e/admin/conftest.py
+++ b/tests/e2e/admin/conftest.py
@@ -29,6 +29,13 @@ if TYPE_CHECKING:
     from playwright.sync_api import Page
 
 
+@pytest.fixture
+def page_revoke_ssh_keys(page: Page) -> Generator[Page]:
+    helpers.log_in(page)
+    helpers.visit(page, admin_helpers.REVOKE_SSH_KEYS_PATH)
+    yield page
+
+
 @pytest.fixture
 def page_revoke_tokens(page: Page) -> Generator[Page]:
     helpers.log_in(page)
diff --git a/tests/e2e/admin/helpers.py b/tests/e2e/admin/helpers.py
index 13c17d25..006630c9 100644
--- a/tests/e2e/admin/helpers.py
+++ b/tests/e2e/admin/helpers.py
@@ -20,6 +20,7 @@ from typing import Final
 from playwright.sync_api import Page
 
 REVOKE_TOKENS_PATH: Final[str] = "/admin/revoke-user-tokens"
+REVOKE_SSH_KEYS_PATH: Final[str] = "/admin/revoke-user-ssh-keys"
 TOKENS_PATH: Final[str] = "/tokens"
 TOKEN_LABEL_FOR_TESTING: Final[str] = "e2e-revoke-test-token"
 
diff --git a/tests/e2e/admin/test_revoke_ssh_keys.py b/tests/e2e/admin/test_revoke_ssh_keys.py
new file mode 100644
index 00000000..72f4bedb
--- /dev/null
+++ b/tests/e2e/admin/test_revoke_ssh_keys.py
@@ -0,0 +1,69 @@
+# 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 playwright.sync_api import Page, expect
+
+
+def test_revoke_ssh_keys_page_loads(page_revoke_ssh_keys: Page) -> None:
+    expect(page_revoke_ssh_keys).to_have_title("Revoke user SSH keys ~ ATR")
+
+
+def test_revoke_ssh_keys_page_has_heading(page_revoke_ssh_keys: Page) -> None:
+    heading = page_revoke_ssh_keys.get_by_role("heading", name="Revoke user SSH keys")
+    expect(heading).to_be_visible()
+
+
+def test_revoke_ssh_keys_page_has_uid_input(page_revoke_ssh_keys: Page) -> None:
+    uid_input = page_revoke_ssh_keys.locator('input[name="asf_uid"]')
+    expect(uid_input).to_be_visible()
+
+
+def test_revoke_ssh_keys_page_has_confirmation_input(page_revoke_ssh_keys: Page) -> None:
+    confirm_input = page_revoke_ssh_keys.locator('input[name="confirm_revoke"]')
+    expect(confirm_input).to_be_visible()
+
+
+def test_revoke_ssh_keys_page_has_submit_button(page_revoke_ssh_keys: Page) -> None:
+    button = page_revoke_ssh_keys.get_by_role("button", name="Revoke all SSH keys")
+    expect(button).to_be_visible()
+
+
+def test_revoke_ssh_keys_shows_error_for_wrong_confirmation(page_revoke_ssh_keys: Page) -> None:
+    page = page_revoke_ssh_keys
+    page.locator('input[name="asf_uid"]').fill("test")
+    page.locator('input[name="confirm_revoke"]').fill("WRONG")
+    page.get_by_role("button", name="Revoke all SSH keys").click()
+    page.wait_for_load_state()
+
+    error_message = page.locator(".flash-message.flash-error")
+    expect(error_message).to_be_visible()
+
+
+def test_revoke_ssh_keys_nonexistent_user_shows_info(page_revoke_ssh_keys: Page) -> None:
+    page = page_revoke_ssh_keys
+    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 SSH keys").click()
+    page.wait_for_load_state()
+
+    info_message = page.locator('.flash-message:has-text("No SSH keys found")')
+    expect(info_message).to_be_visible()
+
+
+def test_revoke_ssh_keys_nav_link_exists(page_revoke_ssh_keys: Page) -> None:
+    nav_link = page_revoke_ssh_keys.locator('a.dropdown-item:has-text("Revoke user SSH keys")')
+    expect(nav_link).to_have_count(1)
diff --git a/uv.lock b/uv.lock
index 7a28b2e0..01607277 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
 requires-python = "==3.13.*"
 
 [options]
-exclude-newer = "2026-03-17T17:33:15Z"
+exclude-newer = "2026-03-19T23:31:09Z"
 
 [[package]]
 name = "aiofiles"
@@ -175,7 +175,7 @@ wheels = [
 [[package]]
 name = "asfquart"
 version = "0.1.13"
-source = { git = "https://github.com/apache/infrastructure-asfquart.git?rev=main#ae3b020002b63688c65715f4f45bd351d868da3d" }
+source = { git = "https://github.com/apache/infrastructure-asfquart.git?rev=main#7ff9f50dcfca338bc935646323c1942275b85195" }
 dependencies = [
     { name = "aiohttp" },
     { name = "asfpy" },
@@ -201,11 +201,11 @@ wheels = [
 
 [[package]]
 name = "attrs"
-version = "25.4.0"
+version = "26.1.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+    { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
 ]
 
 [[package]]
@@ -779,17 +779,17 @@ wheels = [
 
 [[package]]
 name = "hyperscan"
-version = "0.8.1"
+version = "0.8.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cc/56/6359bfa86a3bfeeecbeeb1ffcfc9484058c8a8ae153c8bda2181c3511e6b/hyperscan-0.8.1.tar.gz", hash = "sha256:d50bf70b0110817a308bfb1855055dc4d649934857b958498a1791164f512779", size = 125303, upload-time = "2026-02-11T19:19:46.323Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/26/21daad311299a416059cf1919c51410573180cf7133b42927693f19c0af7/hyperscan-0.8.2.tar.gz", hash = "sha256:1724e87e8f77f033a4592dc2cda7aecd10c91dfc718b55fa5379d0c95cff28e8", size = 125600, upload-time = "2026-03-19T01:47:34.538Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/17/b2/6060a2a84a2dae7024d03719ecf6b0438b6f40aeba11a34ede6ffdea7b91/hyperscan-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee39b88a85fbd0b262e2a458985f878f6cf726e41099c601e0c2ce681aa16d98", size = 2044166, upload-time = "2026-02-11T19:19:09.761Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/44/6a727f676c0cf86efed79320dfe968d53d66f8f2df4f9ebab33cabd648c9/hyperscan-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6ce5d67e90ad18800f68a7ef52fdba60223ff5ebfa19b945d6abbc0a6163e69f", size = 2033045, upload-time = "2026-02-11T19:19:11.156Z" },
-    { url = "https://files.pythonhosted.org/packages/a0/32/6edc476f9623ef7f87dc851e28803ad0b765202f129f399223a7b917fb32/hyperscan-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d504b8fda00678d86cd1ea63ca15ef19e553da411636eb36acc1c85d1d0ee2c", size = 2763694, upload-time = "2026-02-11T19:19:12.522Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/83/e05ec0da2f856925dae6c978fc67c354d9a48712626cc455c891995b236f/hyperscan-0.8.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f25ec48d0a8d3d97ebc758fdfa601c89db26039004c7f1ce2823249adbc7960", size = 2567752, upload-time = "2026-02-11T19:19:13.932Z" },
-    { url = "https://files.pythonhosted.org/packages/4e/b0/44679375c66a7ee30c05e31f92cbeeade4bd9efe225b0e4080657a2258de/hyperscan-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4952a74ab75196ce937d3d60d37fe7157e6b570b87726366f6ec7a22f204519d", size = 2389687, upload-time = "2026-02-11T19:19:15.485Z" },
-    { url = "https://files.pythonhosted.org/packages/af/32/e056369242414849e8ea4ea6efd03fdccf05b953623baca77b5ac6a33640/hyperscan-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f22cfcba25ff91ecaa655eb5e666760e2f5fde1af6c91548b71a7336cb0531e", size = 2429033, upload-time = "2026-02-11T19:19:16.843Z" },
-    { url = "https://files.pythonhosted.org/packages/0c/ac/2e53b22605671a872fb2a8ef8ff40a051ced0caae71d91a690af4f08fffe/hyperscan-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:773fc1373a6a12b09e3b1580dcb238dc5e2ebc17482284a436958cbb432e439f", size = 1956074, upload-time = "2026-02-11T19:19:18.571Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/fd/34ed5d1ddb1b0ad384a05b5afdb1f302c145cb4bb885a1cd91266be04740/hyperscan-0.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4fee39d8af5738e51dd6aa3684ffcb1c782dfa907a7a64f50c599635e80606dc", size = 2044020, upload-time = "2026-03-19T01:46:56.576Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/2b/a222d1cce1d203ef9c14ab48d6b5d8c9e3c457a7ebf29ed8dcd9b5ff9193/hyperscan-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7bdac73df001759538f9beee957ac2224739b5ac49814f96a6c3cd2a1fcdafa0", size = 2032948, upload-time = "2026-03-19T01:46:58.688Z" },
+    { url = "https://files.pythonhosted.org/packages/74/d7/44b8879c6e6e5c32f3d47f6be425778bd4124a5f19d0d30610f60a61f817/hyperscan-0.8.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:177692a7688e64e1c77f0af5f23eaad937c452798cd15c0db86bf98b5dce4671", size = 2763696, upload-time = "2026-03-19T01:47:00.159Z" },
+    { url = "https://files.pythonhosted.org/packages/48/0f/d0014ef543ef7327c437337905acbba271632698bd755673126d698bb1fe/hyperscan-0.8.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ec49927002a38ac767d0f18e17135602e493bf2f720548bf7d43a3af2f810a0", size = 2567752, upload-time = "2026-03-19T01:47:01.97Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/25/e25ce2c7b76d758e3ca8013e1df3c7388240e9f72e07f003ce55f0fef628/hyperscan-0.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1055fac1eec046bfc67254d4ea900852597b2eca8e7219e3e558fb869c48100e", size = 2389688, upload-time = "2026-03-19T01:47:03.482Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/bd/b0afe3df17a843a9df3cd60e6a63b31b6c3d5a672f5641eb64eeb91a1707/hyperscan-0.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d94495f8be1c0efe9e24ca3f10796c23921f8556a53b20d5619d4e96861d2f59", size = 2429031, upload-time = "2026-03-19T01:47:05.088Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/62/9e62e22214b47fbd42c58397691d119cb73c0e60ca6a932cf597aaf65f30/hyperscan-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d5a6ac08dab6c9879c87221858371d63545c08920e09bffa258a555843f6ef3", size = 1956255, upload-time = "2026-03-19T01:47:06.645Z" },
 ]
 
 [[package]]
@@ -1493,15 +1493,15 @@ wheels = [
 
 [[package]]
 name = "python-discovery"
-version = "1.1.3"
+version = "1.2.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "filelock" },
     { name = "platformdirs" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" },
 ]
 
 [[package]]
@@ -1774,27 +1774,27 @@ wheels = [
 
 [[package]]
 name = "ruff"
-version = "0.15.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
-    { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
-    { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
-    { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
-    { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
-    { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
-    { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
-    { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
-    { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
-    { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
-    { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
-    { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
-    { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
-    { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
-    { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
-    { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
+version = "0.15.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
+    { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
+    { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
+    { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
+    { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
 ]
 
 [[package]]


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org