← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:ocirecipe-private-flag into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:ocirecipe-private-flag into launchpad:master with ~pappacena/launchpad:ocirecipebuild-private-repos as a prerequisite.

Commit message:
Adding OCIRecipe.private model attribute mapping

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/397176

Corresponding db patch (that should be landed before merging this MP): https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/397167
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:ocirecipe-private-flag into launchpad:master.
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index 633831f..425d5ae 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -20,9 +20,12 @@ __all__ = [
     'OCIRecipeBuildAlreadyPending',
     'OCIRecipeFeatureDisabled',
     'OCIRecipeNotOwner',
+    'OCIRecipePrivacyMismatch',
+    'OCIRecipePrivateFeatureDisabled',
     'OCI_RECIPE_ALLOW_CREATE',
     'OCI_RECIPE_BUILD_DISTRIBUTION',
-    'OCI_RECIPE_WEBHOOKS_FEATURE_FLAG',
+    'OCI_RECIPE_PRIVATE_FEATURE_FLAG',
+    'OCI_RECIPE_WEBHOOKS_FEATURE_FLAG'
     ]
 
 from lazr.lifecycle.snapshot import doNotSnapshot
@@ -141,6 +144,25 @@ class CannotModifyOCIRecipeProcessor(Exception):
             self._fmt % {'processor': processor.name})
 
 
+@error_status(http_client.BAD_REQUEST)
+class OCIRecipePrivacyMismatch(Exception):
+    """OCI recipe privacy does not match its content."""
+
+    def __init__(self, message=None):
+        super(OCIRecipePrivacyMismatch, self).__init__(
+            message or
+            "OCI recipe contains private information and cannot be public.")
+
+
+@error_status(http_client.UNAUTHORIZED)
+class OCIRecipePrivateFeatureDisabled(Unauthorized):
+    """Only certain users can create private OCI recipe objects."""
+
+    def __init__(self):
+        super(OCIRecipePrivateFeatureDisabled, self).__init__(
+            "You do not have permission to create private OCI recipes.")
+
+
 @exported_as_webservice_entry(
     publish_web_link=True, as_of="devel",
     singular_name="oci_recipe_build_request")
@@ -447,6 +469,10 @@ class IOCIRecipeAdminAttributes(Interface):
     These attributes need launchpad.View to see, and launchpad.Admin to change.
     """
 
+    private = exported(Bool(
+        title=_("Private"), required=False, readonly=False,
+        description=_("Whether or not this OCI recipe is private.")))
+
     require_virtualized = Bool(
         title=_("Require virtualized builders"), required=True, readonly=False,
         description=_("Only build this OCI recipe on virtual builders."))
@@ -479,7 +505,7 @@ class IOCIRecipeSet(Interface):
     def new(name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
             build_daily=False, processors=None, date_created=DEFAULT,
-            allow_internet=True, build_args=None):
+            allow_internet=True, build_args=None, private=False):
         """Create an IOCIRecipe."""
 
     def exists(owner, oci_project, name):
@@ -511,3 +537,6 @@ class IOCIRecipeSet(Interface):
         After this, any OCI recipes that previously used this repository
         will have no source and so cannot dispatch new builds.
         """
