You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/05 11:01:31 UTC

(tooling-trusted-releases) branch sbp updated: Add missing committee guards to the storage interface

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 ec3fdaab Add missing committee guards to the storage interface
ec3fdaab is described below

commit ec3fdaab3b78d2bda154efa43bb1f30938f6fd6f
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Sun Jul 5 12:00:55 2026 +0100

    Add missing committee guards to the storage interface
---
 atr/post/projects.py           |  4 ++--
 atr/storage/writers/project.py | 39 ++++++++++++++++++++++++---------------
 tests/unit/test_cap.py         | 24 ++++++++++++++++++++++++
 3 files changed, 50 insertions(+), 17 deletions(-)

diff --git a/atr/post/projects.py b/atr/post/projects.py
index 4917e005..496594f9 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -60,9 +60,9 @@ async def add_project(
         raise ValueError(f"Invalid committee key: {committee_key}")
 
     async with storage.write(session) as write:
-        wacm = await write.as_project_committee_member(safe.ProjectKey(str(committee_key)))
+        wacm = write.as_committee_member(str(committee_key))
         try:
-            await wacm.project.create(committee_key, display_name, project_key)
+            await wacm.project.create(display_name, project_key)
         except storage.AccessError as e:
             return await session.redirect(
                 get.projects.add_project, committee_key=str(committee_key), error=f"Error adding project: {e}"
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 6627bef1..cabe5a4a 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -197,10 +197,7 @@ class CommitteeMember(ReleaseManager):
         self.__committee_key = committee_key
 
     async def category_add(self, project_key: safe.ProjectKey, new_category: str) -> bool:
-        project = await self.__data.project(key=str(project_key)).get()
-        if not project:
-            raise storage.AccessError(f"Project '{project_key}' not found.", status=404)
-        storage.ensure_project_active(project)
+        project = await self.__validate_project_in_committee(project_key)
         new_category = new_category.strip()
         current_categories = self.__current_categories(project)
         if new_category and (new_category not in current_categories):
@@ -224,10 +221,7 @@ class CommitteeMember(ReleaseManager):
         return False
 
     async def category_remove(self, project_key: safe.ProjectKey, action_value: str) -> bool:
-        project = await self.__data.project(key=str(project_key)).get()
-        if not project:
-            raise storage.AccessError(f"Project '{project_key}' not found.", status=404)
-        storage.ensure_project_active(project)
+        project = await self.__validate_project_in_committee(project_key)
         current_categories = self.__current_categories(project)
         if action_value in current_categories:
             if action_value in registry.FORBIDDEN_PROJECT_CATEGORIES:
@@ -246,7 +240,8 @@ class CommitteeMember(ReleaseManager):
             return True
         return False
 
-    async def create(self, committee_key: safe.CommitteeKey, display_name: str, project_key: safe.ProjectKey) -> None:
+    async def create(self, display_name: str, project_key: safe.ProjectKey) -> None:
+        committee_key = safe.CommitteeKey(self.__committee_key)
         try:
             await _build_and_add_project(self.__data, self.__asf_uid, committee_key, display_name, project_key)
             await self.__data.commit()
@@ -274,6 +269,10 @@ class CommitteeMember(ReleaseManager):
             ).get()
             if not project:
                 raise storage.AccessError(f"Project '{project_key}' not found.")
+            if project.committee_key != self.__committee_key:
+                raise storage.AccessError(
+                    f"Project {project_key} is not in committee {self.__committee_key}", status=403
+                )
 
             # TODO: when a project has current or archived releases, archiving the project
             # should be allowed and should cascade-archive the current releases as well -
@@ -325,6 +324,10 @@ class CommitteeMember(ReleaseManager):
             ).get()
             if not project:
                 raise storage.AccessError(f"Project '{project_key}' not found.", status=404)
