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

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 48ba851  Add a storage interface method for deleting OpenPGP signing 
keys
48ba851 is described below

commit 48ba8514ffaf2b0b381dc67ef744dc9fdf675fd1
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 22 17:00:53 2025 +0100

    Add a storage interface method for deleting OpenPGP signing keys
---
 atr/blueprints/admin/admin.py | 31 +++---------------
 atr/blueprints/api/api.py     | 26 ++++++++-------
 atr/routes/keys.py            | 44 +++++++++++++------------
 atr/storage/__init__.py       | 74 +++++++++++++++----------------------------
 atr/storage/types.py          | 47 +++++++++++++++++----------
 atr/storage/writers/keys.py   | 48 ++++++++++++++++++++++++----
 6 files changed, 138 insertions(+), 132 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 03edd75..14e0d52 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -28,7 +28,6 @@ from typing import Any, Final
 
 import aiofiles.os
 import aiohttp
-import aioshutil
 import asfquart
 import asfquart.base as base
 import asfquart.session as session
@@ -45,7 +44,6 @@ import atr.db as db
 import atr.db.interaction as interaction
 import atr.ldap as ldap
 import atr.models.sql as sql
-import atr.routes.keys as keys
 import atr.routes.mapping as mapping
 import atr.storage as storage
 import atr.storage.types as types
@@ -436,7 +434,9 @@ async def admin_keys_regenerate_all() -> quart.Response:
     outcomes = types.Outcomes[str]()
     async with storage.write(asf_uid) as write:
         for committee_name in committee_names:
-            wacm = write.as_committee_member(committee_name).writer_or_raise()
+            wacm = write.as_committee_member(committee_name).result_or_none()
+            if wacm is None:
+                continue
             outcomes.append(await wacm.keys.autogenerate_keys_file())
 
     response_lines = []
@@ -674,7 +674,7 @@ async def admin_test() -> quart.wrappers.response.Response:
     if asf_uid is None:
         raise base.ASFQuartException("Invalid session, uid is None", 500)
     async with storage.write(asf_uid) as write:
-        wacm = write.as_committee_member("tooling").writer_or_raise()
+        wacm = write.as_committee_member("tooling").result_or_raise()
         start = time.perf_counter_ns()
         outcomes: types.Outcomes[types.Key] = await 
wacm.keys.ensure_stored(keys_file_text)
         end = time.perf_counter_ns()