+
+    def isValidPrivacy(private, owner, git_ref=None):
+        """Whether or not the privacy context is valid."""
diff --git a/lib/lp/oci/model/ocirecipe.py b/lib/lp/oci/model/ocirecipe.py
index fd14762..1c726d2 100644
--- a/lib/lp/oci/model/ocirecipe.py
+++ b/lib/lp/oci/model/ocirecipe.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """A recipe for building Open Container Initiative images."""
@@ -67,9 +67,12 @@ from lp.oci.interfaces.ocirecipe import (
     NoSuchOCIRecipe,
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_BUILD_DISTRIBUTION,
+    OCI_RECIPE_PRIVATE_FEATURE_FLAG,
     OCIRecipeBuildAlreadyPending,
     OCIRecipeFeatureDisabled,
     OCIRecipeNotOwner,
+    OCIRecipePrivacyMismatch,
+    OCIRecipePrivateFeatureDisabled,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
 from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
@@ -79,8 +82,12 @@ from lp.oci.interfaces.ociregistrycredentials import (
 from lp.oci.model.ocipushrule import OCIPushRule
 from lp.oci.model.ocirecipebuild import OCIRecipeBuild
 from lp.oci.model.ocirecipejob import OCIRecipeJob
+from lp.registry.errors import PrivatePersonLinkageError
 from lp.registry.interfaces.distribution import IDistributionSet
-from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.person import (
+    IPersonSet,
+    validate_public_person,
+    )
 from lp.registry.interfaces.role import IPersonRoles
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
@@ -135,7 +142,18 @@ class OCIRecipe(Storm, WebhookTargetMixin):
     registrant_id = Int(name='registrant', allow_none=False)
     registrant = Reference(registrant_id, "Person.id")
 
-    owner_id = Int(name='owner', allow_none=False)
+    def _validate_owner(self, attr, value):
+        if not self.private:
+            try:
+                validate_public_person(self, attr, value)
+            except PrivatePersonLinkageError:
+                raise OCIRecipePrivacyMismatch(
+                    "A public OCI recipe cannot have a private owner.")
+        return value
+
+    owner_id = Int(
+        name='owner', allow_none=False,
+        validator=_validate_owner)
     owner = Reference(owner_id, 'Person.id')
 
     oci_project_id = Int(name='oci_project', allow_none=False)
@@ -148,13 +166,31 @@ class OCIRecipe(Storm, WebhookTargetMixin):
     # oci_project.setOfficialRecipe method.
     _official = Bool(name="official", default=False)
 
-    git_repository_id = Int(name="git_repository", allow_none=True)
+    def _validate_git_repository(self, attr, value):
+        if not self.private and value is not None:
+            if IStore(GitRepository).get(GitRepository, value).private:
+                raise OCIRecipePrivacyMismatch(
+                    "A public OCI recipe cannot have a private repository.")
+        return value
+
+    git_repository_id = Int(
+        name="git_repository", allow_none=True,
+        validator=_validate_git_repository)
+
     git_repository = Reference(git_repository_id, "GitRepository.id")
     git_path = Unicode(name="git_path", allow_none=True)
     build_file = Unicode(name="build_file", allow_none=False)
     build_path = Unicode(name="build_path", allow_none=False)
     _build_args = JSON(name="build_args", allow_none=True)
 
+    def _validate_private(self, attr, value):
+        if not getUtility(IOCIRecipeSet).isValidPrivacy(
+                value, self.owner, self.git_ref):
+            raise OCIRecipePrivacyMismatch
+        return value
+
+    private = Bool(name='private', validator=_validate_private)
+
     require_virtualized = Bool(name="require_virtualized", default=True,
                                allow_none=False)
 
@@ -165,11 +201,15 @@ class OCIRecipe(Storm, WebhookTargetMixin):
     def __init__(self, name, registrant, owner, oci_project, git_ref,
                  description=None, official=False, require_virtualized=True,
                  build_file=None, build_daily=False, date_created=DEFAULT,
-                 allow_internet=True, build_args=None, build_path=None):
+                 allow_internet=True, build_args=None, build_path=None,
+                 private=False):
         if not getFeatureFlag(OCI_RECIPE_ALLOW_CREATE):
             raise OCIRecipeFeatureDisabled()
         super(OCIRecipe, self).__init__()
         self.name = name
+        # Must set "private" before owner and git_ref because of validation
+        # callbacks.
+        self.private = private
         self.registrant = registrant
         self.owner = owner
         self.oci_project = oci_project
@@ -593,7 +633,8 @@ class OCIRecipeSet:
     def new(self, name, registrant, owner, oci_project, git_ref, build_file,
             description=None, official=False, require_virtualized=True,
             build_daily=False, processors=None, date_created=DEFAULT,
-            allow_internet=True, build_args=None, build_path=None):
+            allow_internet=True, build_args=None, build_path=None,
+            private=False):
         """See `IOCIRecipeSet`."""
         if not registrant.inTeam(owner):
             if owner.is_team:
@@ -618,7 +659,7 @@ class OCIRecipeSet:
         oci_recipe = OCIRecipe(
             name, registrant, owner, oci_project, git_ref, description,
             official, require_virtualized, build_file, build_daily,
-            date_created, allow_internet, build_args, build_path)
+            date_created, allow_internet, build_args, build_path, private)
         store.add(oci_recipe)
 
         if processors is None:
@@ -697,6 +738,25 @@ class OCIRecipeSet:
             if git_ref is not None:
                 recipe.git_ref = git_ref
 