+            if project.committee_key != self.__committee_key:
+                raise storage.AccessError(
+                    f"Project {project_key} is not in committee {self.__committee_key}", status=403
+                )
 
             if project.committee_key:
                 committee_projects = await self.__data.project(
@@ -354,9 +357,7 @@ class CommitteeMember(ReleaseManager):
         return None
 
     async def language_add(self, project_key: safe.ProjectKey, new_language: str) -> bool:
-        project = await self.__data.project(key=str(project_key)).get()
-        if not project:
-            raise storage.AccessError(f"Project '{project_key}' not found.", status=404)
+        project = await self.__validate_project_in_committee(project_key)
         new_language = new_language.strip()
         current_languages = self.__current_languages(project)
         if new_language and (new_language not in current_languages):
@@ -378,9 +379,7 @@ class CommitteeMember(ReleaseManager):
         return False
 
     async def language_remove(self, project_key: safe.ProjectKey, action_value: str) -> bool:
-        project = await self.__data.project(key=str(project_key)).get()
-        if not project:
-            raise storage.AccessError(f"Project '{project_key}' not found.", status=404)
+        project = await self.__validate_project_in_committee(project_key)
         current_languages = self.__current_languages(project)
         if action_value in current_languages:
             current_languages.remove(action_value)
@@ -404,6 +403,7 @@ class CommitteeMember(ReleaseManager):
         cap_question_id: int,
         closes_at: datetime.datetime,
     ) -> sql.ApprovalRequest:
+        await self.__validate_project_in_committee(project_key)
         approval = sql.ApprovalRequest(
             project_key=str(project_key),
             committee_key=self.__committee_key,
@@ -467,6 +467,15 @@ class CommitteeMember(ReleaseManager):
             else []
         )
 
+    async def __validate_project_in_committee(self, project_key: safe.ProjectKey) -> sql.Project:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.", status=404)
+        )
+        if project.committee_key != self.__committee_key:
+            raise storage.AccessError(f"Project {project_key} is not in committee {self.__committee_key}", status=403)
+        storage.ensure_project_active(project)
+        return project
+
 
 # No committee gate - the caller's right to act for the committee must be
 # established upstream, in the .asf.yaml feature.
diff --git a/tests/unit/test_cap.py b/tests/unit/test_cap.py
index 9fe83364..714837ba 100644
--- a/tests/unit/test_cap.py
+++ b/tests/unit/test_cap.py
@@ -78,9 +78,20 @@ class FakeQuery:
         return self._row
 
 
+class FakeProjectQuery:
+    def __init__(self, row: sql.Project | None):
+        self._row = row
+
+    async def demand(self, error: Exception, log_query: bool = False) -> sql.Project:
+        if self._row is None:
+            raise error
+        return self._row
+
+
 class FakeData:
     def __init__(self, row: sql.ApprovalRequest | None):
         self.row = row
+        self.project_row = sql.Project(key="commons-foo", committee_key="commons", status=sql.ProjectStatus.ACTIVE)
 
     def approval_request(self, **kwargs: object) -> FakeQuery:
         return FakeQuery(self.row)
@@ -102,6 +113,9 @@ class FakeData:
     async def flush(self) -> None:
         return None
 
+    def project(self, **kwargs: object) -> FakeProjectQuery:
+        return FakeProjectQuery(self.project_row)
+
     async def rollback(self) -> None:
         return None
 
@@ -299,6 +313,16 @@ async def test_request_approval_question_id_collision(monkeypatch: pytest.Monkey
         await wacm.request_approval(safe.ProjectKey("commons-foo"), sql.ApprovalAction.ARCHIVE, 42, closes_at)
 
 
+@pytest.mark.asyncio
+async def test_request_approval_rejects_cross_committee_project() -> None:
+    data = FakeData(None)
+    data.project_row = sql.Project(key="tomcat-foo", committee_key="tomcat", status=sql.ProjectStatus.ACTIVE)
+    wacm = project.CommitteeMember(FakeWrite(), FakeWriteAs(), data, "commons")
+    closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    with pytest.raises(storage.AccessError, match="not in committee"):
+        await wacm.request_approval(safe.ProjectKey("tomcat-foo"), sql.ApprovalAction.ARCHIVE, 42, closes_at)
+
+
 @pytest.mark.asyncio
 async def test_request_approval_schedules_initial_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
     data = FakeData(None)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org