← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:distribution-information-type into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:distribution-information-type into launchpad:master.

Commit message:
Add Distribution.information_type

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This is mostly copied from `Product`, especially the validation logic and the test cases.  There are no sharing policies yet, and no UI for setting the information type; these will come in future branches.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:distribution-information-type into launchpad:master.
diff --git a/lib/lp/archivepublisher/tests/test_dominator.py b/lib/lp/archivepublisher/tests/test_dominator.py
index aefd466..01c56e4 100755
--- a/lib/lp/archivepublisher/tests/test_dominator.py
+++ b/lib/lp/archivepublisher/tests/test_dominator.py
@@ -41,6 +41,7 @@ from lp.testing import (
     StormStatementRecorder,
     TestCaseWithFactory,
     )
+from lp.testing.dbuser import lp_dbuser
 from lp.testing.fakemethod import FakeMethod
 from lp.testing.layers import ZopelessDatabaseLayer
 from lp.testing.matchers import HasQueryCount
@@ -165,6 +166,8 @@ class TestDominator(TestNativePublishingBase):
 
     def test_dominateBinaries_rejects_empty_publication_list(self):
         """Domination asserts for non-empty input list."""
+        with lp_dbuser():
+            distroseries = self.factory.makeDistroArchSeries().distroseries
         package = self.factory.makeBinaryPackageName()
         dominator = Dominator(self.logger, self.ubuntutest.main_archive)
         dominator._sortPackages = FakeMethod({package.name: []})
@@ -173,11 +176,12 @@ class TestDominator(TestNativePublishingBase):
         self.assertRaises(
             AssertionError,
             dominator.dominateBinaries,
-            self.factory.makeDistroArchSeries().distroseries,
-            self.factory.getAnyPocket())
+            distroseries, self.factory.getAnyPocket())
 
     def test_dominateSources_rejects_empty_publication_list(self):
         """Domination asserts for non-empty input list."""
+        with lp_dbuser():
+            distroseries = self.factory.makeDistroSeries()
         package = self.factory.makeSourcePackageName()
         dominator = Dominator(self.logger, self.ubuntutest.main_archive)
         dominator._sortPackages = FakeMethod({package.name: []})
@@ -186,7 +190,7 @@ class TestDominator(TestNativePublishingBase):
         self.assertRaises(
             AssertionError,
             dominator.dominateSources,
-            self.factory.makeDistroSeries(), self.factory.getAnyPocket())
+            distroseries, self.factory.getAnyPocket())
 
     def test_archall_domination(self):
         # Arch-all binaries should not be dominated when a new source
diff --git a/lib/lp/archivepublisher/tests/test_publisher.py b/lib/lp/archivepublisher/tests/test_publisher.py
index c0891dd..754138b 100644
--- a/lib/lp/archivepublisher/tests/test_publisher.py
+++ b/lib/lp/archivepublisher/tests/test_publisher.py
@@ -1288,7 +1288,8 @@ class TestPublisher(TestPublisherBase):
         ubuntu = getUtility(IDistributionSet)['ubuntu']
 
         ppa = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
-        copy_archive = self.factory.makeArchive(purpose=ArchivePurpose.COPY)
+        copy_archive = self.factory.makeArchive(
+            distribution=self.ubuntu, purpose=ArchivePurpose.COPY)
         self.assertNotIn(ppa, ubuntu.getPendingPublicationPPAs())
         self.assertNotIn(copy_archive, ubuntu.getPendingPublicationPPAs())
         ppa.status = ArchiveStatus.DELETING
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index fbeaffd..10d677f 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1821,7 +1821,8 @@
         class="lp.registry.model.distribution.Distribution">
         <allow
             interface="lp.registry.interfaces.distribution.IDistributionPublic
-                       lp.bugs.interfaces.bugsummary.IBugSummaryDimension"/>
+                       lp.bugs.interfaces.bugsummary.IBugSummaryDimension"
+            attributes="information_type"/>
         <require
             permission="launchpad.LimitedView"
             interface="lp.registry.interfaces.distribution.IDistributionLimitedView"/>
@@ -1830,7 +1831,8 @@
             interface="lp.registry.interfaces.distribution.IDistributionView"/>
         <require
             permission="launchpad.Edit"
-            interface="lp.registry.interfaces.distribution.IDistributionEditRestricted"/>
+            interface="lp.registry.interfaces.distribution.IDistributionEditRestricted"
+            set_schema="lp.app.interfaces.informationtype.IInformationType"/>
         <require
             permission="launchpad.Edit"
             set_attributes="answers_usage blueprints_usage codehosting_usage
