From 3b22026a0f63ea918b7eac90d2a49751c467b978 Mon Sep 17 00:00:00 2001 From: Donald Stufft Date: Mon, 24 Feb 2025 09:49:03 -0500 Subject: [PATCH] Implement PEP 752 --- tests/common/db/organizations.py | 17 ++ tests/conftest.py | 9 +- tests/unit/admin/test_routes.py | 6 + tests/unit/admin/views/test_namespaces.py | 150 ++++++++++++++++ tests/unit/api/test_simple.py | 97 ++++++++++ tests/unit/manage/test_forms.py | 31 ++++ tests/unit/manage/views/test_organizations.py | 91 ++++++++++ tests/unit/organizations/test_init.py | 8 +- tests/unit/organizations/test_models.py | 131 +++++++++++++- tests/unit/organizations/test_services.py | 41 ++++- tests/unit/packaging/test_services.py | 49 +++++- tests/unit/test_config.py | 4 + tests/unit/test_routes.py | 7 + warehouse/admin/routes.py | 4 + warehouse/admin/templates/admin/base.html | 5 + .../templates/admin/namespaces/detail.html | 79 +++++++++ .../templates/admin/namespaces/list.html | 95 ++++++++++ warehouse/admin/views/namespaces.py | 133 ++++++++++++++ warehouse/authnz/_permissions.py | 7 + warehouse/config.py | 4 + warehouse/events/tags.py | 1 + warehouse/locale/messages.pot | 166 ++++++++++++------ warehouse/manage/forms.py | 36 ++++ warehouse/manage/views/organizations.py | 74 +++++++- .../cd69005ab09c_add_namespace_support.py | 107 +++++++++++ warehouse/organizations/__init__.py | 10 +- warehouse/organizations/interfaces.py | 14 ++ warehouse/organizations/models.py | 91 +++++++++- warehouse/organizations/services.py | 46 ++++- warehouse/packaging/services.py | 41 +++++ warehouse/packaging/utils.py | 30 ++++ warehouse/routes.py | 7 + warehouse/static/images/circle-nodes.png | Bin 0 -> 15408 bytes warehouse/static/images/circle-nodes.svg | 1 + .../sass/blocks/_namespace-snippet.scss | 33 ++++ warehouse/static/sass/warehouse.scss | 1 + .../manage/manage-organization-menu.html | 7 + .../manage/organization/history.html | 11 ++ .../manage/organization/namespaces.html | 90 ++++++++++ 39 files changed, 1671 insertions(+), 63 deletions(-) create mode 100644 tests/unit/admin/views/test_namespaces.py create mode 100644 warehouse/admin/templates/admin/namespaces/detail.html create mode 100644 warehouse/admin/templates/admin/namespaces/list.html create mode 100644 warehouse/admin/views/namespaces.py create mode 100644 warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py create mode 100644 warehouse/static/images/circle-nodes.png create mode 100755 warehouse/static/images/circle-nodes.svg create mode 100644 warehouse/static/sass/blocks/_namespace-snippet.scss create mode 100644 warehouse/templates/manage/organization/namespaces.html diff --git a/tests/common/db/organizations.py b/tests/common/db/organizations.py index 67a366aff659..6cfbaa01a428 100644 --- a/tests/common/db/organizations.py +++ b/tests/common/db/organizations.py @@ -14,8 +14,10 @@ import factory import faker +import packaging.utils from warehouse.organizations.models import ( + Namespace, Organization, OrganizationApplication, OrganizationInvitation, @@ -186,3 +188,18 @@ class Meta: role_name = TeamProjectRoleType.Owner project = factory.SubFactory(ProjectFactory) team = factory.SubFactory(TeamFactory) + + +class NamespaceFactory(WarehouseFactory): + class Meta: + model = Namespace + + is_approved = True + created = factory.Faker( + "date_time_between_dates", datetime_start=datetime.datetime(2008, 1, 1) + ) + name = factory.Faker("pystr", max_chars=12) + normalized_name = factory.LazyAttribute( + lambda o: packaging.utils.canonicalize_name(o.name) + ) + owner = factory.SubFactory(OrganizationFactory) diff --git a/tests/conftest.py b/tests/conftest.py index 33cf262c69c0..ec32b4a65d3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,7 @@ from warehouse.oidc.interfaces import IOIDCPublisherService from warehouse.oidc.utils import ACTIVESTATE_OIDC_ISSUER_URL, GITHUB_OIDC_ISSUER_URL from warehouse.organizations import services as organization_services -from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.interfaces import INamespaceService, IOrganizationService from warehouse.packaging import services as packaging_services from warehouse.packaging.interfaces import IProjectService from warehouse.subscriptions import services as subscription_services @@ -153,6 +153,7 @@ def pyramid_services( email_service, metrics, organization_service, + namespace_service, subscription_service, token_service, user_service, @@ -171,6 +172,7 @@ def pyramid_services( services.register_service(email_service, IEmailSender, None, name="") services.register_service(metrics, IMetricsService, None, name="") services.register_service(organization_service, IOrganizationService, None, name="") + services.register_service(namespace_service, INamespaceService, None, name="") services.register_service(subscription_service, ISubscriptionService, None, name="") services.register_service(token_service, ITokenService, None, name="password") services.register_service(token_service, ITokenService, None, name="email") @@ -484,6 +486,11 @@ def organization_service(db_session): return organization_services.DatabaseOrganizationService(db_session) +@pytest.fixture +def namespace_service(db_session): + return organization_services.DatabaseNamespaceService(db_session) + + @pytest.fixture def billing_service(app_config): stripe.api_base = app_config.registry.settings["billing.api_base"] diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index 8f1ddfba2620..c956b3a6d051 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -54,6 +54,12 @@ def test_includeme(): "/admin/organization_applications/{organization_application_id}/decline/", domain=warehouse, ), + pretend.call("admin.namespace.list", "/admin/namespaces/", domain=warehouse), + pretend.call( + "admin.namespace.detail", + "/admin/namespaces/{namespace_id}/", + domain=warehouse, + ), pretend.call("admin.user.list", "/admin/users/", domain=warehouse), pretend.call( "admin.user.detail", diff --git a/tests/unit/admin/views/test_namespaces.py b/tests/unit/admin/views/test_namespaces.py new file mode 100644 index 000000000000..56b917eb4ac8 --- /dev/null +++ b/tests/unit/admin/views/test_namespaces.py @@ -0,0 +1,150 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pretend +import pytest + +from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound + +from warehouse.admin.views import namespaces as views + +from ....common.db.organizations import NamespaceFactory + + +class TestNamespaceList: + + def test_no_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(30), key=lambda n: n.normalized_name + ) + result = views.namespace_list(db_request) + + assert result == {"namespaces": namespaces[:25], "query": "", "terms": []} + + def test_with_page(self, db_request): + db_request.GET["page"] = "2" + namespaces = sorted( + NamespaceFactory.create_batch(30), key=lambda n: n.normalized_name + ) + result = views.namespace_list(db_request) + + assert result == {"namespaces": namespaces[25:], "query": "", "terms": []} + + def test_with_invalid_page(self): + request = pretend.stub( + flags=pretend.stub(enabled=lambda *a: False), + params={"page": "not an integer"}, + ) + + with pytest.raises(HTTPBadRequest): + views.namespace_list(request) + + def test_basic_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + db_request.GET["q"] = namespaces[0].name + result = views.namespace_list(db_request) + + assert namespaces[0] in result["namespaces"] + assert result["query"] == namespaces[0].name + assert result["terms"] == [namespaces[0].name] + + def test_name_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + db_request.GET["q"] = f"name:{namespaces[0].name}" + result = views.namespace_list(db_request) + + assert namespaces[0] in result["namespaces"] + assert result["query"] == f"name:{namespaces[0].name}" + assert result["terms"] == [f"name:{namespaces[0].name}"] + + def test_organization_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + db_request.GET["q"] = f"organization:{namespaces[0].owner.name}" + result = views.namespace_list(db_request) + + assert namespaces[0] in result["namespaces"] + assert result["query"] == f"organization:{namespaces[0].owner.name}" + assert result["terms"] == [f"organization:{namespaces[0].owner.name}"] + + def test_is_approved_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + namespaces[0].is_approved = True + namespaces[1].is_approved = True + namespaces[2].is_approved = False + namespaces[3].is_approved = False + namespaces[4].is_approved = False + db_request.GET["q"] = "is:approved" + result = views.namespace_list(db_request) + + assert result == { + "namespaces": namespaces[:2], + "query": "is:approved", + "terms": ["is:approved"], + } + + def test_is_pending_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + namespaces[0].is_approved = True + namespaces[1].is_approved = True + namespaces[2].is_approved = False + namespaces[3].is_approved = False + namespaces[4].is_approved = False + db_request.GET["q"] = "is:pending" + result = views.namespace_list(db_request) + + assert result == { + "namespaces": namespaces[2:], + "query": "is:pending", + "terms": ["is:pending"], + } + + def test_is_invalid_query(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + db_request.GET["q"] = "is:not-actually-a-valid-query" + result = views.namespace_list(db_request) + + assert result == { + "namespaces": namespaces[:25], + "query": "is:not-actually-a-valid-query", + "terms": ["is:not-actually-a-valid-query"], + } + + +class TestNamespaceDetail: + def test_detail(self, db_request): + namespaces = sorted( + NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name + ) + db_request.matchdict["namespace_id"] = str(namespaces[1].id) + + assert views.namespace_detail(db_request) == { + "namespace": namespaces[1], + } + + def test_detail_not_found(self, db_request): + NamespaceFactory.create_batch(5) + db_request.matchdict["namespace_id"] = "c6a1a66b-d1af-45fc-ae9f-21b36662c2ac" + + with pytest.raises(HTTPNotFound): + views.namespace_detail(db_request) diff --git a/tests/unit/api/test_simple.py b/tests/unit/api/test_simple.py index ce05298afd91..6afc26119680 100644 --- a/tests/unit/api/test_simple.py +++ b/tests/unit/api/test_simple.py @@ -22,6 +22,11 @@ from warehouse.packaging.utils import API_VERSION, _valid_simple_detail_context from ...common.db.accounts import UserFactory +from ...common.db.organizations import ( + NamespaceFactory, + OrganizationFactory, + OrganizationProjectFactory, +) from ...common.db.packaging import ( AlternateRepositoryFactory, FileFactory, @@ -221,6 +226,7 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override): "files": [], "versions": [], "alternate-locations": [], + "namespace": None, } context = _update_context(context, content_type, renderer_override) assert simple.simple_detail(project, db_request) == context @@ -253,6 +259,92 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override) "files": [], "versions": [], "alternate-locations": sorted(al.url for al in als), + "namespace": None, + } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context + + assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id) + assert db_request.response.content_type == content_type + _assert_has_cors_headers(db_request.response.headers) + + if renderer_override is not None: + assert db_request.override_renderer == renderer_override + + @pytest.mark.parametrize( + ("content_type", "renderer_override"), + CONTENT_TYPE_PARAMS, + ) + def test_with_namespaces_authorized( + self, db_request, content_type, renderer_override + ): + db_request.accept = content_type + org = OrganizationFactory.create() + namespace = NamespaceFactory.create(owner=org) + project = ProjectFactory.create(name=f"{namespace.name}-foo") + OrganizationProjectFactory.create(organization=org, project=project) + db_request.matchdict["name"] = project.normalized_name + user = UserFactory.create() + je = JournalEntryFactory.create(name=project.name, submitted_by=user) + als = [ + AlternateRepositoryFactory.create(project=project), + AlternateRepositoryFactory.create(project=project), + ] + + context = { + "meta": {"_last-serial": je.id, "api-version": API_VERSION}, + "name": project.normalized_name, + "files": [], + "versions": [], + "alternate-locations": sorted(al.url for al in als), + "namespace": { + "prefix": namespace.normalized_name, + "open": namespace.is_open, + "authorized": True, + }, + } + context = _update_context(context, content_type, renderer_override) + assert simple.simple_detail(project, db_request) == context + + assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id) + assert db_request.response.content_type == content_type + _assert_has_cors_headers(db_request.response.headers) + + if renderer_override is not None: + assert db_request.override_renderer == renderer_override + + @pytest.mark.parametrize( + ("content_type", "renderer_override"), + CONTENT_TYPE_PARAMS, + ) + def test_with_namespaces_not_authorized( + self, db_request, content_type, renderer_override + ): + db_request.accept = content_type + org = OrganizationFactory.create() + namespace = NamespaceFactory.create(owner=org) + project = ProjectFactory.create(name=f"{namespace.name}-foo") + project2 = ProjectFactory.create(name=f"{namespace.name}-foo2") + OrganizationProjectFactory.create(organization=org, project=project2) + db_request.matchdict["name"] = project.normalized_name + user = UserFactory.create() + je = JournalEntryFactory.create(name=project.name, submitted_by=user) + als = [ + AlternateRepositoryFactory.create(project=project), + AlternateRepositoryFactory.create(project=project), + ] + + context = { + "meta": {"_last-serial": je.id, "api-version": API_VERSION}, + "name": project.normalized_name, + "files": [], + "versions": [], + "alternate-locations": sorted(al.url for al in als), + "namespace": { + "prefix": namespace.normalized_name, + "open": namespace.is_open, + "authorized": False, + }, } context = _update_context(context, content_type, renderer_override) assert simple.simple_detail(project, db_request) == context @@ -305,6 +397,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override) for f in files ], "alternate-locations": [], + "namespace": None, } context = _update_context(context, content_type, renderer_override) assert simple.simple_detail(project, db_request) == context @@ -357,6 +450,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid for f in files ], "alternate-locations": [], + "namespace": None, } context = _update_context(context, content_type, renderer_override) assert simple.simple_detail(project, db_request) == context @@ -454,6 +548,7 @@ def test_with_files_with_version_multi_digit( for f in files ], "alternate-locations": [], + "namespace": None, } context = _update_context(context, content_type, renderer_override) assert simple.simple_detail(project, db_request) == context @@ -486,6 +581,7 @@ def test_with_files_quarantined_omitted_from_index( "files": [], "versions": [], "alternate-locations": [], + "namespace": None, } context = _update_context(context, content_type, renderer_override) @@ -606,6 +702,7 @@ def route_url(route, **kw): for f in files ], "alternate-locations": [], + "namespace": None, } context = _update_context(context, content_type, renderer_override) diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py index ba0f09bd6095..d800924c102d 100644 --- a/tests/unit/manage/test_forms.py +++ b/tests/unit/manage/test_forms.py @@ -1108,3 +1108,34 @@ def test_validate(self, pyramid_request, name, errors): # NOTE(jleightcap): testing with Regexp validators returns raw LazyString # objects in the error dict's values. Just assert on keys. assert list(form.errors.keys()) == errors + + +class TestRequestOrganizationNamespaceForm: + + @pytest.mark.parametrize( + ("name", "errors", "existing"), + [ + ("", ["name"], False), + (" namespace ", ["name"], False), + (".namespace", ["name"], False), + ("namespace-", ["name"], False), + ("namespace", ["name"], True), + ("namespace", [], False), + ], + ) + def test_validate(self, pyramid_request, name, errors, existing): + pyramid_request.POST = MultiDict({"name": name}) + namespace_service = pretend.stub( + get_namespace=lambda name: pretend.stub() if existing else None, + ) + + form = forms.RequestOrganizationNamespaceForm( + pyramid_request.POST, + namespace_service=namespace_service, + ) + + assert form.namespace_service is namespace_service + assert not form.validate() if errors else form.validate(), str(form.errors) + # NOTE(jleightcap): testing with Regexp validators returns raw LazyString + # objects in the error dict's values. Just assert on keys. + assert list(form.errors.keys()) == errors diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index f65bed0e08f4..8f07adbb3902 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -22,6 +22,7 @@ from tests.common.db.accounts import EmailFactory, UserFactory from tests.common.db.organizations import ( + NamespaceFactory, OrganizationEventFactory, OrganizationFactory, OrganizationInvitationFactory, @@ -3017,3 +3018,93 @@ def test_raises_404_with_out_of_range_page(self, db_request): with pytest.raises(HTTPNotFound): assert org_views.manage_organization_history(organization, db_request) + + +class TestManageOrganizationNamespaces: + @pytest.mark.usefixtures("_enable_organizations") + def test_manage_organization_namespaces( + self, + db_request, + pyramid_user, + organization_service, + monkeypatch, + ): + organization = OrganizationFactory.create() + organization.namespaces = [NamespaceFactory.create()] + + db_request.POST = MultiDict() + + view = org_views.ManageOrganizationNamespacesViews(organization, db_request) + result = view.manage_organization_namespaces() + form = result["request_organization_namespace_form"] + + assert view.request == db_request + assert view.organization_service == organization_service + assert result == { + "organization": organization, + "request_organization_namespace_form": form, + } + + @pytest.mark.usefixtures("_enable_organizations") + def test_request_namespace( + self, + db_request, + pyramid_user, + namespace_service, + monkeypatch, + ): + organization = OrganizationFactory.create() + organization.namespaces = [NamespaceFactory.create()] + + db_request.POST = MultiDict({"name": "my-ns"}) + + OrganizationRoleFactory.create( + organization=organization, user=db_request.user, role_name="Owner" + ) + + def request_namespace(name, *args, **kwargs): + ns = NamespaceFactory.create(name=name) + organization.namespaces.append(ns) + return ns + + monkeypatch.setattr(namespace_service, "request_namespace", request_namespace) + + view = org_views.ManageOrganizationNamespacesViews(organization, db_request) + result = view.request_organization_namespace() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == db_request.path + assert len(organization.namespaces) == 2 + assert organization.namespaces[-1].name == "my-ns" + + @pytest.mark.usefixtures("_enable_organizations") + def test_request_namespace_invalid( + self, + db_request, + pyramid_user, + namespace_service, + monkeypatch, + ): + organization = OrganizationFactory.create() + organization.namespaces = [NamespaceFactory.create()] + + OrganizationRoleFactory.create( + organization=organization, user=db_request.user, role_name="Owner" + ) + + db_request.POST = MultiDict({"name": organization.namespaces[0].name}) + + view = org_views.ManageOrganizationNamespacesViews(organization, db_request) + result = view.request_organization_namespace() + form = result["request_organization_namespace_form"] + + assert view.request == db_request + assert view.namespace_service == namespace_service + assert result == { + "organization": organization, + "request_organization_namespace_form": form, + } + assert form.name.errors == [ + "This namespace has already been requested. Choose a different namespace." + ] + assert len(organization.namespaces) == 1 diff --git a/tests/unit/organizations/test_init.py b/tests/unit/organizations/test_init.py index 4904040b1c45..f251cdf0c4b5 100644 --- a/tests/unit/organizations/test_init.py +++ b/tests/unit/organizations/test_init.py @@ -15,8 +15,11 @@ from celery.schedules import crontab from warehouse import organizations -from warehouse.organizations.interfaces import IOrganizationService -from warehouse.organizations.services import database_organization_factory +from warehouse.organizations.interfaces import INamespaceService, IOrganizationService +from warehouse.organizations.services import ( + database_namespace_factory, + database_organization_factory, +) from warehouse.organizations.tasks import ( delete_declined_organizations, update_organization_invitation_status, @@ -36,6 +39,7 @@ def test_includeme(): assert config.register_service_factory.calls == [ pretend.call(database_organization_factory, IOrganizationService), + pretend.call(database_namespace_factory, INamespaceService), ] assert config.add_periodic_task.calls == [ diff --git a/tests/unit/organizations/test_models.py b/tests/unit/organizations/test_models.py index a1418cf2a2b5..4aa940c53a73 100644 --- a/tests/unit/organizations/test_models.py +++ b/tests/unit/organizations/test_models.py @@ -13,7 +13,7 @@ import pretend import pytest -from pyramid.authorization import Allow +from pyramid.authorization import Allow, Authenticated from pyramid.httpexceptions import HTTPPermanentRedirect from pyramid.location import lineage @@ -25,6 +25,7 @@ ) from ...common.db.organizations import ( + NamespaceFactory as DBNamespaceFactory, OrganizationFactory as DBOrganizationFactory, OrganizationNameCatalogFactory as DBOrganizationNameCatalogFactory, OrganizationRoleFactory as DBOrganizationRoleFactory, @@ -138,6 +139,7 @@ def test_acl(self, db_session): Permissions.OrganizationsBillingManage, Permissions.OrganizationProjectsAdd, Permissions.OrganizationProjectsRemove, + Permissions.OrganizationNamespaceManage, ], ), ( @@ -151,6 +153,7 @@ def test_acl(self, db_session): Permissions.OrganizationsBillingManage, Permissions.OrganizationProjectsAdd, Permissions.OrganizationProjectsRemove, + Permissions.OrganizationNamespaceManage, ], ), ], @@ -187,6 +190,7 @@ def test_acl(self, db_session): Permissions.OrganizationTeamsRead, Permissions.OrganizationTeamsManage, Permissions.OrganizationProjectsAdd, + Permissions.OrganizationNamespaceManage, ], ), ( @@ -197,6 +201,7 @@ def test_acl(self, db_session): Permissions.OrganizationTeamsRead, Permissions.OrganizationTeamsManage, Permissions.OrganizationProjectsAdd, + Permissions.OrganizationNamespaceManage, ], ), ], @@ -355,6 +360,7 @@ def test_acl(self, db_session): Permissions.OrganizationsBillingManage, Permissions.OrganizationProjectsAdd, Permissions.OrganizationProjectsRemove, + Permissions.OrganizationNamespaceManage, ], ), ( @@ -368,6 +374,7 @@ def test_acl(self, db_session): Permissions.OrganizationsBillingManage, Permissions.OrganizationProjectsAdd, Permissions.OrganizationProjectsRemove, + Permissions.OrganizationNamespaceManage, ], ), ], @@ -404,6 +411,7 @@ def test_acl(self, db_session): Permissions.OrganizationTeamsRead, Permissions.OrganizationTeamsManage, Permissions.OrganizationProjectsAdd, + Permissions.OrganizationNamespaceManage, ], ), ( @@ -414,6 +422,7 @@ def test_acl(self, db_session): Permissions.OrganizationTeamsRead, Permissions.OrganizationTeamsManage, Permissions.OrganizationProjectsAdd, + Permissions.OrganizationNamespaceManage, ], ), ], @@ -491,3 +500,123 @@ def test_manageable_subscription_none(self, db_session): ) assert organization.active_subscription is None assert organization.manageable_subscription is None + + +class TestNamespace: + def test_acl(self, db_session): + organization = DBOrganizationFactory.create() + namespace = DBNamespaceFactory.create(owner=organization) + owner1 = DBOrganizationRoleFactory.create(organization=organization) + owner2 = DBOrganizationRoleFactory.create(organization=organization) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.BillingManager + ) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.BillingManager + ) + account_mgr1 = DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Manager + ) + account_mgr2 = DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Manager + ) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Member + ) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Member + ) + + acls = [] + for location in lineage(namespace): + try: + acl = location.__acl__ + except AttributeError: + continue + + if acl and callable(acl): + acl = acl() + + acls.extend(acl) + + assert acls == sorted( + [ + (Allow, f"user:{owner1.user.id}", [Permissions.NamespaceProjectsAdd]), + (Allow, f"user:{owner2.user.id}", [Permissions.NamespaceProjectsAdd]), + ], + key=lambda x: x[1], + ) + sorted( + [ + ( + Allow, + f"user:{account_mgr1.user.id}", + [Permissions.NamespaceProjectsAdd], + ), + ( + Allow, + f"user:{account_mgr2.user.id}", + [Permissions.NamespaceProjectsAdd], + ), + ], + key=lambda x: x[1], + ) + + def test_acl_open(self, db_session): + organization = DBOrganizationFactory.create() + namespace = DBNamespaceFactory.create(owner=organization, is_open=True) + owner1 = DBOrganizationRoleFactory.create(organization=organization) + owner2 = DBOrganizationRoleFactory.create(organization=organization) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.BillingManager + ) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.BillingManager + ) + account_mgr1 = DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Manager + ) + account_mgr2 = DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Manager + ) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Member + ) + DBOrganizationRoleFactory.create( + organization=organization, role_name=OrganizationRoleType.Member + ) + + acls = [] + for location in lineage(namespace): + try: + acl = location.__acl__ + except AttributeError: + continue + + if acl and callable(acl): + acl = acl() + + acls.extend(acl) + + assert acls == sorted( + [ + (Allow, f"user:{owner1.user.id}", [Permissions.NamespaceProjectsAdd]), + (Allow, f"user:{owner2.user.id}", [Permissions.NamespaceProjectsAdd]), + ], + key=lambda x: x[1], + ) + sorted( + [ + ( + Allow, + f"user:{account_mgr1.user.id}", + [Permissions.NamespaceProjectsAdd], + ), + ( + Allow, + f"user:{account_mgr2.user.id}", + [Permissions.NamespaceProjectsAdd], + ), + ], + key=lambda x: x[1], + ) + [ + (Allow, Authenticated, [Permissions.NamespaceProjectsAdd]) + ] diff --git a/tests/unit/organizations/test_services.py b/tests/unit/organizations/test_services.py index 6b8e235b4c5e..6fa71051c48f 100644 --- a/tests/unit/organizations/test_services.py +++ b/tests/unit/organizations/test_services.py @@ -18,7 +18,7 @@ from warehouse.accounts.models import User from warehouse.events.tags import EventTag from warehouse.organizations import services -from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.interfaces import INamespaceService, IOrganizationService from warehouse.organizations.models import ( Organization, OrganizationInvitation, @@ -38,6 +38,7 @@ from warehouse.subscriptions.models import StripeSubscription from ...common.db.organizations import ( + NamespaceFactory, OrganizationApplicationFactory, OrganizationFactory, OrganizationInvitationFactory, @@ -916,3 +917,41 @@ def test_delete_team_project_role(self, organization_service): organization_service.delete_team_project_role(team_project_role.id) assert organization_service.get_team_role(team_project_role_id) is None + + +def test_database_namespace_factory(): + db = pretend.stub() + context = pretend.stub() + request = pretend.stub(db=db) + + service = services.database_namespace_factory(context, request) + assert service.db is db + + +class TestDatabaseNamespaceService: + def test_verify_service(self): + assert verifyClass(INamespaceService, services.DatabaseNamespaceService) + + def test_service_creation(self): + session = pretend.stub() + service = services.DatabaseNamespaceService(session) + + assert service.db is session + + def test_get_namespace(self, db_session): + ns = NamespaceFactory.create() + service = services.DatabaseNamespaceService(db_session) + assert service.get_namespace(ns.name).id == ns.id + + def test_get_namespace_with_children(self, db_session): + ns = NamespaceFactory.create() + NamespaceFactory.create(name=f"{ns.name}-child", parent=ns, owner=ns.owner) + service = services.DatabaseNamespaceService(db_session) + assert service.get_namespace(ns.name).id == ns.id + + def test_request_namespace(self, db_session): + org = OrganizationFactory.create() + service = services.DatabaseNamespaceService(db_session) + ns = service.request_namespace(name="foo", organization_id=org.id) + assert ns.name == "foo" + assert ns.owner_id == org.id diff --git a/tests/unit/packaging/test_services.py b/tests/unit/packaging/test_services.py index 83dbbb2e60b4..d57b94e5f77d 100644 --- a/tests/unit/packaging/test_services.py +++ b/tests/unit/packaging/test_services.py @@ -20,6 +20,7 @@ import pretend import pytest +from pyramid.httpexceptions import HTTPForbidden from zope.interface.verify import verifyClass import warehouse.packaging.services @@ -51,7 +52,12 @@ project_service_factory, ) -from ...common.db.packaging import ProhibitedProjectFactory, ProjectFactory +from ...common.db.accounts import UserFactory +from ...common.db.organizations import NamespaceFactory +from ...common.db.packaging import ( + ProhibitedProjectFactory, + ProjectFactory, +) class TestLocalFileStorage: @@ -1056,6 +1062,47 @@ def test_check_project_name_ok(self, db_session): # Should not raise any exception service.check_project_name("foo") + def test_check_namespaces_ok(self, db_session): + NamespaceFactory.create(name="foo") + + request = pretend.stub() + + service = ProjectService(session=db_session) + service.check_namespaces(request, "bar") + service.check_namespaces(request, "bar-foo") + + def test_check_namespaces_no_permissions( + self, pyramid_config, db_request, db_session + ): + user = UserFactory.create() + pyramid_config.testing_securitypolicy(identity=user, permissive=False) + + NamespaceFactory.create(name="foo") + + service = ProjectService(session=db_session) + with pytest.raises(HTTPForbidden): + service.check_namespaces(db_request, "foo") + with pytest.raises(HTTPForbidden): + service.check_namespaces(db_request, "foo-bar") + with pytest.raises(HTTPForbidden): + service.check_namespaces(db_request, "foo.bar") + with pytest.raises(HTTPForbidden): + service.check_namespaces(db_request, "Foo-Bar") + + def test_check_namespaces_with_permission( + self, pyramid_config, db_request, db_session + ): + user = UserFactory.create() + pyramid_config.testing_securitypolicy(identity=user, permissive=True) + + NamespaceFactory.create(name="foo") + + service = ProjectService(session=db_session) + service.check_namespaces(db_request, "foo") + service.check_namespaces(db_request, "foo-bar") + service.check_namespaces(db_request, "foo.bar") + service.check_namespaces(db_request, "Foo-Bar") + def test_project_service_factory(): db = pretend.stub() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 4835bb8dec9b..ff8aa8bfad90 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -576,6 +576,8 @@ def test_root_factory_access_control_list(): Permissions.AdminObservationsWrite, Permissions.AdminOrganizationsRead, Permissions.AdminOrganizationsWrite, + Permissions.AdminNamespacesRead, + Permissions.AdminNamespacesWrite, Permissions.AdminProhibitedEmailDomainsRead, Permissions.AdminProhibitedEmailDomainsWrite, Permissions.AdminProhibitedProjectsRead, @@ -608,6 +610,7 @@ def test_root_factory_access_control_list(): Permissions.AdminObservationsRead, Permissions.AdminObservationsWrite, Permissions.AdminOrganizationsRead, + Permissions.AdminNamespacesRead, Permissions.AdminProhibitedEmailDomainsRead, Permissions.AdminProhibitedProjectsRead, Permissions.AdminProhibitedUsernameRead, @@ -634,6 +637,7 @@ def test_root_factory_access_control_list(): Permissions.AdminObservationsRead, Permissions.AdminObservationsWrite, Permissions.AdminOrganizationsRead, + Permissions.AdminNamespacesRead, Permissions.AdminProhibitedEmailDomainsRead, Permissions.AdminProhibitedProjectsRead, Permissions.AdminProhibitedUsernameRead, diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index a46221a109ba..1a2c20ca5fc3 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -315,6 +315,13 @@ def add_redirect_rule(*args, **kwargs): traverse="/{organization_name}", domain=warehouse, ), + pretend.call( + "manage.organization.namespaces", + "/manage/organization/{organization_name}/namespaces/", + factory="warehouse.organizations.models:OrganizationFactory", + traverse="/{organization_name}", + domain=warehouse, + ), pretend.call( "manage.organization.roles", "/manage/organization/{organization_name}/people/", diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index 0cff26f3ee25..e0e9e360e010 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -50,6 +50,10 @@ def includeme(config): "/admin/organization_applications/{organization_application_id}/decline/", domain=warehouse, ) + config.add_route("admin.namespace.list", "/admin/namespaces/", domain=warehouse) + config.add_route( + "admin.namespace.detail", "/admin/namespaces/{namespace_id}/", domain=warehouse + ) # User related Admin pages config.add_route("admin.user.list", "/admin/users/", domain=warehouse) diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index 05cc6209dfff..8c3761462222 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -128,6 +128,11 @@

