Merge ~pappacena/launchpad:snap-pillar-ui into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 92b05132afbd563bf786c3204eacd2a96b10ab5b
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:snap-pillar-ui
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:snap-pillar
Diff against target: 751 lines (+275/-68)
7 files modified
lib/lp/registry/personmerge.py (+1/-1)
lib/lp/snappy/browser/snap.py (+69/-15)
lib/lp/snappy/browser/tests/test_snap.py (+84/-3)
lib/lp/snappy/interfaces/snap.py (+16/-7)
lib/lp/snappy/model/snap.py (+83/-31)
lib/lp/snappy/tests/test_snap.py (+12/-6)
lib/lp/testing/factory.py (+10/-5)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+397529@code.launchpad.net

Commit message

UI to associate a project pillar to Snaps on +admin view, and replacing "private" Snap flag with "information_type".

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes. This should be ready for another round of review.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Thiago F. Pappacena (pappacena) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py
index 50e0f91..949e8d9 100644
--- a/lib/lp/registry/personmerge.py
+++ b/lib/lp/registry/personmerge.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Person/team merger implementation."""4"""Person/team merger implementation."""
diff --git a/lib/lp/snappy/browser/snap.py b/lib/lp/snappy/browser/snap.py
index 3b06a89..9759e66 100644
--- a/lib/lp/snappy/browser/snap.py
+++ b/lib/lp/snappy/browser/snap.py
@@ -1,4 +1,4 @@
1# Copyright 2015-2020 Canonical Ltd. This software is licensed under the1# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Snap views."""4"""Snap views."""
@@ -48,10 +48,12 @@ from lp.app.browser.tales import format_link
48from lp.app.enums import PRIVATE_INFORMATION_TYPES48from lp.app.enums import PRIVATE_INFORMATION_TYPES
49from lp.app.interfaces.informationtype import IInformationType49from lp.app.interfaces.informationtype import IInformationType
50from lp.app.interfaces.launchpad import ILaunchpadCelebrities50from lp.app.interfaces.launchpad import ILaunchpadCelebrities
51from lp.app.vocabularies import InformationTypeVocabulary
51from lp.app.widgets.itemswidgets import (52from lp.app.widgets.itemswidgets import (
52 LabeledMultiCheckBoxWidget,53 LabeledMultiCheckBoxWidget,
53 LaunchpadDropdownWidget,54 LaunchpadDropdownWidget,
54 LaunchpadRadioWidget,55 LaunchpadRadioWidget,
56 LaunchpadRadioWidgetWithDescription,
55 )57 )
56from lp.buildmaster.interfaces.processor import IProcessorSet58from lp.buildmaster.interfaces.processor import IProcessorSet
57from lp.code.browser.widgets.gitref import GitRefWidget59from lp.code.browser.widgets.gitref import GitRefWidget
@@ -343,7 +345,8 @@ class ISnapEditSchema(Interface):
343 use_template(ISnap, include=[345 use_template(ISnap, include=[
344 'owner',346 'owner',
345 'name',347 'name',
346 'private',348 'information_type',
349 'project',
347 'require_virtualized',350 'require_virtualized',
348 'allow_internet',351 'allow_internet',
349 'build_source_tarball',352 'build_source_tarball',
@@ -351,6 +354,7 @@ class ISnapEditSchema(Interface):
351 'auto_build_channels',354 'auto_build_channels',
352 'store_upload',355 'store_upload',
353 ])356 ])
357
354 store_distro_series = Choice(358 store_distro_series = Choice(
355 vocabulary='SnappyDistroSeries', required=True,359 vocabulary='SnappyDistroSeries', required=True,
356 title='Series')360 title='Series')
@@ -520,8 +524,12 @@ class SnapAddView(
520 kwargs = {'git_ref': self.context}524 kwargs = {'git_ref': self.context}
521 else:525 else:
522 kwargs = {'branch': self.context}526 kwargs = {'branch': self.context}
523 private = not getUtility(527 # XXX pappacena 2021-03-01: We should consider the pillar's branch
524 ISnapSet).isValidPrivacy(False, data['owner'], **kwargs)528 # sharing policy when setting the information_type.
529 # Once we move the information_type and pillar edition from the
530 # admin view to the create/edit views, we should change this.
531 information_type = getUtility(ISnapSet).getSnapSuggestedPrivacy(
532 data['owner'], **kwargs)
525 if not data.get('auto_build', False):533 if not data.get('auto_build', False):
526 data['auto_build_archive'] = None534 data['auto_build_archive'] = None
527 data['auto_build_pocket'] = None535 data['auto_build_pocket'] = None
@@ -532,7 +540,8 @@ class SnapAddView(
532 auto_build_archive=data['auto_build_archive'],540 auto_build_archive=data['auto_build_archive'],
533 auto_build_pocket=data['auto_build_pocket'],541 auto_build_pocket=data['auto_build_pocket'],
534 auto_build_channels=data['auto_build_channels'],542 auto_build_channels=data['auto_build_channels'],
535 processors=data['processors'], private=private,543 information_type=information_type,
544 processors=data['processors'],
536 build_source_tarball=data['build_source_tarball'],545 build_source_tarball=data['build_source_tarball'],
537 store_upload=data['store_upload'],546 store_upload=data['store_upload'],
538 store_series=data['store_distro_series'].snappy_series,547 store_series=data['store_distro_series'].snappy_series,
@@ -559,6 +568,16 @@ class BaseSnapEditView(LaunchpadEditFormView, SnapAuthorizeMixin):
559568
560 schema = ISnapEditSchema569 schema = ISnapEditSchema
561570
571 def getInformationTypesToShow(self):
572 """Get the information types to display on the edit form.
573
574 We display a customised set of information types: anything allowed
575 by the repository's model, plus the current type.
576 """
577 allowed_types = set(self.context.getAllowedInformationTypes(self.user))
578 allowed_types.add(self.context.information_type)
579 return allowed_types
580
562 @property581 @property
563 def cancel_url(self):582 def cancel_url(self):
564 return canonical_url(self.context)583 return canonical_url(self.context)
@@ -612,25 +631,36 @@ class BaseSnapEditView(LaunchpadEditFormView, SnapAuthorizeMixin):
612631
613 def validate(self, data):632 def validate(self, data):
614 super(BaseSnapEditView, self).validate(data)633 super(BaseSnapEditView, self).validate(data)
615 if data.get('private', self.context.private) is False:634 info_type = data.get('information_type', self.context.information_type)
616 if 'private' in data or 'owner' in data:635 editing_info_type = 'information_type' in data
636 private = info_type in PRIVATE_INFORMATION_TYPES
637 if private is False:
638 # These are the requirements for public snaps.
639 if 'information_type' in data or 'owner' in data:
617 owner = data.get('owner', self.context.owner)640 owner = data.get('owner', self.context.owner)
618 if owner is not None and owner.private:641 if owner is not None and owner.private:
619 self.setFieldError(642 self.setFieldError(
620 'private' if 'private' in data else 'owner',643 'information_type' if editing_info_type else 'owner',
621 'A public snap cannot have a private owner.')644 'A public snap cannot have a private owner.')
622 if 'private' in data or 'branch' in data:645 if 'information_type' in data or 'branch' in data:
623 branch = data.get('branch', self.context.branch)646 branch = data.get('branch', self.context.branch)
624 if branch is not None and branch.private:647 if branch is not None and branch.private:
625 self.setFieldError(648 self.setFieldError(
626 'private' if 'private' in data else 'branch',649 'information_type' if editing_info_type else 'branch',
627 'A public snap cannot have a private branch.')650 'A public snap cannot have a private branch.')
628 if 'private' in data or 'git_ref' in data:651 if 'information_type' in data or 'git_ref' in data:
629 ref = data.get('git_ref', self.context.git_ref)652 ref = data.get('git_ref', self.context.git_ref)
630 if ref is not None and ref.private:653 if ref is not None and ref.private:
631 self.setFieldError(654 self.setFieldError(
632 'private' if 'private' in data else 'git_ref',655 'information_type' if editing_info_type else 'git_ref',
633 'A public snap cannot have a private repository.')656 'A public snap cannot have a private repository.')
657 else:
658 # Requirements for private snaps.
659 project = data.get('project', self.context.project)
660 if project is None:
661 msg = ('Private snap recipes must be associated '
662 'with a project.')
663 self.setFieldError('project', msg)
634664
635 def _needStoreReauth(self, data):665 def _needStoreReauth(self, data):
636 """Does this change require reauthorizing to the store?"""666 """Does this change require reauthorizing to the store?"""
@@ -696,16 +726,40 @@ class SnapAdminView(BaseSnapEditView):
696726
697 page_title = 'Administer'727 page_title = 'Administer'
698728
699 field_names = ['private', 'require_virtualized', 'allow_internet']729 # XXX pappacena 2021-02-19: Once we have the whole privacy work in
730 # place, we should move "project" and "information_type" from +admin
731 # page to +edit, to allow common users to edit this.
732 field_names = [
733 'project', 'information_type', 'require_virtualized', 'allow_internet']
734
735 # See `setUpWidgets` method.
736 custom_widget_information_type = CustomWidgetFactory(
737 LaunchpadRadioWidgetWithDescription,
738 vocabulary=InformationTypeVocabulary(types=[]))
739
740 @property
741 def initial_values(self):
742 """Set initial values for the form."""
743 # XXX pappacena 2021-02-12: Until we back fill information_type
744 # database column, it will be NULL, but snap.information_type
745 # property has a fallback to check "private" property. This should
746 # be removed once we back fill snap.information_type.
747 return {'information_type': self.context.information_type}
748
749 def setUpWidgets(self):
750 super(SnapAdminView, self).setUpWidgets()
751 info_type_widget = self.widgets['information_type']
752 info_type_widget.vocabulary = InformationTypeVocabulary(
753 types=self.getInformationTypesToShow())
700754
701 def validate(self, data):755 def validate(self, data):
702 super(SnapAdminView, self).validate(data)756 super(SnapAdminView, self).validate(data)
703 # BaseSnapEditView.validate checks the rules for 'private' in757 # BaseSnapEditView.validate checks the rules for 'private' in
704 # combination with other attributes.758 # combination with other attributes.
705 if data.get('private', None) is True:759 if data.get('information_type', None) in PRIVATE_INFORMATION_TYPES:
706 if not getFeatureFlag(SNAP_PRIVATE_FEATURE_FLAG):760 if not getFeatureFlag(SNAP_PRIVATE_FEATURE_FLAG):
707 self.setFieldError(761 self.setFieldError(
708 'private',762 'information_type',
709 'You do not have permission to create private snaps.')763 'You do not have permission to create private snaps.')
710764
711765
diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
index 3e48162..5f33900 100644
--- a/lib/lp/snappy/browser/tests/test_snap.py
+++ b/lib/lp/snappy/browser/tests/test_snap.py
@@ -2,7 +2,7 @@
2# NOTE: The first line above must stay first; do not move the copyright2# NOTE: The first line above must stay first; do not move the copyright
3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.3# notice to the top. See http://www.python.org/dev/peps/pep-0263/.
4#4#
5# Copyright 2015-2020 Canonical Ltd. This software is licensed under the5# Copyright 2015-2021 Canonical Ltd. This software is licensed under the
6# GNU Affero General Public License version 3 (see the file LICENSE).6# GNU Affero General Public License version 3 (see the file LICENSE).
77
8"""Test snap package views."""8"""Test snap package views."""
@@ -85,6 +85,7 @@ from lp.testing import (
85 admin_logged_in,85 admin_logged_in,
86 BrowserTestCase,86 BrowserTestCase,
87 login,87 login,
88 login_admin,
88 login_person,89 login_person,
89 person_logged_in,90 person_logged_in,
90 TestCaseWithFactory,91 TestCaseWithFactory,
@@ -400,6 +401,64 @@ class TestSnapAddView(BaseTestSnapView):
400 extract_text(find_tag_by_id(browser.contents, "privacy"))401 extract_text(find_tag_by_id(browser.contents, "privacy"))
401 )402 )
402403
404 def test_create_new_snap_private_team_with_private_branch(self):
405 # Creating snaps from private branch should make the snap follow its
406 # privacy setting.
407 self.useFixture(BranchHostingFixture(blob=b""))
408 login_person(self.person)
409 private_team = self.factory.makeTeam(
410 name='super-private', owner=self.person,
411 visibility=PersonVisibility.PRIVATE)
412 branch = self.factory.makeAnyBranch(
413 owner=self.person, registrant=self.person,
414 information_type=InformationType.PRIVATESECURITY)
415
416 browser = self.getViewBrowser(
417 branch, view_name="+new-snap", user=self.person)
418 browser.getControl(name="field.name").value = "private-snap"
419 browser.getControl("Owner").value = ['super-private']
420 browser.getControl("Create snap package").click()
421
422 content = find_main_content(browser.contents)
423 self.assertEqual("private-snap", extract_text(content.h1))
424 self.assertEqual(
425 'This snap contains Private information',
426 extract_text(find_tag_by_id(browser.contents, "privacy"))
427 )
428 login_admin()
429 snap = getUtility(ISnapSet).getByName(private_team, 'private-snap')
430 self.assertEqual(
431 InformationType.PRIVATESECURITY, snap.information_type)
432
433 def test_create_new_snap_private_team_with_private_git_repo(self):
434 # Creating snaps from private repos should make the snap follow its
435 # privacy setting.
436 self.useFixture(BranchHostingFixture(blob=b""))
437 login_person(self.person)
438 private_team = self.factory.makeTeam(
439 name='super-private', owner=self.person,
440 visibility=PersonVisibility.PRIVATE)
441 [git_ref] = self.factory.makeGitRefs(
442 owner=self.person, registrant=self.person,
443 information_type=InformationType.PRIVATESECURITY)
444
445 browser = self.getViewBrowser(
446 git_ref, view_name="+new-snap", user=self.person)
447 browser.getControl(name="field.name").value = "private-snap"
448 browser.getControl("Owner").value = ['super-private']
449 browser.getControl("Create snap package").click()
450
451 content = find_main_content(browser.contents)
452 self.assertEqual("private-snap", extract_text(content.h1))
453 self.assertEqual(
454 'This snap contains Private information',
455 extract_text(find_tag_by_id(browser.contents, "privacy"))
456 )
457 login_admin()
458 snap = getUtility(ISnapSet).getByName(private_team, 'private-snap')
459 self.assertEqual(
460 InformationType.PRIVATESECURITY, snap.information_type)
461
403 def test_create_new_snap_build_source_tarball(self):462 def test_create_new_snap_build_source_tarball(self):
404 # We can create a new snap and ask for it to build a source tarball.463 # We can create a new snap and ask for it to build a source tarball.
405 self.useFixture(BranchHostingFixture(blob=b""))464 self.useFixture(BranchHostingFixture(blob=b""))
@@ -655,22 +714,43 @@ class TestSnapAdminView(BaseTestSnapView):
655 member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])714 member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
656 login_person(self.person)715 login_person(self.person)
657 snap = self.factory.makeSnap(registrant=self.person)716 snap = self.factory.makeSnap(registrant=self.person)
717 project = self.factory.makeProduct(name='my-project')
658 self.assertTrue(snap.require_virtualized)718 self.assertTrue(snap.require_virtualized)
719 self.assertIsNone(snap.project)
659 self.assertFalse(snap.private)720 self.assertFalse(snap.private)
660 self.assertTrue(snap.allow_internet)721 self.assertTrue(snap.allow_internet)
661722
723 private = InformationType.PRIVATESECURITY.name
662 browser = self.getViewBrowser(snap, user=commercial_admin)724 browser = self.getViewBrowser(snap, user=commercial_admin)
663 browser.getLink("Administer snap package").click()725 browser.getLink("Administer snap package").click()
726 browser.getControl(name='field.project').value = "my-project"
664 browser.getControl("Require virtualized builders").selected = False727 browser.getControl("Require virtualized builders").selected = False
665 browser.getControl("Private").selected = True728 browser.getControl(name="field.information_type").value = private
666 browser.getControl("Allow external network access").selected = False729 browser.getControl("Allow external network access").selected = False
667 browser.getControl("Update snap package").click()730 browser.getControl("Update snap package").click()
668731
669 login_person(self.person)732 login_person(self.person)
733 self.assertEqual(project, snap.project)
670 self.assertFalse(snap.require_virtualized)734 self.assertFalse(snap.require_virtualized)
671 self.assertTrue(snap.private)735 self.assertTrue(snap.private)
672 self.assertFalse(snap.allow_internet)736 self.assertFalse(snap.allow_internet)
673737
738 def test_admin_snap_private_without_project(self):
739 # Cannot make snap private if it doesn't have a project associated.
740 login_person(self.person)
741 snap = self.factory.makeSnap(registrant=self.person)
742 commercial_admin = self.factory.makePerson(
743 member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
744 private = InformationType.PRIVATESECURITY.name
745 browser = self.getViewBrowser(snap, user=commercial_admin)
746 browser.getLink("Administer snap package").click()
747 browser.getControl(name='field.project').value = ''
748 browser.getControl(name="field.information_type").value = private
749 browser.getControl("Update snap package").click()
750 self.assertEqual(
751 'Private snap recipes must be associated with a project.',
752 extract_text(find_tags_by_class(browser.contents, "message")[1]))
753
674 def test_admin_snap_privacy_mismatch(self):754 def test_admin_snap_privacy_mismatch(self):
675 # Cannot make snap public if it still contains private information.755 # Cannot make snap public if it still contains private information.
676 login_person(self.person)756 login_person(self.person)
@@ -682,9 +762,10 @@ class TestSnapAdminView(BaseTestSnapView):
682 # can reach this snap because it's owned by a private team.762 # can reach this snap because it's owned by a private team.
683 commercial_admin = self.factory.makePerson(763 commercial_admin = self.factory.makePerson(
684 member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])764 member_of=[getUtility(ILaunchpadCelebrities).commercial_admin])
765 public = InformationType.PUBLIC.name
685 browser = self.getViewBrowser(snap, user=commercial_admin)766 browser = self.getViewBrowser(snap, user=commercial_admin)
686 browser.getLink("Administer snap package").click()767 browser.getLink("Administer snap package").click()
687 browser.getControl("Private").selected = False768 browser.getControl(name="field.information_type").value = public
688 browser.getControl("Update snap package").click()769 browser.getControl("Update snap package").click()
689 self.assertEqual(770 self.assertEqual(
690 'A public snap cannot have a private owner.',771 'A public snap cannot have a private owner.',
diff --git a/lib/lp/snappy/interfaces/snap.py b/lib/lp/snappy/interfaces/snap.py
index 9b19b1b..8ac5838 100644
--- a/lib/lp/snappy/interfaces/snap.py
+++ b/lib/lp/snappy/interfaces/snap.py
@@ -67,6 +67,7 @@ from lazr.restful.fields import (
67 Reference,67 Reference,
68 ReferenceChoice,68 ReferenceChoice,
69 )69 )
70from lazr.restful.interface import copy_field
70from six.moves import http_client71from six.moves import http_client
71from zope.interface import (72from zope.interface import (
72 Attribute,73 Attribute,
@@ -570,6 +571,12 @@ class ISnapView(Interface):
570 # Really ISnapBuild, patched in lp.snappy.interfaces.webservice.571 # Really ISnapBuild, patched in lp.snappy.interfaces.webservice.
571 value_type=Reference(schema=Interface), readonly=True)))572 value_type=Reference(schema=Interface), readonly=True)))
572573
574 def getAllowedInformationTypes(user):
575 """Get a list of acceptable `InformationType`s for this snap recipe.
576
577 If the user is a Launchpad admin, any type is acceptable.
578 """
579
573580
574class ISnapEdit(IWebhookTarget):581class ISnapEdit(IWebhookTarget):
575 """`ISnap` methods that require launchpad.Edit permission."""582 """`ISnap` methods that require launchpad.Edit permission."""
@@ -672,7 +679,7 @@ class ISnapEditableAttributes(IHasOwner):
672 description=_("The owner of this snap package.")))679 description=_("The owner of this snap package.")))
673680
674 project = ReferenceChoice(681 project = ReferenceChoice(
675 title=_('The project that this Snap is associated with.'),682 title=_('The project that this Snap is associated with'),
676 schema=IProduct, vocabulary='Product',683 schema=IProduct, vocabulary='Product',
677 required=False, readonly=False)684 required=False, readonly=False)
678685
@@ -845,7 +852,7 @@ class ISnapAdminAttributes(Interface):
845852
846 information_type = exported(Choice(853 information_type = exported(Choice(
847 title=_("Information type"), vocabulary=InformationType,854 title=_("Information type"), vocabulary=InformationType,
848 required=True, readonly=True, default=InformationType.PUBLIC,855 required=True, readonly=False, default=InformationType.PUBLIC,
849 description=_(856 description=_(
850 "The type of information contained in this Snap recipe.")))857 "The type of information contained in this Snap recipe.")))
851858
@@ -884,6 +891,7 @@ class ISnapSet(Interface):
884891
885 @call_with(registrant=REQUEST_USER)892 @call_with(registrant=REQUEST_USER)
886 @operation_parameters(893 @operation_parameters(
894 information_type=copy_field(ISnap["information_type"], required=False),
887 processors=List(895 processors=List(
888 value_type=Reference(schema=IProcessor), required=False))896 value_type=Reference(schema=IProcessor), required=False))
889 @export_factory_operation(897 @export_factory_operation(
@@ -891,15 +899,16 @@ class ISnapSet(Interface):
891 "owner", "distro_series", "name", "description", "branch",899 "owner", "distro_series", "name", "description", "branch",
892 "git_repository", "git_repository_url", "git_path", "git_ref",900 "git_repository", "git_repository_url", "git_path", "git_ref",
893 "auto_build", "auto_build_archive", "auto_build_pocket",901 "auto_build", "auto_build_archive", "auto_build_pocket",
894 "private", "store_upload", "store_series", "store_name",902 "store_upload", "store_series", "store_name", "store_channels",
895 "store_channels"])903 "project"])
896 @operation_for_version("devel")904 @operation_for_version("devel")
897 def new(registrant, owner, distro_series, name, description=None,905 def new(registrant, owner, distro_series, name, description=None,
898 branch=None, git_repository=None, git_repository_url=None,906 branch=None, git_repository=None, git_repository_url=None,
899 git_path=None, git_ref=None, auto_build=False,907 git_path=None, git_ref=None, auto_build=False,
900 auto_build_archive=None, auto_build_pocket=None,908 auto_build_archive=None, auto_build_pocket=None,
901 require_virtualized=True, processors=None, date_created=None,909 require_virtualized=True, processors=None, date_created=None,
902 private=False, store_upload=False, store_series=None,910 information_type=InformationType.PUBLIC, store_upload=False,
911 store_series=None,
903 store_name=None, store_secrets=None, store_channels=None,912 store_name=None, store_secrets=None, store_channels=None,
904 project=None):913 project=None):
905 """Create an `ISnap`."""914 """Create an `ISnap`."""
@@ -907,8 +916,8 @@ class ISnapSet(Interface):
907 def exists(owner, name):916 def exists(owner, name):
908 """Check to see if a matching snap exists."""917 """Check to see if a matching snap exists."""
909918
910 def isValidPrivacy(private, owner, branch=None, git_ref=None):919 def getSnapSuggestedPrivacy(owner, branch=None, git_ref=None):
911 """Whether or not the privacy context is valid."""920 """Which privacy a Snap should have based on its creation params."""
912921
913 def isValidInformationType(922 def isValidInformationType(
914 information_type, owner, branch=None, git_ref=None):923 information_type, owner, branch=None, git_ref=None):
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index 8ed9215..2c298fb 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -29,6 +29,7 @@ from storm.expr import (
29 Not,29 Not,
30 Or,30 Or,
31 Select,31 Select,
32 SQL,
32 )33 )
33from storm.locals import (34from storm.locals import (
34 Bool,35 Bool,
@@ -58,11 +59,14 @@ from lp.app.browser.tales import (
58 DateTimeFormatterAPI,59 DateTimeFormatterAPI,
59 )60 )
60from lp.app.enums import (61from lp.app.enums import (
62 FREE_INFORMATION_TYPES,
61 InformationType,63 InformationType,
62 PRIVATE_INFORMATION_TYPES,64 PRIVATE_INFORMATION_TYPES,
65 PUBLIC_INFORMATION_TYPES,
63 )66 )
64from lp.app.errors import IncompatibleArguments67from lp.app.errors import IncompatibleArguments
65from lp.app.interfaces.security import IAuthorization68from lp.app.interfaces.security import IAuthorization
69from lp.app.interfaces.services import IService
66from lp.buildmaster.enums import BuildStatus70from lp.buildmaster.enums import BuildStatus
67from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet71from lp.buildmaster.interfaces.buildqueue import IBuildQueueSet
68from lp.buildmaster.model.builder import Builder72from lp.buildmaster.model.builder import Builder
@@ -95,6 +99,10 @@ from lp.code.interfaces.gitrepository import (
95 )99 )
96from lp.code.model.branch import Branch100from lp.code.model.branch import Branch
97from lp.code.model.branchcollection import GenericBranchCollection101from lp.code.model.branchcollection import GenericBranchCollection
102from lp.code.model.branchnamespace import (
103 BRANCH_POLICY_ALLOWED_TYPES,
104 BRANCH_POLICY_REQUIRED_GRANTS,
105 )
98from lp.code.model.gitcollection import GenericGitCollection106from lp.code.model.gitcollection import GenericGitCollection
99from lp.code.model.gitrepository import GitRepository107from lp.code.model.gitrepository import GitRepository
100from lp.registry.errors import PrivatePersonLinkageError108from lp.registry.errors import PrivatePersonLinkageError
@@ -202,6 +210,17 @@ def snap_modified(snap, event):
202 removeSecurityProxy(snap).date_last_modified = UTC_NOW210 removeSecurityProxy(snap).date_last_modified = UTC_NOW
203211
204212
213def user_has_special_snap_access(user):
214 """Admins have special access.
215
216 :param user: An `IPerson` or None.
217 """
218 if user is None:
219 return False
220 roles = IPersonRoles(user)
221 return roles.in_admin
222
223
205@implementer(ISnapBuildRequest)224@implementer(ISnapBuildRequest)
206class SnapBuildRequest:225class SnapBuildRequest:
207 """See `ISnapBuildRequest`.226 """See `ISnapBuildRequest`.
@@ -351,13 +370,7 @@ class Snap(Storm, WebhookTargetMixin):
351370
352 require_virtualized = Bool(name='require_virtualized')371 require_virtualized = Bool(name='require_virtualized')
353372
354 def _validate_private(self, attr, value):373 _private = Bool(name='private')
355 if not getUtility(ISnapSet).isValidPrivacy(
356 value, self.owner, self.branch, self.git_ref):
357 raise SnapPrivacyMismatch
358 return value
359
360 private = Bool(name='private', validator=_validate_private)
361374
362 def _valid_information_type(self, attr, value):375 def _valid_information_type(self, attr, value):
363 if not getUtility(ISnapSet).isValidInformationType(376 if not getUtility(ISnapSet).isValidInformationType(
@@ -389,20 +402,18 @@ class Snap(Storm, WebhookTargetMixin):
389 description=None, branch=None, git_ref=None, auto_build=False,402 description=None, branch=None, git_ref=None, auto_build=False,
390 auto_build_archive=None, auto_build_pocket=None,403 auto_build_archive=None, auto_build_pocket=None,
391 auto_build_channels=None, require_virtualized=True,404 auto_build_channels=None, require_virtualized=True,
392 date_created=DEFAULT, private=False, allow_internet=True,405 date_created=DEFAULT, information_type=InformationType.PUBLIC,
393 build_source_tarball=False, store_upload=False,406 allow_internet=True, build_source_tarball=False,
394 store_series=None, store_name=None, store_secrets=None,407 store_upload=False, store_series=None, store_name=None,
395 store_channels=None, project=None):408 store_secrets=None, store_channels=None, project=None):
396 """Construct a `Snap`."""409 """Construct a `Snap`."""
397 super(Snap, self).__init__()410 super(Snap, self).__init__()
398411
399 # Set the private flag first so that other validators can perform412 # Set the information type first so that other validators can perform
400 # suitable privacy checks, but pillar should also be set, since it's413 # suitable privacy checks, but pillar should also be set, since it's
401 # mandatory for private snaps.414 # mandatory for private snaps.
402 self.project = project415 self.project = project
403 self.private = private416 self.information_type = information_type
404 self.information_type = (InformationType.PROPRIETARY if private else
405 InformationType.PUBLIC)
406417
407 self.registrant = registrant418 self.registrant = registrant
408 self.owner = owner419 self.owner = owner
@@ -432,7 +443,7 @@ class Snap(Storm, WebhookTargetMixin):
432 @property443 @property
433 def information_type(self):444 def information_type(self):
434 if self._information_type is None:445 if self._information_type is None:
435 return (InformationType.PROPRIETARY if self.private446 return (InformationType.PROPRIETARY if self._private
436 else InformationType.PUBLIC)447 else InformationType.PUBLIC)
437 return self._information_type448 return self._information_type
438449
@@ -441,6 +452,10 @@ class Snap(Storm, WebhookTargetMixin):
441 self._information_type = information_type452 self._information_type = information_type
442453
443 @property454 @property
455 def private(self):
456 return self.information_type not in PUBLIC_INFORMATION_TYPES
457
458 @property
444 def valid_webhook_event_types(self):459 def valid_webhook_event_types(self):
445 return ["snap:build:0.1"]460 return ["snap:build:0.1"]
446461
@@ -618,6 +633,24 @@ class Snap(Storm, WebhookTargetMixin):
618 def store_channels(self, value):633 def store_channels(self, value):
619 self._store_channels = value or None634 self._store_channels = value or None
620635
636 def getAllowedInformationTypes(self, user):
637 """See `ISnap`."""
638 if user_has_special_snap_access(user):
639 # Admins can set any type.
640 return set(PUBLIC_INFORMATION_TYPES + PRIVATE_INFORMATION_TYPES)
641 if self.pillar is None:
642 return set(FREE_INFORMATION_TYPES)
643 required_grant = BRANCH_POLICY_REQUIRED_GRANTS[
644 self.project.branch_sharing_policy]
645 if (required_grant is not None
646 and not getUtility(IService, 'sharing').checkPillarAccess(
647 [self.project], required_grant, self.owner)
648 and (user is None
649 or not getUtility(IService, 'sharing').checkPillarAccess(
650 [self.project], required_grant, user))):
651 return []
652 return BRANCH_POLICY_ALLOWED_TYPES[self.project.branch_sharing_policy]
653
621 @staticmethod654 @staticmethod
622 def extractSSOCaveats(macaroon):655 def extractSSOCaveats(macaroon):
623 locations = [656 locations = [
@@ -1143,10 +1176,11 @@ class SnapSet:
1143 git_path=None, git_ref=None, auto_build=False,1176 git_path=None, git_ref=None, auto_build=False,
1144 auto_build_archive=None, auto_build_pocket=None,1177 auto_build_archive=None, auto_build_pocket=None,
1145 auto_build_channels=None, require_virtualized=True,1178 auto_build_channels=None, require_virtualized=True,
1146 processors=None, date_created=DEFAULT, private=False,1179 processors=None, date_created=DEFAULT,
1147 allow_internet=True, build_source_tarball=False,1180 information_type=InformationType.PUBLIC, allow_internet=True,
1148 store_upload=False, store_series=None, store_name=None,1181 build_source_tarball=False, store_upload=False,
1149 store_secrets=None, store_channels=None, project=None):1182 store_series=None, store_name=None, store_secrets=None,
1183 store_channels=None, project=None):
1150 """See `ISnapSet`."""1184 """See `ISnapSet`."""
1151 if not registrant.inTeam(owner):1185 if not registrant.inTeam(owner):
1152 if owner.is_team:1186 if owner.is_team:
@@ -1183,7 +1217,8 @@ class SnapSet:
1183 # IntegrityError due to exceptions being raised during object1217 # IntegrityError due to exceptions being raised during object
1184 # creation and to ensure that everything relevant is in the Storm1218 # creation and to ensure that everything relevant is in the Storm
1185 # cache.1219 # cache.
1186 if not self.isValidPrivacy(private, owner, branch, git_ref):1220 if not self.isValidInformationType(
1221 information_type, owner, branch, git_ref):
1187 raise SnapPrivacyMismatch1222 raise SnapPrivacyMismatch
11881223
1189 store = IMasterStore(Snap)1224 store = IMasterStore(Snap)
@@ -1194,7 +1229,7 @@ class SnapSet:
1194 auto_build_pocket=auto_build_pocket,1229 auto_build_pocket=auto_build_pocket,
1195 auto_build_channels=auto_build_channels,1230 auto_build_channels=auto_build_channels,
1196 require_virtualized=require_virtualized, date_created=date_created,1231 require_virtualized=require_virtualized, date_created=date_created,
1197 private=private, allow_internet=allow_internet,1232 information_type=information_type, allow_internet=allow_internet,
1198 build_source_tarball=build_source_tarball,1233 build_source_tarball=build_source_tarball,
1199 store_upload=store_upload, store_series=store_series,1234 store_upload=store_upload, store_series=store_series,
1200 store_name=store_name, store_secrets=store_secrets,1235 store_name=store_name, store_secrets=store_secrets,
@@ -1208,9 +1243,24 @@ class SnapSet:
12081243
1209 return snap1244 return snap
12101245
1211 def isValidPrivacy(self, private, owner, branch=None, git_ref=None):1246 def getSnapSuggestedPrivacy(self, owner, branch=None, git_ref=None):
1212 """See `ISnapSet`."""1247 """See `ISnapSet`."""
1213 # Private snaps may contain anything ...1248 # Public snaps with private sources are not allowed.
1249 source = branch or git_ref
1250 if source is not None and source.private:
1251 return source.information_type
1252
1253 # Public snaps owned by private teams are not allowed.
1254 if owner is not None and owner.private:
1255 return InformationType.PROPRIETARY
1256
1257 # XXX pappacena 2021-03-02: We need to consider the pillar's branch
1258 # sharing policy here instead of suggesting PUBLIC.
1259 return InformationType.PUBLIC
1260
1261 def isValidInformationType(self, information_type, owner, branch=None,
1262 git_ref=None):
1263 private = information_type not in PUBLIC_INFORMATION_TYPES
1214 if private:1264 if private:
1215 # If appropriately enabled via feature flag.1265 # If appropriately enabled via feature flag.
1216 if not getFeatureFlag(SNAP_PRIVATE_FEATURE_FLAG):1266 if not getFeatureFlag(SNAP_PRIVATE_FEATURE_FLAG):
@@ -1228,11 +1278,6 @@ class SnapSet:
12281278
1229 return True1279 return True
12301280
1231 def isValidInformationType(self, information_type, owner, branch=None,
1232 git_ref=None):
1233 private = information_type in PRIVATE_INFORMATION_TYPES
1234 return self.isValidPrivacy(private, owner, branch, git_ref)
1235
1236 def _getByName(self, owner, name):1281 def _getByName(self, owner, name):
1237 return IStore(Snap).find(1282 return IStore(Snap).find(
1238 Snap, Snap.owner == owner, Snap.name == name).one()1283 Snap, Snap.owner == owner, Snap.name == name).one()
@@ -1333,15 +1378,22 @@ class SnapSet:
1333 # XXX cjwatson 2016-11-25: This is in principle a poor query, but we1378 # XXX cjwatson 2016-11-25: This is in principle a poor query, but we
1334 # don't yet have the access grant infrastructure to do better, and1379 # don't yet have the access grant infrastructure to do better, and
1335 # in any case the numbers involved should be very small.1380 # in any case the numbers involved should be very small.
1381 # XXX pappacena 2021-02-12: Once we do the migration to back fill
1382 # information_type, we should be able to change this.
1383 private_snap = SQL(
1384 "CASE information_type"
1385 " WHEN NULL THEN private"
1386 " ELSE information_type NOT IN ?"
1387 "END", params=[tuple(i.value for i in PUBLIC_INFORMATION_TYPES)])
1336 if visible_by_user is None:1388 if visible_by_user is None:
1337 return Snap.private == False1389 return private_snap == False
1338 else:1390 else:
1339 roles = IPersonRoles(visible_by_user)1391 roles = IPersonRoles(visible_by_user)
1340 if roles.in_admin or roles.in_commercial_admin:1392 if roles.in_admin or roles.in_commercial_admin:
1341 return True1393 return True
1342 else:1394 else:
1343 return Or(1395 return Or(
1344 Snap.private == False,1396 private_snap == False,
1345 Snap.owner_id.is_in(Select(1397 Snap.owner_id.is_in(Select(
1346 TeamParticipation.teamID,1398 TeamParticipation.teamID,
1347 TeamParticipation.person == visible_by_user)))1399 TeamParticipation.person == visible_by_user)))
diff --git a/lib/lp/snappy/tests/test_snap.py b/lib/lp/snappy/tests/test_snap.py
index cdf8813..755851a 100644
--- a/lib/lp/snappy/tests/test_snap.py
+++ b/lib/lp/snappy/tests/test_snap.py
@@ -166,7 +166,8 @@ class TestSnapFeatureFlag(TestCaseWithFactory):
166 self.assertRaises(166 self.assertRaises(
167 SnapPrivateFeatureDisabled, getUtility(ISnapSet).new,167 SnapPrivateFeatureDisabled, getUtility(ISnapSet).new,
168 person, person, None, None,168 person, person, None, None,
169 branch=self.factory.makeAnyBranch(), private=True)169 branch=self.factory.makeAnyBranch(),
170 information_type=InformationType.PROPRIETARY)
170171
171172
172class TestSnap(TestCaseWithFactory):173class TestSnap(TestCaseWithFactory):
@@ -1419,13 +1420,15 @@ class TestSnapSet(TestCaseWithFactory):
14191420
1420 def test_private_snap_information_type_compatibility(self):1421 def test_private_snap_information_type_compatibility(self):
1421 login_admin()1422 login_admin()
1423 private = InformationType.PROPRIETARY
1424 public = InformationType.PUBLIC
1422 private_snap = getUtility(ISnapSet).new(1425 private_snap = getUtility(ISnapSet).new(
1423 private=True, **self.makeSnapComponents())1426 information_type=private, **self.makeSnapComponents())
1424 self.assertEqual(1427 self.assertEqual(
1425 InformationType.PROPRIETARY, private_snap.information_type)1428 InformationType.PROPRIETARY, private_snap.information_type)
14261429
1427 public_snap = getUtility(ISnapSet).new(1430 public_snap = getUtility(ISnapSet).new(
1428 private=False, **self.makeSnapComponents())1431 information_type=public, **self.makeSnapComponents())
1429 self.assertEqual(1432 self.assertEqual(
1430 InformationType.PUBLIC, public_snap.information_type)1433 InformationType.PUBLIC, public_snap.information_type)
14311434
@@ -1433,7 +1436,7 @@ class TestSnapSet(TestCaseWithFactory):
1433 # Creating private snaps for public sources is allowed.1436 # Creating private snaps for public sources is allowed.
1434 [ref] = self.factory.makeGitRefs()1437 [ref] = self.factory.makeGitRefs()
1435 components = self.makeSnapComponents(git_ref=ref)1438 components = self.makeSnapComponents(git_ref=ref)
1436 components['private'] = True1439 components['information_type'] = InformationType.PROPRIETARY
1437 components['project'] = self.factory.makeProduct()1440 components['project'] = self.factory.makeProduct()
1438 snap = getUtility(ISnapSet).new(**components)1441 snap = getUtility(ISnapSet).new(**components)
1439 with person_logged_in(components['owner']):1442 with person_logged_in(components['owner']):
@@ -2519,9 +2522,11 @@ class TestSnapWebservice(TestCaseWithFactory):
2519 if auto_build_pocket is not None:2522 if auto_build_pocket is not None:
2520 kwargs["auto_build_pocket"] = auto_build_pocket.title2523 kwargs["auto_build_pocket"] = auto_build_pocket.title
2521 logout()2524 logout()
2525 information_type = (InformationType.PROPRIETARY if private else
2526 InformationType.PUBLIC)
2522 response = webservice.named_post(2527 response = webservice.named_post(
2523 "/+snaps", "new", owner=owner_url, distro_series=distroseries_url,2528 "/+snaps", "new", owner=owner_url, distro_series=distroseries_url,
2524 name="mir", private=private, **kwargs)2529 name="mir", information_type=information_type.title, **kwargs)
2525 self.assertEqual(201, response.status)2530 self.assertEqual(201, response.status)
2526 return webservice.get(response.getHeader("Location")).jsonBody()2531 return webservice.get(response.getHeader("Location")).jsonBody()
25272532
@@ -2666,7 +2671,8 @@ class TestSnapWebservice(TestCaseWithFactory):
2666 admin, permission=OAuthPermission.WRITE_PRIVATE)2671 admin, permission=OAuthPermission.WRITE_PRIVATE)
2667 admin_webservice.default_api_version = "devel"2672 admin_webservice.default_api_version = "devel"
2668 response = admin_webservice.patch(2673 response = admin_webservice.patch(
2669 snap_url, "application/json", json.dumps({"private": False}))2674 snap_url, "application/json",
2675 json.dumps({"information_type": 'Public'}))
2670 self.assertEqual(400, response.status)2676 self.assertEqual(400, response.status)
2671 self.assertEqual(2677 self.assertEqual(
2672 b"Snap recipe contains private information and cannot be public.",2678 b"Snap recipe contains private information and cannot be public.",
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 9712bea..bc89da6 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4748,10 +4748,10 @@ class BareLaunchpadObjectFactory(ObjectFactory):
4748 auto_build_archive=None, auto_build_pocket=None,4748 auto_build_archive=None, auto_build_pocket=None,
4749 auto_build_channels=None, is_stale=None,4749 auto_build_channels=None, is_stale=None,
4750 require_virtualized=True, processors=None,4750 require_virtualized=True, processors=None,
4751 date_created=DEFAULT, private=False, allow_internet=True,4751 date_created=DEFAULT, private=False, information_type=None,
4752 build_source_tarball=False, store_upload=False,4752 allow_internet=True, build_source_tarball=False,
4753 store_series=None, store_name=None, store_secrets=None,4753 store_upload=False, store_series=None, store_name=None,
4754 store_channels=None, project=_DEFAULT):4754 store_secrets=None, store_channels=None, project=_DEFAULT):
4755 """Make a new Snap."""4755 """Make a new Snap."""
4756 if registrant is None:4756 if registrant is None:
4757 registrant = self.makePerson()4757 registrant = self.makePerson()
@@ -4775,13 +4775,18 @@ class BareLaunchpadObjectFactory(ObjectFactory):
4775 project = self.makeProduct()4775 project = self.makeProduct()
4776 if project is _DEFAULT:4776 if project is _DEFAULT:
4777 project = None4777 project = None
4778 assert information_type is None or private is None
4779 if private is not None:
4780 information_type = (InformationType.PUBLIC if not private
4781 else InformationType.PROPRIETARY)
4778 snap = getUtility(ISnapSet).new(4782 snap = getUtility(ISnapSet).new(
4779 registrant, owner, distroseries, name,4783 registrant, owner, distroseries, name,
4780 require_virtualized=require_virtualized, processors=processors,4784 require_virtualized=require_virtualized, processors=processors,
4781 date_created=date_created, branch=branch, git_ref=git_ref,4785 date_created=date_created, branch=branch, git_ref=git_ref,
4782 auto_build=auto_build, auto_build_archive=auto_build_archive,4786 auto_build=auto_build, auto_build_archive=auto_build_archive,
4783 auto_build_pocket=auto_build_pocket,4787 auto_build_pocket=auto_build_pocket,
4784 auto_build_channels=auto_build_channels, private=private,4788 auto_build_channels=auto_build_channels,
4789 information_type=information_type,
4785 allow_internet=allow_internet,4790 allow_internet=allow_internet,
4786 build_source_tarball=build_source_tarball,4791 build_source_tarball=build_source_tarball,
4787 store_upload=store_upload, store_series=store_series,4792 store_upload=store_upload, store_series=store_series,