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 8a6f74b  Use a hierarchy between storage access levels
8a6f74b is described below

commit 8a6f74bd2fdd1e7c4ecd86f4dd2a193fcfdd6b5c
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Jul 20 11:45:39 2025 +0100

    Use a hierarchy between storage access levels
---
 atr/blueprints/api/api.py   |  35 +++----
 atr/models/api.py           |   2 +-
 atr/storage/__init__.py     | 114 +++++++++-------------
 atr/storage/types.py        |  18 +++-
 atr/storage/writers/keys.py | 229 ++++++++++++++++++++++++++++++--------------
 5 files changed, 235 insertions(+), 163 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 291aaf1..0c1b3f8 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -265,6 +265,7 @@ async def jwt(data: models.api.JwtArgs) -> DictResponse:
     ).model_dump(), 200
 
 
+# TODO: Deprecate this endpoint
 @api.BLUEPRINT.route("/keys")
 @quart_schema.validate_querystring(models.api.KeysQuery)
 @quart_schema.validate_response(models.api.KeysResults, 200)
@@ -302,29 +303,22 @@ async def keys_add(data: models.api.KeysAddArgs) -> 
DictResponse:
     asf_uid = _jwt_asf_uid()
     selected_committee_names = data.committees
 
-    async with db.session() as db_data:
-        participant_of_committees = await 
interaction.user_committees_member(asf_uid, caller_data=db_data)
-        selected_committees = await 
db_data.committee(name_in=selected_committee_names).all()
-        committee_is_podling = {c.name: c.is_podling for c in 
selected_committees}
-        for committee in selected_committees:
-            if committee not in participant_of_committees:
-                raise exceptions.BadRequest(f"You are not a member of 
committee {committee.name}")
-        added_keys = await interaction.key_user_add(asf_uid, data.key, 
selected_committee_names)
-        fingerprints = []
-        for key_info in added_keys:
-            if key_info:
-                fingerprint = key_info.get("fingerprint", "").upper()
-                fingerprints.append(fingerprint)
-                for committee_name in selected_committee_names:
-                    is_podling = committee_is_podling[committee_name]
-                    await keys.autogenerate_keys_file(committee_name, 
is_podling)
-
-        if not added_keys:
-            raise exceptions.BadRequest("No keys were added.")
+    async with storage.write(asf_uid) as write:
+        wafm = write.as_foundation_member().writer_or_raise()
+        ocr: types.KeyOutcome = await wafm.keys.ensure_stored_one(data.key)
+        key = ocr.result_or_raise()
+
+        # outcomes = types.LinkedCommitteeOutcomes()
+        for selected_committee_name in selected_committee_names:
+            wacm = 
write.as_committee_member(selected_committee_name).writer_or_raise()
+            outcome: types.LinkedCommitteeOutcome = await 
wacm.keys.associate_fingerprint(key.key_model.fingerprint)
+            outcome.result_or_raise()
+            # outcomes.append(outcome)
+
     return models.api.KeysAddResults(
         endpoint="/keys/add",
         success="Key added",
-        fingerprints=fingerprints,
+        fingerprint=key.key_model.fingerprint.upper(),
     ).model_dump(), 200
 
 
@@ -337,6 +331,7 @@ 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")
diff --git a/atr/models/api.py b/atr/models/api.py
index 377f0ab..127baf4 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -122,7 +122,7 @@ class KeysAddArgs(schema.Strict):
 class KeysAddResults(schema.Strict):
     endpoint: Literal["/keys/add"] = schema.Field(alias="endpoint")
     success: str
-    fingerprints: list[str]
+    fingerprint: str
 
 
 class KeysCommitteeResults(schema.Strict):
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 78035cb..74392d1 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -56,6 +56,7 @@ class AccessError(RuntimeError): ...
 ## Access outcome
 
 
+# TODO: Use actual outcomes here
 class AccessOutcome[A]:
     pass
 
@@ -110,24 +111,11 @@ class Read:
 # Write
 
 
-class WriteAsCommitteeMember(AccessCredentialsWrite):
-    def __init__(self, data: db.Session, asf_uid: str, committee_name: str):
-        self.__authenticated = False
-        self.__validate_at_runtime = VALIDATE_AT_RUNTIME
-        if self.__validate_at_runtime:
-            if not isinstance(asf_uid, str):
-                raise AccessError("ASF UID must be a string")
-            if not isinstance(committee_name, str):
-                raise AccessError("Committee name must be a string")
+class WriteAsFoundationParticipant(AccessCredentialsWrite):
+    def __init__(self, data: db.Session):
         self.__data = data
-        self.__asf_uid = asf_uid
+        self.__asf_uid = None
         self.__authenticated = True
-        self.keys = writers.keys.CommitteeMember(
-            self,
-            self.__data,
-            self.__asf_uid,
-            committee_name,
-        )
 
     @property
     def authenticated(self) -> bool:
@@ -135,22 +123,23 @@ class WriteAsCommitteeMember(AccessCredentialsWrite):
 
     @property
     def validate_at_runtime(self) -> bool:
-        return self.__validate_at_runtime
+        return VALIDATE_AT_RUNTIME
 
 
-class WriteAsCommitteeParticipant(AccessCredentialsWrite):
-    def __init__(self, data: db.Session, asf_uid: str, committee_name: str):
-        self.__authenticated = False
-        self.__validate_at_runtime = VALIDATE_AT_RUNTIME
-        if self.__validate_at_runtime:
+class WriteAsFoundationMember(WriteAsFoundationParticipant):
+    def __init__(self, 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")
-            if not isinstance(committee_name, str):
-                raise AccessError("Committee name must be a string")
         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.__data,
+            self.__asf_uid,
+        )
 
     @property
     def authenticated(self) -> bool:
@@ -158,20 +147,24 @@ class WriteAsCommitteeParticipant(AccessCredentialsWrite):
 
     @property
     def validate_at_runtime(self) -> bool:
-        return self.__validate_at_runtime
+        return VALIDATE_AT_RUNTIME
 
 
-class WriteAsFoundationMember(AccessCredentialsWrite):
-    def __init__(self, data: db.Session, asf_uid: str):
-        self.__authenticated = False
-        self.__validate_at_runtime = VALIDATE_AT_RUNTIME
-        if self.__validate_at_runtime:
-            if not isinstance(asf_uid, str):
-                raise AccessError("ASF UID must be a string")
+class WriteAsCommitteeParticipant(WriteAsFoundationMember):
+    def __init__(self, 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.__data = data
         self.__asf_uid = asf_uid
-        # TODO: We need a definitive list of ASF UIDs
+        self.__committee_name = committee_name
         self.__authenticated = True
+        self.keys = writers.keys.CommitteeParticipant(
+            self,
+            self.__data,
+            self.__asf_uid,
+            self.__committee_name,
+        )
 
     @property
     def authenticated(self) -> bool:
@@ -179,19 +172,21 @@ class WriteAsFoundationMember(AccessCredentialsWrite):
 
     @property
     def validate_at_runtime(self) -> bool:
-        return self.__validate_at_runtime
+        return VALIDATE_AT_RUNTIME
 
 
-class WriteAsFoundationParticipant(AccessCredentialsWrite):
-    def __init__(self, data: db.Session, asf_uid: str | None = None):
-        self.__authenticated = False
-        self.__validate_at_runtime = VALIDATE_AT_RUNTIME
-        if self.__validate_at_runtime:
-            if not isinstance(asf_uid, str | None):
-                raise AccessError("ASF UID must be a string or None")
+class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
+    def __init__(self, data: db.Session, asf_uid: str, committee_name: str):
         self.__data = data
         self.__asf_uid = asf_uid
+        self.__committee_name = committee_name
         self.__authenticated = True
+        self.keys = writers.keys.CommitteeMember(
+            self,
+            self.__data,
+            self.__asf_uid,
+            committee_name,
+        )
 
     @property
     def authenticated(self) -> bool:
@@ -199,7 +194,7 @@ class WriteAsFoundationParticipant(AccessCredentialsWrite):
 
     @property
     def validate_at_runtime(self) -> bool:
-        return self.__validate_at_runtime
+        return VALIDATE_AT_RUNTIME
 
 
 class Write:
@@ -217,6 +212,15 @@ class Write:
             return AccessOutcomeWrite(e)
         return AccessOutcomeWrite(wacm)
 
+    def as_foundation_member(self) -> 
AccessOutcomeWrite[WriteAsFoundationMember]:
+        if self.__asf_uid is None:
+            return AccessOutcomeWrite(AccessError("No ASF UID"))
+        try:
+            wafm = WriteAsFoundationMember(self.__data, self.__asf_uid)
+        except Exception as e:
+            return AccessOutcomeWrite(e)
+        return AccessOutcomeWrite(wafm)
+
 
 # Outcome
 
@@ -387,30 +391,6 @@ class Outcomes[T]:
                 else:
                     self.__outcomes[i] = OutcomeResult(result, outcome.name)
 
-    # def update_results_batch(self, f: Callable[[Sequence[T]], Sequence[T]]) 
-> None:
-    #     results = self.collect_results()
-    #     new_results = iter(f(results))
-    #     new_outcomes = []
-    #     for outcome in self.__outcomes:
-    #         match outcome:
-    #             case OutcomeResult():
-    #                 new_outcomes.append(OutcomeResult(next(new_results), 
outcome.name))
-    #             case OutcomeError():
-    #                 
new_outcomes.append(OutcomeError(outcome.exception_or_none(), outcome.name))
-    #     self.__outcomes[:] = new_outcomes
-
-    # def update_results_stream(self, f: Callable[[Sequence[T]], Generator[T | 
Exception]]) -> None:
-    #     results = self.collect_results()
-    #     new_results = f(results)
-    #     new_outcomes = []
-    #     for outcome in self.__outcomes:
-    #         match outcome:
-    #             case OutcomeResult():
-    #                 new_outcomes.append(OutcomeResult(next(new_results), 
outcome.name))
-    #             case OutcomeError():
-    #                 
new_outcomes.append(OutcomeError(outcome.exception_or_none(), outcome.name))
-    #     self.__outcomes[:] = new_outcomes
-
 
 def outcomes_merge[T](*outcomes: Outcomes[T]) -> Outcomes[T]:
     return Outcomes(*[outcome for outcome in outcomes for outcome in 
outcome.outcomes()])
diff --git a/atr/storage/types.py b/atr/storage/types.py
index 09bf7b3..afd90ae 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import dataclasses
 import enum
 
 import atr.models.schema as schema
