This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 7d013b90 Check LDAP account status during SSH authentication
7d013b90 is described below
commit 7d013b9077518d416574ce24c4ce405ddcf28f5e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Apr 2 19:27:20 2026 +0100
Check LDAP account status during SSH authentication
---
atr/ssh.py | 33 +++--
tests/unit/test_ssh_account_status.py | 253 ++++++++++++++++++++++++++++++++++
2 files changed, 275 insertions(+), 11 deletions(-)
diff --git a/atr/ssh.py b/atr/ssh.py
index e596769b..d011649b 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
@@ -102,6 +103,10 @@ class SSHServer(asyncssh.SSHServer):
log.info("GitHub authentication will use validate_public_key")
return True
+ if not await ldap.is_active(username):
+ log.failed_authentication("ssh_account_disabled")
+ raise asyncssh.PermissionDenied("Account disabled")
+
try:
# Load SSH keys for this user from the database
async with db.session() as data:
@@ -154,18 +159,22 @@ class SSHServer(asyncssh.SSHServer):
if workflow_key is None:
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)
+ # 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)
- 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:
- log.failed_authentication("public_key_expired")
- return False
+ if not await ldap.is_active(self._github_asf_uid):
+ log.failed_authentication("ssh_workflow_account_disabled")
+ return False
- self._github_payload =
github.TrustedPublisherPayload.model_validate(workflow_key.github_payload)
- return True
+ 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:
+ log.failed_authentication("public_key_expired")
+ return False
+
+ self._github_payload =
github.TrustedPublisherPayload.model_validate(workflow_key.github_payload)
+ return True
def _get_asf_uid(self, process: asyncssh.SSHServerProcess) -> str:
username = process.get_extra_info("username")
@@ -261,6 +270,9 @@ async def _step_02_handle_safely(process:
asyncssh.SSHServerProcess, server: SSH
log.set_asf_uid(asf_uid)
log.info(f"Handling command for authenticated user: {asf_uid}")
+ if not await ldap.is_active(asf_uid):
+ raise RsyncArgsError("Account disabled")
+
if not process.command:
raise RsyncArgsError("No command specified")
@@ -452,7 +464,6 @@ async def _step_06a_validate_read_permissions(
sql.ReleasePhase.RELEASE_CANDIDATE,
sql.ReleasePhase.RELEASE_PREVIEW,
}
- print(release)
if release.phase not in allowed_read_phases:
raise RsyncArgsError(f"Release '{release.key}' is not in a readable
phase ({release.phase.value})")
diff --git a/tests/unit/test_ssh_account_status.py
b/tests/unit/test_ssh_account_status.py
new file mode 100644
index 00000000..faa174ae
--- /dev/null
+++ b/tests/unit/test_ssh_account_status.py
@@ -0,0 +1,253 @@
+# 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 unittest.mock as mock
+from typing import TYPE_CHECKING
+
+import asyncssh
+import pytest
+
+import atr.ssh as ssh
+
+if TYPE_CHECKING:
+ from pytest import MonkeyPatch
+
+
[email protected]
[email protected]("_patch_ldap_active")
+async def test_begin_auth_allows_active_user(monkeypatch: "MonkeyPatch"):
+ server = _make_server()
+ server._conn = _make_conn()
+ mock_data = mock.MagicMock()
+ mock_data.ssh_key.return_value.all = mock.AsyncMock(return_value=[])
+ mock_session = mock.AsyncMock()
+ mock_session.__aenter__.return_value = mock_data
+ monkeypatch.setattr("atr.db.session", lambda: mock_session)
+ result = await server.begin_auth("alice")
+ assert result is True
+
+
[email protected]
[email protected]("_patch_ldap_disabled")
+async def test_begin_auth_rejects_disabled_user():
+ server = _make_server()
+ server._conn = _make_conn()
+ with pytest.raises(asyncssh.PermissionDenied, match="Account disabled"):
+ await server.begin_auth("banned-user")
+
+
[email protected]
+async def test_begin_auth_skips_ldap_for_github(monkeypatch: "MonkeyPatch"):
+ is_active_mock = mock.AsyncMock(return_value=False)
+ monkeypatch.setattr("atr.ldap.is_active", is_active_mock)
+ server = _make_server()
+ server._conn = _make_conn()
+ result = await server.begin_auth("github")
+ assert result is True
+ is_active_mock.assert_not_called()
+
+
[email protected]
[email protected]("_patch_ldap_active")
+async def test_step_02_allows_active_account():
+ server = _make_server()
+ process = _make_process(username="alice", command="not-rsync")
+ with pytest.raises(ssh.RsyncArgsError, match="The first two arguments must
be rsync"):
+ await ssh._step_02_handle_safely(process, server)
+
+
[email protected]
[email protected]("_patch_ldap_disabled")
+async def test_step_02_rejects_disabled_account():
+ server = _make_server()
+ server._github_asf_uid = None
+ process = _make_process(username="banned-user", command="rsync --server
--sender -vlogDtpre.iLsfxCIvu . /proj/v1/")
+ with pytest.raises(ssh.RsyncArgsError, match="Account disabled"):
+ await ssh._step_02_handle_safely(process, server)
+
+
[email protected]
[email protected]("_patch_ldap_active")
+async def test_validate_public_key_allows_active_workflow_user(monkeypatch:
"MonkeyPatch"):
+ server = _make_server()
+ key = mock.MagicMock(spec=asyncssh.SSHKey)
+ key.get_fingerprint.return_value = "SHA256:abc"
+ mock_workflow_key = mock.MagicMock()
+ mock_workflow_key.asf_uid = "alice"
+ mock_workflow_key.expires = 9999999999
+ mock_workflow_key.github_payload = {
+ "actor": "alice",
+ "actor_id": 1,
+ "aud": "atr-test-v1",
+ "base_ref": "",
+ "check_run_id": "",
+ "enterprise": "the-asf",
+ "enterprise_id": "212555",
+ "event_name": "push",
+ "head_ref": "",
+ "iat": 1000000000,
+ "iss": "https://token.actions.githubusercontent.com",
+ "job_workflow_ref":
"apache/test/.github/workflows/ci.yml@refs/heads/main",
+ "job_workflow_sha": "abc123",
+ "jti": "x",
+ "ref": "refs/heads/main",
+ "ref_protected": "true",
+ "ref_type": "branch",
+ "repository": "apache/test",
+ "repository_owner": "apache",
+ "repository_visibility": "public",
+ "run_attempt": "1",
+ "run_number": "1",
+ "runner_environment": "github-hosted",
+ "sha": "abc123",
+ "sub": "repo:apache/test:ref:refs/heads/main",
+ "workflow": "CI",
+ "workflow_ref": "apache/test/.github/workflows/ci.yml@refs/heads/main",
+ "workflow_sha": "abc123",
+ }
+ mock_data = mock.MagicMock()
+ mock_data.workflow_ssh_key.return_value.get =
mock.AsyncMock(return_value=mock_workflow_key)
+ mock_session = mock.AsyncMock()
+ mock_session.__aenter__.return_value = mock_data
+ monkeypatch.setattr("atr.db.session", lambda: mock_session)
+ result = await server.validate_public_key("github", key)
+ assert result is True
+
+
[email protected]
[email protected]("_patch_ldap_disabled")
+async def test_validate_public_key_rejects_disabled_workflow_user(monkeypatch:
"MonkeyPatch"):
+ server = _make_server()
+ key = mock.MagicMock(spec=asyncssh.SSHKey)
+ key.get_fingerprint.return_value = "SHA256:abc"
+ mock_workflow_key = mock.MagicMock()
+ mock_workflow_key.asf_uid = "banned-user"
+ mock_workflow_key.expires = 9999999999
+ mock_data = mock.MagicMock()
+ mock_data.workflow_ssh_key.return_value.get =
mock.AsyncMock(return_value=mock_workflow_key)
+ mock_session = mock.AsyncMock()
+ mock_session.__aenter__.return_value = mock_data
+ monkeypatch.setattr("atr.db.session", lambda: mock_session)
+ result = await server.validate_public_key("github", key)
+ assert result is False
+
+
[email protected]
+async def test_validate_public_key_closes_db_session_before_ldap(monkeypatch:
"MonkeyPatch"):
+ server = _make_server()
+ key = mock.MagicMock(spec=asyncssh.SSHKey)
+ key.get_fingerprint.return_value = "SHA256:abc"
+
+ mock_workflow_key = mock.MagicMock()
+ mock_workflow_key.asf_uid = "alice"
+ mock_workflow_key.expires = 9999999999
+ mock_workflow_key.github_payload = {
+ "actor": "alice",
+ "actor_id": 1,
+ "aud": "atr-test-v1",
+ "base_ref": "",
+ "check_run_id": "",
+ "enterprise": "the-asf",
+ "enterprise_id": "212555",
+ "event_name": "push",
+ "head_ref": "",
+ "iat": 1000000000,
+ "iss": "https://token.actions.githubusercontent.com",
+ "job_workflow_ref":
"apache/test/.github/workflows/ci.yml@refs/heads/main",
+ "job_workflow_sha": "abc123",
+ "jti": "x",
+ "ref": "refs/heads/main",
+ "ref_protected": "true",
+ "ref_type": "branch",
+ "repository": "apache/test",
+ "repository_owner": "apache",
+ "repository_visibility": "public",
+ "run_attempt": "1",
+ "run_number": "1",
+ "runner_environment": "github-hosted",
+ "sha": "abc123",
+ "sub": "repo:apache/test:ref:refs/heads/main",
+ "workflow": "CI",
+ "workflow_ref": "apache/test/.github/workflows/ci.yml@refs/heads/main",
+ "workflow_sha": "abc123",
+ }
+
+ session_closed = False
+
+ def mark_session_closed() -> None:
+ nonlocal session_closed
+ session_closed = True
+
+ async def is_active(asf_uid: str) -> bool:
+ assert asf_uid == "alice"
+ assert session_closed
+ return True
+
+ monkeypatch.setattr("atr.ldap.is_active", is_active)
+ monkeypatch.setattr("atr.db.session", lambda:
_WorkflowKeySession(mock_workflow_key, mark_session_closed))
+
+ result = await server.validate_public_key("github", key)
+
+ assert result is True
+ assert session_closed is True
+
+
+def _make_conn(authorized_keys: list[str] | None = None) -> mock.MagicMock:
+ conn = mock.MagicMock(spec=asyncssh.SSHServerConnection)
+ conn.get_extra_info.return_value = ("127.0.0.1", 22)
+ return conn
+
+
+def _make_process(username: str = "alice", command: str = "") ->
mock.MagicMock:
+ process = mock.MagicMock(spec=asyncssh.SSHServerProcess)
+ process.get_extra_info.return_value = username
+ process.command = command
+ process.is_closing.return_value = False
+ process.stderr = mock.MagicMock()
+ process.stderr.is_closing.return_value = False
+ return process
+
+
+def _make_server() -> ssh.SSHServer:
+ server = ssh.SSHServer.__new__(ssh.SSHServer)
+ server._github_asf_uid = None
+ server._github_payload = None
+ return server
+
+
+class _WorkflowKeySession:
+ def __init__(self, workflow_key: mock.MagicMock, on_exit):
+ self._workflow_key = workflow_key
+ self._on_exit = on_exit
+
+ async def __aenter__(self) -> mock.MagicMock:
+ data = mock.MagicMock()
+ data.workflow_ssh_key.return_value.get =
mock.AsyncMock(return_value=self._workflow_key)
+ return data
+
+ async def __aexit__(self, exc_type, exc, tb) -> None:
+ self._on_exit()
+
+
[email protected]
+def _patch_ldap_active(monkeypatch: "MonkeyPatch"):
+ monkeypatch.setattr("atr.ldap.is_active",
mock.AsyncMock(return_value=True))
+
+
[email protected]
+def _patch_ldap_disabled(monkeypatch: "MonkeyPatch"):
+ monkeypatch.setattr("atr.ldap.is_active",
mock.AsyncMock(return_value=False))
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]