Repository: pypi/warehouse

Issue:
Cannot create a project in an organization account
For an owner who does not have any prior projects in PyPI, creating a project in an organization does not work. 

To recreate the steps-
1. Navigate to 'Your organizations'
2. Select a specific organization and click on 'Manage'
3. Click on 'Projects'
4. Select 'New Project'
5. Enter the name of the project
6. Click on 'Create and add new project'

The new project is not added to the organization. Note: This feature does work when the user has prior projects. 
![project1](https://user-images.githubusercontent.com/37237726/177815345-023aeadc-94a8-46a0-8e37-a8cf54d2bd70.png)


Discussion:
Priority 3/3

Resolving patch:
diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py
--- a/warehouse/manage/forms.py
+++ b/warehouse/manage/forms.py
@@ -97,6 +97,7 @@ class CreateInternalRoleForm(
     team_name = wtforms.SelectField(
         "Select team",
         choices=[("", "Select team")],
+        default="",  # Set default to avoid error when there are no team choices.
         validators=[wtforms.validators.InputRequired()],
     )
 
@@ -448,6 +449,7 @@ class AddOrganizationProjectForm(forms.Form):
     existing_project_name = wtforms.SelectField(
         "Select project",
         choices=[("", "Select project")],
+        default="",  # Set default to avoid error when there are no project choices.
     )
 
     new_project_name = wtforms.StringField()
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -1655,7 +1655,7 @@ def manage_organization_projects(self):
 
         return self.default_response
 
-    @view_config(request_method="POST")
+    @view_config(request_method="POST", permission="add:project")
     def add_organization_project(self):
         if self.request.flags.enabled(AdminFlagValue.DISABLE_ORGANIZATIONS):
             raise HTTPNotFound
@@ -2613,14 +2613,20 @@ def __init__(self, project, request):
     @view_config(request_method="GET")
     def manage_project_settings(self):
         if self.request.flags.enabled(AdminFlagValue.DISABLE_ORGANIZATIONS):
+            # Disable transfer of project to any organization.
             organization_choices = set()
         else:
+            # Allow transfer of project to organizations owned or managed by user.
             all_user_organizations = user_organizations(self.request)
             organizations_owned = set(
                 organization.name
                 for organization in all_user_organizations["organizations_owned"]
             )
-            organization_choices = organizations_owned - (
+            organizations_managed = set(
+                organization.name
+                for organization in all_user_organizations["organizations_managed"]
+            )
+            organization_choices = (organizations_owned | organizations_managed) - (
                 {self.project.organization.name} if self.project.organization else set()
             )
 
@@ -2944,6 +2950,23 @@ def remove_organization_project(project, request):
             request.route_path("manage.project.settings", project_name=project.name)
         )
 
+    if (
+        # Check that user has permission to remove projects from organization.
+        (project.organization and request.user not in project.organization.owners)
+        # Check that project has an individual owner.
+        or not project_owners(request, project)
+    ):
+        request.session.flash(
+            (
+                "Could not remove project from organization - "
+                "you do not have the required permissions"
+            ),
+            queue="error",
+        )
+        return HTTPSeeOther(
+            request.route_path("manage.project.settings", project_name=project.name)
+        )
+
     confirm_project(
         project,
         request,
@@ -2952,14 +2975,6 @@ def remove_organization_project(project, request):
         error_message="Could not remove project from organization",
     )
 
-    if not project_owners(request, project):
-        request.session.flash(
-            "Could not remove project from organization", queue="error"
-        )
-        return HTTPSeeOther(
-            request.route_path("manage.project.settings", project_name=project.name)
-        )
-
     # Remove project from current organization.
     organization_service = request.find_service(IOrganizationService, context=None)
     if organization := project.organization:
@@ -3018,6 +3033,16 @@ def transfer_organization_project(project, request):
             request.route_path("manage.project.settings", project_name=project.name)
         )
 
+    # Check that user has permission to remove projects from organization.
+    if project.organization and request.user not in project.organization.owners:
+        request.session.flash(
+            "Could not transfer project - you do not have the required permissions",
+            queue="error",
+        )
+        return HTTPSeeOther(
+            request.route_path("manage.project.settings", project_name=project.name)
+        )
+
     confirm_project(
         project,
         request,
@@ -3031,7 +3056,11 @@ def transfer_organization_project(project, request):
         organization.name
         for organization in all_user_organizations["organizations_owned"]
     )
-    organization_choices = organizations_owned - (
+    organizations_managed = set(
+        organization.name
+        for organization in all_user_organizations["organizations_managed"]
+    )
+    organization_choices = (organizations_owned | organizations_managed) - (
         {project.organization.name} if project.organization else set()
     )
 
diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py
--- a/warehouse/organizations/models.py
+++ b/warehouse/organizations/models.py
@@ -246,6 +246,16 @@ def __acl__(self):
             # Allow all people in organization read access.
             # Allow write access depending on role.
             if role.role_name == OrganizationRoleType.Owner:
+                # Allowed:
+                # - View organization ("view:organization")
+                # - View team ("view:team")
+                # - Invite/remove organization member ("manage:organization")
+                # - Add/remove team member ("manage:team")
+                # - Manage billing ("manage:billing")
+                # - Create project ("add:project")
+                # - Delete project ("remove:project")
+                # Disallowed:
+                # - (none)
                 acls.append(
                     (
                         Allow,
@@ -255,10 +265,22 @@ def __acl__(self):
                             "view:team",
                             "manage:organization",
                             "manage:team",
+                            "manage:billing",
+                            "add:project",
+                            "remove:project",
                         ],
                     )
                 )
             elif role.role_name == OrganizationRoleType.BillingManager:
+                # Allowed:
+                # - View organization ("view:organization")
+                # - View team ("view:team")
+                # - Manage billing ("manage:billing")
+                # Disallowed:
+                # - Invite/remove organization member ("manage:organization")
+                # - Add/remove team member ("manage:team")
+                # - Create project ("add:project")
+                # - Delete project ("remove:project")
                 acls.append(
                     (
                         Allow,
@@ -267,15 +289,39 @@ def __acl__(self):
                     )
                 )
             elif role.role_name == OrganizationRoleType.Manager:
+                # Allowed:
+                # - View organization ("view:organization")
+                # - View team ("view:team")
+                # - Add/remove team member ("manage:team")
+                # - Create project ("add:project")
+                # Disallowed:
+                # - Invite/remove organization member ("manage:organization")
+                # - Manage billing ("manage:billing")
+                # - Delete project ("remove:project")
                 acls.append(
                     (
                         Allow,
                         f"user:{role.user.id}",
-                        ["view:organization", "view:team", "manage:team"],
+                        [
+                            "view:organization",
+                            "view:team",
+                            "manage:team",
+                            "add:project",
+                        ],
                     )
                 )
             else:
                 # No member-specific write access needed for now.
+
+                # Allowed:
+                # - View organization ("view:organization")
+                # - View team ("view:team")
+                # Disallowed:
+                # - Invite/remove organization member ("manage:organization")
+                # - Add/remove team member ("manage:team")
+                # - Manage billing ("manage:billing")
+                # - Create project ("add:project")
+                # - Delete project ("remove:project")
                 acls.append(
                     (Allow, f"user:{role.user.id}", ["view:organization", "view:team"])
                 )


Test patch:
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -5365,9 +5365,15 @@ def test_toggle_2fa_requirement_non_critical(
         assert event.additional == {"modified_by": db_request.user.username}
 
     def test_remove_organization_project_no_confirm(self):
-        project = pretend.stub(normalized_name="foo")
+        user = pretend.stub()
+        project = pretend.stub(
+            normalized_name="foo",
+            organization=pretend.stub(owners=[user]),
+            owners=[user],
+        )
         request = pretend.stub(
             POST={},
+            user=user,
             flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             route_path=lambda *a, **kw: "/foo/bar/",
@@ -5386,9 +5392,15 @@ def test_remove_organization_project_no_confirm(self):
         ]
 
     def test_remove_organization_project_wrong_confirm(self):
-        project = pretend.stub(normalized_name="foo")
+        user = pretend.stub()
+        project = pretend.stub(
+            normalized_name="foo",
+            organization=pretend.stub(owners=[user]),
+            owners=[user],
+        )
         request = pretend.stub(
             POST={"confirm_remove_organization_project_name": "bar"},
+            user=user,
             flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             route_path=lambda *a, **kw: "/foo/bar/",
@@ -5472,6 +5484,39 @@ def test_remove_organization_project_no_current_organization(
         assert result.headers["Location"] == "/the-redirect"
         assert send_organization_project_removed_email.calls == []
 
+    def test_remove_organization_project_not_organization_owner(self):
+        user = pretend.stub()
+        project = pretend.stub(
+            name="foo",
+            normalized_name="foo",
+            organization=pretend.stub(owners=[]),
+            owners=[user],
+        )
+        request = pretend.stub(
+            POST={},
+            user=user,
+            flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
+            session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
+            route_path=lambda *a, **kw: "/foo/bar/",
+        )
+
+        result = views.remove_organization_project(project, request)
+
+        assert isinstance(result, HTTPSeeOther)
+        assert result.headers["Location"] == "/foo/bar/"
+        assert request.flags.enabled.calls == [
+            pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS)
+        ]
+        assert request.session.flash.calls == [
+            pretend.call(
+                (
+                    "Could not remove project from organization - "
+                    "you do not have the required permissions"
+                ),
+                queue="error",
+            )
+        ]
+
     def test_remove_organization_project_no_individual_owner(
         self, monkeypatch, db_request
     ):
@@ -5504,7 +5549,13 @@ def test_remove_organization_project_no_individual_owner(
             pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS)
         ]
         assert db_request.session.flash.calls == [
-            pretend.call("Could not remove project from organization", queue="error")
+            pretend.call(
+                (
+                    "Could not remove project from organization - "
+                    "you do not have the required permissions"
+                ),
+                queue="error",
+            )
         ]
         assert db_request.route_path.calls == [
             pretend.call("manage.project.settings", project_name="foo")
@@ -5562,9 +5613,14 @@ def test_remove_organization_project(self, monkeypatch, db_request):
         ]
 
     def test_transfer_organization_project_no_confirm(self):
-        project = pretend.stub(normalized_name="foo")
+        user = pretend.stub()
+        project = pretend.stub(
+            normalized_name="foo",
+            organization=pretend.stub(owners=[user]),
+        )
         request = pretend.stub(
             POST={},
+            user=user,
             flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             route_path=lambda *a, **kw: "/foo/bar/",
@@ -5583,9 +5639,14 @@ def test_transfer_organization_project_no_confirm(self):
         ]
 
     def test_transfer_organization_project_wrong_confirm(self):
-        project = pretend.stub(normalized_name="foo")
+        user = pretend.stub()
+        project = pretend.stub(
+            normalized_name="foo",
+            organization=pretend.stub(owners=[user]),
+        )
         request = pretend.stub(
             POST={"confirm_transfer_organization_project_name": "bar"},
+            user=user,
             flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
             session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
             route_path=lambda *a, **kw: "/foo/bar/",
@@ -5692,6 +5753,38 @@ def test_transfer_organization_project_no_current_organization(
             )
         ]
 
+    def test_transfer_organization_project_not_organization_owner(self):
+        user = pretend.stub()
+        project = pretend.stub(
+            name="foo",
+            normalized_name="foo",
+            organization=pretend.stub(owners=[]),
+        )
+        request = pretend.stub(
+            POST={},
+            user=user,
+            flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
+            session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
+            route_path=lambda *a, **kw: "/foo/bar/",
+        )
+
+        result = views.transfer_organization_project(project, request)
+
+        assert isinstance(result, HTTPSeeOther)
+        assert result.headers["Location"] == "/foo/bar/"
+        assert request.flags.enabled.calls == [
+            pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS)
+        ]
+        assert request.session.flash.calls == [
+            pretend.call(
+                (
+                    "Could not transfer project - "
+                    "you do not have the required permissions"
+                ),
+                queue="error",
+            )
+        ]
+
     def test_transfer_organization_project_no_individual_owner(
         self, monkeypatch, db_request
     ):
diff --git a/tests/unit/organizations/test_models.py b/tests/unit/organizations/test_models.py
--- a/tests/unit/organizations/test_models.py
+++ b/tests/unit/organizations/test_models.py
@@ -108,6 +108,9 @@ def test_acl(self, db_session):
                         "view:team",
                         "manage:organization",
                         "manage:team",
+                        "manage:billing",
+                        "add:project",
+                        "remove:project",
                     ],
                 ),
                 (
@@ -118,6 +121,9 @@ def test_acl(self, db_session):
                         "view:team",
                         "manage:organization",
                         "manage:team",
+                        "manage:billing",
+                        "add:project",
+                        "remove:project",
                     ],
                 ),
             ],