@@ -34,6 +35,11 @@ class Key(schema.Strict):
     key_model: sql.PublicSigningKey
 
 
[email protected]
+class LinkedCommittee:
+    name: str
+
+
 class PublicKeyError(Exception):
     def __init__(self, key: Key, original_error: Exception):
         self.__key = key
@@ -51,6 +57,14 @@ class PublicKeyError(Exception):
         return self.__original_error
 
 
+type KeyOutcomeResult = storage.OutcomeResult[Key]
+type KeyOutcomeException = storage.OutcomeException[Key, Exception]
+type KeyOutcome = KeyOutcomeResult | KeyOutcomeException
+
 type KeyOutcomes = storage.Outcomes[Key]
-# type KeyOutcomeResult = storage.OutcomeResult[Key]
-# type KeyOutcomeError = storage.OutcomeError[Key, Exception]
+
+type LinkedCommitteeOutcomeResult = storage.OutcomeResult[LinkedCommittee]
+type LinkedCommitteeOutcomeException = 
storage.OutcomeException[LinkedCommittee, Exception]
+type LinkedCommitteeOutcome = LinkedCommitteeOutcomeResult | 
LinkedCommitteeOutcomeException
+
+type LinkedCommitteeOutcomes = storage.Outcomes[LinkedCommittee]
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index cf876b4..6949f77 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -69,22 +69,168 @@ def performance_async(func: Callable[..., Coroutine[Any, 
Any, Any]]) -> Callable
     return wrapper
 
 
