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]