@@ -141,12 +147,12 @@ def test_acl(self, db_session):
                 (
                     Allow,
                     f"user:{account_mgr1.user.id}",
-                    ["view:organization", "view:team", "manage:team"],
+                    ["view:organization", "view:team", "manage:team", "add:project"],
                 ),
                 (
                     Allow,
                     f"user:{account_mgr2.user.id}",
-                    ["view:organization", "view:team", "manage:team"],
+                    ["view:organization", "view:team", "manage:team", "add:project"],
                 ),
             ],
             key=lambda x: x[1],
@@ -228,6 +234,9 @@ def test_acl(self, db_session):
                         "view:team",
                         "manage:organization",
                         "manage:team",
+                        "manage:billing",
+                        "add:project",
+                        "remove:project",
                     ],
                 ),
                 (
@@ -238,6 +247,9 @@ def test_acl(self, db_session):
                         "view:team",
                         "manage:organization",
                         "manage:team",
+                        "manage:billing",
+                        "add:project",
+                        "remove:project",
                     ],
                 ),
             ],
@@ -261,12 +273,12 @@ def test_acl(self, db_session):
                 (
                     Allow,
                     f"user:{account_mgr1.user.id}",
-                    ["view:organization", "view:team", "manage:team"],
+                    ["view:organization", "view:team", "manage:team", "add:project"],
                 ),
                 (
                     Allow,
                     f"user:{account_mgr2.user.id}",
-                    ["view:organization", "view:team", "manage:team"],
+                    ["view:organization", "view:team", "manage:team", "add:project"],
                 ),
             ],
             key=lambda x: x[1],