+    def isValidPrivacy(self, private, owner, git_ref=None):
+        """See `IOCIRecipeSet`."""
+        # Private OCI recipes may contain anything ...
+        if private:
+            # If appropriately enabled via feature flag.
+            if not getFeatureFlag(OCI_RECIPE_PRIVATE_FEATURE_FLAG):
+                raise OCIRecipePrivateFeatureDisabled
+            return True
+
+        # Public OCI recipe with private sources are not allowed.
+        if git_ref is not None and git_ref.private:
+            return False
+
+        # Public OCI recipe owned by private teams are not allowed.
+        if owner is not None and owner.private:
+            return False
+
+        return True
+
 
 @implementer(IOCIRecipeBuildRequest)
 class OCIRecipeBuildRequest:
diff --git a/lib/lp/oci/tests/helpers.py b/lib/lp/oci/tests/helpers.py
index b656ca6..9042f5b 100644
--- a/lib/lp/oci/tests/helpers.py
+++ b/lib/lp/oci/tests/helpers.py
@@ -37,11 +37,13 @@ class OCIConfigHelperMixin:
             registry_secrets_private_key=base64.b64encode(
                 bytes(self.private_key)).decode("UTF-8"))
         # Default feature flags for our tests
-        feature_flags = feature_flags or {}
-        feature_flags.update({
+        flags = {
             OCI_RECIPE_ALLOW_CREATE: 'on',
-            OCI_RECIPE_PRIVATE_FEATURE_FLAG: 'on'})
-        self.useFixture(FeatureFixture(feature_flags))
+            OCI_RECIPE_PRIVATE_FEATURE_FLAG: 'on'}
+        # Allow users to set their own flags or override our previously set
+        # flags.
+        flags.update(feature_flags or {})
+        self.useFixture(FeatureFixture(flags))
 
 
 class MatchesOCIRegistryCredentials(MatchesAll):