Organizations

+ + + {% endblock %} + + {% block content %} +
+
+
+
+
+

{{ namespace.name }}

+
+
+
+

+ Created on {{ namespace.created|format_date() }} +

+
+
+ +
+
+

Actions

+
+
+
+
+
+ +
+
+
+

Namespace

+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+ {% endblock %} + \ No newline at end of file diff --git a/warehouse/admin/templates/admin/namespaces/list.html b/warehouse/admin/templates/admin/namespaces/list.html new file mode 100644 index 000000000000..0e261e9b4aeb --- /dev/null +++ b/warehouse/admin/templates/admin/namespaces/list.html @@ -0,0 +1,95 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. + -#} + {% extends "admin/base.html" %} + + {% import "admin/utils/pagination.html" as pagination %} + + {% block title %}Namespaces{% endblock %} + + {% block breadcrumb %} + + {% endblock %} + + {% block content %} +
+
+
+
+ +
+ +
+
+
+ Examples: word "whole phrase" + name:psf + org:python +
+ Filters:  +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + {% for namespace in namespaces %} + + + + {% if namespace.is_approved %} + + {% else %} + + {% endif %} + + {% endfor %} + +
NamespaceOrganization Status
+ {{ namespace.name }} + + {{ namespace.owner.display_name }} + Approved Pending
+
+ + +
+ {% endblock content %} + \ No newline at end of file diff --git a/warehouse/admin/views/namespaces.py b/warehouse/admin/views/namespaces.py new file mode 100644 index 000000000000..8e7256c936f2 --- /dev/null +++ b/warehouse/admin/views/namespaces.py @@ -0,0 +1,133 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import shlex + +from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage +from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound +from pyramid.view import view_config +from sqlalchemy import or_ + +from warehouse.authnz import Permissions +from warehouse.organizations.models import Namespace, Organization +from warehouse.utils.paginate import paginate_url_factory + + +@view_config( + route_name="admin.namespace.list", + renderer="admin/namespaces/list.html", + permission=Permissions.AdminNamespacesRead, + uses_session=True, +) +def namespace_list(request): + q = request.params.get("q", "") + terms = shlex.split(q) + + try: + page_num = int(request.params.get("page", 1)) + except ValueError: + raise HTTPBadRequest("'page' must be an integer.") from None + + query = ( + request.db.query(Namespace) + .join(Namespace.owner) + .order_by(Namespace.normalized_name) + ) + + if q: + filters: list = [] + for term in terms: + # Examples: + # - search individual words or "whole phrase" in any field + # - name:psf + # - org:python + # - organization:python + # - is:approved + # - is:pending + try: + field, value = term.lower().split(":", 1) + except ValueError: + field, value = "", term + if field == "name": + # Add filter for `name` or `normalized_name` fields. + filters.append( + [ + Namespace.name.ilike(f"%{value}%"), + Namespace.normalized_name.ilike(f"%{value}%"), + ] + ) + elif field == "org" or field == "organization": + # Add filter for `Organization.Name` or `Organization.normalized_name` + # field. + filters.append( + [ + Organization.name.ilike(f"%{value}%"), + Organization.normalized_name.ilike(f"%{value}%"), + ] + ) + elif field == "is": + # Add filter for `is_approved` field. + if "approved".startswith(value): + filters.append(Namespace.is_approved == True) # noqa: E712 + elif "pending".startswith(value): + filters.append(Namespace.is_approved == False) # noqa: E712 + else: + # Add filter for any field. + filters.append( + [ + Namespace.name.ilike(f"%{term}%"), + Namespace.normalized_name.ilike(f"%{term}%"), + ] + ) + # Use AND to add each filter. Use OR to combine subfilters. + for filter_or_subfilters in filters: + if isinstance(filter_or_subfilters, list): + # Add list of subfilters combined with OR. + filter_or_subfilters = filter_or_subfilters or [True] + query = query.filter(or_(False, *filter_or_subfilters)) + else: + # Add single filter. + query = query.filter(filter_or_subfilters) + + namespaces = SQLAlchemyORMPage( + query, + page=page_num, + items_per_page=25, + url_maker=paginate_url_factory(request), + ) + + return {"namespaces": namespaces, "query": q, "terms": terms} + + +@view_config( + route_name="admin.namespace.detail", + require_methods=False, + renderer="admin/namespaces/detail.html", + permission=Permissions.AdminNamespacesRead, + has_translations=True, + uses_session=True, + require_csrf=True, + require_reauth=True, +) +def namespace_detail(request): + namespace = ( + request.db.query(Namespace) + .join(Namespace.owner) + .filter(Namespace.id == request.matchdict["namespace_id"]) + .first() + ) + if namespace is None: + raise HTTPNotFound + + return { + "namespace": namespace, + } diff --git a/warehouse/authnz/_permissions.py b/warehouse/authnz/_permissions.py index 62d60ac07088..aa6f75221644 100644 --- a/warehouse/authnz/_permissions.py +++ b/warehouse/authnz/_permissions.py @@ -60,6 +60,9 @@ class Permissions(StrEnum): AdminOrganizationsRead = "admin:organizations:read" AdminOrganizationsWrite = "admin:organizations:write" + AdminNamespacesRead = "admin:namespaces:read" + AdminNamespacesWrite = "admin:namespaces:write" + AdminProhibitedEmailDomainsRead = "admin:prohibited-email-domains:read" AdminProhibitedEmailDomainsWrite = "admin:prohibited-email-domains:write" @@ -111,7 +114,11 @@ class Permissions(StrEnum): OrganizationProjectsAdd = "organizations:projects:add" OrganizationProjectsRemove = "organizations:projects:remove" # TODO: unused? OrganizationTeamsManage = "organizations:teams:manage" + OrganizationNamespaceManage = "organizations:namespaces:manage" OrganizationTeamsRead = "organizations:teams:read" + # Namespace Permissions + NamespaceProjectsAdd = "namespaces:projects:add" + # Observer Permissions SubmitMalwareObservation = "observer:submit-malware-observation" diff --git a/warehouse/config.py b/warehouse/config.py index 8f486547c8bb..236dcb67772d 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -86,6 +86,8 @@ class RootFactory: Permissions.AdminObservationsWrite, Permissions.AdminOrganizationsRead, Permissions.AdminOrganizationsWrite, + Permissions.AdminNamespacesRead, + Permissions.AdminNamespacesWrite, Permissions.AdminProhibitedEmailDomainsRead, Permissions.AdminProhibitedEmailDomainsWrite, Permissions.AdminProhibitedProjectsRead, @@ -118,6 +120,7 @@ class RootFactory: Permissions.AdminObservationsRead, Permissions.AdminObservationsWrite, Permissions.AdminOrganizationsRead, + Permissions.AdminNamespacesRead, Permissions.AdminProhibitedEmailDomainsRead, Permissions.AdminProhibitedProjectsRead, Permissions.AdminProhibitedUsernameRead, @@ -144,6 +147,7 @@ class RootFactory: Permissions.AdminObservationsRead, Permissions.AdminObservationsWrite, Permissions.AdminOrganizationsRead, + Permissions.AdminNamespacesRead, Permissions.AdminProhibitedEmailDomainsRead, Permissions.AdminProhibitedProjectsRead, Permissions.AdminProhibitedUsernameRead, diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py index 22eaaaec92f9..e95190a4f121 100644 --- a/warehouse/events/tags.py +++ b/warehouse/events/tags.py @@ -190,6 +190,7 @@ class Organization(EventTagEnum): TeamProjectRoleRemove = "organization:team_project_role:remove" TeamRoleAdd = "organization:team_role:add" TeamRoleRemove = "organization:team_role:remove" + NamespaceRequest = "organization:namespace_request" class Team(EventTagEnum): """Tags for Organization events. diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 98cdb36f3e51..01e9ecf71c85 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -93,8 +93,8 @@ msgid "" "different email." msgstr "" -#: warehouse/accounts/forms.py:409 warehouse/manage/forms.py:140 -#: warehouse/manage/forms.py:742 +#: warehouse/accounts/forms.py:409 warehouse/manage/forms.py:141 +#: warehouse/manage/forms.py:778 msgid "The name is too long. Choose a name with 100 characters or less." msgstr "" @@ -361,11 +361,11 @@ msgstr "" msgid "Banner Preview" msgstr "" -#: warehouse/manage/forms.py:420 +#: warehouse/manage/forms.py:421 msgid "Choose an organization account name with 50 characters or less." msgstr "" -#: warehouse/manage/forms.py:428 +#: warehouse/manage/forms.py:429 msgid "" "The organization account name is invalid. Organization account names must" " be composed of letters, numbers, dots, hyphens and underscores. And must" @@ -373,90 +373,98 @@ msgid "" "organization account name." msgstr "" -#: warehouse/manage/forms.py:451 +#: warehouse/manage/forms.py:452 msgid "" "This organization account name has already been used. Choose a different " "organization account name." msgstr "" -#: warehouse/manage/forms.py:466 +#: warehouse/manage/forms.py:467 msgid "" "You have already submitted an application for that name. Choose a " "different organization account name." msgstr "" -#: warehouse/manage/forms.py:501 +#: warehouse/manage/forms.py:502 msgid "Select project" msgstr "" -#: warehouse/manage/forms.py:506 warehouse/oidc/forms/_core.py:29 +#: warehouse/manage/forms.py:507 warehouse/oidc/forms/_core.py:29 #: warehouse/oidc/forms/gitlab.py:57 msgid "Specify project name" msgstr "" -#: warehouse/manage/forms.py:510 +#: warehouse/manage/forms.py:511 msgid "" "Start and end with a letter or numeral containing only ASCII numeric and " "'.', '_' and '-'." msgstr "" -#: warehouse/manage/forms.py:517 +#: warehouse/manage/forms.py:518 msgid "This project name has already been used. Choose a different project name." msgstr "" -#: warehouse/manage/forms.py:590 +#: warehouse/manage/forms.py:591 msgid "" "The organization name is too long. Choose a organization name with 100 " "characters or less." msgstr "" -#: warehouse/manage/forms.py:602 +#: warehouse/manage/forms.py:603 msgid "" "The organization URL is too long. Choose a organization URL with 400 " "characters or less." msgstr "" -#: warehouse/manage/forms.py:608 +#: warehouse/manage/forms.py:609 msgid "The organization URL must start with http:// or https://" msgstr "" -#: warehouse/manage/forms.py:620 +#: warehouse/manage/forms.py:621 msgid "" "The organization description is too long. Choose a organization " "description with 400 characters or less." msgstr "" -#: warehouse/manage/forms.py:655 +#: warehouse/manage/forms.py:656 msgid "You have already submitted the maximum number of " msgstr "" -#: warehouse/manage/forms.py:684 +#: warehouse/manage/forms.py:685 msgid "Choose a team name with 50 characters or less." msgstr "" -#: warehouse/manage/forms.py:691 +#: warehouse/manage/forms.py:692 msgid "" "The team name is invalid. Team names cannot start or end with a space, " "period, underscore, hyphen, or slash. Choose a different team name." msgstr "" -#: warehouse/manage/forms.py:719 +#: warehouse/manage/forms.py:720 msgid "This team name has already been used. Choose a different team name." msgstr "" -#: warehouse/manage/forms.py:737 +#: warehouse/manage/forms.py:742 +msgid "The namespace name is invalid. Namespace must be valid project names." +msgstr "" + +#: warehouse/manage/forms.py:759 +msgid "This namespace has already been requested. Choose a different namespace." +msgstr "" + +#: warehouse/manage/forms.py:773 msgid "Specify your alternate repository name" msgstr "" -#: warehouse/manage/forms.py:751 +#: warehouse/manage/forms.py:787 msgid "Specify your alternate repository URL" msgstr "" -#: warehouse/manage/forms.py:756 +#: warehouse/manage/forms.py:792 msgid "The URL is too long. Choose a URL with 400 characters or less." msgstr "" -#: warehouse/manage/forms.py:770 +#: warehouse/manage/forms.py:806 msgid "" "The description is too long. Choose a description with 400 characters or " "less." @@ -607,13 +615,13 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2961 -#: warehouse/manage/views/organizations.py:893 +#: warehouse/manage/views/organizations.py:965 #, python-brace-format msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:3026 -#: warehouse/manage/views/organizations.py:958 +#: warehouse/manage/views/organizations.py:1030 #, python-brace-format msgid "Invitation sent to '${username}'" msgstr "" @@ -627,33 +635,33 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:3102 -#: warehouse/manage/views/organizations.py:1145 +#: warehouse/manage/views/organizations.py:1217 #, python-brace-format msgid "Invitation revoked from '${username}'." msgstr "" -#: warehouse/manage/views/organizations.py:869 +#: warehouse/manage/views/organizations.py:941 #, python-brace-format msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:880 +#: warehouse/manage/views/organizations.py:952 #, python-brace-format msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:1040 -#: warehouse/manage/views/organizations.py:1082 +#: warehouse/manage/views/organizations.py:1112 +#: warehouse/manage/views/organizations.py:1154 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1050 +#: warehouse/manage/views/organizations.py:1122 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1098 +#: warehouse/manage/views/organizations.py:1170 #, python-brace-format msgid "Expired invitation for '${username}' deleted." msgstr "" @@ -1538,6 +1546,7 @@ msgstr "" #: warehouse/templates/manage/account/totp-provision.html:69 #: warehouse/templates/manage/account/webauthn-provision.html:44 #: warehouse/templates/manage/organization/activate_subscription.html:34 +#: warehouse/templates/manage/organization/namespaces.html:70 #: warehouse/templates/manage/organization/projects.html:128 #: warehouse/templates/manage/organization/projects.html:151 #: warehouse/templates/manage/organization/roles.html:270 @@ -3018,7 +3027,12 @@ msgstr "" msgid "Teams" msgstr "" -#: warehouse/templates/includes/manage/manage-organization-menu.html:41 +#: warehouse/templates/includes/manage/manage-organization-menu.html:40 +#: warehouse/templates/manage/organization/namespaces.html:25 +msgid "Namespaces" +msgstr "" + +#: warehouse/templates/includes/manage/manage-organization-menu.html:48 #: warehouse/templates/includes/manage/manage-project-menu.html:37 #: warehouse/templates/includes/manage/manage-team-menu.html:34 #: warehouse/templates/manage/account.html:505 @@ -3029,7 +3043,7 @@ msgstr "" msgid "Security history" msgstr "" -#: warehouse/templates/includes/manage/manage-organization-menu.html:48 +#: warehouse/templates/includes/manage/manage-organization-menu.html:55 #: warehouse/templates/includes/manage/manage-project-menu.html:57 #: warehouse/templates/includes/manage/manage-team-menu.html:41 msgid "Settings" @@ -3864,7 +3878,7 @@ msgid "Recent account activity" msgstr "" #: warehouse/templates/manage/account.html:780 -#: warehouse/templates/manage/organization/history.html:201 +#: warehouse/templates/manage/organization/history.html:212 #: warehouse/templates/manage/project/history.html:364 #: warehouse/templates/manage/team/history.html:108 #: warehouse/templates/manage/unverified-account.html:466 @@ -3872,8 +3886,8 @@ msgid "Event" msgstr "" #: warehouse/templates/manage/account.html:781 -#: warehouse/templates/manage/organization/history.html:202 -#: warehouse/templates/manage/organization/history.html:211 +#: warehouse/templates/manage/organization/history.html:213 +#: warehouse/templates/manage/organization/history.html:222 #: warehouse/templates/manage/project/history.html:365 #: warehouse/templates/manage/project/history.html:374 #: warehouse/templates/manage/team/history.html:109 @@ -3883,7 +3897,7 @@ msgid "Time" msgstr "" #: warehouse/templates/manage/account.html:782 -#: warehouse/templates/manage/organization/history.html:203 +#: warehouse/templates/manage/organization/history.html:214 #: warehouse/templates/manage/team/history.html:110 #: warehouse/templates/manage/unverified-account.html:468 msgid "Additional Info" @@ -3895,13 +3909,13 @@ msgid "Date / time" msgstr "" #: warehouse/templates/manage/account.html:793 -#: warehouse/templates/manage/organization/history.html:215 +#: warehouse/templates/manage/organization/history.html:226 #: warehouse/templates/manage/unverified-account.html:479 msgid "Location Info" msgstr "" #: warehouse/templates/manage/account.html:795 -#: warehouse/templates/manage/organization/history.html:217 +#: warehouse/templates/manage/organization/history.html:228 #: warehouse/templates/manage/project/history.html:380 #: warehouse/templates/manage/team/history.html:124 #: warehouse/templates/manage/unverified-account.html:481 @@ -4229,7 +4243,7 @@ msgid "Any" msgstr "" #: warehouse/templates/manage/manage_base.html:582 -#: warehouse/templates/manage/organization/history.html:166 +#: warehouse/templates/manage/organization/history.html:177 #: warehouse/templates/manage/project/history.html:43 #: warehouse/templates/manage/project/history.html:97 #: warehouse/templates/manage/project/history.html:137 @@ -4242,7 +4256,7 @@ msgid "Added by:" msgstr "" #: warehouse/templates/manage/manage_base.html:584 -#: warehouse/templates/manage/organization/history.html:171 +#: warehouse/templates/manage/organization/history.html:182 #: warehouse/templates/manage/project/history.html:62 #: warehouse/templates/manage/project/history.html:128 #: warehouse/templates/manage/project/history.html:144 @@ -4280,6 +4294,7 @@ msgstr "" msgid "Pending invitations" msgstr "" +#: warehouse/templates/manage/organization/namespaces.html:42 #: warehouse/templates/manage/organization/projects.html:55 #: warehouse/templates/manage/organizations.html:35 #: warehouse/templates/manage/organizations.html:96 @@ -4316,6 +4331,7 @@ msgstr "" msgid "%(org_type)s" msgstr "" +#: warehouse/templates/manage/organization/namespaces.html:38 #: warehouse/templates/manage/organizations.html:78 #: warehouse/templates/manage/organizations.html:158 msgid "Request Submitted" @@ -5421,56 +5437,65 @@ msgstr "" msgid "%(username)s removed from %(team_name)s team" msgstr "" -#: warehouse/templates/manage/organization/history.html:132 +#: warehouse/templates/manage/organization/history.html:126 +#, python-format +msgid "%(namespace_name)s namespace requested" +msgstr "" + +#: warehouse/templates/manage/organization/history.html:136 msgid "Registered by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:139 +#: warehouse/templates/manage/organization/history.html:143 #: warehouse/templates/manage/project/history.html:34 #: warehouse/templates/manage/team/history.html:71 msgid "Created by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:144 +#: warehouse/templates/manage/organization/history.html:148 #: warehouse/templates/manage/project/history.html:322 #: warehouse/templates/manage/project/history.html:344 #: warehouse/templates/manage/team/history.html:76 msgid "Deleted by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:149 +#: warehouse/templates/manage/organization/history.html:153 #: warehouse/templates/manage/team/history.html:81 msgid "Renamed by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:154 +#: warehouse/templates/manage/organization/history.html:158 msgid "Approved by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:159 +#: warehouse/templates/manage/organization/history.html:163 msgid "Declined by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:176 +#: warehouse/templates/manage/organization/history.html:170 +msgid "Requested by:" +msgstr "" + +#: warehouse/templates/manage/organization/history.html:187 #: warehouse/templates/manage/project/history.html:151 #: warehouse/templates/manage/project/history.html:198 #: warehouse/templates/manage/team/history.html:98 msgid "Changed by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:181 -#: warehouse/templates/manage/organization/history.html:186 +#: warehouse/templates/manage/organization/history.html:192 +#: warehouse/templates/manage/organization/history.html:197 #: warehouse/templates/manage/project/history.html:158 #: warehouse/templates/manage/project/history.html:165 msgid "Invited by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:191 +#: warehouse/templates/manage/organization/history.html:202 #: warehouse/templates/manage/project/history.html:172 msgid "Revoked by:" msgstr "" -#: warehouse/templates/manage/organization/history.html:198 +#: warehouse/templates/manage/organization/history.html:209 #: warehouse/templates/manage/project/history.html:361 #: warehouse/templates/manage/team/history.html:105 #, python-format @@ -5486,6 +5511,43 @@ msgstr "" msgid "Back to organizations" msgstr "" +#: warehouse/templates/manage/organization/namespaces.html:17 +msgid "Organization namespaces" +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:21 +#, python-format +msgid "Manage '%(organization_name)s' namespaces" +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:38 +msgid "You will receive an email when the namespace has been approved" +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:52 +msgid "" +"There are no namespaces in your organization, yet. Organization owners " +"and managers can request new namespaces for the organization." +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:63 +msgid "Request namespace" +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:69 +msgid "️Namespace" +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:80 +msgid "" +"Owners and managers of this organization can request a namespace for the " +"organization." +msgstr "" + +#: warehouse/templates/manage/organization/namespaces.html:85 +msgid "Request" +msgstr "" + #: warehouse/templates/manage/organization/projects.html:17 msgid "Organization projects" msgstr "" diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py index cfe8293bf2dc..ad3abb5b1f78 100644 --- a/warehouse/manage/forms.py +++ b/warehouse/manage/forms.py @@ -11,6 +11,7 @@ # limitations under the License. import json +import re import wtforms @@ -726,6 +727,41 @@ class CreateTeamForm(SaveTeamForm): __params__ = SaveTeamForm.__params__ +class RequestOrganizationNamespaceForm(wtforms.Form): + __params__ = ["name"] + + name = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired(message="Specify namespace name"), + # the regexp below must match the CheckConstraint + # for the name field in organizations.models.Namespace + wtforms.validators.Regexp( + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", + flags=re.IGNORECASE, + message=_( + "The namespace name is invalid. Namespace must be valid " + "project names." + ), + ), + ] + ) + + def __init__(self, *args, namespace_service, **kwargs): + super().__init__(*args, **kwargs) + self.namespace_service = namespace_service + + def validate_name(self, field): + # Our name is only valid if there isn't already another namespace by + # that name. + if self.namespace_service.get_namespace(field.data) is not None: + raise wtforms.validators.ValidationError( + _( + "This namespace has already been requested. " + "Choose a different namespace." + ) + ) + + class AddAlternateRepositoryForm(wtforms.Form): """Form to add an Alternate Repository Location for a Project.""" diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index ee59addaa8ec..78dd04078b65 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -52,6 +52,7 @@ CreateOrganizationRoleForm, CreateTeamForm, OrganizationActivateBillingForm, + RequestOrganizationNamespaceForm, SaveOrganizationForm, SaveOrganizationNameForm, TransferOrganizationProjectForm, @@ -61,7 +62,7 @@ user_organizations, user_projects, ) -from warehouse.organizations import IOrganizationService +from warehouse.organizations import INamespaceService, IOrganizationService from warehouse.organizations.models import ( Organization, OrganizationInvitationStatus, @@ -846,6 +847,77 @@ def add_organization_project(self): return HTTPSeeOther(self.request.path) +@view_defaults( + route_name="manage.organization.namespaces", + context=Organization, + renderer="manage/organization/namespaces.html", + uses_session=True, + require_active_organization=True, + require_csrf=True, + require_methods=False, + permission=Permissions.OrganizationsManage, + has_translations=True, + require_reauth=True, +) +class ManageOrganizationNamespacesViews: + def __init__(self, organization, request): + self.organization = organization + self.request = request + self.user_service = request.find_service(IUserService, context=None) + self.organization_service = request.find_service( + IOrganizationService, context=None + ) + self.namespace_service = request.find_service(INamespaceService, context=None) + + @property + def default_response(self): + return { + "organization": self.organization, + "request_organization_namespace_form": RequestOrganizationNamespaceForm( + self.request.POST, + namespace_service=self.namespace_service, + ), + } + + @view_config(request_method="GET", permission=Permissions.OrganizationsRead) + def manage_organization_namespaces(self): + return self.default_response + + @view_config( + request_method="POST", permission=Permissions.OrganizationNamespaceManage + ) + def request_organization_namespace(self): + # Get and validate form from default response. + default_response = self.default_response + form = default_response["request_organization_namespace_form"] + if not form.validate(): + return default_response + + # Create namespace request + namespace = self.namespace_service.request_namespace( + organization_id=self.organization.id, name=form.name.data + ) + + # Record events. + self.organization.record_event( + tag=EventTag.Organization.NamespaceRequest, + request=self.request, + additional={ + "requested_by_user_id": str(self.request.user.id), + "namespace_name": namespace.name, + }, + ) + + # Display notification message. + self.request.session.flash( + f"Request namespace {namespace.name!r} in {self.organization.name!r}", + queue="success", + ) + + # Refresh namespace list. + return HTTPSeeOther(self.request.path) + + def _send_organization_invitation(request, organization, role_name, user): organization_service = request.find_service(IOrganizationService, context=None) token_service = request.find_service(ITokenService, name="email") diff --git a/warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py b/warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py new file mode 100644 index 000000000000..f31713e60c84 --- /dev/null +++ b/warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py @@ -0,0 +1,107 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +add namespace support + +Revision ID: cd69005ab09c +Revises: 6cac7b706953 +Create Date: 2025-02-19 12:38:52.758352 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "cd69005ab09c" +down_revision = "635b80625fc9" + + +def upgrade(): + op.create_table( + "project_namespaces", + sa.Column( + "is_approved", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("name", sa.String(), nullable=False), + sa.Column("normalized_name", sa.String(), nullable=False), + sa.Column("owner_id", sa.UUID(), nullable=False), + sa.Column("parent_id", sa.UUID(), nullable=True), + sa.Column( + "is_open", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "is_hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.CheckConstraint( + "name ~* '^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$'::text", + name="project_namespaces_valid_name", + ), + sa.ForeignKeyConstraint( + ["parent_id"], + ["project_namespaces.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("normalized_name"), + ) + op.create_index( + op.f("ix_project_namespaces_parent_id"), + "project_namespaces", + ["parent_id"], + unique=False, + ) + op.create_index( + op.f("ix_project_namespaces_owner_id"), + "project_namespaces", + ["owner_id"], + unique=False, + ) + + op.execute( + """ CREATE OR REPLACE FUNCTION maintain_project_namespaces_normalized_name() + RETURNS TRIGGER AS $$ + BEGIN + NEW.normalized_name := normalize_pep426_name(NEW.name); + RETURN NEW; + END; + $$ + LANGUAGE plpgsql + """ + ) + + op.execute( + """ CREATE TRIGGER project_namespaces_update_normalized_name + BEFORE INSERT OR UPDATE OF name ON project_namespaces + FOR EACH ROW + EXECUTE PROCEDURE maintain_project_namespaces_normalized_name() + """ + ) + + +def downgrade(): + op.drop_index( + op.f("ix_project_namespaces_owner_id"), table_name="project_namespaces" + ) + op.drop_index( + op.f("ix_project_namespaces_parent_id"), table_name="project_namespaces" + ) + op.drop_table("project_namespaces") diff --git a/warehouse/organizations/__init__.py b/warehouse/organizations/__init__.py index bfbff0e57dd1..d0d74198b8f1 100644 --- a/warehouse/organizations/__init__.py +++ b/warehouse/organizations/__init__.py @@ -12,8 +12,11 @@ from celery.schedules import crontab -from warehouse.organizations.interfaces import IOrganizationService -from warehouse.organizations.services import database_organization_factory +from warehouse.organizations.interfaces import INamespaceService, IOrganizationService +from warehouse.organizations.services import ( + database_namespace_factory, + database_organization_factory, +) from warehouse.organizations.tasks import ( delete_declined_organizations, update_organization_invitation_status, @@ -25,6 +28,9 @@ def includeme(config): # Register our organization service config.register_service_factory(database_organization_factory, IOrganizationService) + # Register our namespace service + config.register_service_factory(database_namespace_factory, INamespaceService) + config.add_periodic_task( crontab(minute="*/5"), update_organization_invitation_status ) diff --git a/warehouse/organizations/interfaces.py b/warehouse/organizations/interfaces.py index 31dc9cede4b9..3b62c44bfef3 100644 --- a/warehouse/organizations/interfaces.py +++ b/warehouse/organizations/interfaces.py @@ -274,3 +274,17 @@ def delete_team_project_role(team_project_role_id): """ Delete an team project role for a specified team project role id """ + + +class INamespaceService(Interface): + def get_namespace(name): + """ + Return the namespace object that represents the given namespace, or None if + there is no namespace for that name. + """ + + def request_namespace(organization_id, name, is_open=False, is_hidden=False): + """ + Request a new namespace, returning the object that represents this newly + requested namespace. + """ diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py index f2cbf2180c1d..e496b4d0054e 100644 --- a/warehouse/organizations/models.py +++ b/warehouse/organizations/models.py @@ -17,11 +17,12 @@ from uuid import UUID -from pyramid.authorization import Allow +from pyramid.authorization import Allow, Authenticated from pyramid.httpexceptions import HTTPPermanentRedirect from sqlalchemy import ( CheckConstraint, Enum, + FetchedValue, ForeignKey, Index, UniqueConstraint, @@ -314,6 +315,7 @@ class Organization(OrganizationMixin, HasEvents, db.Model): back_populates="organization", order_by=lambda: Team.name.asc(), ) + namespaces: Mapped[list[Namespace]] = orm.relationship(back_populates="owner") projects: Mapped[list[Project]] = relationship( secondary=OrganizationProject.__table__, back_populates="organization", @@ -401,6 +403,7 @@ def __acl__(self): # - Manage billing (Permissions.OrganizationsBillingManage) # - Add project (Permissions.OrganizationProjectsAdd) # - Remove project (Permissions.OrganizationProjectsRemove) + # - Request namespaces (OrganizationNamespaceManage) # Disallowed: # - (none) acls.append( @@ -415,6 +418,7 @@ def __acl__(self): Permissions.OrganizationsBillingManage, Permissions.OrganizationProjectsAdd, Permissions.OrganizationProjectsRemove, + Permissions.OrganizationNamespaceManage, ], ) ) @@ -428,6 +432,7 @@ def __acl__(self): # - Create/delete team and add/remove members (OrganizationTeamsManage) # - Add project (Permissions.OrganizationProjectsAdd) # - Remove project (Permissions.OrganizationProjectsRemove) + # - Request namespaces (OrganizationNamespaceManage) acls.append( ( Allow, @@ -445,6 +450,7 @@ def __acl__(self): # - View team (Permissions.OrganizationTeamsRead) # - Create/delete team and add/remove members (OrganizationTeamsManage) # - Add project (Permissions.OrganizationProjectsAdd) + # - Request namespaces (OrganizationNamespaceManage) # Disallowed: # - Invite/remove organization member (Permissions.OrganizationsManage) # - Manage billing (Permissions.OrganizationsBillingManage) @@ -458,6 +464,7 @@ def __acl__(self): Permissions.OrganizationTeamsRead, Permissions.OrganizationTeamsManage, Permissions.OrganizationProjectsAdd, + Permissions.OrganizationNamespaceManage, ], ) ) @@ -473,6 +480,7 @@ def __acl__(self): # - Manage billing (Permissions.OrganizationsBillingManage) # - Add project (Permissions.OrganizationProjectsAdd) # - Remove project (Permissions.OrganizationProjectsRemove) + # - Request namespaces (OrganizationNamespaceManage) acls.append( ( Allow, @@ -736,3 +744,84 @@ def record_event(self, *, tag, request: Request = None, additional=None): def __acl__(self): return self.organization.__acl__() + + +class Namespace(db.Model): + __tablename__ = "project_namespaces" + __table_args__ = ( + CheckConstraint( + "name ~* '^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$'::text", + name="project_namespaces_valid_name", + ), + ) + + is_approved: Mapped[bool_false] + created: Mapped[datetime_now] + name: Mapped[str] = mapped_column(unique=True) + normalized_name: Mapped[str] = mapped_column( + unique=True, + server_default=FetchedValue(), + server_onupdate=FetchedValue(), + ) + owner_id = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("organizations.id"), + index=True, + nullable=False, + ) + owner: Mapped[Organization] = orm.relationship(back_populates="namespaces") + _parent_id = mapped_column( + "parent_id", + PG_UUID(as_uuid=True), + ForeignKey("project_namespaces.id"), + index=True, + nullable=True, + ) + parent: Mapped[Namespace] = orm.relationship() + is_open: Mapped[bool_false] + is_hidden: Mapped[bool_false] + + def is_project_authorized(self, project): + # To determine if a project is "authorized" to be part of this namespace + # we need to see if any owners of the project match the owner of this + # namespace. + for org_project in self.owner.projects: + if org_project.normalized_name == project.normalized_name: + return True + + # Otherwise, the project is not authorized to be part of this namespace. + return False + + def __acl__(self): + session = orm_session_from_obj(self) + acls = [] + + # Namespaces have a strong sense of ownership, unlike projects which can + # be owned by many identities, Namespaces can only be owned by a single + # identity, and that identity *must* be an organization. + # + # These are meant to map closely to the OrganizationProjectsAdd + # permission, so the same roles that have access to add a project to an + # organization also have access to use the name. + query = session.query(OrganizationRole).filter( + OrganizationRole.organization == self.owner, + OrganizationRole.role_name.in_( + [OrganizationRoleType.Owner, OrganizationRoleType.Manager] + ), + ) + query = query.options(orm.lazyload(OrganizationRole.organization)) + query = query.join(User).order_by(User.id.asc()) + for role in sorted( + query.all(), + key=lambda x: [e.value for e in OrganizationRoleType].index(x.role_name), + ): + acls.append( + (Allow, f"user:{role.user.id}", [Permissions.NamespaceProjectsAdd]) + ) + + # If the Namespace is "open", then any authenticated user is able to + # add a Project to this namespace. + if self.is_open: + acls.append((Allow, Authenticated, [Permissions.NamespaceProjectsAdd])) + + return acls diff --git a/warehouse/organizations/services.py b/warehouse/organizations/services.py index b24857122383..5ea98d2a2d5a 100644 --- a/warehouse/organizations/services.py +++ b/warehouse/organizations/services.py @@ -24,8 +24,9 @@ send_new_organization_declined_email, ) from warehouse.events.tags import EventTag -from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.interfaces import INamespaceService, IOrganizationService from warehouse.organizations.models import ( + Namespace, Organization, OrganizationApplication, OrganizationInvitation, @@ -779,3 +780,46 @@ def delete_team_project_role(self, team_project_role_id): def database_organization_factory(context, request): return DatabaseOrganizationService(request.db) + + +@implementer(INamespaceService) +class DatabaseNamespaceService: + def __init__(self, db_session): + self.db = db_session + + def get_namespace(self, name): + """ + Return the namespace object that represents the given namespace, or None if + there is no namespace for that name. + + This will return the "parent" namespace, even for sub namespaces. + """ + return ( + self.db.query(Namespace) + .filter( + ( + (Namespace.normalized_name == func.normalize_pep426_name(name)) + | func.starts_with( + func.normalize_pep426_name(name), + func.concat(Namespace.normalized_name, "-"), + ) + ) + & (Namespace.parent == None) # noqa E711 + ) + .first() + ) + + def request_namespace(self, organization_id, name, is_open=False, is_hidden=False): + """ + Request a new namespace, returning the object that represents this newly + requested namespace. + """ + ns = Namespace( + name=name, owner_id=organization_id, is_open=is_open, is_hidden=is_hidden + ) + self.db.add(ns) + return ns + + +def database_namespace_factory(context, request): + return DatabaseNamespaceService(request.db) diff --git a/warehouse/packaging/services.py b/warehouse/packaging/services.py index a7713ae93b0a..98222e30f399 100644 --- a/warehouse/packaging/services.py +++ b/warehouse/packaging/services.py @@ -34,10 +34,12 @@ from zope.interface import implementer from warehouse.admin.flags import AdminFlagValue +from warehouse.authnz import Permissions from warehouse.email import send_pending_trusted_publisher_invalidated_email from warehouse.events.tags import EventTag from warehouse.metrics import IMetricsService from warehouse.oidc.models import PendingOIDCPublisher +from warehouse.organizations.models import Namespace from warehouse.packaging.interfaces import ( IDocsStorage, IFileStorage, @@ -455,6 +457,7 @@ def check_project_name(self, name: str) -> None: if canonicalize_name(name) in STDLIB_PROHIBITED: raise ProjectNameUnavailableStdlibError() + # Check if the project name matches one of the existing names. if existing_project := self.db.scalars( select(Project).where( Project.normalized_name == func.normalize_pep426_name(name) @@ -462,6 +465,7 @@ def check_project_name(self, name: str) -> None: ).first(): raise ProjectNameUnavailableExistingError(existing_project) + # Check if the project name matches one of the prohibited names. if self.db.query( exists().where( ProhibitedProjectName.name == func.normalize_pep426_name(name) @@ -469,6 +473,7 @@ def check_project_name(self, name: str) -> None: ).scalar(): raise ProjectNameUnavailableProhibitedError() + # Check if the project name is too similiar to an existing project name. if similar_project_name := self.db.scalars( select(Project.name).where( func.ultranormalize_name(Project.name) == func.ultranormalize_name(name) @@ -478,6 +483,38 @@ def check_project_name(self, name: str) -> None: return None + def check_namespaces(self, request, name: str) -> None: + # TODO: This query will (without the first) give us a list of _all_ of + # the namespace reservations that match the desired project name, + # but we filter it down to just the most specific grant. + # + # This might be wrong? Rather than using the most specific grant + # we might want to look at _all_ of the grants? Either using all() + # semantics or any() semantics. + if ns := self.db.scalars( + select(Namespace) + .where( + ( + (Namespace.normalized_name == func.normalize_pep426_name(name)) + | func.starts_with( + func.normalize_pep426_name(name), + func.concat(Namespace.normalized_name, "-"), + ) + ) + & (Namespace.is_approved == True) # noqa E712 + ) + .order_by(func.length(Namespace.normalized_name).desc()) + ).first(): + # If we've found a namespace that matches this, so we'll check to + # see if we're allowed to upload to this namespace. + if not request.has_permission(Permissions.NamespaceProjectsAdd, ns): + raise HTTPForbidden( + ( + "The name {name!r} conflicts with a registered namespace which " + "you do not have permission for." + ).format(name=name) + ) + def create_project( self, name, creator, request, *, creator_is_owner=True, ratelimited=True ): @@ -543,6 +580,10 @@ def create_project( ), ) from None + # The name is otherwise valid, but we need to check if the name is part + # of a namespace. + self.check_namespaces(request, name) + # The project name is valid: create it and add it project = Project(name=name) self.db.add(project) diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py index 7397cf45a740..0b58694719f8 100644 --- a/warehouse/packaging/utils.py +++ b/warehouse/packaging/utils.py @@ -17,8 +17,10 @@ import packaging_legacy.version from pyramid_jinja2 import IJinja2Environment +from sqlalchemy import func from sqlalchemy.orm import joinedload +from warehouse.organizations.models import Namespace from warehouse.packaging.interfaces import ISimpleStorage from warehouse.packaging.models import File, LifecycleStatus, Project, Release @@ -46,6 +48,33 @@ def _simple_index(request, serial): def _simple_detail(project, request): + # Get the namespace information for this project. + # TODO: The PEP states that if we get multiple matching namespaces, it must + # be the one with the most characters, but does that mean that if orgA + # owns the NS `foo`, and delegates `foo-bar` to orgB, that orgA's grant + # on `foo` does not authorize them to release a package under `foo-bar`? + namespace = None + if ( + ns := request.db.query(Namespace) + .filter( + ( + (Namespace.normalized_name == project.normalized_name) + | func.starts_with( + project.normalized_name, + func.concat(Namespace.normalized_name, "-"), + ) + ) + & (Namespace.is_approved == True) # noqa E712 + ) + .order_by(func.length(Namespace.normalized_name).desc()) + .first() + ): + namespace = { + "prefix": ns.normalized_name, + "authorized": ns.is_project_authorized(project), + "open": ns.is_open, + } + # Get all of the files for this project. files = sorted( request.db.query(File) @@ -70,6 +99,7 @@ def _simple_detail(project, request): return { "meta": {"api-version": API_VERSION, "_last-serial": project.last_serial}, "name": project.normalized_name, + "namespace": namespace, "versions": versions, "alternate-locations": alternate_repositories, "files": [ diff --git a/warehouse/routes.py b/warehouse/routes.py index 6c057befa50e..33a9bdc81c2d 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -315,6 +315,13 @@ def includeme(config): traverse="/{organization_name}", domain=warehouse, ) + config.add_route( + "manage.organization.namespaces", + "/manage/organization/{organization_name}/namespaces/", + factory="warehouse.organizations.models:OrganizationFactory", + traverse="/{organization_name}", + domain=warehouse, + ) config.add_route( "manage.organization.roles", "/manage/organization/{organization_name}/people/", diff --git a/warehouse/static/images/circle-nodes.png b/warehouse/static/images/circle-nodes.png new file mode 100644 index 0000000000000000000000000000000000000000..fd1f006ca7d302b13cadb3d0c517e0697706bde3 GIT binary patch literal 15408 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 mBK*N4_ \ No newline at end of file diff --git a/warehouse/static/sass/blocks/_namespace-snippet.scss b/warehouse/static/sass/blocks/_namespace-snippet.scss new file mode 100644 index 000000000000..30fed7743fef --- /dev/null +++ b/warehouse/static/sass/blocks/_namespace-snippet.scss @@ -0,0 +1,33 @@ +/*! + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "snippet"; + +/* + A card that contains information about a namespace. Often found in namespace lists. + The card can be an "a" or "div" element, but if it contains a link, choosing + a top-level "a" element is recommended for accessibility reasons. + + +

