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]