diff --git a/lib/lp/oci/tests/test_ocirecipe.py b/lib/lp/oci/tests/test_ocirecipe.py
index 1cb69af..7df296e 100644
--- a/lib/lp/oci/tests/test_ocirecipe.py
+++ b/lib/lp/oci/tests/test_ocirecipe.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for OCI image building recipe functionality."""
@@ -30,6 +30,7 @@ from zope.security.interfaces import (
     )
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.enums import InformationType
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.oci.interfaces.ocipushrule import (
@@ -45,10 +46,13 @@ from lp.oci.interfaces.ocirecipe import (
     NoSuchOCIRecipe,
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_BUILD_DISTRIBUTION,
+    OCI_RECIPE_PRIVATE_FEATURE_FLAG,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
     OCIRecipeBuildAlreadyPending,
     OCIRecipeBuildRequestStatus,
     OCIRecipeNotOwner,
+    OCIRecipePrivacyMismatch,
+    OCIRecipePrivateFeatureDisabled,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
 from lp.oci.interfaces.ocirecipejob import IOCIRecipeRequestBuildsJobSource
@@ -59,6 +63,7 @@ from lp.oci.tests.helpers import (
     MatchesOCIRegistryCredentials,
     OCIConfigHelperMixin,
     )
+from lp.registry.enums import PersonVisibility
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.config import config
 from lp.services.database.constants import (
@@ -751,6 +756,64 @@ class TestOCIRecipe(OCIConfigHelperMixin, TestCaseWithFactory):
         }, recipe.build_args)
 
 
+class TestOCIRecipePrivacy(OCIConfigHelperMixin, TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestOCIRecipePrivacy, self).setUp()
+        self.setConfig()
+        login_admin()
+
+    def test_create_private(self):
+        recipe = self.factory.makeOCIRecipe(private=True)
+        self.assertTrue(recipe.private)
+
+    def test_create_private_with_feature_disabled(self):
+        self.setConfig(feature_flags={OCI_RECIPE_PRIVATE_FEATURE_FLAG: ''})
+        recipe = self.factory.makeOCIRecipe()
+        self.assertRaises(
+            OCIRecipePrivateFeatureDisabled,
+            setattr, recipe, 'private', True)
+        IStore(recipe).invalidate()
+        self.assertFalse(recipe.private)
+
+    def test_recipe_cannot_set_public_with_private_owner(self):
+        owner = self.factory.makeTeam(visibility=PersonVisibility.PRIVATE)
+        recipe = self.factory.makeOCIRecipe(
+            registrant=owner, owner=owner, private=True)
+        self.assertRaises(
+            OCIRecipePrivacyMismatch, setattr, recipe, 'private', False)
+        IStore(recipe).invalidate()
+        self.assertTrue(recipe.private)
+
+    def test_recipe_cannot_set_private_owner_for_public_recipe(self):
+        recipe = self.factory.makeOCIRecipe(private=False)
+        owner = self.factory.makeTeam(visibility=PersonVisibility.PRIVATE)
+        self.assertRaises(
+            OCIRecipePrivacyMismatch, setattr, recipe, 'owner', owner)
+
+    def test_recipe_cannot_set_public_with_private_repository(self):
+        [git_ref] = self.factory.makeGitRefs()
+        git_ref.repository.transitionToInformationType(
+            InformationType.PRIVATESECURITY, git_ref.repository.owner)
+        recipe = self.factory.makeOCIRecipe(git_ref=git_ref, private=True)
+        self.assertRaises(
+            OCIRecipePrivacyMismatch,
+            setattr, recipe, 'private', False)
+        IStore(recipe).invalidate()
+        self.assertTrue(recipe.private)
+
+    def test_recipe_cannot_set_private_repo_for_public_recipe(self):
+        recipe = self.factory.makeOCIRecipe(private=False)
+        [git_ref] = self.factory.makeGitRefs()
+        git_ref.repository.transitionToInformationType(
+            InformationType.PRIVATESECURITY, git_ref.repository.owner)
+
+        self.assertRaises(
+            OCIRecipePrivacyMismatch, setattr, recipe, 'git_ref', git_ref)
+
+
 class TestOCIRecipeProcessors(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
diff --git a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
index b684842..c825b03 100644
--- a/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
+++ b/lib/lp/oci/tests/test_ocirecipebuildbehaviour.py
@@ -72,6 +72,7 @@ from lp.buildmaster.tests.test_buildfarmjobbehaviour import (
     )
 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
 from lp.oci.model.ocirecipebuildbehaviour import OCIRecipeBuildBehaviour
+from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.compat import mock
 from lp.services.config import config
@@ -156,7 +157,8 @@ class TestOCIBuildBehaviour(TestCaseWithFactory):
 
 
 class TestAsyncOCIRecipeBuildBehaviour(
-        StatsMixin, MakeOCIBuildMixin, TestCaseWithFactory):
+        OCIConfigHelperMixin, StatsMixin, MakeOCIBuildMixin,
+        TestCaseWithFactory):
 
     run_tests_with = AsynchronousDeferredRunTestForBrokenTwisted.make_factory(
         timeout=30)
@@ -180,7 +182,7 @@ class TestAsyncOCIRecipeBuildBehaviour(
         self.now = time.time()
         self.useFixture(fixtures.MockPatch(
             "time.time", return_value=self.now))
-        self.useFixture(FeatureFixture({OCI_RECIPE_ALLOW_CREATE: 'on'}))
+        self.setConfig()
         self.addCleanup(shut_down_default_process_pool)
         self.setUpStats()
 
@@ -417,7 +419,8 @@ class TestAsyncOCIRecipeBuildBehaviour(
         [ref] = self.factory.makeGitRefs()
         ref.repository.transitionToInformationType(
             InformationType.PRIVATESECURITY, ref.repository.owner)
-        job = self.makeJob(git_ref=ref)
+        job = self.makeJob(
+            git_ref=ref, recipe=self.factory.makeOCIRecipe(private=True))
         expected_archives, expected_trusted_keys = (
             yield get_sources_list_for_building(
                 job.build, job.build.distro_arch_series, None))
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 0bd8f83..4e4cfeb 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -2,7 +2,7 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Testing infrastructure for the Launchpad application.
@@ -4924,7 +4924,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
                       oci_project=None, git_ref=None, description=None,
                       official=False, require_virtualized=True,
                       build_file=None, date_created=DEFAULT,
-                      allow_internet=True, build_args=None, build_path=None):
+                      allow_internet=True, build_args=None, build_path=None,
+                      private=False):
         """Make a new OCIRecipe."""
         if name is None:
             name = self.getUniqueString(u"oci-recipe-name")
@@ -4955,7 +4956,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             require_virtualized=require_virtualized,
             date_created=date_created,
             allow_internet=allow_internet,
-            build_args=build_args)
+            build_args=build_args,
+            private=private)
 
     def makeOCIRecipeArch(self, recipe=None, processor=None):
         """Make a new OCIRecipeArch."""

Follow ups