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 000000000000..fd1f006ca7d3 Binary files /dev/null and b/warehouse/static/images/circle-nodes.png differ diff --git a/warehouse/static/images/circle-nodes.svg b/warehouse/static/images/circle-nodes.svg new file mode 100755 index 000000000000..44e4bb3fc8a0 --- /dev/null +++ b/warehouse/static/images/circle-nodes.svg @@ -0,0 +1 @@ + \ 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