-class CommitteeMember:
-    def __init__(
-        self, credentials: storage.WriteAsCommitteeMember, data: db.Session, 
asf_uid: str, committee_name: str
-    ):
+class FoundationMember:
+    def __init__(self, credentials: storage.WriteAsFoundationMember, 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.__data = data
         self.__asf_uid = asf_uid
-        self.__committee_name = committee_name
         self.__key_block_models_cache = {}
 
     @performance_async
-    async def associate(self, outcomes: storage.Outcomes[types.Key]) -> 
storage.Outcomes[types.Key]:
-        raise NotImplementedError("Not implemented")
+    async def ensure_stored_one(self, key_file_text: str) -> types.KeyOutcome:
+        return await self.__ensure_one(key_file_text, associate=False)
+
+    @performance
+    def __block_model(self, key_block: str, ldap_data: dict[str, str]) -> 
types.Key | Exception:
+        # This cache is only held for the session
+        if key_block in self.__key_block_models_cache:
+            cached_key_models = self.__key_block_models_cache[key_block]
+            if len(cached_key_models) == 1:
+                return cached_key_models[0]
+            else:
+                return ValueError("Expected one key block, got none or 
multiple")
+
+        with tempfile.NamedTemporaryFile(delete=True) as tmpfile:
+            tmpfile.write(key_block.encode())
+            tmpfile.flush()
+            keyring = pgpy.PGPKeyring()
+            fingerprints = keyring.load(tmpfile.name)
+            key = None
+            for fingerprint in fingerprints:
+                try:
+                    key_model = self.__keyring_fingerprint_model(keyring, 
fingerprint, ldap_data)
+                    if key_model is None:
+                        # Was not a primary key, so skip it
+                        continue
+                    if key is not None:
+                        return ValueError("Expected one key block, got 
multiple")
+                    key = types.Key(status=types.KeyStatus.PARSED, 
key_model=key_model)
+                except Exception as e:
+                    return e
+        if key is None:
+            return ValueError("Expected a key, got none")
+        self.__key_block_models_cache[key_block] = [key]
+        return key
+
+    @performance_async
+    async def __ensure_one(self, key_file_text: str, associate: bool = True) 
-> types.KeyOutcome:
+        try:
+            key_blocks = util.parse_key_blocks(key_file_text)
+        except Exception as e:
+            return storage.OutcomeException(e)
+        if len(key_blocks) != 1:
+            return storage.OutcomeException(ValueError("Expected one key 
block, got none or multiple"))
+        key_block = key_blocks[0]
+        try:
+            ldap_data = await util.email_to_uid_map()
+            key_model = await asyncio.to_thread(self.__block_model, key_block, 
ldap_data)
+            return storage.OutcomeResult(key_model)
+        except Exception as e:
+            return storage.OutcomeException(e)
+
+    @performance
+    def __keyring_fingerprint_model(
+        self, keyring: pgpy.PGPKeyring, fingerprint: str, ldap_data: dict[str, 
str]
+    ) -> sql.PublicSigningKey | None:
+        with keyring.key(fingerprint) as key:
+            if not key.is_primary:
+                return None
+            uids = [uid.userid for uid in key.userids]
+            asf_uid = self.__uids_asf_uid(uids, ldap_data)
+
+            # TODO: Improve this
+            key_size = key.key_size
+            length = 0
+            if isinstance(key_size, constants.EllipticCurveOID):
+                if isinstance(key_size.key_size, int):
+                    length = key_size.key_size
+                else:
+                    raise ValueError(f"Key size is not an integer: 
{type(key_size.key_size)}, {key_size.key_size}")
+            elif isinstance(key_size, int):
+                length = key_size
+            else:
+                raise ValueError(f"Key size is not an integer: 
{type(key_size)}, {key_size}")
+
+            return sql.PublicSigningKey(
+                fingerprint=str(key.fingerprint).lower(),
+                algorithm=key.key_algorithm.value,
+                length=length,
+                created=key.created,
+                latest_self_signature=key.expires_at,
+                expires=key.expires_at,
+                primary_declared_uid=uids[0],
+                secondary_declared_uids=uids[1:],
+                apache_uid=asf_uid,
+                ascii_armored_key=str(key),
+            )
+
+    @performance
+    def __uids_asf_uid(self, uids: list[str], ldap_data: dict[str, str]) -> 
str | None:
+        # Test data
+        test_key_uids = [
+            "Apache Tooling (For test use only) 
<[email protected]>",
+        ]
+        is_admin = user.is_admin(self.__asf_uid)
+        if (uids == test_key_uids) and is_admin:
+            # Allow the test key
+            # TODO: We should fix the test key, not add an exception for it
+            # But the admin check probably makes this safe enough
+            return self.__asf_uid
+
+        # Regular data
+        emails = []
+        for uid in uids:
+            # This returns a lower case email address, whatever the case of 
the input
+            if email := util.email_from_uid(uid):
+                if email.endswith("@apache.org"):
+                    return email.removesuffix("@apache.org")
+                emails.append(email)
+        # We did not find a direct @apache.org email address
+        # Therefore, search cached LDAP data
+        for email in emails:
+            if email in ldap_data:
+                return ldap_data[email]
+        return None
+
+
+class CommitteeParticipant(FoundationMember):
+    def __init__(
+        self, credentials: storage.WriteAsCommitteeParticipant, data: 
db.Session, asf_uid: str, committee_name: str
+    ):
+        super().__init__(credentials, 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
+    ):
+        super().__init__(credentials, data, asf_uid, committee_name)
+        self.__committee_name = committee_name
+
+    @performance_async
+    async def associate_fingerprint(self, fingerprint: str) -> 
types.LinkedCommitteeOutcome:
+        via = sql.validate_instrumented_attribute
+        link_values = [{"committee_name": self.__committee_name, 
"key_fingerprint": fingerprint}]
+        try:
+            link_insert_result = await self.__data.execute(
+                sqlite.insert(sql.KeyLink)
+                .values(link_values)
+                .on_conflict_do_nothing(index_elements=["committee_name", 
"key_fingerprint"])
+                .returning(via(sql.KeyLink.key_fingerprint))
+            )
+            if link_insert_result.one_or_none() is None:
+                return storage.OutcomeException(storage.AccessError(f"Key not 
found: {fingerprint}"))
+        except Exception as e:
+            return storage.OutcomeException(e)
+        return storage.OutcomeResult(
+            types.LinkedCommittee(
+                name=self.__committee_name,
+            )
+        )
 
     @performance_async
     async def committee(self) -> sql.Committee:
@@ -94,6 +240,7 @@ class CommitteeMember:
 
     @performance_async
     async def ensure_associated(self, keys_file_text: str) -> 
storage.Outcomes[types.Key]:
+        # TODO: Autogenerate KEYS file
         return await self.__ensure(keys_file_text, associate=True)
 
     @performance_async
@@ -122,8 +269,8 @@ class CommitteeMember:
                     key_list.append(key)
                 except Exception as e:
                     key_list.append(e)
-            self.__key_block_models_cache[key_block] = key_list
-            return key_list
+        self.__key_block_models_cache[key_block] = key_list
+        return key_list
 
     @performance_async
     async def __database_add_models(
@@ -230,67 +377,3 @@ class CommitteeMember:
             for key, value in PERFORMANCES.items():
                 logging.info(f"{key}: {value}")
         return outcomes
-
-    @performance
-    def __keyring_fingerprint_model(
-        self, keyring: pgpy.PGPKeyring, fingerprint: str, ldap_data: dict[str, 
str]
-    ) -> sql.PublicSigningKey | None:
-        with keyring.key(fingerprint) as key:
-            if not key.is_primary:
-                return None
-            uids = [uid.userid for uid in key.userids]
-            asf_uid = self.__uids_asf_uid(uids, ldap_data)
-
-            # TODO: Improve this
-            key_size = key.key_size
-            length = 0
-            if isinstance(key_size, constants.EllipticCurveOID):
-                if isinstance(key_size.key_size, int):
-                    length = key_size.key_size
-                else:
-                    raise ValueError(f"Key size is not an integer: 
{type(key_size.key_size)}, {key_size.key_size}")
-            elif isinstance(key_size, int):
-                length = key_size
-            else:
-                raise ValueError(f"Key size is not an integer: 
{type(key_size)}, {key_size}")
-
-            return sql.PublicSigningKey(
-                fingerprint=str(key.fingerprint).lower(),
-                algorithm=key.key_algorithm.value,
-                length=length,
-                created=key.created,
-                latest_self_signature=key.expires_at,
-                expires=key.expires_at,
-                primary_declared_uid=uids[0],
-                secondary_declared_uids=uids[1:],
-                apache_uid=asf_uid,
-                ascii_armored_key=str(key),
-            )
-
-    @performance
-    def __uids_asf_uid(self, uids: list[str], ldap_data: dict[str, str]) -> 
str | None:
-        # Test data
-        test_key_uids = [
-            "Apache Tooling (For test use only) 
<[email protected]>",
-        ]
-        is_admin = user.is_admin(self.__asf_uid)
-        if (uids == test_key_uids) and is_admin:
-            # Allow the test key
-            # TODO: We should fix the test key, not add an exception for it
-            # But the admin check probably makes this safe enough
-            return self.__asf_uid
-
-        # Regular data
-        emails = []
-        for uid in uids:
-            # This returns a lower case email address, whatever the case of 
the input
-            if email := util.email_from_uid(uid):
-                if email.endswith("@apache.org"):
-                    return email.removesuffix("@apache.org")
-                emails.append(email)
-        # We did not find a direct @apache.org email address
-        # Therefore, search cached LDAP data
-        for email in emails:
-            if email in ldap_data:
-                return ldap_data[email]
-        return None


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

Reply via email to