@@ -835,29 +835,6 @@ def _project_status(pmc: sql.Committee, project_name: str, 
project_status: apach
     return sql.ProjectStatus.ACTIVE
 
 
-async def _regenerate_keys_all() -> tuple[int, list[str]]:
-    okay = 0
-    failures = []
-    downloads_dir = util.get_downloads_dir()
-    async with db.session() as data:
-        committees = await data.committee().all()
-        for committee in committees:
-            try:
-                error_msg = await keys.autogenerate_keys_file(committee.name, 
committee.is_podling, caller_data=data)
-            except Exception as e:
-                failures.append(f"Caller error regenerating KEYS file for 
committee {committee.name}: {e!s}")
-                continue
-            if error_msg:
-                failures.append(error_msg)
-            else:
-                okay += 1
-            if committee.is_podling:
-                if await aiofiles.os.path.isdir(downloads_dir / 
committee.name):
-                    # Accidental top level directory, so remove it
-                    await aioshutil.rmtree(downloads_dir / committee.name)
-    return okay, failures
-
-
 def _session_data(
     ldap_data: dict[str, Any],
     new_uid: str,
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 43712ba..2a9c8ae 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -304,12 +304,12 @@ async def keys_add(data: models.api.KeysAddArgs) -> 
DictResponse:
     selected_committee_names = data.committees
 
     async with storage.write(asf_uid) as write:
-        wafm = write.as_foundation_member().writer_or_raise()
+        wafm = write.as_foundation_member().result_or_raise()
         ocr: types.Outcome[types.Key] = await 
wafm.keys.ensure_stored_one(data.key)
         key = ocr.result_or_raise()
 
         for selected_committee_name in selected_committee_names:
-            wacm = 
write.as_committee_member(selected_committee_name).writer_or_raise()
+            wacm = 
write.as_committee_member(selected_committee_name).result_or_raise()
             outcome: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
                 key.key_model.fingerprint
             )
@@ -330,15 +330,19 @@ async def keys_add(data: models.api.KeysAddArgs) -> 
DictResponse:
 async def keys_delete(data: models.api.KeysDeleteArgs) -> DictResponse:
     asf_uid = _jwt_asf_uid()
     fingerprint = data.fingerprint.lower()
-    async with db.session() as db_data:
-        # TODO: Migrate to the storage interface
-        key = await db_data.public_signing_key(fingerprint=fingerprint, 
apache_uid=asf_uid, _committees=True).get()
-        if key is None:
-            raise ValueError(f"Key {fingerprint} not found")
-        await db_data.delete(key)
+
+    outcomes = types.Outcomes[str]()
+    async with storage.write(asf_uid) as write:
+        wafm = write.as_foundation_member().result_or_raise()
+        outcome: types.Outcome[sql.PublicSigningKey] = await 
wafm.keys.delete_key(fingerprint)
+        key = outcome.result_or_raise()
+
         for committee in key.committees:
-            await keys.autogenerate_keys_file(committee.name, 
committee.is_podling)
-        await db_data.commit()
+            wacm = write.as_committee_member(committee.name).result_or_none()
+            if wacm is None:
+                continue
+            outcomes.append(await wacm.keys.autogenerate_keys_file())
+    # TODO: Add error outcomes as warnings to the response
 
     return models.api.KeysDeleteResults(
         endpoint="/keys/delete",
@@ -383,7 +387,7 @@ async def keys_upload(data: models.api.KeysUploadArgs) -> 
DictResponse:
     filetext = data.filetext
     selected_committee_name = data.committee
     async with storage.write(asf_uid) as write:
-        wacm = 
write.as_committee_member(selected_committee_name).writer_or_raise()
+        wacm = 
write.as_committee_member(selected_committee_name).result_or_raise()
         outcomes: types.Outcomes[types.Key] = await 
wacm.keys.ensure_associated(filetext)
 
         # TODO: It would be nice to serialise the actual outcomes
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 93ad707..83597d8 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -169,12 +169,12 @@ async def add(session: routes.CommitterSession) -> str:
             selected_committee_names: list[str] = 
util.unwrap(form.selected_committees.data)
 
             async with storage.write(asf_uid) as write:
-                wafm = write.as_foundation_member().writer_or_raise()
+                wafm = write.as_foundation_member().result_or_raise()
                 ocr: types.Outcome[types.Key] = await 
wafm.keys.ensure_stored_one(key_text)
                 key = ocr.result_or_raise()
 
                 for selected_committee_name in selected_committee_names:
-                    wacm = 
write.as_committee_member(selected_committee_name).writer_or_raise()
+                    wacm = 
write.as_committee_member(selected_committee_name).result_or_raise()
                     outcome: types.Outcome[types.LinkedCommittee] = await 
wacm.keys.associate_fingerprint(
                         key.key_model.fingerprint
                     )
@@ -240,26 +240,28 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     if not fingerprint:
         return await session.redirect(keys, error="Missing key fingerprint for 
deletion.")
 
+    # Try to delete an SSH key first
     async with db.session() as data:
-        async with data.begin():
-            # Try to get an OpenPGP key first
-            key = await data.public_signing_key(fingerprint=fingerprint, 
apache_uid=session.uid.lower()).get()
-            if key:
-                # Delete the OpenPGP key
-                await data.delete(key)
-                for committee in key.committees:
-                    await autogenerate_keys_file(committee.name, 
committee.is_podling, caller_data=data)
-                return await session.redirect(keys, success="OpenPGP key 
deleted successfully")
-
-            # If not an OpenPGP key, try to get an SSH key
-            ssh_key = await data.ssh_key(fingerprint=fingerprint, 
asf_uid=session.uid).get()
-            if ssh_key:
-                # Delete the SSH key
-                await data.delete(ssh_key)
-                return await session.redirect(keys, success="SSH key deleted 
successfully")
-
-            # No key was found
+        ssh_key = await data.ssh_key(fingerprint=fingerprint, 
asf_uid=session.uid).get()
+        if ssh_key:
+            # Delete the SSH key
+            await data.delete(ssh_key)
+            await data.commit()
+            return await session.redirect(keys, success="SSH key deleted 
successfully")
+
+    # Otherwise, delete an OpenPGP key
+    async with storage.write(session.uid) as write:
+        wafm = write.as_foundation_member().result_or_none()
+        if wafm is None:
             return await session.redirect(keys, error="Key not found or not 
owned by you")
+        outcome: types.Outcome[sql.PublicSigningKey] = await 
wafm.keys.delete_key(fingerprint)
+        match outcome:
+            case types.OutcomeResult():
+                return await session.redirect(keys, success="Key deleted 
successfully")
+            case types.OutcomeException():
+                return await session.redirect(keys, error=f"Error deleting 
key: {outcome.exception_or_raise()}")
+
+    return await session.redirect(keys, error="Key not found or not owned by 
you")
 
 
 @routes.committer("/keys/details/<fingerprint>", methods=["GET", "POST"])
@@ -745,7 +747,7 @@ async def _upload_keys(
     selected_committee: str,
 ) -> types.Outcomes[types.Key]:
     async with storage.write(asf_uid) as write:
-        wacm = write.as_committee_member(selected_committee).writer_or_raise()
+        wacm = write.as_committee_member(selected_committee).result_or_raise()
         outcomes: types.Outcomes[types.Key] = await 
wacm.keys.ensure_associated(filetext)
     return outcomes
 
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 642780a..05c1c18 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
     from collections.abc import AsyncGenerator
 
 import atr.db as db
+import atr.storage.types as types
 import atr.storage.writers as writers
 
 VALIDATE_AT_RUNTIME: Final[bool] = True
@@ -53,40 +54,6 @@ W = TypeVar("W", bound=AccessCredentialsWrite)
 class AccessError(RuntimeError): ...
 
 
-## Access outcome
-
-
-# TODO: Use actual outcomes here
-class AccessOutcome[A]:
-    pass
-
-
-class AccessOutcomeRead(AccessOutcome[R]):
-    def __init__(self, accessor_or_exception: R | Exception):
-        self.__accessor_or_exception = accessor_or_exception
-
-    def reader_or_raise(self) -> R:
-        match self.__accessor_or_exception:
-            case AccessCredentialsRead():
-                return self.__accessor_or_exception
-            case Exception():
-                raise self.__accessor_or_exception
-        raise AssertionError("Unreachable")
-
-
-class AccessOutcomeWrite(AccessOutcome[W]):
-    def __init__(self, accessor_or_exception: W | Exception):
-        self.__accessor_or_exception = accessor_or_exception
-
-    def writer_or_raise(self) -> W:
-        match self.__accessor_or_exception:
-            case AccessCredentialsWrite():
-                return self.__accessor_or_exception
-            case Exception():
-                raise self.__accessor_or_exception
-        raise AssertionError("Unreachable")
-
-
 # Read
 
 
@@ -112,7 +79,8 @@ class Read:
 
 
 class WriteAsFoundationParticipant(AccessCredentialsWrite):
-    def __init__(self, data: db.Session):
+    def __init__(self, write: Write, data: db.Session):
+        self.__write = write
         self.__data = data
         self.__asf_uid = None
         self.__authenticated = True
@@ -127,16 +95,18 @@ class WriteAsFoundationParticipant(AccessCredentialsWrite):
 
 
 class WriteAsFoundationMember(WriteAsFoundationParticipant):
-    def __init__(self, data: db.Session, asf_uid: str):
+    def __init__(self, write: Write, data: db.Session, asf_uid: str):
         if self.validate_at_runtime:
             if not isinstance(asf_uid, str):
                 raise AccessError("ASF UID must be a string")
+        self.__write = write
         self.__data = data
         self.__asf_uid = asf_uid
         self.__authenticated = True
         # TODO: We need a definitive list of ASF UIDs
         self.keys = writers.keys.FoundationMember(
             self,
+            self.__write,
             self.__data,
             self.__asf_uid,
         )
@@ -151,16 +121,18 @@ class 
WriteAsFoundationMember(WriteAsFoundationParticipant):
 
 
 class WriteAsCommitteeParticipant(WriteAsFoundationMember):
-    def __init__(self, data: db.Session, asf_uid: str, committee_name: str):
+    def __init__(self, write: Write, data: db.Session, asf_uid: str, 
committee_name: str):
         if self.validate_at_runtime:
             if not isinstance(committee_name, str):
                 raise AccessError("Committee name must be a string")
+        self.__write = write
         self.__data = data
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
         self.__authenticated = True
         self.keys = writers.keys.CommitteeParticipant(
             self,
+            self.__write,
             self.__data,
             self.__asf_uid,
             self.__committee_name,
@@ -176,13 +148,15 @@ class 
WriteAsCommitteeParticipant(WriteAsFoundationMember):
 
 
 class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
-    def __init__(self, data: db.Session, asf_uid: str, committee_name: str):
+    def __init__(self, write: Write, data: db.Session, asf_uid: str, 
committee_name: str):
+        self.__write = write
         self.__data = data
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
         self.__authenticated = True
         self.keys = writers.keys.CommitteeMember(
             self,
+            self.__write,
             self.__data,
             self.__asf_uid,
             committee_name,
@@ -198,12 +172,14 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
 
 
 # class WriteAsFoundationAdmin(WriteAsFoundationMember):
-#     def __init__(self, data: db.Session, asf_uid: str):
+#     def __init__(self, write: Write, data: db.Session, asf_uid: str):
+#         self.__write = write
 #         self.__data = data
 #         self.__asf_uid = asf_uid
 #         self.__authenticated = True
 #         self.keys = writers.keys.FoundationAdmin(
 #             self,
+#             self.__write,
 #             self.__data,
 #             self.__asf_uid,
 #         )
@@ -223,23 +199,23 @@ class Write:
         self.__data = data
         self.__asf_uid = asf_uid
 
-    def as_committee_member(self, committee_name: str) -> 
AccessOutcomeWrite[WriteAsCommitteeMember]:
+    def as_committee_member(self, committee_name: str) -> 
types.Outcome[WriteAsCommitteeMember]:
         if self.__asf_uid is None:
-            return AccessOutcomeWrite(AccessError("No ASF UID"))
+            return types.OutcomeException(AccessError("No ASF UID"))
         try:
-            wacm = WriteAsCommitteeMember(self.__data, self.__asf_uid, 
committee_name)
+            wacm = WriteAsCommitteeMember(self, self.__data, self.__asf_uid, 
committee_name)
         except Exception as e:
-            return AccessOutcomeWrite(e)
-        return AccessOutcomeWrite(wacm)
+            return types.OutcomeException(e)
+        return types.OutcomeResult(wacm)
 
-    def as_foundation_member(self) -> 
AccessOutcomeWrite[WriteAsFoundationMember]:
+    def as_foundation_member(self) -> types.Outcome[WriteAsFoundationMember]:
         if self.__asf_uid is None:
-            return AccessOutcomeWrite(AccessError("No ASF UID"))
+            return types.OutcomeException(AccessError("No ASF UID"))
         try:
-            wafm = WriteAsFoundationMember(self.__data, self.__asf_uid)
+            wafm = WriteAsFoundationMember(self, self.__data, self.__asf_uid)
         except Exception as e:
-            return AccessOutcomeWrite(e)
-        return AccessOutcomeWrite(wafm)
+            return types.OutcomeException(e)
+        return types.OutcomeResult(wafm)
 
 
 # Context managers
diff --git a/atr/storage/types.py b/atr/storage/types.py
index 9752c84..096d48e 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -29,29 +29,32 @@ E = TypeVar("E", bound=Exception)
 T = TypeVar("T", bound=object)
 
 
-class OutcomeCore[T]:
-    @property
-    def ok(self) -> bool:
-        raise NotImplementedError("ok is not implemented")
+# class OutcomeCore[T]:
+#     @property
+#     def ok(self) -> bool:
+#         raise NotImplementedError("ok is not implemented")
 
-    @property
-    def name(self) -> str | None:
-        raise NotImplementedError("name is not implemented")
+#     @property
+#     def name(self) -> str | None:
+#         raise NotImplementedError("name is not implemented")
 
-    def result_or_none(self) -> T | None:
-        raise NotImplementedError("result_or_none is not implemented")
+#     def result_or_none(self) -> T | None:
+#         raise NotImplementedError("result_or_none is not implemented")
 
-    def result_or_raise(self, exception_class: type[E] | None = None) -> T:
-        raise NotImplementedError("result_or_raise is not implemented")
+#     def result_or_raise(self, exception_class: type[E] | None = None) -> T:
+#         raise NotImplementedError("result_or_raise is not implemented")
 
-    def exception_or_none(self) -> Exception | None:
-        raise NotImplementedError("exception_or_none is not implemented")
+#     def exception_or_none(self) -> Exception | None:
+#         raise NotImplementedError("exception_or_none is not implemented")
 
-    def exception_type_or_none(self) -> type[Exception] | None:
-        raise NotImplementedError("exception_type_or_none is not implemented")
+#     def exception_or_raise(self, exception_class: type[E] | None = None) -> 
NoReturn:
+#         raise NotImplementedError("exception_or_raise is not implemented")
 
+#     def exception_type_or_none(self) -> type[Exception] | None:
+#         raise NotImplementedError("exception_type_or_none is not 
implemented")
 
-class OutcomeResult[T](OutcomeCore[T]):
+
+class OutcomeResult[T]:
     __result: T
 
     def __init__(self, result: T, name: str | None = None):
@@ -75,11 +78,16 @@ class OutcomeResult[T](OutcomeCore[T]):
     def exception_or_none(self) -> Exception | None:
         return None
 
+    def exception_or_raise(self, exception_class: type[Exception] | None = 
None) -> NoReturn:
+        if exception_class is not None:
+            raise exception_class(f"Asked for exception on a result: 
{self.__result}")
+        raise RuntimeError(f"Asked for exception on a result: {self.__result}")
+
     def exception_type_or_none(self) -> type[Exception] | None:
         return None
 
 
-class OutcomeException[T, E: Exception = Exception](OutcomeCore[T]):
+class OutcomeException[T, E: Exception = Exception]:
     __exception: E
 
     def __init__(self, exception: E, name: str | None = None):
@@ -105,6 +113,11 @@ class OutcomeException[T, E: Exception = 
Exception](OutcomeCore[T]):
     def exception_or_none(self) -> E | None:
         return self.__exception
 
+    def exception_or_raise(self, exception_class: type[E] | None = None) -> 
NoReturn:
+        if exception_class is not None:
+            raise exception_class(str(self.__exception)) from self.__exception
+        raise self.__exception
+
     def exception_type_or_none(self) -> type[E] | None:
         return type(self.__exception)
 
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 2ee6dfa..fdf8d2a 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -75,15 +75,37 @@ def performance_async(func: Callable[..., Coroutine[Any, 
Any, Any]]) -> Callable
 
 
 class FoundationMember:
-    def __init__(self, credentials: storage.WriteAsFoundationMember, data: 
db.Session, asf_uid: str):
+    def __init__(
+        self, credentials: storage.WriteAsFoundationMember, write: 
storage.Write, data: db.Session, asf_uid: str
+    ):
         if credentials.validate_at_runtime:
             if credentials.authenticated is not True:
                 raise storage.AccessError("Writer is not authenticated")
         self.__credentials = credentials
+        self.__write = write
         self.__data = data
         self.__asf_uid = asf_uid
         self.__key_block_models_cache = {}
 
+    @performance_async
+    async def delete_key(self, fingerprint: str) -> 
types.Outcome[sql.PublicSigningKey]:
+        try:
+            key = await self.__data.public_signing_key(
+                fingerprint=fingerprint,
+                apache_uid=self.__asf_uid,
+                _committees=True,
+            ).demand(storage.AccessError(f"Key not found: {fingerprint}"))
+            await self.__data.delete(key)
+            await self.__data.commit()
+            for committee in key.committees:
+                wacm = 
self.__write.as_committee_member(committee.name).result_or_none()
+                if wacm is None:
+                    continue
+                await wacm.keys.autogenerate_keys_file()
+            return types.OutcomeResult(key)
+        except Exception as e:
+            return types.OutcomeException(e)
+
     @performance_async
     async def ensure_stored_one(self, key_file_text: str) -> 
types.Outcome[types.Key]:
         return await self.__ensure_one(key_file_text, associate=False)
@@ -229,18 +251,29 @@ class FoundationMember:
 
 class CommitteeParticipant(FoundationMember):
     def __init__(
-        self, credentials: storage.WriteAsCommitteeParticipant, data: 
db.Session, asf_uid: str, committee_name: str
+        self,
+        credentials: storage.WriteAsCommitteeParticipant,
+        write: storage.Write,
+        data: db.Session,
+        asf_uid: str,
+        committee_name: str,
     ):
-        super().__init__(credentials, data, asf_uid)
+        super().__init__(credentials, write, data, asf_uid)
         self.__committee_name = committee_name
 
 
 class CommitteeMember(CommitteeParticipant):
     def __init__(
-        self, credentials: storage.WriteAsCommitteeMember, data: db.Session, 
asf_uid: str, committee_name: str
+        self,
+        credentials: storage.WriteAsCommitteeMember,
+        write: storage.Write,
+        data: db.Session,
+        asf_uid: str,
+        committee_name: str,
     ):
-        self.__data = data
         self.__credentials = credentials
+        self.__write = write
+        self.__data = data
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
 
@@ -542,10 +575,11 @@ and was published by the committee.\
 
 # class FoundationAdmin(FoundationMember):
 #     def __init__(
-#         self, credentials: storage.WriteAsFoundationAdmin, data: db.Session, 
asf_uid: str
+#         self, credentials: storage.WriteAsFoundationAdmin, write: 
storage.Write, data: db.Session, asf_uid: str
 #     ):
-#         self.__data = data
 #         self.__credentials = credentials
+#         self.__write = write
+#         self.__data = data
 #         self.__asf_uid = asf_uid
 
 #     @performance_async


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to