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 cbec8f50 Add audit logging to key writer methods
cbec8f50 is described below
commit cbec8f50ddde1b0a940b7bd36fe09d107bdc9a17
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Apr 3 19:28:28 2026 +0100
Add audit logging to key writer methods
---
atr/storage/writers/keys.py | 63 ++++++++++++++++++++++++++++++++++++++++--
tests/unit/test_keys_writer.py | 57 ++++++++++++++++++++++++++++++++++++--
2 files changed, 114 insertions(+), 6 deletions(-)
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 2b7d1313..b4e69594 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-# TODO: Add auditing
# TODO: Always raise and catch AccessError
# Removing this will cause circular imports
@@ -86,6 +85,12 @@ class FoundationCommitter(GeneralPublic):
affected_committee_keys = {committee.key for committee in
key.committees}
await self.__data.delete(key)
await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ action="key_delete",
+ asf_uid=self.__asf_uid,
+ fingerprint=key.fingerprint,
+ committee_keys=[k for k in sorted(affected_committee_keys)],
+ )
for committee_key in sorted(affected_committee_keys):
await self._sync_committee_keys_file(committee_key)
return outcome.Result(key)
@@ -227,6 +232,7 @@ class FoundationCommitter(GeneralPublic):
test_user_keys = await
self.__data.public_signing_key(apache_uid=test_uid, _committees=True).all()
deleted_count = 0
+ deleted_fingerprints: list[str] = []
for key in test_user_keys:
# We must do this here otherwise SQLAlchemy does not know
about the deletions
key.committees.clear()
@@ -240,8 +246,17 @@ class FoundationCommitter(GeneralPublic):
await self.__data.delete(key)
deleted_count += 1
+ deleted_fingerprints.append(key.fingerprint)
await self.__data.commit()
+ if deleted_count > 0:
+ self.__write_as.append_to_audit_log(
+ action="key_delete_all_for_test_user",
+ asf_uid=self.__asf_uid,
+ target_asf_uid=test_uid,
+ keys_deleted=deleted_count,
+ fingerprints=[f for f in sorted(deleted_fingerprints)],
+ )
return outcome.Result(deleted_count)
except Exception as e:
return outcome.Error(e)
@@ -292,6 +307,13 @@ class FoundationCommitter(GeneralPublic):
)
await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ action="key_update_committee_associations",
+ asf_uid=self.__asf_uid,
+ fingerprint=fingerprint,
+ committees_added=[k for k in sorted(to_add)],
+ committees_removed=[k for k in sorted(to_remove)],
+ )
for committee_key in sorted(affected):
await self._sync_committee_keys_file(committee_key)
@@ -353,6 +375,12 @@ class FoundationCommitter(GeneralPublic):
return outcome.Result(types.Key(status=types.KeyStatus.PARSED,
key_model=key.key_model))
log.info(f"Inserted key {key.key_model.fingerprint}")
+ self.__write_as.append_to_audit_log(
+ action="key_insert",
+ asf_uid=self.__asf_uid,
+ fingerprint=key.key_model.fingerprint,
+ key_apache_uid=key.key_model.apache_uid,
+ )
# TODO: PARSED now acts as "ALREADY_ADDED"
return outcome.Result(types.Key(status=types.KeyStatus.INSERTED,
key_model=key.key_model))
@@ -483,13 +511,21 @@ class CommitteeParticipant(FoundationCommitter):
.on_conflict_do_nothing(index_elements=["committee_key",
"key_fingerprint"])
.returning(via(sql.KeyLink.key_fingerprint))
)
- if link_insert_result.one_or_none() is None:
+ link_inserted = link_insert_result.one_or_none() is not None
+ if not link_inserted:
# e = storage.AccessError(f"Key not found: {fingerprint}")
# return storage.OutcomeException(e)
pass
await self.__data.commit()
except Exception as e:
return outcome.Error(e)
+ if link_inserted:
+ self.__write_as.append_to_audit_log(
+ action="key_associate_committee",
+ asf_uid=self.__asf_uid,
+ fingerprint=fingerprint,
+ committee_key=self.__committee_key,
+ )
try:
autogenerated_outcome = await self.autogenerate_keys_file()
except Exception as e:
@@ -559,6 +595,7 @@ class CommitteeParticipant(FoundationCommitter):
)
outcomes = await self.ensure_associated(keys_file_text)
+ release_keys_removed = False
# Remove the KEYS file if 100% imported
if (outcomes.result_count > 0) and (outcomes.error_count == 0):
description = "Removed KEYS file after successful import through
web interface"
@@ -575,6 +612,17 @@ class CommitteeParticipant(FoundationCommitter):
description=description,
modify=modify,
)
+ release_keys_removed = True
+ self.__write_as.append_to_audit_log(
+ action="key_import",
+ asf_uid=self.__asf_uid,
+ committee_key=self.__committee_key,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ imported_keys=outcomes.result_count,
+ failed_keys=outcomes.error_count,
+ release_keys_removed=release_keys_removed,
+ )
return outcomes
def __block_models(self, key_block: str, ldap_data: dict[str, str]) ->
list[types.Key | Exception]:
@@ -625,7 +673,7 @@ class CommitteeParticipant(FoundationCommitter):
outcomes.update_roes(Exception, raise_post_parse_error)
return outcomes
- async def __database_add_models_core(
+ async def __database_add_models_core( # noqa: C901
self,
outcomes: outcome.List[types.Key],
associate: bool = True,
@@ -665,6 +713,7 @@ class CommitteeParticipant(FoundationCommitter):
existing_fingerprints = {k.fingerprint for k in
committee.public_signing_keys}
new_fingerprints = persisted_fingerprints - existing_fingerprints
+ link_inserts = set()
if new_fingerprints and associate:
link_values = [{"committee_key": self.__committee_key,
"key_fingerprint": fp} for fp in new_fingerprints]
link_insert_result = await self.__data.execute(
@@ -692,6 +741,14 @@ class CommitteeParticipant(FoundationCommitter):
log.info("Inserted 0 key links (none to insert)")
await self.__data.commit()
+ if key_inserts or link_inserts:
+ self.__write_as.append_to_audit_log(
+ action="key_insert_or_associate_bulk",
+ asf_uid=self.__asf_uid,
+ committee_key=self.__committee_key,
+ inserted_fingerprints=sorted(key_inserts),
+ linked_fingerprints=sorted(link_inserts),
+ )
return outcomes
async def __ensure(self, keys_file_text: str, associate: bool = True) ->
outcome.List[types.Key]:
diff --git a/tests/unit/test_keys_writer.py b/tests/unit/test_keys_writer.py
index f60433b6..d183e20e 100644
--- a/tests/unit/test_keys_writer.py
+++ b/tests/unit/test_keys_writer.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
import unittest.mock as mock
from types import SimpleNamespace
@@ -57,6 +58,40 @@ class MockData:
return Query(self._committees_after_commit[key])
[email protected]
+async def test_database_add_model_audits_inserted_key():
+ data = MockData(None, committees_after_commit={})
+ writer, _write, write_as = _make_foundation_committer_with_audit(data)
+ insert_result = mock.MagicMock()
+ insert_result.one_or_none.return_value = object()
+ data.execute.return_value = insert_result
+ key_model = keys_writer.sql.PublicSigningKey(
+ fingerprint="fp1",
+ algorithm=1,
+ length=4096,
+ created=datetime.datetime.now(datetime.UTC),
+ latest_self_signature=None,
+ expires=None,
+ primary_declared_uid="Alice <[email protected]>",
+ secondary_declared_uids=[],
+ apache_uid="alice",
+ ascii_armored_key="-----BEGIN PGP PUBLIC KEY
BLOCK-----\nbody\n-----END PGP PUBLIC KEY BLOCK-----\n",
+ )
+ key = SimpleNamespace(key_model=key_model)
+
+ result = await writer._FoundationCommitter__database_add_model(key)
+
+ assert isinstance(result, outcome.Result)
+ data.begin_immediate.assert_awaited_once()
+ data.commit.assert_awaited_once()
+ write_as.append_to_audit_log.assert_called_once()
+ audit_kwargs = write_as.append_to_audit_log.call_args.kwargs
+ assert audit_kwargs["action"] == "key_insert"
+ assert audit_kwargs["asf_uid"] == "alice"
+ assert audit_kwargs["fingerprint"] == "fp1"
+ assert audit_kwargs["key_apache_uid"] == "alice"
+
+
@pytest.mark.asyncio
async def test_delete_committee_keys_audits_committed_delete_when_sync_fails():
key_orphaned = SimpleNamespace(fingerprint="fp1", committees=[])
@@ -140,7 +175,7 @@ async def
test_delete_key_removal_deletes_empty_keys_file(tmp_path):
owned_key,
committees_after_commit={"alpha": _committee("alpha", [])},
)
- writer, _write = _make_foundation_committer(data)
+ writer, _write, write_as = _make_foundation_committer_with_audit(data)
keys_path = tmp_path / "alpha" / "KEYS"
keys_path.parent.mkdir(parents=True)
@@ -156,6 +191,11 @@ async def
test_delete_key_removal_deletes_empty_keys_file(tmp_path):
assert not keys_path.exists()
data.delete.assert_awaited_once_with(owned_key)
data.commit.assert_awaited_once()
+ write_as.append_to_audit_log.assert_called_once()
+ audit_kwargs = write_as.append_to_audit_log.call_args.kwargs
+ assert audit_kwargs["action"] == "key_delete"
+ assert audit_kwargs["fingerprint"] == "fp1"
+ assert audit_kwargs["committee_keys"] == ["alpha"]
@pytest.mark.asyncio
@@ -165,7 +205,7 @@ async def
test_update_committee_associations_removal_deletes_empty_keys_file(tmp
owned_key,
committees_after_commit={"alpha": _committee("alpha", [])},
)
- writer, write = _make_foundation_committer(data)
+ writer, write, write_as = _make_foundation_committer_with_audit(data)
keys_path = tmp_path / "alpha" / "KEYS"
keys_path.parent.mkdir(parents=True)
@@ -182,6 +222,12 @@ async def
test_update_committee_associations_removal_deletes_empty_keys_file(tmp
assert write.as_committee_participant.call_count == 0
data.begin_immediate.assert_awaited_once()
data.commit.assert_awaited_once()
+ write_as.append_to_audit_log.assert_called_once()
+ audit_kwargs = write_as.append_to_audit_log.call_args.kwargs
+ assert audit_kwargs["action"] == "key_update_committee_associations"
+ assert audit_kwargs["fingerprint"] == "fp1"
+ assert audit_kwargs["committees_added"] == []
+ assert audit_kwargs["committees_removed"] == ["alpha"]
@pytest.mark.asyncio
@@ -230,11 +276,16 @@ def _make_foundation_admin(data: MockData, committee_key:
str):
def _make_foundation_committer(data: MockData):
+ writer, write, _write_as = _make_foundation_committer_with_audit(data)
+ return writer, write
+
+
+def _make_foundation_committer_with_audit(data: MockData):
write = mock.MagicMock()
write.authorisation.asf_uid = "alice"
write.as_committee_participant = mock.MagicMock()
write_as = mock.MagicMock()
- return keys_writer.FoundationCommitter(write, write_as, data), write
+ return keys_writer.FoundationCommitter(write, write_as, data), write,
write_as
def _public_key(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]