diff --git a/lib/lp/registry/errors.py b/lib/lp/registry/errors.py
index 4e1c52f..09acb08 100644
--- a/lib/lp/registry/errors.py
+++ b/lib/lp/registry/errors.py
@@ -27,7 +27,7 @@ __all__ = [
     'InclusiveTeamLinkageError',
     'PPACreationError',
     'PrivatePersonLinkageError',
-    'ProprietaryProduct',
+    'ProprietaryPillar',
     'TeamMembershipTransitionError',
     'TeamMembershipPolicyError',
     'UserCannotChangeMembershipSilently',
@@ -100,8 +100,8 @@ class CommercialSubscribersOnly(Unauthorized):
     """
 
 
-class ProprietaryProduct(Exception):
-    """Cannot make the change because the project is proprietary."""
+class ProprietaryPillar(Exception):
+    """Cannot make the change because the pillar is proprietary."""
 
 
 class NoSuchSourcePackageName(NameLookupFailed):
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index b102de8..7e943c1 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -59,6 +59,7 @@ from lp import _
 from lp.answers.interfaces.faqtarget import IFAQTarget
 from lp.answers.interfaces.questiontarget import IQuestionTarget
 from lp.app.errors import NameLookupFailed
+from lp.app.interfaces.informationtype import IInformationType
 from lp.app.interfaces.launchpad import (
     IHasIcon,
     IHasLogo,
@@ -163,6 +164,14 @@ class IDistributionPublic(Interface):
     def userCanLimitedView(user):
         """True if the given user has limited access to this distribution."""
 
+    private = exported(
+        Bool(
+            title=_("Distribution is confidential"),
+            required=False, readonly=True, default=False,
+            description=_(
+                "If set, this distribution is visible only to those with "
+                "access grants.")))
+
 
 class IDistributionLimitedView(IHasIcon, IHasLogo, IHasOwner, ILaunchpadUsage):
     """IDistribution attributes visible to people with artifact grants."""
@@ -456,6 +465,9 @@ class IDistributionView(
             "An object which contains the timeframe and the voucher code of a "
             "subscription.")))
 
+    has_current_commercial_subscription = Attribute(
+        "Whether the distribution has a current commercial subscription.")
+
     def getArchiveIDList(archive=None):
         """Return a list of archive IDs suitable for sqlvalues() or quote().
 
@@ -768,6 +780,14 @@ class IDistributionView(
 class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
     """IDistribution properties requiring launchpad.Edit permission."""
 
+    def checkInformationType(value):
+        """Check whether the information type change should be permitted.
+
+        Iterate through exceptions explaining why the type should not be
+        changed.  Has the side-effect of creating a commercial subscription
+        if permitted.
+        """
+
     @call_with(registrant=REQUEST_USER)
     @operation_parameters(
         registry_url=TextLine(
@@ -802,7 +822,8 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
 class IDistribution(
         IDistributionEditRestricted, IDistributionPublic,
         IDistributionLimitedView, IDistributionView, IHasBugSupervisor,
-        IFAQTarget, IQuestionTarget, IStructuralSubscriptionTarget):
+        IFAQTarget, IQuestionTarget, IStructuralSubscriptionTarget,
+        IInformationType):
     """An operating system distribution.
 
     Launchpadlib example: retrieving the current version of a package in a
@@ -853,7 +874,8 @@ class IDistributionSet(Interface):
         """Return the IDistribution with the given name or None."""
 
     def new(name, display_name, title, description, summary, domainname,
-            members, owner, registrant, mugshot=None, logo=None, icon=None):
+            members, owner, registrant, mugshot=None, logo=None, icon=None,
+            information_type=None):
         """Create a new distribution."""
 
     def getCurrentSourceReleases(distro_to_source_packagenames):
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index c6327d2..94558ca 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -9,11 +9,17 @@ __all__ = [
     ]
 
 from collections import defaultdict
+from datetime import (
+    datetime,
+    timedelta,
+    )
 import itertools
 from operator import itemgetter
 
+import pytz
 from storm.expr import (
     And,
+    Coalesce,
     Desc,
     Exists,
     Join,
@@ -27,6 +33,7 @@ from storm.expr import (
 from storm.info import ClassAlias
 from storm.locals import (
     Int,
+    List,
     Reference,
     )
 from storm.store import Store
@@ -40,15 +47,23 @@ from lp.answers.model.faq import (
     FAQSearch,
     )
 from lp.answers.model.question import (
+    Question,
     QuestionTargetMixin,
     QuestionTargetSearch,
     )
 from lp.app.enums import (
     FREE_INFORMATION_TYPES,
     InformationType,
+    PILLAR_INFORMATION_TYPES,
+    PRIVATE_INFORMATION_TYPES,
+    PROPRIETARY_INFORMATION_TYPES,
+    PUBLIC_INFORMATION_TYPES,
     ServiceUsage,
     )
-from lp.app.errors import NotFoundError
+from lp.app.errors import (
+    NotFoundError,
+    ServiceUsageForbidden,
+    )
 from lp.app.interfaces.launchpad import (
     IHasIcon,
     IHasLogo,
@@ -57,11 +72,14 @@ from lp.app.interfaces.launchpad import (
     ILaunchpadUsage,
     IServiceUsage,
     )
+from lp.app.interfaces.services import IService
+from lp.app.model.launchpad import InformationTypeMixin
 from lp.app.validators.name import (
     sanitize_name,
     valid_name,
     )
 from lp.archivepublisher.debversion import Version
+from lp.blueprints.enums import SpecificationFilter
 from lp.blueprints.model.specification import (
     HasSpecificationsMixin,
     Specification,
@@ -75,12 +93,14 @@ from lp.bugs.model.bugtarget import (
     BugTargetBase,
     OfficialBugTagTargetMixin,
     )
+from lp.bugs.model.bugtaskflat import BugTaskFlat
 from lp.bugs.model.structuralsubscription import (
     StructuralSubscriptionTargetMixin,
     )
 from lp.code.interfaces.seriessourcepackagebranch import (
     IFindOfficialBranchLinks,
     )
+from lp.code.model.branch import Branch
 from lp.oci.interfaces.ociregistrycredentials import (
     IOCIRegistryCredentialsSet,
     )
@@ -88,11 +108,20 @@ from lp.registry.enums import (
     BranchSharingPolicy,
     BugSharingPolicy,
     DistributionDefaultTraversalPolicy,
+    INCLUSIVE_TEAM_POLICY,
     SpecificationSharingPolicy,
     VCSType,
     )
-from lp.registry.errors import NoSuchDistroSeries
-from lp.registry.interfaces.accesspolicy import IAccessPolicySource
+from lp.registry.errors import (
+    CannotChangeInformationType,
+    CommercialSubscribersOnly,
+    NoSuchDistroSeries,
+    ProprietaryPillar,
+    )
+from lp.registry.interfaces.accesspolicy import (
+    IAccessPolicyGrantSource,
+    IAccessPolicySource,
+    )
 from lp.registry.interfaces.distribution import (
     IDistribution,
     IDistributionSet,
@@ -119,6 +148,7 @@ from lp.registry.interfaces.pocket import suffixpocket
 from lp.registry.interfaces.role import IPersonRoles
 from lp.registry.interfaces.series import SeriesStatus
 from lp.registry.interfaces.sourcepackagename import ISourcePackageName
+from lp.registry.model.accesspolicy import AccessPolicyGrantFlat
 from lp.registry.model.announcement import MakesAnnouncements
 from lp.registry.model.commercialsubscription import CommercialSubscription
 from lp.registry.model.distributionmirror import (
@@ -142,6 +172,7 @@ from lp.registry.model.ociprojectname import OCIProjectName
 from lp.registry.model.oopsreferences import referenced_oops
 from lp.registry.model.pillar import HasAliasMixin
 from lp.registry.model.sourcepackagename import SourcePackageName
+from lp.registry.model.teammembership import TeamParticipation
 from lp.services.database.bulk import load_referencing
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.datetimecol import UtcDateTimeCol
@@ -159,6 +190,8 @@ from lp.services.database.sqlobject import (
     StringCol,
     )
 from lp.services.database.stormexpr import (
+    ArrayAgg,
+    ArrayIntersects,
     fti_search,
     rank_by_fti,
     )
@@ -203,6 +236,7 @@ from lp.translations.enums import TranslationPermission
 from lp.translations.model.hastranslationimports import (
     HasTranslationImportsMixin,
     )
+from lp.translations.model.potemplate import POTemplate
 from lp.translations.model.translationpolicy import TranslationPolicyMixin
 
 
@@ -215,7 +249,8 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
                    HasTranslationImportsMixin, KarmaContextMixin,
                    OfficialBugTagTargetMixin, QuestionTargetMixin,
                    StructuralSubscriptionTargetMixin, HasMilestonesMixin,
-                   HasDriversMixin, TranslationPolicyMixin):
+                   HasDriversMixin, TranslationPolicyMixin,
+                   InformationTypeMixin):
     """A distribution of an operating system, e.g. Debian GNU/Linux."""
 
     _table = 'Distribution'
@@ -281,9 +316,12 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
     oci_registry_credentials = Reference(
         oci_registry_credentials_id, "OCIRegistryCredentials.id")
 
+    _creating = False
+
     def __init__(self, name, display_name, title, description, summary,
                  domainname, members, owner, registrant, mugshot=None,
-                 logo=None, icon=None, vcs=None):
+                 logo=None, icon=None, vcs=None, information_type=None):
+        self._creating = True
         try:
             self.name = name
             self.display_name = display_name
@@ -299,9 +337,11 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             self.logo = logo
             self.icon = icon
             self.vcs = vcs
+            self.information_type = information_type
         except Exception:
             IStore(self).remove(self)
             raise
+        del self._creating
 
     def __repr__(self):
         display_name = backslashreplace(self.display_name)
@@ -321,6 +361,109 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
         """See `IBugTarget`."""
         return self
 
+    def _valid_distribution_information_type(self, attr, value):
+        for exception in self.checkInformationType(value):
+            raise exception
+        return value
+
+    def checkInformationType(self, value):
+        """See `IDistribution`."""
+        if value not in PILLAR_INFORMATION_TYPES:
+            yield CannotChangeInformationType(
+                'Not supported for distributions.')
+        if value in PROPRIETARY_INFORMATION_TYPES:
+            if self.answers_usage == ServiceUsage.LAUNCHPAD:
+                yield CannotChangeInformationType('Answers is enabled.')
+        if self._creating or value not in PROPRIETARY_INFORMATION_TYPES:
+            return
+        # Additional checks when transitioning an existing distribution to a
+        # proprietary type.
+        # All specs located by an ALL search are public.
+        public_specs = self.specifications(
+            None, filter=[SpecificationFilter.ALL])
+        if not public_specs.is_empty():
+            # Unlike bugs and branches, specifications cannot be USERDATA or a
+            # security type.
+            yield CannotChangeInformationType('Some blueprints are public.')
+        store = Store.of(self)
+        series_ids = [series.id for series in self.series]
+        non_proprietary_bugs = store.find(
+            BugTaskFlat,
+            BugTaskFlat.information_type.is_in(FREE_INFORMATION_TYPES),
+            Or(
+                BugTaskFlat.distribution == self.id,
+                BugTaskFlat.distroseries_id.is_in(series_ids)))
+        if not non_proprietary_bugs.is_empty():
+            yield CannotChangeInformationType(
+                'Some bugs are neither proprietary nor embargoed.')
+        # Default returns all public branches.
+        non_proprietary_branches = store.find(
+            Branch,
+            DistroSeries.distribution == self.id,
+            Branch.distroseries == DistroSeries.id,
+            Not(Branch.information_type.is_in(PROPRIETARY_INFORMATION_TYPES)))
+        if not non_proprietary_branches.is_empty():
+            yield CannotChangeInformationType(
+                'Some branches are neither proprietary nor embargoed.')
+        questions = store.find(Question, Question.distribution == self.id)
+        if not questions.is_empty():
+            yield CannotChangeInformationType(
+                'This distribution has questions.')
+        templates = store.find(
+            POTemplate, DistroSeries.distribution == self.id,
+            POTemplate.distroseries == DistroSeries.id)
+        if not templates.is_empty():
+            yield CannotChangeInformationType(
+                'This distribution has translations.')
+        if not self.getTranslationImportQueueEntries().is_empty():
+            yield CannotChangeInformationType(
+                'This distribution has queued translations.')
+        if self.translations_usage == ServiceUsage.LAUNCHPAD:
+            yield CannotChangeInformationType('Translations are enabled.')
+        bug_supervisor = self.bug_supervisor
+        if (bug_supervisor is not None and
+                bug_supervisor.membership_policy in INCLUSIVE_TEAM_POLICY):
+            yield CannotChangeInformationType(
+                'Bug supervisor has inclusive membership.')
+
+        # Proprietary check works only after creation, because during
+        # creation, has_current_commercial_subscription cannot give the
+        # right value and triggers an inappropriate DB flush.
+
+        # Create the complimentary commercial subscription for the
+        # distribution.
+        self._ensure_complimentary_subscription()
+
+        # If you have a commercial subscription, but it's not current, you
+        # cannot set the information type to a PROPRIETARY type.
+        if not self.has_current_commercial_subscription:
+            yield CommercialSubscribersOnly(
+                'A valid commercial subscription is required for private'
+                ' distributions.')
+
+    _information_type = DBEnum(
+        enum=InformationType, default=InformationType.PUBLIC,
+        name="information_type",
+        validator=_valid_distribution_information_type)
+
+    @property
+    def information_type(self):
+        return self._information_type or InformationType.PUBLIC
+
+    @information_type.setter
+    def information_type(self, value):
+        old_info_type = self._information_type
+        self._information_type = value
+        # Make sure that policies are updated to grant permission to the
+        # maintainer as required for the Distribution.
+        # However, only on edits.  If this is a new Distribution it's
+        # handled already.
+        if not self._creating:
+            if (old_info_type == InformationType.PUBLIC and
+                    value != InformationType.PUBLIC):
+                self._ensure_complimentary_subscription()
+            self._ensurePolicies([value])
+
     @property
     def pillar_category(self):
         """See `IPillar`."""
@@ -344,12 +487,77 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
         # Sharing policy for distributions is always PUBLIC.
         return SpecificationSharingPolicy.PUBLIC
 
+    # Cache of AccessPolicy.ids that convey launchpad.LimitedView.
+    # Unlike artifacts' cached access_policies, an AccessArtifactGrant
+    # to an artifact in the policy is sufficient for access.
+    access_policies = List(type=Int())
+
+    def _ensurePolicies(self, information_types):
+        # Ensure that the distribution has access policies for the specified
+        # information types.
+        aps = getUtility(IAccessPolicySource)
+        existing_policies = aps.findByPillar([self])
+        existing_types = {
+            access_policy.type for access_policy in existing_policies}
+        # Create the missing policies.
+        required_types = set(information_types).difference(
+            existing_types).intersection(PRIVATE_INFORMATION_TYPES)
+        policies = itertools.product((self,), required_types)
+        policies = getUtility(IAccessPolicySource).create(policies)
+
+        # Add the maintainer to the policies.
+        grants = []
+        for p in policies:
+            grants.append((p, self.owner, self.owner))
+        getUtility(IAccessPolicyGrantSource).grant(grants)
+
+        self._cacheAccessPolicies()
+
+    def _cacheAccessPolicies(self):
+        # Update the cache of AccessPolicy.ids for which an
+        # AccessPolicyGrant or AccessArtifactGrant is sufficient to
+        # convey launchpad.LimitedView on this Distribution.
+        #
+        # We only need a cache for proprietary types, and it only
+        # includes proprietary policies in case a policy like Private
+        # Security was somehow left around when a project was
+        # transitioned to Proprietary.
+        if self.information_type in PROPRIETARY_INFORMATION_TYPES:
+            self.access_policies = [
+                policy.id for policy in
+                getUtility(IAccessPolicySource).find(
+                    [(self, type) for type in PROPRIETARY_INFORMATION_TYPES])]
+        else:
+            self.access_policies = None
+
     @cachedproperty
     def commercial_subscription(self):
         return IStore(CommercialSubscription).find(
             CommercialSubscription, distribution=self).one()
 
     @property
+    def has_current_commercial_subscription(self):
+        now = datetime.now(pytz.UTC)
+        return (self.commercial_subscription
+            and self.commercial_subscription.date_expires > now)
+
+    def _ensure_complimentary_subscription(self):
+        """Create a complementary commercial subscription for the distro."""
+        if not self.commercial_subscription:
+            lp_janitor = getUtility(ILaunchpadCelebrities).janitor
+            now = datetime.now(pytz.UTC)
+            date_expires = now + timedelta(days=30)
+            sales_system_id = "complimentary-30-day-%s" % now
+            whiteboard = (
+                "Complimentary 30 day subscription. -- Launchpad %s" %
+                now.date().isoformat())
+            subscription = CommercialSubscription(
+                pillar=self, date_starts=now, date_expires=date_expires,
+                registrant=lp_janitor, purchaser=lp_janitor,
+                sales_system_id=sales_system_id, whiteboard=whiteboard)
+            get_property_cache(self).commercial_subscription = subscription
+
+    @property
     def uploaders(self):
         """See `IDistribution`."""
         # Get all the distribution archives and find out the uploaders
@@ -397,6 +605,10 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
         return self._answers_usage
 
     def _set_answers_usage(self, val):
+        if val == ServiceUsage.LAUNCHPAD:
+            if self.information_type in PROPRIETARY_INFORMATION_TYPES:
+                raise ServiceUsageForbidden(
+                    "Answers not allowed for non-public distributions.")
         self._answers_usage = val
         if val == ServiceUsage.LAUNCHPAD:
             self.official_answers = True
@@ -432,9 +644,17 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
         _set_blueprints_usage,
         doc="Indicates if the product uses the blueprints service.")
 
+    def validate_translations_usage(self, attr, value):
+        if value == ServiceUsage.LAUNCHPAD and self.private:
+            raise ProprietaryPillar(
+                "Translations are not supported for proprietary "
+                "distributions.")
+        return value
+
     translations_usage = DBEnum(
         name="translations_usage", allow_none=False,
-        enum=ServiceUsage, default=ServiceUsage.UNKNOWN)
+        enum=ServiceUsage, default=ServiceUsage.UNKNOWN,
+        validator=validate_translations_usage)
 
     @property
     def codehosting_usage(self):
@@ -1568,17 +1788,41 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             self.oci_registry_credentials = None
             old_credentials.destroySelf()
 
+    @cachedproperty
+    def _known_viewers(self):
+        """A set of known persons able to view this distribution."""
+        return set()
+
     def userCanView(self, user):
         """See `IDistributionPublic`."""
-        # All distributions are public until we finish introducing privacy
-        # support.
-        return True
+        if self.information_type in PUBLIC_INFORMATION_TYPES:
+            return True
+        if user is None:
+            return False
+        if user.id in self._known_viewers:
+            return True
+        if not IPersonRoles.providedBy(user):
+            user = IPersonRoles(user)
+        if user.in_commercial_admin or user.in_admin:
+            self._known_viewers.add(user.id)
+            return True
+        if getUtility(IService, 'sharing').checkPillarAccess(
+                [self], self.information_type, user):
+            self._known_viewers.add(user.id)
+            return True
+        return False
 
     def userCanLimitedView(self, user):
         """See `IDistributionPublic`."""
-        # All distributions are public until we finish introducing privacy
-        # support.
-        return True
+        if self.userCanView(user):
+            return True
+        if user is None:
+            return False
+        return not Store.of(self).find(
+            Distribution,
+            Distribution.id == self.id,
+            DistributionSet.getDistributionPrivacyFilter(user.person),
+            ).is_empty()
 
 
 @implementer(IDistributionSet)
@@ -1618,10 +1862,48 @@ class DistributionSet:
             return None
         return pillar
 
+    @staticmethod
+    def getDistributionPrivacyFilter(user):
+        # Anonymous users can only see public distributions.  This is also
+        # sometimes used with an outer join with e.g. Product, so we let
+        # NULL through too.
+        public_filter = Or(
+            Distribution._information_type == None,
+            Distribution._information_type == InformationType.PUBLIC)
+        if user is None:
+            return public_filter
+
+        # (Commercial) admins can see any project.
+        roles = IPersonRoles(user)
+        if roles.in_admin or roles.in_commercial_admin:
+            return True
+
+        # Normal users can see any project for which they can see either
+        # an entire policy or an artifact.
+        # XXX wgrant 2015-06-26: This is slower than ideal for people in
+        # teams with lots of artifact grants, as there can be tens of
+        # thousands of APGF rows for a single policy. But it's tens of
+        # milliseconds at most.
+        grant_filter = Coalesce(
+            ArrayIntersects(
+                SQL('Distribution.access_policies'),
+                Select(
+                    ArrayAgg(AccessPolicyGrantFlat.policy_id),
+                    tables=(AccessPolicyGrantFlat,
+                            Join(TeamParticipation,
+                                TeamParticipation.teamID ==
+                                AccessPolicyGrantFlat.grantee_id)),
+                    where=(TeamParticipation.person == user)
+                    )),
+            False)
+        return Or(public_filter, grant_filter)
+
     def new(self, name, display_name, title, description, summary, domainname,
             members, owner, registrant, mugshot=None, logo=None, icon=None,
-            vcs=None):
+            vcs=None, information_type=None):
         """See `IDistributionSet`."""
+        if information_type is None:
+            information_type = InformationType.PUBLIC
         distro = Distribution(
             name=name,
             display_name=display_name,
@@ -1635,14 +1917,21 @@ class DistributionSet:
             mugshot=mugshot,
             logo=logo,
             icon=icon,
-            vcs=vcs)
+            vcs=vcs,
+            information_type=information_type)
         IStore(distro).add(distro)
-        getUtility(IArchiveSet).new(distribution=distro,
-            owner=owner, purpose=ArchivePurpose.PRIMARY)
-        policies = itertools.product(
-            (distro,), (InformationType.USERDATA,
-                InformationType.PRIVATESECURITY))
-        getUtility(IAccessPolicySource).create(policies)
+        if information_type != InformationType.PUBLIC:
+            distro._ensure_complimentary_subscription()
+        # XXX cjwatson 2022-02-10: Replace this with sharing policies once
+        # those are defined here.
+        distro._ensurePolicies(
+            [information_type]
+            if information_type == InformationType.PROPRIETARY
+            else FREE_INFORMATION_TYPES)
+        if information_type == InformationType.PUBLIC:
+            getUtility(IArchiveSet).new(
+                distribution=distro, owner=owner,
+                purpose=ArchivePurpose.PRIMARY)
         return distro
 
     def getCurrentSourceReleases(self, distro_source_packagenames):
diff --git a/lib/lp/registry/model/person.py b/lib/lp/registry/model/person.py
index b544ae3..1d5f2be 100644
--- a/lib/lp/registry/model/person.py
+++ b/lib/lp/registry/model/person.py
@@ -1158,6 +1158,7 @@ class Person(
         from lp.registry.model.commercialsubscription import (
             CommercialSubscription,
             )
+        from lp.registry.model.distribution import Distribution
         from lp.registry.model.person import Person
         from lp.registry.model.product import Product
         from lp.registry.model.teammembership import TeamParticipation
@@ -1166,11 +1167,16 @@ class Person(
             Join(
                 TeamParticipation,
                 Person.id == TeamParticipation.personID),
-            Join(
+            LeftJoin(
                 Product, TeamParticipation.teamID == Product._ownerID),
+            LeftJoin(
+                Distribution,
+                TeamParticipation.teamID == Distribution.ownerID),
             Join(
                 CommercialSubscription,
-                CommercialSubscription.product_id == Product.id)
+                Or(
+                    CommercialSubscription.product_id == Product.id,
+                    CommercialSubscription.distribution_id == Distribution.id))
             ).find(
                 Person,
                 CommercialSubscription.date_expires > datetime.now(
diff --git a/lib/lp/registry/model/product.py b/lib/lp/registry/model/product.py
index 0d9235c..8e8c0f2 100644
--- a/lib/lp/registry/model/product.py
+++ b/lib/lp/registry/model/product.py
@@ -128,7 +128,7 @@ from lp.registry.enums import (
 from lp.registry.errors import (
     CannotChangeInformationType,
     CommercialSubscribersOnly,
-    ProprietaryProduct,
+    ProprietaryPillar,
     )
 from lp.registry.interfaces.accesspolicy import (
     IAccessPolicyArtifactSource,
@@ -560,7 +560,7 @@ class Product(SQLBase, BugTargetBase, MakesAnnouncements,
 
     def validate_translations_usage(self, attr, value):
         if value == ServiceUsage.LAUNCHPAD and self.private:
-            raise ProprietaryProduct(
+            raise ProprietaryPillar(
                 "Translations are not supported for proprietary products.")
         return value
 
@@ -683,7 +683,7 @@ class Product(SQLBase, BugTargetBase, MakesAnnouncements,
                 "proprietary %s." % kind)
         if self.information_type != InformationType.PUBLIC:
             if InformationType.PUBLIC in allowed_types[var]:
-                raise ProprietaryProduct(
+                raise ProprietaryPillar(
                     "The project is %s." % self.information_type.title)
         self._ensurePolicies(allowed_types[var])
 
diff --git a/lib/lp/registry/model/productrelease.py b/lib/lp/registry/model/productrelease.py
index a1ae8fd..ba96c73 100644
--- a/lib/lp/registry/model/productrelease.py
+++ b/lib/lp/registry/model/productrelease.py
@@ -26,7 +26,7 @@ from lp.app.enums import InformationType
 from lp.app.errors import NotFoundError
 from lp.registry.errors import (
     InvalidFilename,
-    ProprietaryProduct,
+    ProprietaryPillar,
     )
 from lp.registry.interfaces.person import (
     validate_person,
@@ -151,7 +151,7 @@ class ProductRelease(SQLBase):
                        description=None, from_api=False):
         """See `IProductRelease`."""
         if not self.can_have_release_files:
-            raise ProprietaryProduct(
+            raise ProprietaryPillar(
                 "Only public projects can have download files.")
         if self.hasReleaseFile(filename):
             raise InvalidFilename
diff --git a/lib/lp/registry/model/productseries.py b/lib/lp/registry/model/productseries.py
index cf28a82..772373d 100644
--- a/lib/lp/registry/model/productseries.py
+++ b/lib/lp/registry/model/productseries.py
@@ -43,7 +43,7 @@ from lp.bugs.model.bugtarget import BugTargetBase
 from lp.bugs.model.structuralsubscription import (
     StructuralSubscriptionTargetMixin,
     )
-from lp.registry.errors import ProprietaryProduct
+from lp.registry.errors import ProprietaryPillar
 from lp.registry.interfaces.packaging import PackagingType
 from lp.registry.interfaces.person import validate_person
 from lp.registry.interfaces.productrelease import IProductReleaseSet
@@ -141,8 +141,8 @@ class ProductSeries(SQLBase, BugTargetBase, HasMilestonesMixin,
             return value
         if (self.product.private and
             value != TranslationsBranchImportMode.NO_IMPORT):
-            raise ProprietaryProduct('Translations are disabled for'
-                                     ' proprietary projects.')
+            raise ProprietaryPillar('Translations are disabled for'
+                                    ' proprietary projects.')
         return value
 
     translations_autoimport_mode = DBEnum(
diff --git a/lib/lp/registry/services/sharingservice.py b/lib/lp/registry/services/sharingservice.py
index dda0934..9480512 100644
--- a/lib/lp/registry/services/sharingservice.py
+++ b/lib/lp/registry/services/sharingservice.py
@@ -187,7 +187,13 @@ class SharingService:
 
     def getSharedDistributions(self, person, user):
         """See `ISharingService`."""
-        return self._getSharedPillars(person, user, Distribution)
+        commercial_filter = None
+        if user and IPersonRoles(user).in_commercial_admin:
+            commercial_filter = Exists(Select(
+                1, tables=CommercialSubscription,
+                where=CommercialSubscription.distribution == Distribution.id))
+        return self._getSharedPillars(
+            person, user, Distribution, commercial_filter)
 
     def getArtifactGrantsForPersonOnPillar(self, pillar, person):
         """Return the artifact grants for the given person and pillar."""
diff --git a/lib/lp/registry/services/tests/test_sharingservice.py b/lib/lp/registry/services/tests/test_sharingservice.py
index e7444fe..8ce3be1 100644
--- a/lib/lp/registry/services/tests/test_sharingservice.py
+++ b/lib/lp/registry/services/tests/test_sharingservice.py
@@ -93,8 +93,7 @@ class PillarScenariosMixin(WithScenarios):
     def _makePillar(self, **kwargs):
         if ("bug_sharing_policy" in kwargs or
                 "branch_sharing_policy" in kwargs or
-                "specification_sharing_policy" in kwargs or
-                "information_type" in kwargs):
+                "specification_sharing_policy" in kwargs):
             self._skipUnlessProduct()
         return getattr(self.factory, self.pillar_factory_name)(**kwargs)
 
@@ -215,7 +214,6 @@ class TestSharingService(
             [InformationType.PRIVATESECURITY, InformationType.USERDATA])
 
     def test_getInformationTypes_expired_commercial(self):
-        self._skipUnlessProduct()
         pillar = self._makePillar()
         self.factory.makeCommercialSubscription(pillar, expired=True)
         self._assert_getAllowedInformationTypes(
@@ -268,6 +266,7 @@ class TestSharingService(
     def test_getBranchSharingPolicies_non_public(self):
         # When the pillar is non-public the policy options are limited to
         # only proprietary or embargoed/proprietary.
+        self._skipUnlessProduct()
         owner = self.factory.makePerson()
         pillar = self._makePillar(
             information_type=InformationType.PROPRIETARY,
@@ -334,6 +333,7 @@ class TestSharingService(
     def test_getSpecificationSharingPolicies_non_public(self):
         # When the pillar is non-public the policy options are limited to
         # only proprietary or embargoed/proprietary.
+        self._skipUnlessProduct()
         owner = self.factory.makePerson()
         pillar = self._makePillar(
             information_type=InformationType.PROPRIETARY,
@@ -386,6 +386,7 @@ class TestSharingService(
     def test_getBugSharingPolicies_non_public(self):
         # When the pillar is non-public the policy options are limited to
         # only proprietary or embargoed/proprietary.
+        self._skipUnlessProduct()
         owner = self.factory.makePerson()
         pillar = self._makePillar(
             information_type=InformationType.PROPRIETARY,
@@ -477,14 +478,13 @@ class TestSharingService(
             self._makeGranteeData(
                 artifact_grant.grantee,
                 [(InformationType.PROPRIETARY, SharingPermission.SOME)],
-                [InformationType.PROPRIETARY])]
-        if IProduct.providedBy(pillar):
-            owner_data = self._makeGranteeData(
+                [InformationType.PROPRIETARY]),
+            self._makeGranteeData(
                 pillar.owner,
                 [(InformationType.USERDATA, SharingPermission.ALL),
                  (InformationType.PRIVATESECURITY, SharingPermission.ALL)],
-                [])
-            expected_grantees.append(owner_data)
+                []),
+            ]
         self.assertContentEqual(expected_grantees, grantees)
 
     def test_getPillarGranteeData(self):
@@ -503,7 +503,6 @@ class TestSharingService(
 
         Steps 2 and 3 are split out to allow batching on persons.
         """
-        self._skipUnlessProduct()
         driver = self.factory.makePerson()
         pillar = self._makePillar(driver=driver)
         login_person(driver)
@@ -577,19 +576,15 @@ class TestSharingService(
             artifact=artifact_grant.abstract_artifact, policy=access_policy)
 
         grantees = self.service.getPillarGrantees(pillar)
+        policies = getUtility(IAccessPolicySource).findByPillar([pillar])
+        policies = [policy for policy in policies
+                    if policy.type != InformationType.PROPRIETARY]
         expected_grantees = [
             (grantee, {access_policy: SharingPermission.ALL}, []),
             (artifact_grant.grantee, {access_policy: SharingPermission.SOME},
-             [access_policy.type])]
-        if IProduct.providedBy(pillar):
-            policies = getUtility(IAccessPolicySource).findByPillar([pillar])
-            policies = [policy for policy in policies
-                            if policy.type != InformationType.PROPRIETARY]
-            owner_data = (
-                pillar.owner,
-                dict.fromkeys(policies, SharingPermission.ALL),
-                [])
-            expected_grantees.append(owner_data)
+             [access_policy.type]),
+            (pillar.owner, dict.fromkeys(policies, SharingPermission.ALL), []),
+            ]
         self.assertContentEqual(expected_grantees, grantees)
 
     def test_getPillarGrantees(self):
@@ -703,22 +698,13 @@ class TestSharingService(
         self.assertContentEqual(
             expected_grantee_data, grantee_data['grantee_entry'])
         # Check that getPillarGrantees returns what we expect.
-        if IProduct.providedBy(pillar):
-            expected_grantee_grants = [
-                (grantee,
-                 {ud_policy: SharingPermission.SOME,
-                  es_policy: SharingPermission.ALL},
-                 [InformationType.PRIVATESECURITY,
-                  InformationType.USERDATA]),
-                 ]
-        else:
-            expected_grantee_grants = [
-                (grantee,
-                 {es_policy: SharingPermission.ALL,
-                  ud_policy: SharingPermission.SOME},
-                 [InformationType.PRIVATESECURITY,
-                  InformationType.USERDATA]),
-                 ]
+        expected_grantee_grants = [
+            (grantee,
+             {ud_policy: SharingPermission.SOME,
+              es_policy: SharingPermission.ALL},
+             [InformationType.PRIVATESECURITY,
+              InformationType.USERDATA]),
+             ]
 
         grantee_grants = list(self.service.getPillarGrantees(pillar))
         # Again, filter out the owner, if one exists.
@@ -842,11 +828,10 @@ class TestSharingService(
             yet_another, policy_permissions,
             [InformationType.PRIVATESECURITY, InformationType.USERDATA])
         expected_data.append(yet_another_person_data)
-        if IProduct.providedBy(pillar):
-            policy_permissions = {
-                policy: SharingPermission.ALL for policy in access_policies}
-            owner_data = (pillar.owner, policy_permissions, [])
-            expected_data.append(owner_data)
+        policy_permissions = {
+            policy: SharingPermission.ALL for policy in access_policies}
+        owner_data = (pillar.owner, policy_permissions, [])
+        expected_data.append(owner_data)
         self._assert_grantee_data(
             expected_data, self.service.getPillarGrantees(pillar))
 
@@ -1516,8 +1501,9 @@ class TestSharingService(
             grant_access(branch, i == 9)
         for i, gitrepository in enumerate(gitrepositories):
             grant_access(gitrepository, i == 9)
-        getUtility(IService, 'sharing').ensureAccessGrants(
-            [grantee], pillar.owner, snaps=snaps[:9])
+        if snaps:
+            getUtility(IService, 'sharing').ensureAccessGrants(
+                [grantee], pillar.owner, snaps=snaps[:9])
         getUtility(IService, 'sharing').ensureAccessGrants(
             [grantee], pillar.owner, specifications=specs[:9])
         getUtility(IService, 'sharing').ensureAccessGrants(
@@ -1593,7 +1579,6 @@ class TestSharingService(
 
     def test_getSharedPillars_commercial_admin_current(self):
         # Commercial admins can see all current commercial pillars.
-        self._skipUnlessProduct()
         admin = getUtility(ILaunchpadCelebrities).commercial_admin.teamowner
         pillar = self._makePillar()
         self.factory.makeCommercialSubscription(pillar)
@@ -1601,7 +1586,6 @@ class TestSharingService(
 
     def test_getSharedPillars_commercial_admin_expired(self):
         # Commercial admins can see all expired commercial pillars.
-        self._skipUnlessProduct()
         admin = getUtility(ILaunchpadCelebrities).commercial_admin.teamowner
         pillar = self._makePillar()
         self.factory.makeCommercialSubscription(pillar, expired=True)
@@ -1684,6 +1668,7 @@ class TestSharingService(
 
     def test_getSharedSnaps(self):
         # Test the getSharedSnaps method.
+        self._skipUnlessProduct()
         owner = self.factory.makePerson()
         pillar = self._makePillar(
             owner=owner, specification_sharing_policy=(
@@ -1981,7 +1966,6 @@ class TestSharingService(
     def test_getAccessPolicyGrantCounts(self):
         # checkPillarAccess checks whether the user has full access to
         # an information type.
-        self._skipUnlessProduct()
         pillar = self._makePillar()
         grantee = self.factory.makePerson()
         with admin_logged_in():
diff --git a/lib/lp/registry/stories/webservice/xx-distribution.txt b/lib/lp/registry/stories/webservice/xx-distribution.txt
index 4f98323..0e64400 100644
--- a/lib/lp/registry/stories/webservice/xx-distribution.txt
+++ b/lib/lp/registry/stories/webservice/xx-distribution.txt
@@ -40,6 +40,7 @@ And for every distribution we publish most of its attributes.
     driver_link: None
     homepage_content: None
     icon_link: 'http://.../ubuntu/icon'
+    information_type: 'Public'
     logo_link: 'http://.../ubuntu/logo'
     main_archive_link: 'http://.../ubuntu/+archive/primary'
     members_link: 'http://.../~ubuntu-team'
@@ -54,6 +55,7 @@ And for every distribution we publish most of its attributes.
     official_codehosting: False
     official_packages: True
     owner_link: 'http://.../~ubuntu-team'
+    private: False
     redirect_default_traversal: False
     redirect_release_uploads: False
     registrant_link: 'http://.../~registry'
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index 427d95e..49f55df 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -21,10 +21,15 @@ from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import (
+    FREE_INFORMATION_TYPES,
     InformationType,
+    PILLAR_INFORMATION_TYPES,
     ServiceUsage,
     )
-from lp.app.errors import NotFoundError
+from lp.app.errors import (
+    NotFoundError,
+    ServiceUsageForbidden,
+    )
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.registry.enums import (
@@ -33,12 +38,19 @@ from lp.registry.enums import (
     DistributionDefaultTraversalPolicy,
     EXCLUSIVE_TEAM_POLICY,
     INCLUSIVE_TEAM_POLICY,
+    TeamMembershipPolicy,
     )
 from lp.registry.errors import (
+    CannotChangeInformationType,
+    CommercialSubscribersOnly,
     InclusiveTeamLinkageError,
     NoSuchDistroSeries,
+    ProprietaryPillar,
+    )
+from lp.registry.interfaces.accesspolicy import (
+    IAccessPolicyGrantSource,
+    IAccessPolicySource,
     )
-from lp.registry.interfaces.accesspolicy import IAccessPolicySource
 from lp.registry.interfaces.distribution import (
     IDistribution,
     IDistributionSet,
@@ -46,7 +58,9 @@ from lp.registry.interfaces.distribution import (
 from lp.registry.interfaces.oopsreferences import IHasOOPSReferences
 from lp.registry.interfaces.person import IPersonSet
 from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.distribution import Distribution
 from lp.registry.tests.test_distroseries import CurrentSourceReleasesMixin
+from lp.services.librarianserver.testing.fake import FakeLibrarian
 from lp.services.propertycache import get_property_cache
 from lp.services.webapp import canonical_url
 from lp.services.webapp.interfaces import OAuthPermission
@@ -73,6 +87,9 @@ from lp.testing.views import create_initialized_view
 from lp.translations.enums import TranslationPermission
 
 
+PRIVATE_DISTRIBUTION_TYPES = [InformationType.PROPRIETARY]
+
+
 class TestDistribution(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
@@ -370,6 +387,362 @@ class TestDistribution(TestCaseWithFactory):
                 DistributionDefaultTraversalPolicy.SERIES)
             distro.redirect_default_traversal = True
 
+    def test_creation_grants_maintainer_access(self):
+        # Creating a new distribution creates an access grant for the
+        # maintainer for all default policies.
+        distribution = self.factory.makeDistribution()
+        policies = getUtility(IAccessPolicySource).findByPillar(
+            (distribution,))
+        grants = getUtility(IAccessPolicyGrantSource).findByPolicy(policies)
+        expected_grantess = {distribution.owner}
+        grantees = {grant.grantee for grant in grants}
+        self.assertEqual(expected_grantess, grantees)
+
+    def test_change_info_type_proprietary_check_artifacts(self):
+        # Cannot change distribution information_type if any artifacts are
+        # public.
+        # XXX cjwatson 2022-02-11: Make this use
+        # artifact.transitionToInformationType once sharing policies are in
+        # place.
+        distribution = self.factory.makeDistribution()
+        self.useContext(person_logged_in(distribution.owner))
+        spec = self.factory.makeSpecification(distribution=distribution)
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            with ExpectedException(
+                    CannotChangeInformationType,
+                    "Some blueprints are public."):
+                distribution.information_type = info_type
+        removeSecurityProxy(spec).information_type = (
+            InformationType.PROPRIETARY)
+        dsp = self.factory.makeDistributionSourcePackage(
+            distribution=distribution)
+        bug = self.factory.makeBug(target=dsp)
+        for bug_info_type in FREE_INFORMATION_TYPES:
+            removeSecurityProxy(bug).information_type = bug_info_type
+            for info_type in PRIVATE_DISTRIBUTION_TYPES:
+                with ExpectedException(
+                        CannotChangeInformationType,
+                        "Some bugs are neither proprietary nor embargoed."):
+                    distribution.information_type = info_type
+        removeSecurityProxy(bug).information_type = InformationType.PROPRIETARY
+        distroseries = self.factory.makeDistroSeries(distribution=distribution)
+        sp = self.factory.makeSourcePackage(distroseries=distroseries)
+        branch = self.factory.makeBranch(sourcepackage=sp)
+        for branch_info_type in FREE_INFORMATION_TYPES:
+            removeSecurityProxy(branch).information_type = branch_info_type
+            for info_type in PRIVATE_DISTRIBUTION_TYPES:
+                with ExpectedException(
+                        CannotChangeInformationType,
+                        "Some branches are neither proprietary nor "
+                        "embargoed."):
+                    distribution.information_type = info_type
+        removeSecurityProxy(branch).information_type = (
+            InformationType.PROPRIETARY)
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            distribution.information_type = info_type
+
+    def test_change_info_type_proprietary_check_translations(self):
+        distribution = self.factory.makeDistribution()
+        with person_logged_in(distribution.owner):
+            for usage in ServiceUsage:
+                distribution.information_type = InformationType.PUBLIC
+                distribution.translations_usage = usage.value
+                for info_type in PRIVATE_DISTRIBUTION_TYPES:
+                    if (distribution.translations_usage ==
+                            ServiceUsage.LAUNCHPAD):
+                        with ExpectedException(
+                                CannotChangeInformationType,
+                                "Translations are enabled."):
+                            distribution.information_type = info_type
+                    else:
+                        distribution.information_type = info_type
+
+    def test_cacheAccessPolicies(self):
+        # Distribution.access_policies is a list caching AccessPolicy.ids
+        # for which an AccessPolicyGrant or AccessArtifactGrant gives a
+        # principal LimitedView on the Distribution.
+        aps = getUtility(IAccessPolicySource)
+
+        # Public distributions don't need a cache.
+        distribution = self.factory.makeDistribution()
+        naked_distribution = removeSecurityProxy(distribution)
+        self.assertContentEqual(
+            [InformationType.USERDATA, InformationType.PRIVATESECURITY],
+            [p.type for p in aps.findByPillar([distribution])])
+        self.assertIsNone(naked_distribution.access_policies)
+
+        # A private distribution normally just allows the Proprietary
+        # policy, even if there is still another policy like Private
+        # Security.
+        naked_distribution.information_type = InformationType.PROPRIETARY
+        [prop_policy] = aps.find([(distribution, InformationType.PROPRIETARY)])
+        self.assertEqual([prop_policy.id], naked_distribution.access_policies)
+
+        # If we switch it back to public, the cache is no longer
+        # required.
+        naked_distribution.information_type = InformationType.PUBLIC
+        self.assertIsNone(naked_distribution.access_policies)
+
+    def test_checkInformationType_bug_supervisor(self):
+        # Bug supervisors of proprietary distributions must not have
+        # inclusive membership policies.
+        team = self.factory.makeTeam()
+        distribution = self.factory.makeDistribution(bug_supervisor=team)
+        for policy in (token.value for token in TeamMembershipPolicy):
+            with person_logged_in(team.teamowner):
+                team.membership_policy = policy
+            for info_type in PRIVATE_DISTRIBUTION_TYPES:
+                with person_logged_in(distribution.owner):
+                    errors = list(distribution.checkInformationType(info_type))
+                if policy in EXCLUSIVE_TEAM_POLICY:
+                    self.assertEqual([], errors)
+                else:
+                    with ExpectedException(
+                            CannotChangeInformationType,
+                            "Bug supervisor has inclusive membership."):
+                        raise errors[0]
+
+    def test_checkInformationType_questions(self):
+        # Proprietary distributions must not have questions.
+        distribution = self.factory.makeDistribution()
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            with person_logged_in(distribution.owner):
+                self.assertEqual([],
+                    list(distribution.checkInformationType(info_type)))
+        self.factory.makeQuestion(target=distribution)
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            with person_logged_in(distribution.owner):
+                error, = list(distribution.checkInformationType(info_type))
+            with ExpectedException(
+                    CannotChangeInformationType,
+                    "This distribution has questions."):
+                raise error
+
+    def test_checkInformationType_translations(self):
+        # Proprietary distributions must not have translations.
+        distroseries = self.factory.makeDistroSeries()
+        distribution = distroseries.distribution
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            with person_logged_in(distribution.owner):
+                self.assertEqual(
+                    [], list(distribution.checkInformationType(info_type)))
+        self.factory.makePOTemplate(distroseries=distroseries)
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            with person_logged_in(distribution.owner):
+                error, = list(distribution.checkInformationType(info_type))
+            with ExpectedException(
+                    CannotChangeInformationType,
+                    "This distribution has translations."):
+                raise error
+
+    def test_checkInformationType_queued_translations(self):
+        # Proprietary distributions must not have queued translations.
+        self.useFixture(FakeLibrarian())
+        distroseries = self.factory.makeDistroSeries()
+        distribution = distroseries.distribution
+        entry = self.factory.makeTranslationImportQueueEntry(
+            distroseries=distroseries)
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            with person_logged_in(distribution.owner):
+                error, = list(distribution.checkInformationType(info_type))
+            with ExpectedException(
+                    CannotChangeInformationType,
+                    "This distribution has queued translations."):
+                raise error
+        Store.of(entry).remove(entry)
+        with person_logged_in(distribution.owner):
+            for info_type in PRIVATE_DISTRIBUTION_TYPES:
+                self.assertContentEqual(
+                    [], distribution.checkInformationType(info_type))
+
+    def test_checkInformationType_series_only_bugs(self):
+        # A distribution with bugtasks that are only targeted to a series
+        # cannot change information type.
+        series = self.factory.makeDistroSeries()
+        bug = self.factory.makeBug(target=series.distribution)
+        with person_logged_in(series.owner):
+            bug.addTask(series.owner, series)
+            bug.default_bugtask.delete()
+            for info_type in PRIVATE_DISTRIBUTION_TYPES:
+                error, = list(series.distribution.checkInformationType(
+                    info_type))
+                with ExpectedException(
+                        CannotChangeInformationType,
+                        "Some bugs are neither proprietary nor embargoed."):
+                    raise error
+
+    def test_private_forbids_translations(self):
+        owner = self.factory.makePerson()
+        distribution = self.factory.makeDistribution(owner=owner)
+        self.useContext(person_logged_in(owner))
+        for info_type in PRIVATE_DISTRIBUTION_TYPES:
+            distribution.information_type = info_type
+            with ExpectedException(
+                    ProprietaryPillar,
+                    "Translations are not supported for proprietary "
+                    "distributions."):
+                distribution.translations_usage = ServiceUsage.LAUNCHPAD
+            for usage in ServiceUsage.items:
+                if usage == ServiceUsage.LAUNCHPAD:
+                    continue
+                distribution.translations_usage = usage
+
+    def createDistribution(self, information_type=None):
+        # Convenience method for testing IDistributionSet.new rather than
+        # self.factory.makeDistribution.
+        owner = self.factory.makePerson()
+        members = self.factory.makeTeam(owner=owner)
+        kwargs = {}
+        if information_type is not None:
+            kwargs['information_type'] = information_type
+        with person_logged_in(owner):
+            return getUtility(IDistributionSet).new(
+                name=self.factory.getUniqueUnicode("distro"),
+                display_name="Fnord", title="Fnord",
+                description="test 1", summary="test 2",
+                domainname="distro.example.org",
+                members=members, owner=owner, registrant=owner, **kwargs)
+
+    def test_information_type(self):
+        # Distribution is created with specified information_type.
+        distribution = self.createDistribution(
+            information_type=InformationType.PROPRIETARY)
+        self.assertEqual(
+            InformationType.PROPRIETARY, distribution.information_type)
+        # The owner can set information_type.
+        with person_logged_in(removeSecurityProxy(distribution).owner):
+            distribution.information_type = InformationType.PUBLIC
+        self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+        # The database persists the value of information_type.
+        store = Store.of(distribution)
+        store.flush()
+        store.reset()
+        distribution = store.get(Distribution, distribution.id)
+        self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+        self.assertFalse(distribution.private)
+
+    def test_switching_to_public_does_not_create_policy(self):
+        # Creating a Proprietary distribution and switching it to Public
+        # does not create a PUBLIC AccessPolicy.
+        distribution = self.createDistribution(
+            information_type=InformationType.PROPRIETARY)
+        aps = getUtility(IAccessPolicySource).findByPillar([distribution])
+        self.assertContentEqual(
+            [InformationType.PROPRIETARY],
+            [ap.type for ap in aps])
+        removeSecurityProxy(distribution).information_type = (
+            InformationType.PUBLIC)
+        aps = getUtility(IAccessPolicySource).findByPillar([distribution])
+        self.assertContentEqual(
+            [InformationType.PROPRIETARY],
+            [ap.type for ap in aps])
+
+    def test_information_type_default(self):
+        # The default information_type is PUBLIC.
+        distribution = self.createDistribution()
+        self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+        self.assertFalse(distribution.private)
+
+    invalid_information_types = [
+        info_type for info_type in InformationType.items
+        if info_type not in PILLAR_INFORMATION_TYPES]
+
+    def test_information_type_init_invalid_values(self):
+        # Cannot create Distribution.information_type with invalid values.
+        for info_type in self.invalid_information_types:
+            with ExpectedException(
+                    CannotChangeInformationType,
+                    "Not supported for distributions."):
+                self.createDistribution(information_type=info_type)
+
+    def test_information_type_set_invalid_values(self):
+        # Cannot set Distribution.information_type to invalid values.
+        distribution = self.factory.makeDistribution()
+        for info_type in self.invalid_information_types:
+            with ExpectedException(
+                    CannotChangeInformationType,
+                    "Not supported for distributions."):
+                with person_logged_in(distribution.owner):
+                    distribution.information_type = info_type
+
+    def test_set_proprietary_gets_commercial_subscription(self):
+        # Changing a Distribution to Proprietary will auto-generate a
+        # complimentary subscription just as choosing a proprietary
+        # information type at creation time.
+        owner = self.factory.makePerson()
+        distribution = self.factory.makeDistribution(owner=owner)
+        self.useContext(person_logged_in(owner))
+        self.assertIsNone(distribution.commercial_subscription)
+
+        distribution.information_type = InformationType.PROPRIETARY
+        self.assertEqual(
+            InformationType.PROPRIETARY, distribution.information_type)
+        self.assertIsNotNone(distribution.commercial_subscription)
+
+    def test_set_proprietary_fails_expired_commercial_subscription(self):
+        # Cannot set information type to proprietary with an expired
+        # complimentary subscription.
+        owner = self.factory.makePerson()
+        distribution = self.factory.makeDistribution(
+            information_type=InformationType.PROPRIETARY, owner=owner)
+        self.useContext(person_logged_in(owner))
+
+        # The Distribution now has a complimentary commercial subscription.
+        new_expires_date = (
+            datetime.datetime.now(pytz.UTC) - datetime.timedelta(1))
+        naked_subscription = removeSecurityProxy(
+            distribution.commercial_subscription)
+        naked_subscription.date_expires = new_expires_date
+
+        # We can make the distribution PUBLIC.
+        distribution.information_type = InformationType.PUBLIC
+        self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+
+        # However we can't change it back to Proprietary because our
+        # commercial subscription has expired.
+        with ExpectedException(
+                CommercialSubscribersOnly,
+                "A valid commercial subscription is required for private"
+                " distributions."):
+            distribution.information_type = InformationType.PROPRIETARY
+
+    def test_no_answers_for_proprietary(self):
+        # Enabling Answers is forbidden while information_type is proprietary.
+        distribution = self.factory.makeDistribution(
+            information_type=InformationType.PROPRIETARY)
+        with person_logged_in(removeSecurityProxy(distribution).owner):
+            self.assertEqual(ServiceUsage.UNKNOWN, distribution.answers_usage)
+            for usage in ServiceUsage.items:
+                if usage == ServiceUsage.LAUNCHPAD:
+                    with ExpectedException(
+                            ServiceUsageForbidden,
+                            "Answers not allowed for non-public "
+                            "distributions."):
+                        distribution.answers_usage = ServiceUsage.LAUNCHPAD
+                else:
+                    # All other values are permitted.
+                    distribution.answers_usage = usage
+
+    def test_answers_for_public(self):
+        # Enabling answers is permitted while information_type is PUBLIC.
+        distribution = self.factory.makeDistribution(
+            information_type=InformationType.PUBLIC)
+        self.assertEqual(ServiceUsage.UNKNOWN, distribution.answers_usage)
+        with person_logged_in(distribution.owner):
+            for usage in ServiceUsage.items:
+                # All values are permitted.
+                distribution.answers_usage = usage
+
+    def test_no_proprietary_if_answers(self):
+        # Information type cannot be set to proprietary while Answers are
+        # enabled.
+        distribution = self.factory.makeDistribution()
+        with person_logged_in(distribution.owner):
+            distribution.answers_usage = ServiceUsage.LAUNCHPAD
+            with ExpectedException(
+                    CannotChangeInformationType, "Answers is enabled."):
+                distribution.information_type = InformationType.PROPRIETARY
+
 
 class TestDistributionCurrentSourceReleases(
     CurrentSourceReleasesMixin, TestCase):
diff --git a/lib/lp/registry/tests/test_person.py b/lib/lp/registry/tests/test_person.py
index 10d8456..c601415 100644
--- a/lib/lp/registry/tests/test_person.py
+++ b/lib/lp/registry/tests/test_person.py
@@ -831,14 +831,24 @@ class TestPerson(TestCaseWithFactory):
         self.assertTrue(owner.isAnyPillarOwner())
         self.assertFalse(person.isAnyPillarOwner())
 
-    def test_has_current_commercial_subscription(self):
-        # IPerson.hasCurrentCommercialSubscription() checks for one.
+    def test_has_current_commercial_subscription_product(self):
+        # IPerson.hasCurrentCommercialSubscription() checks for one on a
+        # product.
         team = self.factory.makeTeam(
             membership_policy=TeamMembershipPolicy.MODERATED)
         product = self.factory.makeProduct(owner=team)
         self.factory.makeCommercialSubscription(product)
         self.assertTrue(team.teamowner.hasCurrentCommercialSubscription())
 
+    def test_has_current_commercial_subscription_distribution(self):
+        # IPerson.hasCurrentCommercialSubscription() checks for one on a
+        # distribution.
+        team = self.factory.makeTeam(
+            membership_policy=TeamMembershipPolicy.MODERATED)
+        distro = self.factory.makeDistribution(owner=team)
+        self.factory.makeCommercialSubscription(distro)
+        self.assertTrue(team.teamowner.hasCurrentCommercialSubscription())
+
     def test_does_not_have_current_commercial_subscription(self):
         # IPerson.hasCurrentCommercialSubscription() is false if it has
         # expired.
@@ -846,6 +856,8 @@ class TestPerson(TestCaseWithFactory):
             membership_policy=TeamMembershipPolicy.MODERATED)
         product = self.factory.makeProduct(owner=team)
         self.factory.makeCommercialSubscription(product, expired=True)
+        distro = self.factory.makeDistribution(owner=team)
+        self.factory.makeCommercialSubscription(distro, expired=True)
         self.assertFalse(team.teamowner.hasCurrentCommercialSubscription())
 
     def test_does_not_have_commercial_subscription(self):
diff --git a/lib/lp/registry/tests/test_pillaraffiliation.py b/lib/lp/registry/tests/test_pillaraffiliation.py
index 0098e28..e757d0e 100644
--- a/lib/lp/registry/tests/test_pillaraffiliation.py
+++ b/lib/lp/registry/tests/test_pillaraffiliation.py
@@ -3,11 +3,11 @@
 
 """Tests for adapters."""
 
-from storm.store import Store
 from testtools.matchers import Equals
 from zope.component import getUtility
 
 from lp.registry.model.pillaraffiliation import IHasAffiliation
+from lp.services.database.sqlbase import flush_database_caches
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.testing import (
     person_logged_in,
@@ -146,21 +146,20 @@ class TestPillarAffiliation(TestCaseWithFactory):
         # - Product, Person
         person = self.factory.makePerson()
         product = self.factory.makeProduct(owner=person, name='pting')
-        Store.of(product).invalidate()
+        flush_database_caches()
         with StormStatementRecorder() as recorder:
             IHasAffiliation(product).getAffiliationBadges([person])
-        self.assertThat(recorder, HasQueryCount(Equals(4)))
+        self.assertThat(recorder, HasQueryCount(Equals(2)))
 
     def test_distro_affiliation_query_count(self):
         # Only 2 business queries are expected, selects from:
         # - Distribution, Person
-        # plus an additional query to create a PublisherConfig record.
         person = self.factory.makePerson()
         distro = self.factory.makeDistribution(owner=person, name='pting')
-        Store.of(distro).invalidate()
+        flush_database_caches()
         with StormStatementRecorder() as recorder:
             IHasAffiliation(distro).getAffiliationBadges([person])
-        self.assertThat(recorder, HasQueryCount(Equals(3)))
+        self.assertThat(recorder, HasQueryCount(Equals(2)))
 
 
 class _TestBugTaskorBranchMixin:
diff --git a/lib/lp/registry/tests/test_product.py b/lib/lp/registry/tests/test_product.py
index c037549..61f147a 100644
--- a/lib/lp/registry/tests/test_product.py
+++ b/lib/lp/registry/tests/test_product.py
@@ -68,7 +68,7 @@ from lp.registry.errors import (
     CannotChangeInformationType,
     CommercialSubscribersOnly,
     InclusiveTeamLinkageError,
-    ProprietaryProduct,
+    ProprietaryPillar,
     )
 from lp.registry.interfaces.accesspolicy import (
     IAccessPolicyGrantSource,
@@ -635,7 +635,7 @@ class TestProduct(TestCaseWithFactory):
         for info_type in PRIVATE_PROJECT_TYPES:
             product.information_type = info_type
             with ExpectedException(
-                ProprietaryProduct,
+                ProprietaryPillar,
                 "Translations are not supported for proprietary products."):
                 product.translations_usage = ServiceUsage.LAUNCHPAD
             for usage in ServiceUsage.items:
@@ -1761,7 +1761,7 @@ class BaseSharingPolicyTests:
             InformationType.PUBLIC in self.allowed_types[policy])
         for policy in policies_permitting_public:
             with ExpectedException(
-                ProprietaryProduct, "The project is Proprietary."):
+                ProprietaryPillar, "The project is Proprietary."):
                 self.setSharingPolicy(policy, owner)
 
 
diff --git a/lib/lp/registry/tests/test_productrelease.py b/lib/lp/registry/tests/test_productrelease.py
index 39c8222..85ecbca 100644
--- a/lib/lp/registry/tests/test_productrelease.py
+++ b/lib/lp/registry/tests/test_productrelease.py
@@ -8,7 +8,7 @@ from zope.component import getUtility
 from lp.app.enums import InformationType
 from lp.registry.errors import (
     InvalidFilename,
-    ProprietaryProduct,
+    ProprietaryPillar,
     )
 from lp.registry.interfaces.productrelease import (
     IProductReleaseSet,
@@ -101,5 +101,5 @@ class ProductReleaseFileTestcase(TestCaseWithFactory):
             release = self.factory.makeProductRelease(product=product)
             self.assertFalse(release.can_have_release_files)
             self.assertRaises(
-                ProprietaryProduct, release.addReleaseFile,
+                ProprietaryPillar, release.addReleaseFile,
                 'README', b'test', 'text/plain', owner)
diff --git a/lib/lp/registry/tests/test_productseries.py b/lib/lp/registry/tests/test_productseries.py
index da6f828..e80452e 100644
--- a/lib/lp/registry/tests/test_productseries.py
+++ b/lib/lp/registry/tests/test_productseries.py
@@ -20,7 +20,7 @@ from lp.app.interfaces.services import IService
 from lp.registry.enums import SharingPermission
 from lp.registry.errors import (
     CannotPackageProprietaryProduct,
-    ProprietaryProduct,
+    ProprietaryPillar,
     )
 from lp.registry.interfaces.distribution import IDistributionSet
 from lp.registry.interfaces.distroseries import IDistroSeriesSet
@@ -73,7 +73,7 @@ class TestProductSeries(TestCaseWithFactory):
         for mode in TranslationsBranchImportMode.items:
             if mode == TranslationsBranchImportMode.NO_IMPORT:
                 continue
-            with ExpectedException(ProprietaryProduct,
+            with ExpectedException(ProprietaryPillar,
                     'Translations are disabled for proprietary'
                     ' projects.'):
                 series.translations_autoimport_mode = mode
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 22bc140..95d7a45 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -1282,7 +1282,11 @@ class EditDistributionByDistroOwnersOrAdmins(AuthorizationBase):
     usedfor = IDistribution
 
     def checkAuthenticated(self, user):
-        return user.isOwner(self.obj) or user.in_admin
+        # Commercial admins may help setup commercial distributions.
+        return (
+            user.isOwner(self.obj)
+            or is_commercial_case(self.obj, user)
+            or user.in_admin)
 
 
 class ModerateDistributionByDriversOrOwnersOrAdmins(AuthorizationBase):
@@ -3266,6 +3270,10 @@ class ViewPublisherConfig(AdminByAdminsTeam):
     usedfor = IPublisherConfig
 
 
+class ViewSourcePackage(AnonymousAuthorization):
+    usedfor = ISourcePackage
+
+
 class EditSourcePackage(EditDistributionSourcePackage):
     usedfor = ISourcePackage
 
diff --git a/lib/lp/soyuz/tests/test_build_set.py b/lib/lp/soyuz/tests/test_build_set.py
index 6dcf9fd..b2a795c 100644
--- a/lib/lp/soyuz/tests/test_build_set.py
+++ b/lib/lp/soyuz/tests/test_build_set.py
@@ -29,7 +29,10 @@ from lp.testing import (
     person_logged_in,
     TestCaseWithFactory,
     )
-from lp.testing.dbuser import lp_dbuser
+from lp.testing.dbuser import (
+    lp_dbuser,
+    switch_dbuser,
+    )
 from lp.testing.layers import (
     LaunchpadFunctionalLayer,
     ZopelessDatabaseLayer,
@@ -348,6 +351,13 @@ class BuildRecordCreationTests(TestNativePublishingBase):
 
     def setUp(self):
         super().setUp()
+
+        # TestNativePublishingBase switches to the archive publisher's
+        # database user, but the publisher doesn't create build records so
+        # we aren't really interested in its database permissions here.
+        # Just use the webapp's database user instead.
+        switch_dbuser("launchpad")
+
         self.distro = self.factory.makeDistribution()
         self.avr = self.factory.makeProcessor(
             name="avr2001", supports_virtualized=True)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index b098cba..d4b9f20 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -2709,7 +2709,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
                          publish_root_dir=None, publish_base_url=None,
                          publish_copy_base_url=None, no_pubconf=False,
                          icon=None, summary=None, vcs=None,
-                         oci_project_admin=None):
+                         oci_project_admin=None, information_type=None):
         """Make a new distribution."""
         if name is None:
             name = self.getUniqueString(prefix="distribution")
@@ -2729,7 +2729,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             members = self.makeTeam(owner)
         distro = getUtility(IDistributionSet).new(
             name, displayname, title, description, summary, domainname,
-            members, owner, registrant, icon=icon, vcs=vcs)
+            members, owner, registrant, icon=icon, vcs=vcs,
+            information_type=information_type)
         naked_distro = removeSecurityProxy(distro)
         if aliases is not None:
             naked_distro.setAliases(aliases)

Follow ups