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 <[email protected]>
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)
 
 
[email protected]
+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,
+    )
+
+
[email protected]
+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
 
 
[email protected]
+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: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to