+ // Package title + // Version // Optional! +

+

// Description

+
+*/ + +.namespace-snippet { + @include snippet(url("../images/circle-nodes.png"), url("../images/circle-nodes.svg")); +} diff --git a/warehouse/static/sass/warehouse.scss b/warehouse/static/sass/warehouse.scss index 15b521b0763b..8ccf2fb0128d 100644 --- a/warehouse/static/sass/warehouse.scss +++ b/warehouse/static/sass/warehouse.scss @@ -101,6 +101,7 @@ @import "blocks/package-description"; @import "blocks/package-header"; @import "blocks/package-snippet"; +@import "blocks/namespace-snippet"; /*rtl:end:ignore*/ @import "blocks/password-strength"; /*rtl:begin:ignore*/ diff --git a/warehouse/templates/includes/manage/manage-organization-menu.html b/warehouse/templates/includes/manage/manage-organization-menu.html index c3087ce8773e..248d28b5ed7f 100644 --- a/warehouse/templates/includes/manage/manage-organization-menu.html +++ b/warehouse/templates/includes/manage/manage-organization-menu.html @@ -34,6 +34,13 @@ {{ organization.teams|length }} +
  • + + + {% trans %}Namespaces{% endtrans %} + {{ organization.namespaces|length }} + +
  • {% if request.has_permission(Permissions.OrganizationsManage) %}
  • diff --git a/warehouse/templates/manage/organization/history.html b/warehouse/templates/manage/organization/history.html index a1d2a4381bf7..3b03834c77f7 100644 --- a/warehouse/templates/manage/organization/history.html +++ b/warehouse/templates/manage/organization/history.html @@ -121,6 +121,10 @@

    {% trans %}Security history{% endtrans %}

    {% trans href=request.route_path('accounts.profile', username=target_user), username=target_user, team_name=event.additional.team_name %}
    {{ username }} removed from {{ team_name }} team{% endtrans %} + {# Display action for namespace events #} + {% elif event.tag == EventTag.Organization.NamespaceRequest %} + {% trans namespace_name=event.additional.namespace_name %}{{ namespace_name }} namespace requested{% endtrans %} + {% else %} {{ event.tag }} {% endif %} @@ -159,6 +163,13 @@

    {% trans %}Security history{% endtrans %}

    {% trans %}Declined by:{% endtrans %} {{ declined_by }} + {# Display acting user for namespace events #} + {% elif event.tag == EventTag.Organization.NamespaceRequest %} + {% set requested_by = get_user(event.additional.requested_by_user_id).username %} + + {% trans %}Requested by:{% endtrans %} {{ requested_by }} + + {# Display submitting user for role events #} {% elif event.tag.endswith(":add") %} {% set submitted_by = get_user(event.additional.submitted_by_user_id).username %} diff --git a/warehouse/templates/manage/organization/namespaces.html b/warehouse/templates/manage/organization/namespaces.html new file mode 100644 index 000000000000..99bb7410a3f5 --- /dev/null +++ b/warehouse/templates/manage/organization/namespaces.html @@ -0,0 +1,90 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. + -#} + {% extends "manage_organization_base.html" %} + + {% set user = request.user %} + {% set title = gettext("Organization namespaces") %} + + {% set active_tab = 'namespaces' %} + + {% block title %}{% trans organization_name=organization.name %}Manage '{{ organization_name }}' namespaces{% endtrans %}{% endblock %} + + {% block main %} +

    + {% trans %}Namespaces{% endtrans %} + {{ organization.namespaces|length }} +

    + +
    + {% for namespace in organization.namespaces %} +
    +
    +
    +

    + {{ namespace.name }} + + {% if not namespace.is_approved %} + {% trans %}Request Submitted{% endtrans %} + {% endif %} +

    +

    + {% trans creation_date=humanize(namespace.created) %}Created {{ creation_date }}{% endtrans %} +

    +
    +
    +
    +
    +
    + {% else %} +
    +

    + {% trans %}There are no namespaces in your organization, yet. Organization owners and managers can request new namespaces for the organization.{% endtrans %} +

    +
    + {% endfor %} +
    + + {% if request.has_permission(Permissions.OrganizationNamespaceManage) %} +
    + + {{ form_error_anchor(request_organization_namespace_form) }} +
    +

    {% trans %}Request namespace{% endtrans %}

    +
    + + {{ form_errors(request_organization_namespace_form) }} +
    + + {{ request_organization_namespace_form.name( + class_="form-group__field", + aria_describedby="name-errors", + ) }} +
    + {{ field_errors(request_organization_namespace_form.name) }} +
    +

    + {% trans %} + Owners and managers of this organization can request a namespace for the organization. + {% endtrans %} +

    +
    + +
    +
    + {% endif %} + {% endblock %} + \ No newline at end of file