← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/subscribe-grants-access into lp:launchpad

 

Curtis Hovey has proposed merging lp:~wallyworld/launchpad/subscribe-grants-access into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1000045 in Launchpad itself: "Ensure access when subscribing a user to a bug "
  https://bugs.launchpad.net/launchpad/+bug/1000045

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/subscribe-grants-access/+merge/106277

let me merge
-- 
https://code.launchpad.net/~wallyworld/launchpad/subscribe-grants-access/+merge/106277
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/subscribe-grants-access into lp:launchpad.
=== modified file 'configs/development/launchpad-lazr.conf'
--- configs/development/launchpad-lazr.conf	2012-05-02 01:31:53 +0000
+++ configs/development/launchpad-lazr.conf	2012-05-17 22:13:25 +0000
@@ -221,6 +221,9 @@
 password: guest
 virtual_host: /
 
+[sharing_jobs]
+error_dir: /var/tmp/sharing.test
+
 [txlongpoll]
 launch: True
 frontend_port: 22435

=== modified file 'configs/testrunner/launchpad-lazr.conf'
--- configs/testrunner/launchpad-lazr.conf	2012-05-02 01:31:53 +0000
+++ configs/testrunner/launchpad-lazr.conf	2012-05-17 22:13:25 +0000
@@ -162,6 +162,9 @@
 [packaging_translations]
 error_dir: /var/tmp/lperr.test
 
+[sharing_jobs]
+error_dir: /var/tmp/sharing.test
+
 [upgrade_branches]
 error_dir: /var/tmp/codehosting.test
 

=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2012-05-09 13:50:03 +0000
+++ database/schema/security.cfg	2012-05-17 22:13:25 +0000
@@ -1882,13 +1882,17 @@
 [sharing-jobs]
 groups=script
 public.accessartifactgrant              = SELECT, UPDATE, DELETE
+public.accesspolicy                     = SELECT
 public.accesspolicygrant                = SELECT, UPDATE, DELETE
+public.accesspolicygrantflat            = SELECT
 public.branch                           = SELECT
 public.branchsubscription               = SELECT, UPDATE, DELETE
 public.bug                              = SELECT, UPDATE
 public.bugactivity                      = SELECT, INSERT
 public.bugbranch                        = SELECT
 public.bugsubscription                  = SELECT, UPDATE, DELETE
+public.bugtask                          = SELECT
+public.bugtaskflat                      = SELECT
 public.distribution                     = SELECT
 public.emailaddress                     = SELECT
 public.job                              = SELECT, INSERT, UPDATE

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2012-05-11 13:31:27 +0000
+++ lib/lp/bugs/model/bug.py	2012-05-17 22:13:25 +0000
@@ -4,6 +4,7 @@
 # pylint: disable-msg=E0611,W0212
 
 """Launchpad bug-related database table classes."""
+from lp.app.interfaces.services import IService
 
 __metaclass__ = type
 
@@ -162,6 +163,10 @@
     PRIVATE_INFORMATION_TYPES,
     SECURITY_INFORMATION_TYPES,
     )
+from lp.registry.interfaces.accesspolicy import (
+    IAccessArtifactGrantSource,
+    IAccessArtifactSource,
+    )
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distroseries import IDistroSeries
 from lp.registry.interfaces.person import (
@@ -838,6 +843,15 @@
         # Ensure that the subscription has been flushed.
         Store.of(sub).flush()
 
+        # Grant the subscriber access if they can't see the bug (if the
+        # database triggers aren't going to do it for us).
+        trigger_flag = 'disclosure.access_mirror_triggers.removed'
+        if bool(getFeatureFlag(trigger_flag)):
+            service = getUtility(IService, 'sharing')
+            bugs, ignored = service.getVisibleArtifacts(person, bugs=[self])
+            if not bugs:
+                service.createAccessGrants(subscribed_by, person, bugs=[self])
+
         # In some cases, a subscription should be created without
         # email notifications.  suppress_notify determines if
         # notifications are sent.
@@ -880,6 +894,14 @@
                 store.flush()
                 self.updateHeat()
                 del get_property_cache(self)._known_viewers
+
+                # Revoke access to bug if feature flag is on.
+                flag = 'disclosure.legacy_subscription_visibility.enabled'
+                if bool(getFeatureFlag(flag)):
+                    artifacts_to_delete = getUtility(
+                        IAccessArtifactSource).find([self])
+                    getUtility(IAccessArtifactGrantSource).revokeByArtifact(
+                        artifacts_to_delete, [person])
                 return
 
     def unsubscribeFromDupes(self, person, unsubscribed_by):

=== modified file 'lib/lp/bugs/tests/test_bugvisibility.py'
--- lib/lp/bugs/tests/test_bugvisibility.py	2012-05-09 06:12:52 +0000
+++ lib/lp/bugs/tests/test_bugvisibility.py	2012-05-17 22:13:25 +0000
@@ -4,21 +4,28 @@
 """Tests for visibility of a bug."""
 
 from lp.registry.enums import InformationType
+from lp.services.features.testing import FeatureFixture
 from lp.testing import (
     celebrity_logged_in,
     TestCaseWithFactory,
     )
+from lp.testing.fixture import DisableTriggerFixture
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
-    LaunchpadFunctionalLayer,
+    ZopelessDatabaseLayer,
     )
 
 
+LEGACY_VISIBILITY_FLAG = {
+    u"disclosure.legacy_subscription_visibility.enabled": u"true"}
+TRIGGERS_REMOVED_FLAG = {
+    u"disclosure.access_mirror_triggers.removed": u"true"}
+
+
 class TestPublicBugVisibility(TestCaseWithFactory):
     """Test visibility for a public bug."""
 
     layer = DatabaseFunctionalLayer
-    #layer = LaunchpadFunctionalLayer
 
     def setUp(self):
         super(TestPublicBugVisibility, self).setUp()
@@ -38,7 +45,7 @@
 class TestPrivateBugVisibility(TestCaseWithFactory):
     """Test visibility for a private bug."""
 
-    layer = LaunchpadFunctionalLayer
+    layer = ZopelessDatabaseLayer
 
     def setUp(self):
         super(TestPrivateBugVisibility, self).setUp()
@@ -78,6 +85,63 @@
             self.bug.subscribe(user, self.owner)
         self.assertTrue(self.bug.userCanView(user))
 
-    def test_publicBugAnonUser(self):
+    def test_privateBugAnonUser(self):
         # Since the bug is private, the anonymous user cannot see it.
         self.assertFalse(self.bug.userCanView(None))
+
+    @property
+    def disable_trigger_fixture(self):
+        return DisableTriggerFixture(
+                {'bugsubscription':
+                     'bugsubscription_mirror_legacy_access_t',
+                 'bug': 'bug_mirror_legacy_access_t',
+                 'bugtask': 'bugtask_mirror_legacy_access_t',
+            })
+
+    def test_privateBugUnsubscribeRevokesVisibility(self):
+        # A person unsubscribed from a private bug can no longer see it.
+        # Requires feature flag since the default model behaviour is to leave
+        # any access grants untouched.
+        # This test disables the current temporary database triggers which
+        # mirror subscription status into visibility.
+        with FeatureFixture(LEGACY_VISIBILITY_FLAG):
+            user = self.factory.makePerson()
+            with celebrity_logged_in('admin'):
+                self.bug.subscribe(user, self.owner)
+                self.assertTrue(self.bug.userCanView(user))
+                with self.disable_trigger_fixture:
+                    self.bug.unsubscribe(user, self.owner)
+            self.assertFalse(self.bug.userCanView(user))
+
+    def test_privateBugUnsubscribeRetainsVisibility(self):
+        # A person unsubscribed from a private bug can still see it if the
+        # feature flag to enable legacy subscription visibility is not set.
+        # This test disables the current temporary database triggers which
+        # mirror subscription status into visibility.
+        user = self.factory.makePerson()
+        with celebrity_logged_in('admin'):
+            self.bug.subscribe(user, self.owner)
+            self.assertTrue(self.bug.userCanView(user))
+            with self.disable_trigger_fixture:
+                self.bug.unsubscribe(user, self.owner)
+        self.assertTrue(self.bug.userCanView(user))
+
+    def test_subscribeGrantsVisibilityWithTriggersRemoved(self):
+        # When a user is subscribed to a bug, they are granted access. In this
+        # test, the database triggers are removed and so model code is used.
+        with FeatureFixture(TRIGGERS_REMOVED_FLAG):
+            with self.disable_trigger_fixture:
+                user = self.factory.makePerson()
+                self.assertFalse(self.bug.userCanView(user))
+                with celebrity_logged_in('admin'):
+                    self.bug.subscribe(user, self.owner)
+                    self.assertTrue(self.bug.userCanView(user))
+
+    def test_subscribeGrantsVisibilityUsingTriggers(self):
+        # When a user is subscribed to a bug, they are granted access. In this
+        # test, the database triggers are used.
+        user = self.factory.makePerson()
+        self.assertFalse(self.bug.userCanView(user))
+        with celebrity_logged_in('admin'):
+            self.bug.subscribe(user, self.owner)
+            self.assertTrue(self.bug.userCanView(user))

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2012-05-04 04:52:24 +0000
+++ lib/lp/code/model/branch.py	2012-05-17 22:13:25 +0000
@@ -825,13 +825,15 @@
         """See `IBranch`."""
         return self.getSubscription(person) is not None
 
-    def unsubscribe(self, person, unsubscribed_by):
+    def unsubscribe(self, person, unsubscribed_by, **kwargs):
         """See `IBranch`."""
         subscription = self.getSubscription(person)
         if subscription is None:
             # Silent success seems order of the day (like bugs).
             return
-        if not subscription.canBeUnsubscribedByUser(unsubscribed_by):
+        ignore_permissions = kwargs.get('ignore_permissions', False)
+        if (not ignore_permissions
+            and not subscription.canBeUnsubscribedByUser(unsubscribed_by)):
             raise UserCannotUnsubscribePerson(
                 '%s does not have permission to unsubscribe %s.' % (
                     unsubscribed_by.displayname,

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2012-04-12 04:39:58 +0000
+++ lib/lp/registry/configure.zcml	2012-05-17 22:13:25 +0000
@@ -1983,4 +1983,19 @@
         <allow
             interface="lp.registry.interfaces.accesspolicy.IAccessPolicyGrantFlatSource"/>
     </securedutility>
+
+    <!-- Sharing jobs -->
+    <class class=".model.sharingjob.RemoveSubscriptionsJob">
+      <allow interface=".interfaces.sharingjob.IRemoveSubscriptionsJob"/>
+      <allow attributes="
+         context
+         log_name"/>
+    </class>
+
+    <securedutility
+        component=".model.sharingjob.RemoveSubscriptionsJob"
+        provides=".interfaces.sharingjob.IRemoveSubscriptionsJobSource">
+      <allow interface=".interfaces.sharingjob.IRemoveSubscriptionsJobSource"/>
+    </securedutility>
+
 </configure>

=== modified file 'lib/lp/registry/interfaces/accesspolicy.py'
--- lib/lp/registry/interfaces/accesspolicy.py	2012-05-15 08:16:09 +0000
+++ lib/lp/registry/interfaces/accesspolicy.py	2012-05-17 22:13:25 +0000
@@ -254,9 +254,34 @@
             type.
         """
 
+    def findIndirectGranteePermissionsByPolicy(policies, grantees=None):
+        """Find teams or users with access grants for the policies.
+
+        This method is similar to findGranteePermissionsByPolicy, but the
+        results contain people who have access by virtue of team membership.
+
+        :param policies: a collection of `IAccessPolicy`s.
+        :param grantees: if not None, the result only includes people in the
+            specified list of grantees.
+        :return: a collection of
+            (`IPerson` sharee, `IAccessPolicy`, permission,
+                [`ITeam`] via_teams, shared_artifact_types)
+            where
+            sharee is the person or team with access
+            permission is a SharingPermission enum value.
+            ALL means the person has an access policy grant and can see all
+            artifacts for the associated pillar.
+            SOME means the person only has specified access artifact grants.
+            via_teams is the team the sharee belongs to in order to gain
+            access. If via is None, then the sharee has direct access.
+            shared_artifact_types contains the information_types for which the
+            user has been granted access for one or more artifacts of that
+            type.
+        """
+
     def findArtifactsByGrantee(grantee, policies):
         """Find the `IAccessArtifact`s for grantee and policies.
 
         :param grantee: the access artifact grantee.
-        :param policies: a collection of `IAccesPolicy`s.
+        :param policies: a collection of `IAccessPolicy`s.
         """

=== added file 'lib/lp/registry/interfaces/sharingjob.py'
--- lib/lp/registry/interfaces/sharingjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/interfaces/sharingjob.py	2012-05-17 22:13:25 +0000
@@ -0,0 +1,88 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for sharing jobs."""
+
+__metaclass__ = type
+
+__all__ = [
+    'IRemoveSubscriptionsJob',
+    'IRemoveSubscriptionsJobSource',
+    'ISharingJob',
+    'ISharingJobSource',
+    ]
+
+from zope.interface import Attribute
+from zope.schema import (
+    Int,
+    Object,
+    )
+
+from lp import _
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.product import IProduct
+from lp.services.job.interfaces.job import (
+    IJob,
+    IJobSource,
+    IRunnableJob,
+    )
+
+
+class ISharingJob(IRunnableJob):
+    """A Job for sharing related tasks."""
+
+    id = Int(
+        title=_('DB ID'), required=True, readonly=True,
+        description=_("The tracking number for this job."))
+
+    job = Object(title=_('The common Job attributes'), schema=IJob,
+        required=True)
+
+    product = Object(
+        title=_('The product the job is for'),
+        schema=IProduct)
+
+    distro = Object(
+        title=_('The distribution the job is for'),
+        schema=IDistribution)
+
+    grantee = Object(
+        title=_('The grantee the job is for'),
+        schema=IPerson)
+
+    metadata = Attribute('A dict of data about the job.')
+
+    def destroySelf():
+        """Destroy this object."""
+
+    def getErrorRecipients(self):
+        """See `BaseRunnableJob`."""
+
+    def pillar():
+        """Either product or distro, whichever is not None."""
+
+    def requestor():
+        """The person who initiated the job."""
+
+
+class IRemoveSubscriptionsJob(ISharingJob):
+    """Job to remove subscriptions to artifacts for which access is revoked."""
+
+
+class ISharingJobSource(IJobSource):
+    """Base interface for acquiring ISharingJobs."""
+
+    def create(pillar, grantee, metadata):
+        """Create a new ISharingJob."""
+
+
+class IRemoveSubscriptionsJobSource(ISharingJobSource):
+    """An interface for acquiring IRemoveSubscriptionsJobs."""
+
+    def create(pillar, grantee, requestor, bugs=None, branches=None):
+        """Create a new job to revoke access to the specified artifacts.
+
+        If bug and branches are both None, then all subscriptions the grantee
+        may have to any pillar artifacts are removed.
+        """

=== modified file 'lib/lp/registry/interfaces/sharingservice.py'
--- lib/lp/registry/interfaces/sharingservice.py	2012-05-15 08:37:14 +0000
+++ lib/lp/registry/interfaces/sharingservice.py	2012-05-17 22:13:25 +0000
@@ -58,6 +58,18 @@
         :return: a (bugtasks, branches) tuple
         """
 
+    def getVisibleArtifacts(person, branches=None, bugs=None):
+        """Return the artifacts shared with person.
+
+        Given lists of artifacts, return those a person has access to either
+        via a policy grant or artifact grant.
+
+        :param person: the person whose access is being checked.
+        :param branches: the branches to check for which a person has access.
+        :param bugs: the bugs to check for which a person has access.
+        :return: a collection of artifacts the person can see.
+        """
+
     def getInformationTypes(pillar):
         """Return the allowed information types for the given pillar."""
 
@@ -113,22 +125,25 @@
         """
 
     @export_write_operation()
+    @call_with(user=REQUEST_USER)
     @operation_parameters(
         pillar=Reference(IPillar, title=_('Pillar'), required=True),
         sharee=Reference(IPerson, title=_('Sharee'), required=True),
         information_types=List(
             Choice(vocabulary=InformationType), required=False))
     @operation_for_version('devel')
-    def deletePillarSharee(pillar, sharee, information_types):
+    def deletePillarSharee(pillar, sharee, user, information_types):
         """Remove a sharee from a pillar.
 
         :param pillar: the pillar from which to remove access
+        :param user: the user making the request
         :param sharee: the person or team to remove
         :param information_types: if None, remove all access, otherwise just
                                    remove the specified access_policies
         """
 
     @export_write_operation()
+    @call_with(user=REQUEST_USER)
     @operation_parameters(
         pillar=Reference(IPillar, title=_('Pillar'), required=True),
         sharee=Reference(IPerson, title=_('Sharee'), required=True),
@@ -137,11 +152,30 @@
         branches=List(
             Reference(schema=IBranch), title=_('Branches'), required=False))
     @operation_for_version('devel')
-    def revokeAccessGrants(pillar, sharee, branches=None, bugs=None):
+    def revokeAccessGrants(pillar, user, sharee, branches=None, bugs=None):
         """Remove a sharee's access to the specified artifacts.
 
         :param pillar: the pillar from which to remove access
+        :param user: the user making the request
         :param sharee: the person or team for whom to revoke access
         :param bugs: the bugs for which to revoke access
         :param branches: the branches for which to revoke access
         """
+
+    @export_write_operation()
+    @call_with(user=REQUEST_USER)
+    @operation_parameters(
+        sharee=Reference(IPerson, title=_('Sharee'), required=True),
+        bugs=List(
+            Reference(schema=IBug), title=_('Bugs'), required=False),
+        branches=List(
+            Reference(schema=IBranch), title=_('Branches'), required=False))
+    @operation_for_version('devel')
+    def createAccessGrants(user, sharee, branches=None, bugs=None):
+        """Grant a sharee access to the specified artifacts.
+
+        :param user: the user making the request
+        :param sharee: the person or team for whom to grant access
+        :param bugs: the bugs for which to grant access
+        :param branches: the branches for which to grant access
+        """

=== modified file 'lib/lp/registry/model/accesspolicy.py'
--- lib/lp/registry/model/accesspolicy.py	2012-04-26 08:12:23 +0000
+++ lib/lp/registry/model/accesspolicy.py	2012-05-17 22:13:25 +0000
@@ -14,12 +14,15 @@
 from collections import defaultdict
 
 import pytz
+from storm import Undef
 from storm.expr import (
     And,
     In,
+    Join,
     Or,
     Select,
     SQL,
+    With,
     )
 from storm.properties import (
     DateTime,
@@ -43,6 +46,7 @@
     IAccessPolicyGrant,
     )
 from lp.registry.model.person import Person
+from lp.registry.model.teammembership import TeamParticipation
 from lp.services.database.bulk import create
 from lp.services.database.decoratedresultset import DecoratedResultSet
 from lp.services.database.enumcol import DBEnum
@@ -359,39 +363,21 @@
             Person, Person.id == cls.grantee_id, cls.policy_id.is_in(ids))
 
     @classmethod
-    def findGranteePermissionsByPolicy(cls, policies, grantees=None):
-        """See `IAccessPolicyGrantFlatSource`."""
-        policies_by_id = dict((policy.id, policy) for policy in policies)
-
-        # A cache for the sharing permissions, keyed on grantee
-        permissions_cache = defaultdict(dict)
-        # Information types for which there are shared artifacts.
-        shared_artifact_info_types = defaultdict(list)
-
-        def set_permission(person):
-            # Lookup the permissions from the previously loaded cache.
-            return (
-                person[0],
-                permissions_cache[person[0]],
-                shared_artifact_info_types[person[0]])
-
-        def load_permissions(people):
-            # We now have the grantees and policies we want in the result so
-            # load any corresponding permissions and cache them.
-            people_by_id = dict(
-                (person[0].id, person[0]) for person in people)
+    def _populatePermissionsCache(cls, permissions_cache,
+                                  shared_artifact_info_types, grantee_ids,
+                                  policies_by_id, persons_by_id):
             all_permission_term = SQL("bool_or(artifact IS NULL) as all")
             some_permission_term = SQL(
                 "bool_or(artifact IS NOT NULL) as some")
             constraints = [
-                cls.grantee_id.is_in(people_by_id.keys()),
+                cls.grantee_id.is_in(grantee_ids),
                 cls.policy_id.is_in(policies_by_id.keys())]
             result_set = IStore(cls).find(
                 (cls.grantee_id, cls.policy_id, all_permission_term,
                  some_permission_term),
                 *constraints).group_by(cls.grantee_id, cls.policy_id)
             for (person_id, policy_id, has_all, has_some) in result_set:
-                person = people_by_id[person_id]
+                person = persons_by_id[person_id]
                 policy = policies_by_id[policy_id]
                 permissions_cache[person][policy] = (
                     SharingPermission.ALL if has_all
@@ -399,6 +385,32 @@
                 if has_some:
                     shared_artifact_info_types[person].append(policy.type)
 
+    @classmethod
+    def findGranteePermissionsByPolicy(cls, policies, grantees=None):
+        """See `IAccessPolicyGrantFlatSource`."""
+        policies_by_id = dict((policy.id, policy) for policy in policies)
+
+        # A cache for the sharing permissions, keyed on grantee
+        permissions_cache = defaultdict(dict)
+        # Information types for which there are shared artifacts.
+        shared_artifact_info_types = defaultdict(list)
+
+        def set_permission(person):
+            # Lookup the permissions from the previously loaded cache.
+            return (
+                person[0],
+                permissions_cache[person[0]],
+                shared_artifact_info_types[person[0]])
+
+        def load_permissions(people):
+            # We now have the grantees and policies we want in the result so
+            # load any corresponding permissions and cache them.
+            people_by_id = dict(
+                (person[0].id, person[0]) for person in people)
+            cls._populatePermissionsCache(
+                permissions_cache, shared_artifact_info_types,
+                people_by_id.keys(), policies_by_id, people_by_id)
+
         constraints = [cls.policy_id.is_in(policies_by_id.keys())]
         if grantees:
             grantee_ids = [grantee.id for grantee in grantees]
@@ -417,6 +429,121 @@
             result_decorator=set_permission, pre_iter_hook=load_permissions)
 
     @classmethod
+    def _populateIndirectGranteePermissions(cls,
+                                            policies_by_id, result_set):
+        # A cache for the sharing permissions, keyed on grantee.
+        permissions_cache = defaultdict(dict)
+        # A cache of teams belonged to, keyed by grantee.
+        via_teams_cache = defaultdict(list)
+        grantees_by_id = defaultdict()
+        # Information types for which there are shared artifacts.
+        shared_artifact_info_types = defaultdict(list)
+
+        def set_permission(grantee):
+            # Lookup the permissions from the previously loaded cache.
+            via_team_ids = via_teams_cache[grantee[0].id]
+            via_teams = sorted(
+                [grantees_by_id[team_id] for team_id in via_team_ids],
+                key=lambda x: x.displayname)
+            permissions = permissions_cache[grantee[0]]
+            shared_info_types = shared_artifact_info_types[grantee[0]]
+            # For access via teams, we need to use the team permissions. If a
+            # person has access via more than one team, we use the most
+            # powerful permission of all that are there.
+            for team in via_teams:
+                team_permissions = permissions_cache[team]
+                shared_info_types = []
+                for info_type, permission in team_permissions.items():
+                    permission_to_use = permissions.get(info_type, permission)
+                    if permission == SharingPermission.ALL:
+                        permission_to_use = permission
+                    elif permission == SharingPermission.SOME:
+                        shared_info_types.append(info_type.type)
+                    permissions[info_type] = permission_to_use
+            result = (
+                grantee[0], permissions, via_teams or None,
+                shared_info_types)
+            return result
+
+        def load_teams_and_permissions(grantees):
+            # We now have the grantees we want in the result so load any
+            # associated team memberships and permissions and cache them.
+            if permissions_cache:
+                return
+            store = IStore(cls)
+            for grantee in grantees:
+                grantees_by_id[grantee[0].id] = grantee[0]
+            # Find any teams associated with the grantees. If grantees is a
+            # sliced list (for batching), it may contain indirect grantees but
+            # not the team they belong to so that needs to be fixed below.
+            with_expr = With("grantees", store.find(
+                cls.grantee_id, cls.policy_id.is_in(policies_by_id.keys())
+                ).config(distinct=True)._get_select())
+            result_set = store.with_(with_expr).find(
+                (TeamParticipation.teamID, TeamParticipation.personID),
+                TeamParticipation.personID.is_in(grantees_by_id.keys()),
+                TeamParticipation.teamID.is_in(
+                    Select(
+                        (SQL("grantees.grantee"),),
+                        tables="grantees",
+                        distinct=True)))
+            team_ids = set()
+            direct_grantee_ids = set()
+            for team_id, team_member_id in result_set:
+                if team_member_id == team_id:
+                    direct_grantee_ids.add(team_member_id)
+                else:
+                    via_teams_cache[team_member_id].append(team_id)
+                    team_ids.add(team_id)
+            # Remove from the via_teams cache all the direct grantees.
+            for direct_grantee_id in direct_grantee_ids:
+                if direct_grantee_id in via_teams_cache:
+                    del via_teams_cache[direct_grantee_id]
+            # Load and cache the additional required teams.
+            persons = store.find(Person, Person.id.is_in(team_ids))
+            for person in persons:
+                grantees_by_id[person.id] = person
+
+            cls._populatePermissionsCache(
+                permissions_cache, shared_artifact_info_types,
+                grantees_by_id.keys(), policies_by_id, grantees_by_id)
+
+        return DecoratedResultSet(
+            result_set,
+            result_decorator=set_permission,
+            pre_iter_hook=load_teams_and_permissions)
+
+    @classmethod
+    def findIndirectGranteePermissionsByPolicy(cls, policies,
+                                               grantees=None):
+        """See `IAccessPolicyGrantFlatSource`."""
+        policies_by_id = dict((policy.id, policy) for policy in policies)
+
+        grantee_filter = Undef
+        if grantees:
+            grantee_ids = [grantee.id for grantee in grantees]
+            grantee_filter = TeamParticipation.personID.is_in(grantee_ids)
+
+        store = IStore(cls)
+        with_expr = With("grantees", store.find(
+            cls.grantee_id,
+            cls.policy_id.is_in(policies_by_id.keys()))
+            .config(distinct=True)._get_select())
+        result_set = store.with_(with_expr).find(
+            (Person,),
+            In(
+                Person.id,
+                Select(
+                    (TeamParticipation.personID,),
+                    tables=(TeamParticipation, Join("grantees",
+                        SQL("grantees.grantee = TeamParticipation.team"))),
+                    where=grantee_filter,
+                    distinct=True)))
+
+        return cls._populateIndirectGranteePermissions(
+            policies_by_id, result_set)
+
+    @classmethod
     def findArtifactsByGrantee(cls, grantee, policies):
         """See `IAccessPolicyGrantFlatSource`."""
         ids = [policy.id for policy in policies]

=== added file 'lib/lp/registry/model/sharingjob.py'
--- lib/lp/registry/model/sharingjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/model/sharingjob.py	2012-05-17 22:13:25 +0000
@@ -0,0 +1,355 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+
+"""Job classes related to the sharing feature are in here."""
+
+__metaclass__ = type
+
+
+__all__ = [
+    'RemoveSubscriptionsJob',
+    ]
+
+import contextlib
+import logging
+
+from lazr.delegates import delegates
+from lazr.enum import (
+    DBEnumeratedType,
+    DBItem,
+    enumerated_type_registry,
+    )
+import simplejson
+from sqlobject import SQLObjectNotFound
+from storm.expr import (
+    And,
+    In,
+    Not,
+    Select,
+    )
+from storm.locals import (
+    Int,
+    Reference,
+    Unicode,
+    )
+from storm.store import Store
+from zope.component import getUtility
+from zope.interface import (
+    classProvides,
+    implements,
+    )
+
+from lp.bugs.interfaces.bug import IBugSet
+from lp.bugs.model.bug import Bug
+from lp.bugs.model.bugsubscription import BugSubscription
+from lp.bugs.model.bugtaskflat import BugTaskFlat
+from lp.bugs.model.bugtasksearch import get_bug_privacy_filter
+from lp.code.interfaces.branchlookup import IBranchLookup
+from lp.registry.enums import InformationType
+from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.product import IProduct
+from lp.registry.interfaces.sharingjob import (
+    IRemoveSubscriptionsJob,
+    IRemoveSubscriptionsJobSource,
+    ISharingJob,
+    ISharingJobSource,
+    )
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.person import Person
+from lp.registry.model.product import Product
+from lp.services.config import config
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.lpstorm import IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import (
+    EnumeratedSubclass,
+    Job,
+    )
+from lp.services.job.runner import (
+    BaseRunnableJob,
+    )
+from lp.services.mail.sendmail import format_address_for_person
+from lp.services.webapp import errorlog
+
+
+class SharingJobType(DBEnumeratedType):
+    """Values that ISharingJob.job_type can take."""
+
+    REMOVE_SUBSCRIPTIONS = DBItem(0, """
+        Remove subscriptions of artifacts which are inaccessible.
+
+        This job removes subscriptions to artifacts when access is
+        no longer possible because a user no longer has an access
+        grant (either direct or indirect via team membership).
+        """)
+
+
+class SharingJob(StormBase):
+    """Base class for jobs related to branch merge proposals."""
+
+    implements(ISharingJob)
+
+    __storm_table__ = 'SharingJob'
+
+    id = Int(primary=True)
+
+    job_id = Int('job')
+    job = Reference(job_id, Job.id)
+
+    product_id = Int(name='product')
+    product = Reference(product_id, Product.id)
+
+    distro_id = Int(name='distro')
+    distro = Reference(distro_id, Distribution.id)
+
+    grantee_id = Int(name='grantee')
+    grantee = Reference(grantee_id, Person.id)
+
+    job_type = EnumCol(enum=SharingJobType, notNull=True)
+
+    _json_data = Unicode('json_data')
+
+    @property
+    def metadata(self):
+        return simplejson.loads(self._json_data)
+
+    def __init__(self, job_type, pillar, grantee, metadata):
+        """Constructor.
+
+        :param job_type: The BranchMergeProposalJobType of this job.
+        :param metadata: The type-specific variables, as a JSON-compatible
+            dict.
+        """
+        super(SharingJob, self).__init__()
+        json_data = simplejson.dumps(metadata)
+        self.job = Job()
+        self.job_type = job_type
+        self.grantee = grantee
+        self.product = self.distro = None
+        if IProduct.providedBy(pillar):
+            self.product = pillar
+        else:
+            self.distro = pillar
+        # XXX AaronBentley 2009-01-29 bug=322819: This should be a bytestring,
+        # but the DB representation is unicode.
+        self._json_data = json_data.decode('utf-8')
+
+    def destroySelf(self):
+        Store.of(self).remove(self)
+
+    def makeDerived(self):
+        return SharingJobDerived.makeSubclass(self)
+
+
+class SharingJobDerived(BaseRunnableJob):
+    """Intermediate class for deriving from SharingJob."""
+
+    __metaclass__ = EnumeratedSubclass
+
+    delegates(ISharingJob)
+    classProvides(ISharingJobSource)
+
+    @staticmethod
+    @contextlib.contextmanager
+    def contextManager():
+        """See `IJobSource`."""
+        errorlog.globalErrorUtility.configure('sharing_jobs')
+        yield
+
+    def __init__(self, job):
+        self.context = job
+
+    def __repr__(self):
+        return '<%(job_type)s job for %(grantee)s and %(pillar)s>' % {
+            'job_type': self.context.job_type.name,
+            'grantee': self.grantee.displayname,
+            'pillar': self.pillar_text,
+            }
+
+    @property
+    def pillar(self):
+        if self.product:
+            return self.product
+        else:
+            return self.distro
+
+    @property
+    def pillar_text(self):
+        return self.pillar.displayname if self.pillar else 'all pillars'
+
+    @property
+    def log_name(self):
+        return self.__class__.__name__
+
+    @classmethod
+    def create(cls, pillar, grantee, metadata):
+        base_job = SharingJob(cls.class_job_type, pillar, grantee, metadata)
+        job = cls(base_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @classmethod
+    def get(cls, job_id):
+        """Get a job by id.
+
+        :return: the SharingJob with the specified id, as the
+            current SharingJobDereived subclass.
+        :raises: SQLObjectNotFound if there is no job with the specified id,
+            or its job_type does not match the desired subclass.
+        """
+        job = SharingJob.get(job_id)
+        if job.job_type != cls.class_job_type:
+            raise SQLObjectNotFound(
+                'No object found with id %d and type %s' % (job_id,
+                cls.class_job_type.title))
+        return cls(job)
+
+    @classmethod
+    def iterReady(cls):
+        """See `IJobSource`.
+
+        This version will emit any ready job based on SharingJob.
+        """
+        store = IStore(SharingJob)
+        jobs = store.find(
+            SharingJob,
+            And(SharingJob.job_type == cls.class_job_type,
+                SharingJob.job_id.is_in(Job.ready_jobs)))
+        return (cls.makeSubclass(job) for job in jobs)
+
+    def getOopsVars(self):
+        """See `IRunnableJob`."""
+        vars = BaseRunnableJob.getOopsVars(self)
+        vars.extend([
+            ('sharing_job_id', self.context.id),
+            ('sharing_job_type', self.context.job_type.title),
+            ('grantee', self.grantee.name)])
+        if self.product:
+            vars.append(('product', self.product.name))
+        if self.distro:
+            vars.append(('distro', self.distro.name))
+        return vars
+
+
+class RemoveSubscriptionsJob(SharingJobDerived):
+    """See `IRemoveSubscriptionsJob`."""
+
+    implements(IRemoveSubscriptionsJob)
+    classProvides(IRemoveSubscriptionsJobSource)
+    class_job_type = SharingJobType.REMOVE_SUBSCRIPTIONS
+
+    config = config.sharing_jobs
+
+    @classmethod
+    def create(cls, pillar, grantee, requestor, information_types=None,
+               bugs=None, branches=None):
+        """See `IRemoveSubscriptionsJob`."""
+
+        bug_ids = [
+            bug.id for bug in bugs or []
+        ]
+        branch_names = [
+            branch.unique_name for branch in branches or []
+        ]
+        information_types = [
+            info_type.value for info_type in information_types or []
+        ]
+        metadata = {
+            'bug_ids': bug_ids,
+            'branch_names': branch_names,
+            'information_types': information_types,
+            'requestor.id': requestor.id
+        }
+        return super(RemoveSubscriptionsJob, cls).create(
+            pillar, grantee, metadata)
+
+    @property
+    def requestor_id(self):
+        return self.metadata['requestor.id']
+
+    @property
+    def requestor(self):
+        return getUtility(IPersonSet).get(self.requestor_id)
+
+    @property
+    def bug_ids(self):
+        return self.metadata['bug_ids']
+
+    @property
+    def branch_names(self):
+        return self.metadata['branch_names']
+
+    @property
+    def information_types(self):
+        return [
+            enumerated_type_registry[InformationType.name].items[value]
+            for value in self.metadata['information_types']]
+
+    def getErrorRecipients(self):
+        # If something goes wrong we want to let the requestor know as well
+        # as the pillar maintainer (if there is a pillar).
+        result = set()
+        result.add(format_address_for_person(self.requestor))
+        if self.pillar and self.pillar.owner.preferredemail:
+            result.add(format_address_for_person(self.pillar.owner))
+        return list(result)
+
+    def getOperationDescription(self):
+        return ('removing subscriptions for artifacts '
+            'for %s on %s' % (self.grantee.displayname, self.pillar_text))
+
+    def run(self):
+        """See `IRemoveSubscriptionsJob`."""
+
+        logger = logging.getLogger()
+        logger.info(self.getOperationDescription())
+
+        # Unsubscribe grantee from the specified bugs.
+        if self.bug_ids:
+            bugs = getUtility(IBugSet).getByNumbers(self.bug_ids)
+            for bug in bugs:
+                bug.unsubscribe(
+                    self.grantee, self.requestor, ignore_permissions=True)
+
+        # Unsubscribe grantee from the specified branches.
+        if self.branch_names:
+            branches = [
+                getUtility(IBranchLookup).getByUniqueName(branch_name)
+                for branch_name in self.branch_names]
+            for branch in branches:
+                branch.unsubscribe(
+                    self.grantee, self.requestor, ignore_permissions=True)
+
+        # If required, unsubscribe all pillar artifacts.
+        if not self.bug_ids and not self.branch_names:
+            self._unsubscribe_pillar_artifacts(self.information_types)
+
+    def _unsubscribe_pillar_artifacts(self, only_information_types):
+        # Unsubscribe grantee from pillar artifacts to which they no longer
+        # have access. If only_information_types is specified, filter by the
+        # specified information types, else unsubscribe from all artifacts.
+
+        # Branches are not handled until information_type is supported.
+
+        # Do the bugs.
+        privacy_filter = get_bug_privacy_filter(self.grantee, use_flat=True)
+        bug_filter = Not(In(
+            Bug.id,
+            Select(
+                (BugTaskFlat.bug_id,),
+                where=privacy_filter)))
+        if only_information_types:
+            bug_filter = And(
+                bug_filter,
+                Bug.information_type.is_in(only_information_types)
+            )
+        store = IStore(BugSubscription)
+        subscribed_invisible_bugs = store.find(
+            Bug,
+            BugSubscription.bug_id == Bug.id,
+            BugSubscription.person == self.grantee,
+            bug_filter)
+        for bug in subscribed_invisible_bugs:
+            bug.unsubscribe(
+                self.grantee, self.requestor, ignore_permissions=True)

=== modified file 'lib/lp/registry/model/teammembership.py'
--- lib/lp/registry/model/teammembership.py	2012-02-21 22:46:28 +0000
+++ lib/lp/registry/model/teammembership.py	2012-05-17 22:13:25 +0000
@@ -42,6 +42,7 @@
     IMembershipNotificationJobSource,
     )
 from lp.registry.interfaces.role import IPersonRoles
+from lp.registry.interfaces.sharingjob import IRemoveSubscriptionsJobSource
 from lp.registry.interfaces.teammembership import (
     ACTIVE_STATES,
     CyclicalTeamMembershipError,
@@ -384,6 +385,12 @@
             _fillTeamParticipation(self.person, self.team)
         elif old_status in ACTIVE_STATES:
             _cleanTeamParticipation(self.person, self.team)
+            # A person has left the team so they may no longer have access to
+            # some artifacts shared with the team. We need to run a job to
+            # remove any subscriptions to such artifacts.
+            getUtility(IRemoveSubscriptionsJobSource).create(
+                None, self.person, user)
+
         else:
             # Changed from an inactive state to another inactive one, so no
             # need to fill/clean the TeamParticipation table.

=== modified file 'lib/lp/registry/services/sharingservice.py'
--- lib/lp/registry/services/sharingservice.py	2012-05-16 02:36:30 +0000
+++ lib/lp/registry/services/sharingservice.py	2012-05-17 22:13:25 +0000
@@ -8,6 +8,8 @@
     'SharingService',
     ]
 
+from itertools import product
+
 from lazr.restful.interfaces import IWebBrowserOriginatingRequest
 from lazr.restful.utils import get_current_web_service_request
 from zope.component import getUtility
@@ -31,14 +33,17 @@
     IAccessPolicyGrantSource,
     IAccessPolicySource,
     )
-from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
+from lp.registry.interfaces.sharingjob import IRemoveSubscriptionsJobSource
 from lp.registry.interfaces.sharingservice import ISharingService
 from lp.registry.model.person import Person
 from lp.services.features import getFeatureFlag
 from lp.services.searchbuilder import any
-from lp.services.webapp.authorization import available_with_permission
+from lp.services.webapp.authorization import (
+    available_with_permission,
+    check_permission,
+    )
 
 
 class SharingService:
@@ -57,8 +62,11 @@
 
     @property
     def write_enabled(self):
-        return bool(getFeatureFlag(
-            'disclosure.enhanced_sharing.writable'))
+        return (
+            bool(getFeatureFlag(
+            'disclosure.enhanced_sharing.writable') or
+            bool(getFeatureFlag(
+            'disclosure.access_mirror_triggers.removed'))))
 
     def getSharedArtifacts(self, pillar, person, user):
         """See `ISharingService`."""
@@ -88,6 +96,33 @@
 
         return bugtasks, branches
 
+    def getVisibleArtifacts(self, person, branches=None, bugs=None):
+        """See `ISharingService`."""
+        bugs_by_id = {}
+        branches_by_id = {}
+        for bug in bugs or []:
+            bugs_by_id[bug.id] = bug
+        for branch in branches or []:
+            branches_by_id[branch.id] = branch
+
+        # Load the bugs.
+        visible_bug_ids = []
+        if bugs_by_id:
+            param = BugTaskSearchParams(
+                user=person, bug=any(*bugs_by_id.keys()))
+            visible_bug_ids = list(getUtility(IBugTaskSet).searchBugIds(param))
+        visible_bugs = [bugs_by_id[bug_id] for bug_id in visible_bug_ids]
+
+        # Load the branches.
+        visible_branches = []
+        if branches_by_id:
+            all_branches = getUtility(IAllBranches)
+            wanted_branches = all_branches.visibleByUser(person).withIds(
+                *branches_by_id.keys())
+            visible_branches = list(wanted_branches.getBranches())
+
+        return visible_bugs, visible_branches
+
     def getInformationTypes(self, pillar):
         """See `ISharingService`."""
         allowed_types = [
@@ -246,7 +281,7 @@
         return sharee
 
     @available_with_permission('launchpad.Edit', 'pillar')
-    def deletePillarSharee(self, pillar, sharee,
+    def deletePillarSharee(self, pillar, user, sharee,
                              information_types=None):
         """See `ISharingService`."""
 
@@ -282,8 +317,14 @@
                 IAccessArtifactGrantSource)
             accessartifact_grant_source.revokeByArtifact(to_delete)
 
+        # Create a job to remove subscriptions for artifacts the sharee can no
+        # longer see.
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, sharee, user, information_types=information_types)
+
     @available_with_permission('launchpad.Edit', 'pillar')
-    def revokeAccessGrants(self, pillar, sharee, branches=None, bugs=None):
+    def revokeAccessGrants(self, pillar, user, sharee, branches=None,
+                           bugs=None):
         """See `ISharingService`."""
 
         if not self.write_enabled:
@@ -301,3 +342,32 @@
         accessartifact_grant_source = getUtility(IAccessArtifactGrantSource)
         accessartifact_grant_source.revokeByArtifact(
             artifacts_to_delete, [sharee])
+
+        # Create a job to remove subscriptions for artifacts the sharee can no
+        # longer see.
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, sharee, user, bugs=bugs, branches=branches)
+
+    def createAccessGrants(self, user, sharee, branches=None, bugs=None):
+        """See `ISharingService`."""
+
+        if not self.write_enabled:
+            raise Unauthorized("This feature is not yet enabled.")
+
+        artifacts = []
+        if branches:
+            artifacts.extend(branches)
+        if bugs:
+            artifacts.extend(bugs)
+        # The user needs to have launchpad.Edit permission on all supplied
+        # bugs and branches or else we raise an Unauthorized exception.
+        for artifact in artifacts or []:
+            if not check_permission('launchpad.Edit', artifact):
+                raise Unauthorized
+
+        # Ensure there are access artifacts associated with the bugs and
+        # branches.
+        artifacts = getUtility(IAccessArtifactSource).ensure(artifacts)
+        # Create access to bugs/branches for the specified sharee.
+        getUtility(IAccessArtifactGrantSource).grant(
+            list(product(artifacts, [sharee], [user])))

=== modified file 'lib/lp/registry/services/tests/test_sharingservice.py'
--- lib/lp/registry/services/tests/test_sharingservice.py	2012-05-15 08:16:09 +0000
+++ lib/lp/registry/services/tests/test_sharingservice.py	2012-05-17 22:13:25 +0000
@@ -13,11 +13,13 @@
 from zope.traversing.browser.absoluteurl import absoluteURL
 
 from lp.app.interfaces.services import IService
+from lp.bugs.interfaces.bug import IBug
 from lp.code.enums import (
     BranchSubscriptionDiffSize,
     BranchSubscriptionNotificationLevel,
     CodeReviewNotificationLevel,
     )
+from lp.code.interfaces.branch import IBranch
 from lp.registry.enums import (
     InformationType,
     SharingPermission,
@@ -30,6 +32,7 @@
     )
 from lp.registry.services.sharingservice import SharingService
 from lp.services.features.testing import FeatureFixture
+from lp.services.job.tests import block_on_job
 from lp.services.webapp.interaction import ANONYMOUS
 from lp.services.webapp.interfaces import ILaunchpadRoot
 from lp.services.webapp.publisher import canonical_url
@@ -43,7 +46,7 @@
     )
 from lp.testing.layers import (
     AppServerLayer,
-    DatabaseFunctionalLayer,
+    CeleryJobLayer,
     )
 from lp.testing.matchers import HasQueryCount
 from lp.testing.pages import LaunchpadWebServiceCaller
@@ -51,14 +54,15 @@
 
 WRITE_FLAG = {
     'disclosure.enhanced_sharing.writable': 'true',
-    'disclosure.enhanced_sharing_details.enabled': 'true'}
+    'disclosure.enhanced_sharing_details.enabled': 'true',
+    'jobs.celery.enabled_classes': 'RemoveSubscriptionsJob'}
 DETAILS_FLAG = {'disclosure.enhanced_sharing_details.enabled': 'true'}
 
 
 class TestSharingService(TestCaseWithFactory):
     """Tests for the SharingService."""
 
-    layer = DatabaseFunctionalLayer
+    layer = CeleryJobLayer
 
     def setUp(self):
         super(TestSharingService, self).setUp()
@@ -521,7 +525,8 @@
         self.factory.makeAccessPolicyGrant(access_policies[0], another)
         # Delete data for a specific information type.
         with FeatureFixture(WRITE_FLAG):
-            self.service.deletePillarSharee(pillar, grantee, types_to_delete)
+            self.service.deletePillarSharee(
+                pillar, pillar.owner, grantee, types_to_delete)
         # Assemble the expected data for the remaining access grants for
         # grantee.
         expected_data = []
@@ -577,7 +582,7 @@
         with FeatureFixture(WRITE_FLAG):
             self.assertRaises(
                 Unauthorized, self.service.deletePillarSharee,
-                pillar, [InformationType.USERDATA])
+                pillar, pillar.owner, [InformationType.USERDATA])
 
     def test_deletePillarShareeAnonymous(self):
         # Anonymous users are not allowed.
@@ -600,7 +605,69 @@
         login_person(owner)
         self.assertRaises(
             Unauthorized, self.service.deletePillarSharee,
-            product, [InformationType.USERDATA])
+            product, product.owner, [InformationType.USERDATA])
+
+    def _assert_deleteShareeRemoveSubscriptions(self,
+                                                types_to_delete=None):
+        product = self.factory.makeProduct()
+        access_policies = getUtility(IAccessPolicySource).findByPillar(
+            (product,))
+        information_types = [ap.type for ap in access_policies]
+        grantee = self.factory.makePerson()
+        # Make some access policy grants for our sharee.
+        for access_policy in access_policies:
+            self.factory.makeAccessPolicyGrant(access_policy, grantee)
+
+        login_person(product.owner)
+        # Make some bug artifact grants for our sharee.
+        # Branches will be done when information_type attribute is supported.
+        bugs = []
+        for access_policy in access_policies:
+            bug = self.factory.makeBug(
+                product=product, owner=product.owner,
+                information_type=access_policy.type)
+            bugs.append(bug)
+            artifact = self.factory.makeAccessArtifact(concrete=bug)
+            self.factory.makeAccessArtifactGrant(artifact, grantee)
+
+        # Make some access policy grants for another sharee.
+        another = self.factory.makePerson()
+        self.factory.makeAccessPolicyGrant(access_policies[0], another)
+
+        # Subscribe the grantee and other person to the artifacts.
+        for person in [grantee, another]:
+            for bug in bugs:
+                bug.subscribe(person, product.owner)
+
+        # Delete data for specified information types or all.
+        with FeatureFixture(WRITE_FLAG):
+            self.service.deletePillarSharee(
+                product, product.owner, grantee, types_to_delete)
+        with block_on_job(self):
+            transaction.commit()
+
+        expected_information_types = []
+        if types_to_delete is not None:
+            expected_information_types = (
+                set(information_types).difference(types_to_delete))
+        # Check that grantee is unsubscribed.
+        for bug in bugs:
+            if bug.information_type in expected_information_types:
+                self.assertIn(grantee, bug.getDirectSubscribers())
+            else:
+                self.assertNotIn(grantee, bug.getDirectSubscribers())
+            self.assertIn(another, bug.getDirectSubscribers())
+
+    def test_shareeUnsubscribedWhenDeleted(self):
+        # The sharee is unsubscribed from any inaccessible artifacts when their
+        # access is revoked.
+        self._assert_deleteShareeRemoveSubscriptions()
+
+    def test_shareeUnsubscribedWhenDeletedSelectedPolicies(self):
+        # The sharee is unsubscribed from any inaccessible artifacts when their
+        # access to selected policies is revoked.
+        self._assert_deleteShareeRemoveSubscriptions(
+            [InformationType.USERDATA])
 
     def _assert_revokeAccessGrants(self, pillar, bugs, branches):
         artifacts = []
@@ -624,6 +691,15 @@
                     artifact=access_artifact, grantee=person,
                     grantor=pillar.owner)
 
+        # Subscribe the grantee and other person to the artifacts.
+        for person in [grantee, someone]:
+            for bug in bugs or []:
+                bug.subscribe(person, pillar.owner)
+            for branch in branches or []:
+                branch.subscribe(grantee,
+                    BranchSubscriptionNotificationLevel.NOEMAIL, None,
+                    CodeReviewNotificationLevel.NOEMAIL, pillar.owner)
+
         # Check that grantee has expected access grants.
         accessartifact_grant_source = getUtility(IAccessArtifactGrantSource)
         grants = accessartifact_grant_source.findByArtifact(
@@ -633,17 +709,29 @@
 
         with FeatureFixture(WRITE_FLAG):
             self.service.revokeAccessGrants(
-                pillar, grantee, bugs=bugs, branches=branches)
+                pillar, pillar.owner, grantee, bugs=bugs, branches=branches)
+        with block_on_job(self):
+            transaction.commit()
 
         # The grantee now has no access to anything.
         permission_info = apgfs.findGranteePermissionsByPolicy(
             [policy], [grantee])
         self.assertEqual(0, permission_info.count())
 
+        # Check that the grantee's subscriptions have been removed.
+        # Branches will be done once they have the information_type attribute.
+        for bug in bugs:
+            self.assertNotIn(grantee, bug.getDirectSubscribers())
+
         # Someone else still has access to the bugs and branches.
         grants = accessartifact_grant_source.findByArtifact(
             access_artifacts, [someone])
         self.assertEqual(1, grants.count())
+        # Someone else still has subscriptions to the bugs and branches.
+        for bug in bugs:
+            self.assertIn(someone, bug.getDirectSubscribers())
+        for branch in branches:
+            self.assertIn(someone, branch.subscribers)
 
     def test_revokeAccessGrantsBugs(self):
         # Users with launchpad.Edit can delete all access for a sharee.
@@ -674,7 +762,7 @@
         with FeatureFixture(WRITE_FLAG):
             self.assertRaises(
                 Unauthorized, self.service.revokeAccessGrants,
-                product, sharee, bugs=[bug])
+                product, product.owner, sharee, bugs=[bug])
 
     def test_revokeAccessGrantsAnonymous(self):
         # Anonymous users are not allowed.
@@ -698,7 +786,90 @@
         login_person(owner)
         self.assertRaises(
             Unauthorized, self.service.revokeAccessGrants,
-            product, sharee, bugs=[bug])
+            product, product.owner, sharee, bugs=[bug])
+
+    def _assert_createAccessGrants(self, user, bugs, branches):
+        # Creating access grants works as expected.
+        grantee = self.factory.makePerson()
+        with FeatureFixture(WRITE_FLAG):
+            self.service.createAccessGrants(
+                user, grantee, bugs=bugs, branches=branches)
+
+        # Check that grantee has expected access grants.
+        shared_bugs = []
+        shared_branches = []
+        all_pillars = []
+        for bug in bugs or []:
+            all_pillars.extend(bug.affected_pillars)
+        for branch in branches or []:
+            all_pillars.append(branch.target.context)
+        policies = getUtility(IAccessPolicySource).findByPillar(all_pillars)
+
+        apgfs = getUtility(IAccessPolicyGrantFlatSource)
+        access_artifacts = apgfs.findArtifactsByGrantee(grantee, policies)
+        for a in access_artifacts:
+            if IBug.providedBy(a.concrete_artifact):
+                shared_bugs.append(a.concrete_artifact)
+            elif IBranch.providedBy(a.concrete_artifact):
+                shared_branches.append(a.concrete_artifact)
+        self.assertContentEqual(bugs or [], shared_bugs)
+        self.assertContentEqual(branches or [], shared_branches)
+
+    def test_createAccessGrantsBugs(self):
+        # Access grants can be created for bugs.
+        owner = self.factory.makePerson()
+        distro = self.factory.makeDistribution(owner=owner)
+        login_person(owner)
+        bug = self.factory.makeBug(
+            distribution=distro, owner=owner,
+            information_type=InformationType.USERDATA)
+        self._assert_createAccessGrants(owner, [bug], None)
+
+    def test_createAccessGrantsBranches(self):
+        # Access grants can be created for branches.
+        owner = self.factory.makePerson()
+        product = self.factory.makeProduct(owner=owner)
+        login_person(owner)
+        branch = self.factory.makeBranch(
+            product=product, owner=owner, private=True)
+        self._assert_createAccessGrants(owner, None, [branch])
+
+    def _assert_createAccessGrantsUnauthorized(self, user):
+        # createAccessGrants raises an Unauthorized exception if the user
+        # is not permitted to do so.
+        product = self.factory.makeProduct()
+        bug = self.factory.makeBug(
+            product=product, information_type=InformationType.USERDATA)
+        sharee = self.factory.makePerson()
+        with FeatureFixture(WRITE_FLAG):
+            self.assertRaises(
+                Unauthorized, self.service.createAccessGrants,
+                user, sharee, bugs=[bug])
+
+    def test_createAccessGrantsAnonymous(self):
+        # Anonymous users are not allowed.
+        with FeatureFixture(WRITE_FLAG):
+            login(ANONYMOUS)
+            self._assert_createAccessGrantsUnauthorized(ANONYMOUS)
+
+    def test_createAccessGrantsAnyone(self):
+        # Unauthorized users are not allowed.
+        with FeatureFixture(WRITE_FLAG):
+            anyone = self.factory.makePerson()
+            login_person(anyone)
+            self._assert_createAccessGrantsUnauthorized(anyone)
+
+    def test_createAccessGrants_without_flag(self):
+        # The feature flag needs to be enabled.
+        owner = self.factory.makePerson()
+        product = self.factory.makeProduct(owner=owner)
+        bug = self.factory.makeBug(
+            product=product, information_type=InformationType.USERDATA)
+        sharee = self.factory.makePerson()
+        login_person(owner)
+        self.assertRaises(
+            Unauthorized, self.service.createAccessGrants,
+            product.owner, sharee, bugs=[bug])
 
     def test_getSharedArtifacts(self):
         # Test the getSharedArtifacts method.
@@ -762,6 +933,53 @@
         self.assertContentEqual(bug_tasks[:9], shared_bugtasks)
         self.assertContentEqual(branches[:9], shared_branches)
 
+    def test_getVisibleArtifacts(self):
+        # Test the getVisibleArtifacts method.
+        owner = self.factory.makePerson()
+        product = self.factory.makeProduct(owner=owner)
+        grantee = self.factory.makePerson()
+        login_person(owner)
+
+        bugs = []
+        for x in range(0, 10):
+            bug = self.factory.makeBug(
+                product=product, owner=owner,
+                information_type=InformationType.USERDATA)
+            bugs.append(bug)
+        branches = []
+        for x in range(0, 10):
+            branch = self.factory.makeBranch(
+                product=product, owner=owner, private=True)
+            branches.append(branch)
+
+        def grant_access(artifact):
+            access_artifact = self.factory.makeAccessArtifact(
+                concrete=artifact)
+            self.factory.makeAccessArtifactGrant(
+                artifact=access_artifact, grantee=grantee, grantor=owner)
+            return access_artifact
+
+        # Grant access to some of the bugs and branches.
+        for bug in bugs[:5]:
+            grant_access(bug)
+        for branch in branches[:5]:
+            grant_access(branch)
+            # XXX for now we need to subscribe users to the branch in order
+            # for the underlying BranchCollection to allow access. This will
+            # no longer be the case when BranchCollection supports the new
+            # access policy framework.
+            branch.subscribe(
+                grantee, BranchSubscriptionNotificationLevel.NOEMAIL,
+                BranchSubscriptionDiffSize.NODIFF,
+                CodeReviewNotificationLevel.NOEMAIL,
+                owner)
+
+        # Check the results.
+        shared_bugs, shared_branches = self.service.getVisibleArtifacts(
+            grantee, branches, bugs)
+        self.assertContentEqual(bugs[:5], shared_bugs)
+        self.assertContentEqual(branches[:5], shared_branches)
+
 
 class ApiTestMixin:
     """Common tests for launchpadlib and webservice."""

=== modified file 'lib/lp/registry/tests/test_accesspolicy.py'
--- lib/lp/registry/tests/test_accesspolicy.py	2012-04-26 07:54:34 +0000
+++ lib/lp/registry/tests/test_accesspolicy.py	2012-05-17 22:13:25 +0000
@@ -475,62 +475,102 @@
 
 
 class TestAccessPolicyGrantFlatSource(TestCaseWithFactory):
+
     layer = DatabaseFunctionalLayer
 
+    def setUp(self):
+        super(TestAccessPolicyGrantFlatSource, self).setUp()
+        self.apgfs = getUtility(IAccessPolicyGrantFlatSource)
+
+    def _makePolicyGrants(self):
+        policy_with_no_grantees = self.factory.makeAccessPolicy()
+        policy = self.factory.makeAccessPolicy()
+        policy_grant = self.factory.makeAccessPolicyGrant(policy=policy)
+        return policy, policy_with_no_grantees, policy_grant
+
     def test_findGranteesByPolicy(self):
         # findGranteesByPolicy() returns anyone with a grant for any of
         # the policies or the policies' artifacts.
-        apgfs = getUtility(IAccessPolicyGrantFlatSource)
-
-        # People with grants on the policy show up.
-        policy_with_no_grantees = self.factory.makeAccessPolicy()
-        policy = self.factory.makeAccessPolicy()
-        policy_grant = self.factory.makeAccessPolicyGrant(policy=policy)
-        self.assertContentEqual(
-            [policy_grant.grantee],
-            apgfs.findGranteesByPolicy([policy, policy_with_no_grantees]))
-
-        # But not people with grants on artifacts.
+        # This test checks that people with policy grants are returned.
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
+        self.assertContentEqual(
+            [policy_grant.grantee],
+            self.apgfs.findGranteesByPolicy([policy, policy_with_no_grantees]))
+
+    def test_findGranteesByPolicyIgnoreArtifactGrants(self):
+        # findGranteesByPolicy() returns anyone with a grant for any of
+        # the policies or the policies' artifacts.
+        # This test checks that people with grants on artifacts which are not
+        # linked to the access policy are ignored.
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
+        self.assertContentEqual(
+            [policy_grant.grantee],
+            self.apgfs.findGranteesByPolicy([policy, policy_with_no_grantees]))
+        self.factory.makeAccessArtifactGrant()
+        self.assertContentEqual(
+            [policy_grant.grantee],
+            self.apgfs.findGranteesByPolicy([policy, policy_with_no_grantees]))
+
+    def test_findGranteesByPolicyIncludeArtifactGrants(self):
+        # findGranteesByPolicy() returns anyone with a grant for any of
+        # the policies or the policies' artifacts.
+        # This test checks that people with grants on artifacts which are
+        # linked to the access policy are included.
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
         artifact_grant = self.factory.makeAccessArtifactGrant()
         self.assertContentEqual(
             [policy_grant.grantee],
-            apgfs.findGranteesByPolicy([policy, policy_with_no_grantees]))
-
-        # Unless the artifacts are linked to the policy.
+            self.apgfs.findGranteesByPolicy([policy, policy_with_no_grantees]))
         another_policy = self.factory.makeAccessPolicy()
         self.factory.makeAccessPolicyArtifact(
             artifact=artifact_grant.abstract_artifact, policy=another_policy)
         self.assertContentEqual(
             [policy_grant.grantee, artifact_grant.grantee],
-            apgfs.findGranteesByPolicy([
+            self.apgfs.findGranteesByPolicy([
                 policy, another_policy, policy_with_no_grantees]))
 
     def test_findGranteePermissionsByPolicy(self):
         # findGranteePermissionsByPolicy() returns anyone with a grant for any
         # of the policies or the policies' artifacts.
-        apgfs = getUtility(IAccessPolicyGrantFlatSource)
-
-        # People with grants on the policy show up.
-        policy_with_no_grantees = self.factory.makeAccessPolicy()
-        policy = self.factory.makeAccessPolicy()
-        policy_grant = self.factory.makeAccessPolicyGrant(policy=policy)
-        self.assertContentEqual(
-            [(policy_grant.grantee, {policy: SharingPermission.ALL}, [])],
-            apgfs.findGranteePermissionsByPolicy(
-                [policy, policy_with_no_grantees]))
-
-        # But not people with grants on artifacts.
+        # This test checks that people with policy grants are returned.
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
+        self.assertContentEqual(
+            [(policy_grant.grantee, {policy: SharingPermission.ALL}, [])],
+            self.apgfs.findGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
+    def test_findGranteePermissionsIgnoreArtifactGrants(self):
+        # findGranteePermissionsByPolicy() returns anyone with a grant for any
+        # of the policies or the policies' artifacts.
+        # This test checks that people with grants on artifacts which are not
+        # linked to the access policy are ignored.
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
+        artifact = self.factory.makeAccessArtifact()
+        self.factory.makeAccessArtifactGrant(
+            artifact=artifact, grantee=policy_grant.grantee)
+        self.factory.makeAccessArtifactGrant(artifact=artifact)
+        self.assertContentEqual(
+            [(policy_grant.grantee, {policy: SharingPermission.ALL}, [])],
+            self.apgfs.findGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
+    def test_findGranteePermissionsIncludeArtifactGrants(self):
+        # findGranteePermissionsByPolicy() returns anyone with a grant for any
+        # of the policies or the policies' artifacts.
+        # This test checks that people with grants on artifacts which are
+        # linked to the access policy are included.
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
         artifact = self.factory.makeAccessArtifact()
         artifact_grant = self.factory.makeAccessArtifactGrant(
             artifact=artifact, grantee=policy_grant.grantee)
         other_artifact_grant = self.factory.makeAccessArtifactGrant(
             artifact=artifact)
-        self.assertContentEqual(
-            [(policy_grant.grantee, {policy: SharingPermission.ALL}, [])],
-            apgfs.findGranteePermissionsByPolicy(
-                [policy, policy_with_no_grantees]))
-
-        # Unless the artifacts are linked to the policy.
         another_policy = self.factory.makeAccessPolicy()
         self.factory.makeAccessPolicyArtifact(
             artifact=artifact_grant.abstract_artifact, policy=another_policy)
@@ -542,16 +582,21 @@
              (other_artifact_grant.grantee, {
                  another_policy: SharingPermission.SOME},
               [another_policy.type])],
-            apgfs.findGranteePermissionsByPolicy([
+            self.apgfs.findGranteePermissionsByPolicy([
                 policy, another_policy, policy_with_no_grantees]))
 
-        # Slicing works by person, not by (person, policy).
+    def test_findGranteePermissionsByPolicySlicing(self):
+        # findGranteePermissionsByPolicy() returns anyone with a grant for any
+        # of the policies or the policies' artifacts.
+        # This test checks that slicing works by person, not by
+        # (person, policy).
+        (policy, policy_with_no_grantees,
+         policy_grant) = self._makePolicyGrants()
+        another_policy = self.factory.makeAccessPolicy()
         self.assertContentEqual(
             [(policy_grant.grantee, {
-                policy: SharingPermission.ALL,
-                another_policy: SharingPermission.SOME},
-             [another_policy.type])],
-            apgfs.findGranteePermissionsByPolicy([
+                policy: SharingPermission.ALL}, [])],
+            self.apgfs.findGranteePermissionsByPolicy([
                 policy, another_policy, policy_with_no_grantees]).order_by(
                     Person.id)[:1])
 
@@ -559,31 +604,24 @@
         # findGranteePermissionsByPolicy() returns all information types for
         # which grantees have been granted access one or more artifacts of that
         # type.
-        apgfs = getUtility(IAccessPolicyGrantFlatSource)
-
         policy_with_no_grantees = self.factory.makeAccessPolicy()
         policy = self.factory.makeAccessPolicy()
         policy_grant = self.factory.makeAccessPolicyGrant(policy=policy)
-
         artifact = self.factory.makeAccessArtifact()
         artifact_grant = self.factory.makeAccessArtifactGrant(
             artifact=artifact, grantee=policy_grant.grantee)
         self.factory.makeAccessPolicyArtifact(
             artifact=artifact_grant.abstract_artifact, policy=policy)
-
         self.assertContentEqual(
             [(policy_grant.grantee, {policy: SharingPermission.ALL},
               [policy.type])],
-            apgfs.findGranteePermissionsByPolicy(
+            self.apgfs.findGranteePermissionsByPolicy(
                 [policy, policy_with_no_grantees]))
 
     def test_findGranteePermissionsByPolicy_filter_grantees(self):
         # findGranteePermissionsByPolicy() returns anyone with a grant for any
         # of the policies or the policies' artifacts so long as the grantee is
         # in the specified list of grantees.
-        apgfs = getUtility(IAccessPolicyGrantFlatSource)
-
-        # People with grants on the policy show up.
         policy = self.factory.makeAccessPolicy()
         grantee_in_result = self.factory.makePerson()
         grantee_not_in_result = self.factory.makePerson()
@@ -593,21 +631,263 @@
             policy=policy, grantee=grantee_not_in_result)
         self.assertContentEqual(
             [(policy_grant.grantee, {policy: SharingPermission.ALL}, [])],
-            apgfs.findGranteePermissionsByPolicy(
+            self.apgfs.findGranteePermissionsByPolicy(
                 [policy], [grantee_in_result]))
 
+    def _make_IndirectGrants(self):
+        policy_with_no_grantees = self.factory.makeAccessPolicy()
+        policy = self.factory.makeAccessPolicy()
+        indirect_person_grantee = self.factory.makePerson()
+        team_grantee = self.factory.makeTeam(members=[indirect_person_grantee])
+        self.factory.makeAccessPolicyGrant(policy=policy, grantee=team_grantee)
+        return (
+            policy, policy_with_no_grantees, team_grantee,
+            indirect_person_grantee)
+
+    def test_findIndirectGranteePermissionsByPolicy(self):
+        # findIndirectGranteePermissionsByPolicy() returns anyone with a grant
+        # for any of the policies or the policies' artifacts. The result
+        # includes members of teams with access and a list of teams via which
+        # they have gained access.
+        # This test checks that people with policy grants are returned.
+        (policy,
+         policy_with_no_grantees,
+         team_grantee, indirect_person_grantee) = self._make_IndirectGrants()
+        policy_info = {policy: SharingPermission.ALL}
+        expected_grantees = [
+            (member, policy_info, [team_grantee], [])
+            for member in team_grantee.activemembers]
+        expected_grantees.append(
+            (team_grantee, policy_info, None, []))
+        self.assertContentEqual(
+            expected_grantees,
+            self.apgfs.findIndirectGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
+    def test_findIndirectGranteePermissionsIgnoreArtifactGrants(self):
+        # findIndirectGranteePermissionsByPolicy() returns anyone with a grant
+        # for any of the policies or the policies' artifacts. The result
+        # includes members of teams with access and a list of teams via which
+        # they have gained access.
+        # This test checks that people with grants on artifacts which are not
+        # linked to the access policy are ignored.
+        (policy,
+         policy_with_no_grantees,
+         team_grantee, indirect_person_grantee) = self._make_IndirectGrants()
+        artifact = self.factory.makeAccessArtifact()
+        self.factory.makeAccessArtifactGrant(
+            artifact=artifact, grantee=team_grantee)
+        self.factory.makeAccessArtifactGrant(artifact=artifact)
+        policy_info = {policy: SharingPermission.ALL}
+        expected_grantees = [
+            (member, policy_info, [team_grantee], [])
+            for member in team_grantee.activemembers]
+        expected_grantees.append(
+            (team_grantee, policy_info, None, []))
+        self.assertContentEqual(
+            expected_grantees,
+            self.apgfs.findIndirectGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
+    def test_findIndirectGranteePermissionsIncludeArtifactGrants(self):
+        # findIndirectGranteePermissionsByPolicy() returns anyone with a grant
+        # for any of the policies or the policies' artifacts. The result
+        # includes members of teams with access and a list of teams via which
+        # they have gained access.
+        # This test checks that people with grants on artifacts which are
+        # linked to the access policy are included.
+        (policy,
+         policy_with_no_grantees,
+         team_grantee, indirect_person_grantee) = self._make_IndirectGrants()
+        artifact = self.factory.makeAccessArtifact()
+        artifact_grant = self.factory.makeAccessArtifactGrant(
+            artifact=artifact, grantee=team_grantee)
+        other_artifact_grant = self.factory.makeAccessArtifactGrant(
+            artifact=artifact)
+        another_policy = self.factory.makeAccessPolicy()
+        self.factory.makeAccessPolicyArtifact(
+            artifact=artifact_grant.abstract_artifact, policy=another_policy)
+        policy_info = {
+            policy: SharingPermission.ALL,
+            another_policy: SharingPermission.SOME}
+        expected_grantees = [
+            (member, policy_info, [team_grantee], [another_policy.type])
+            for member in team_grantee.activemembers]
+        expected_grantees.append(
+            (team_grantee, policy_info, None, [another_policy.type]))
+        expected_grantees.append(
+            (other_artifact_grant.grantee,
+             {another_policy: SharingPermission.SOME}, None,
+             [another_policy.type]))
+        self.assertContentEqual(
+            expected_grantees,
+            self.apgfs.findIndirectGranteePermissionsByPolicy([
+                policy, another_policy, policy_with_no_grantees]))
+
+    def test_findIndirectGranteePermissionsByPolicySlicing(self):
+        # findIndirectGranteePermissionsByPolicy() returns anyone with a grant
+        # for any of the policies or the policies' artifacts. The result
+        # includes members of teams with access and a list of teams via which
+        # they have gained access.
+        # This test checks that slicing works by person, not by
+        # (person, policy).
+        (policy,
+         policy_with_no_grantees,
+         team_grantee, indirect_person_grantee) = self._make_IndirectGrants()
+        another_policy = self.factory.makeAccessPolicy()
+        self.assertContentEqual(
+            [(indirect_person_grantee, {
+                policy: SharingPermission.ALL}, [team_grantee], [])],
+            self.apgfs.findIndirectGranteePermissionsByPolicy([
+                policy, another_policy, policy_with_no_grantees]).order_by(
+                    Person.id)[:1])
+
+    def test_findIndirectGranteePermissionsByPolicy_filter_grantees(
+                                                                        self):
+        # findIndirectGranteePermissionsByPolicy() returns anyone with a grant
+        # for any of the policies or the policies' artifacts so long as the
+        # grantee is in the specified list of grantees.
+        policy = self.factory.makeAccessPolicy()
+        grantee_not_in_result = self.factory.makePerson()
+        indirect_person_grantee = self.factory.makePerson()
+        team_grantee = self.factory.makeTeam(members=[indirect_person_grantee])
+        self.factory.makeAccessPolicyGrant(policy=policy, grantee=team_grantee)
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=grantee_not_in_result)
+        self.assertContentEqual(
+            [(indirect_person_grantee, {policy: SharingPermission.ALL},
+              [team_grantee], [])],
+            self.apgfs.findIndirectGranteePermissionsByPolicy(
+                [policy], [indirect_person_grantee]))
+
+    def test_findIndirectGranteePermissionsMultiTeam(self):
+        # Test that findIndirectGranteePermissionsByPolicy() works correctly
+        # when a user is granted access via membership of more than one team.
+        policy_with_no_grantees = self.factory.makeAccessPolicy()
+        policy = self.factory.makeAccessPolicy()
+        # Make an indirect grantee belonging to team1 and team2.
+        indirect_person_grantee = self.factory.makePerson()
+        team_grantee1 = self.factory.makeTeam(
+            members=[indirect_person_grantee])
+        # Make a team for indirect grantee which should not appear in the
+        # results.
+        self.factory.makeTeam(members=[indirect_person_grantee])
+        team_grantee2 = self.factory.makeTeam(
+            members=[indirect_person_grantee])
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=team_grantee1)
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=team_grantee2)
+        policy_info = {policy: SharingPermission.ALL}
+        # Indirect grantee has access.
+        expected_grantees = [
+            (indirect_person_grantee, policy_info,
+             sorted([team_grantee1, team_grantee2],
+                key=lambda x: x.displayname), [])]
+        # All other team members have access.
+        expected_grantees.extend([(member, policy_info, [team_grantee1], [])
+            for member in team_grantee1.activemembers
+            if member != indirect_person_grantee])
+        expected_grantees.extend([(member, policy_info, [team_grantee2], [])
+            for member in team_grantee2.activemembers
+            if member != indirect_person_grantee])
+        # The team itself has access also.
+        expected_grantees.append((team_grantee1, policy_info, None, []))
+        expected_grantees.append((team_grantee2, policy_info, None, []))
+        self.assertContentEqual(
+            expected_grantees,
+            self.apgfs.findIndirectGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
+    def test_findIndirectGranteePermissionsDirect(self):
+        # Test that findIndirectGranteePermissionsByPolicy() works correctly
+        # when a user is granted access directly as well as via membership of a
+        # team.
+        policy_with_no_grantees = self.factory.makeAccessPolicy()
+        policy = self.factory.makeAccessPolicy()
+        # Make an direct grantee.
+        person_grantee = self.factory.makePerson()
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=person_grantee)
+        # Make a team grantee with the person grantee as a member.
+        team_grantee = self.factory.makeTeam(owner=self.factory.makePerson(),
+            members=[person_grantee])
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=team_grantee)
+        # Make a team for indirect grantee which should not appear in the
+        # results.
+        self.factory.makeTeam(members=[person_grantee])
+        policy_info = {policy: SharingPermission.ALL}
+        # Direct grantee has access.
+        expected_grantees = [(person_grantee, policy_info, None, [])]
+        # All other team members have indirect access.
+        expected_grantees.extend([(member, policy_info, [team_grantee], [])
+            for member in team_grantee.activemembers
+            if member != person_grantee])
+        # The team itself has access also.
+        expected_grantees.append((team_grantee, policy_info, None, []))
+        self.assertContentEqual(
+            expected_grantees,
+            self.apgfs.findIndirectGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
+    def test_findIndirectGranteePermissionsMultiTeamPermissions(self):
+        # Test that findIndirectGranteePermissionsByPolicy() works correctly
+        # when a user is granted access via membership of more than one team
+        # where each team has a different level of access. If an indirect
+        # grantee has both ALL and SOME, then ALL is shown.
+        policy_with_no_grantees = self.factory.makeAccessPolicy()
+        policy = self.factory.makeAccessPolicy()
+        indirect_person_grantee = self.factory.makePerson()
+
+        # One team has ALL access.
+        team_grantee1 = self.factory.makeTeam(
+            members=[indirect_person_grantee])
+        self.factory.makeAccessPolicyGrant(
+            policy=policy, grantee=team_grantee1)
+
+        # Another team has SOME access.
+        indirect_person_grantee2 = self.factory.makePerson()
+        team_grantee2 = self.factory.makeTeam(
+            members=[indirect_person_grantee, indirect_person_grantee2])
+        artifact = self.factory.makeAccessArtifact()
+        artifact_grant = self.factory.makeAccessArtifactGrant(
+            artifact=artifact, grantee=team_grantee2)
+        self.factory.makeAccessPolicyArtifact(
+            artifact=artifact_grant.abstract_artifact, policy=policy)
+
+        policy_info = {policy: SharingPermission.ALL}
+        expected_grantees = [
+            (indirect_person_grantee, policy_info,
+             sorted([team_grantee1, team_grantee2],
+                key=lambda x: x.displayname), [policy.type])]
+        expected_grantees.extend([(member, policy_info, [team_grantee1], [])
+            for member in team_grantee1.activemembers
+            if member != indirect_person_grantee])
+        expected_grantees.append((team_grantee1, policy_info, None, []))
+        policy_info = {policy: SharingPermission.SOME}
+        expected_grantees.extend(
+            [(member, policy_info, [team_grantee2], [policy.type])
+            for member in team_grantee2.activemembers
+            if member != indirect_person_grantee])
+        expected_grantees.append(
+            (team_grantee2, policy_info, None, [policy.type]))
+        self.assertContentEqual(
+            expected_grantees,
+            self.apgfs.findIndirectGranteePermissionsByPolicy(
+                [policy, policy_with_no_grantees]))
+
     def test_findArtifactsByGrantee(self):
         # findArtifactsByGrantee() returns the artifacts for grantee for any of
         # the policies.
-        apgfs = getUtility(IAccessPolicyGrantFlatSource)
         policy = self.factory.makeAccessPolicy()
         grantee = self.factory.makePerson()
         # Artifacts not linked to the policy do not show up.
         artifact = self.factory.makeAccessArtifact()
         self.factory.makeAccessArtifactGrant(artifact, grantee)
         self.assertContentEqual(
-            [], apgfs.findArtifactsByGrantee(grantee, [policy]))
+            [], self.apgfs.findArtifactsByGrantee(grantee, [policy]))
         # Artifacts linked to the policy do show up.
         self.factory.makeAccessPolicyArtifact(artifact=artifact, policy=policy)
         self.assertContentEqual(
-            [artifact], apgfs.findArtifactsByGrantee(grantee, [policy]))
+            [artifact], self.apgfs.findArtifactsByGrantee(grantee, [policy]))

=== added file 'lib/lp/registry/tests/test_sharingjob.py'
--- lib/lp/registry/tests/test_sharingjob.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_sharingjob.py	2012-05-17 22:13:25 +0000
@@ -0,0 +1,378 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for SharingJobs."""
+
+__metaclass__ = type
+
+import transaction
+
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.code.enums import (
+    BranchSubscriptionNotificationLevel,
+    CodeReviewNotificationLevel,
+    )
+from lp.registry.enums import InformationType
+from lp.registry.interfaces.accesspolicy import (
+    IAccessArtifactSource,
+    IAccessArtifactGrantSource,
+    )
+from lp.registry.interfaces.person import TeamSubscriptionPolicy
+from lp.registry.interfaces.sharingjob import (
+    IRemoveSubscriptionsJobSource,
+    ISharingJob,
+    ISharingJobSource,
+    )
+from lp.registry.model.sharingjob import (
+    RemoveSubscriptionsJob,
+    SharingJob,
+    SharingJobDerived,
+    SharingJobType,
+    )
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.tests import block_on_job
+from lp.services.mail.sendmail import format_address_for_person
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import (
+    CeleryJobLayer,
+    DatabaseFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
+
+
+class SharingJobTestCase(TestCaseWithFactory):
+    """Test case for basic SharingJob class."""
+
+    layer = LaunchpadZopelessLayer
+
+    def test_init(self):
+        pillar = self.factory.makeProduct()
+        grantee = self.factory.makePerson()
+        metadata = ('some', 'arbitrary', 'metadata')
+        sharing_job = SharingJob(
+            SharingJobType.REMOVE_SUBSCRIPTIONS, pillar, grantee, metadata)
+        self.assertEqual(
+            SharingJobType.REMOVE_SUBSCRIPTIONS, sharing_job.job_type)
+        self.assertEqual(pillar, sharing_job.product)
+        self.assertEqual(grantee, sharing_job.grantee)
+        expected_json_data = '["some", "arbitrary", "metadata"]'
+        self.assertEqual(expected_json_data, sharing_job._json_data)
+
+    def test_metadata(self):
+        # The python structure stored as json is returned as python.
+        metadata = {
+            'a_list': ('some', 'arbitrary', 'metadata'),
+            'a_number': 1,
+            'a_string': 'string',
+            }
+        pillar = self.factory.makeProduct()
+        grantee = self.factory.makePerson()
+        sharing_job = SharingJob(
+            SharingJobType.REMOVE_SUBSCRIPTIONS, pillar, grantee, metadata)
+        metadata['a_list'] = list(metadata['a_list'])
+        self.assertEqual(metadata, sharing_job.metadata)
+
+
+class SharingJobDerivedTestCase(TestCaseWithFactory):
+    """Test case for the SharingJobDerived class."""
+
+    layer = DatabaseFunctionalLayer
+
+    def _makeJob(self, prod_name=None, grantee_name=None):
+        pillar = self.factory.makeProduct(name=prod_name)
+        grantee = self.factory.makePerson(name=grantee_name)
+        requestor = self.factory.makePerson()
+        job = getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, requestor)
+        return job
+
+    def test_repr(self):
+        job = self._makeJob('prod', 'fred')
+        self.assertEqual(
+            '<REMOVE_SUBSCRIPTIONS job for Fred and Prod>', repr(job))
+
+    def test_create_success(self):
+        # Create an instance of SharingJobDerived that delegates to SharingJob.
+        self.assertIs(True, ISharingJobSource.providedBy(SharingJobDerived))
+        job = self._makeJob()
+        self.assertIsInstance(job, SharingJobDerived)
+        self.assertIs(True, ISharingJob.providedBy(job))
+        self.assertIs(True, ISharingJob.providedBy(job.context))
+
+    def test_create_raises_error(self):
+        # SharingJobDerived.create() raises an error because it
+        # needs to be subclassed to work properly.
+        pillar = self.factory.makeProduct()
+        grantee = self.factory.makePerson()
+        self.assertRaises(
+            AttributeError, SharingJobDerived.create, pillar, grantee, {})
+
+    def test_iterReady(self):
+        # iterReady finds job in the READY status that are of the same type.
+        job_1 = self._makeJob()
+        job_2 = self._makeJob()
+        job_2.start()
+        jobs = list(RemoveSubscriptionsJob.iterReady())
+        self.assertEqual(1, len(jobs))
+        self.assertEqual(job_1, jobs[0])
+
+    def test_log_name(self):
+        # The log_name is the name of the implementing class.
+        job = self._makeJob()
+        self.assertEqual('RemoveSubscriptionsJob', job.log_name)
+
+    def test_getOopsVars(self):
+        # The pillar and grantee name are added to the oops vars.
+        pillar = self.factory.makeDistribution()
+        grantee = self.factory.makePerson()
+        requestor = self.factory.makePerson()
+        job = getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, requestor)
+        oops_vars = job.getOopsVars()
+        self.assertIs(True, len(oops_vars) > 4)
+        self.assertIn(('distro', pillar.name), oops_vars)
+        self.assertIn(('grantee', grantee.name), oops_vars)
+
+    def test_getErrorRecipients(self):
+        # The pillar owner and job requestor are the error recipients.
+        pillar = self.factory.makeDistribution()
+        grantee = self.factory.makePerson()
+        requestor = self.factory.makePerson()
+        job = getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, requestor)
+        expected_emails = [
+            format_address_for_person(person)
+            for person in (pillar.owner, requestor)]
+        self.assertContentEqual(
+            expected_emails, job.getErrorRecipients())
+
+
+class RemoveSubscriptionsJobTestCase(TestCaseWithFactory):
+    """Test case for the RemoveSubscriptionsJob class."""
+
+    layer = CeleryJobLayer
+
+    def setUp(self):
+        self.useFixture(FeatureFixture({
+            'jobs.celery.enabled_classes': 'RemoveSubscriptionsJob',
+        }))
+        super(RemoveSubscriptionsJobTestCase, self).setUp()
+
+    def test_create(self):
+        # Create an instance of RemoveSubscriptionsJob that stores
+        # the information type and artifact information.
+        self.assertIs(
+            True,
+            IRemoveSubscriptionsJobSource.providedBy(RemoveSubscriptionsJob))
+        self.assertEqual(
+            SharingJobType.REMOVE_SUBSCRIPTIONS,
+            RemoveSubscriptionsJob.class_job_type)
+        pillar = self.factory.makeProduct()
+        grantee = self.factory.makePerson()
+        requestor = self.factory.makePerson()
+        bug = self.factory.makeBug(product=pillar)
+        branch = self.factory.makeBranch(product=pillar)
+        info_type = InformationType.USERDATA
+        job = getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, requestor, [info_type], [bug], [branch])
+        naked_job = removeSecurityProxy(job)
+        self.assertIsInstance(job, RemoveSubscriptionsJob)
+        self.assertEqual(pillar, job.pillar)
+        self.assertEqual(grantee, job.grantee)
+        self.assertEqual(requestor.id, naked_job.requestor_id)
+        self.assertContentEqual([info_type], naked_job.information_types)
+        self.assertContentEqual([bug.id], naked_job.bug_ids)
+        self.assertContentEqual([branch.unique_name], naked_job.branch_names)
+
+    def test_create_no_pillar(self):
+        # Create an instance of RemoveSubscriptionsJob that stores
+        # the information type and artifact information but with no pillar.
+        grantee = self.factory.makePerson()
+        requestor = self.factory.makePerson()
+        job = getUtility(IRemoveSubscriptionsJobSource).create(
+            None, grantee, requestor)
+        naked_job = removeSecurityProxy(job)
+        self.assertIsInstance(job, RemoveSubscriptionsJob)
+        self.assertEqual(None, job.pillar)
+        self.assertEqual(grantee, job.grantee)
+        self.assertEqual(requestor.id, naked_job.requestor_id)
+        self.assertIn('all pillars', repr(job))
+        self.assertEqual(1, len(job.getErrorRecipients()))
+
+    def _make_subscribed_bug(self, grantee, product=None, distribution=None,
+                             information_type=InformationType.USERDATA):
+        owner = self.factory.makePerson()
+        bug = self.factory.makeBug(
+            owner=owner, product=product, distribution=distribution,
+            information_type=information_type)
+        with person_logged_in(owner):
+            bug.subscribe(grantee, owner)
+        return bug, owner
+
+    def test_unsubscribe_bugs(self):
+        # The requested bug subscriptions are removed.
+        pillar = self.factory.makeDistribution()
+        grantee = self.factory.makePerson()
+        owner = self.factory.makePerson()
+        bug, ignored = self._make_subscribed_bug(grantee, distribution=pillar)
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, owner, bugs=[bug])
+        with block_on_job(self):
+            transaction.commit()
+        self.assertNotIn(
+            grantee, removeSecurityProxy(bug).getDirectSubscribers())
+
+    def _make_subscribed_branch(self, pillar, grantee, private=False):
+        owner = self.factory.makePerson()
+        branch = self.factory.makeBranch(
+            owner=owner, product=pillar, private=private)
+        with person_logged_in(owner):
+            branch.subscribe(grantee,
+                BranchSubscriptionNotificationLevel.NOEMAIL, None,
+                CodeReviewNotificationLevel.NOEMAIL, owner)
+        return branch
+
+    def test_unsubscribe_branches(self):
+        # The requested branch subscriptions are removed.
+        owner = self.factory.makePerson()
+        pillar = self.factory.makeProduct(owner=owner)
+        grantee = self.factory.makePerson()
+        branch = self._make_subscribed_branch(pillar, grantee)
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, owner, branches=[branch])
+        with block_on_job(self):
+            transaction.commit()
+        self.assertNotIn(
+            grantee, list(removeSecurityProxy(branch).subscribers))
+
+    def _assert_unsubscribe_pillar_artifacts_direct_bugs(self,
+                                                         pillar=None):
+        # All direct pillar bug subscriptions are removed.
+        grantee = self.factory.makePerson()
+
+        # Make some bugs subscribed to by grantee.
+        bug1, ignored = self._make_subscribed_bug(
+            grantee, product=pillar,
+            information_type=InformationType.EMBARGOEDSECURITY)
+        bug2, ignored = self._make_subscribed_bug(
+            grantee, product=pillar,
+            information_type=InformationType.USERDATA)
+
+        # Subscribing grantee to bugs creates an access grant so we need to
+        # revoke those for our test.
+        accessartifact_source = getUtility(IAccessArtifactSource)
+        accessartifact_grant_source = getUtility(IAccessArtifactGrantSource)
+        accessartifact_grant_source.revokeByArtifact(
+            accessartifact_source.find([bug1, bug2]), [grantee])
+
+        # Now run the job.
+        requestor = self.factory.makePerson()
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, grantee, requestor)
+        with block_on_job(self):
+            transaction.commit()
+
+        self.assertNotIn(
+            grantee, removeSecurityProxy(bug1).getDirectSubscribers())
+        self.assertNotIn(
+            grantee, removeSecurityProxy(bug2).getDirectSubscribers())
+
+    def test_unsubscribe_pillar_artifacts_direct_bugs(self):
+        pillar = self.factory.makeProduct()
+        self._assert_unsubscribe_pillar_artifacts_direct_bugs(pillar)
+
+    def test_unsubscribe_artifacts_direct_bugs_unspecified_pillar(self):
+        self._assert_unsubscribe_pillar_artifacts_direct_bugs()
+
+    def _assert_unsubscribe_pillar_artifacts_indirect_bugs(self,
+                                                           pillar=None):
+        # Do not delete subscriptions to bugs a user has indirect access to
+        # because they belong to a team which has an artifact grant on the bug.
+
+        person_grantee = self.factory.makePerson(name='grantee')
+
+        # Make a bug the person_grantee is subscribed to.
+        bug1, ignored = self._make_subscribed_bug(
+            person_grantee, product=pillar,
+            information_type=InformationType.USERDATA)
+
+        # Make another bug and grant access to a team.
+        team_owner = self.factory.makePerson(name='teamowner')
+        team_grantee = self.factory.makeTeam(
+            owner=team_owner,
+            subscription_policy=TeamSubscriptionPolicy.RESTRICTED,
+            members=[person_grantee])
+        bug2, bug2_owner = self._make_subscribed_bug(
+            team_grantee, product=pillar,
+            information_type=InformationType.EMBARGOEDSECURITY)
+        # Add a subscription for the person_grantee.
+        with person_logged_in(bug2_owner):
+            bug2.subscribe(person_grantee, bug2_owner)
+
+        # Subscribing person_grantee to bugs creates an access grant so we
+        # need to revoke those for our test.
+        accessartifact_source = getUtility(IAccessArtifactSource)
+        accessartifact_grant_source = getUtility(IAccessArtifactGrantSource)
+        accessartifact_grant_source.revokeByArtifact(
+            accessartifact_source.find([bug1, bug2]), [person_grantee])
+
+        # Now run the job.
+        requestor = self.factory.makePerson()
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, person_grantee, requestor)
+        with block_on_job(self):
+            transaction.commit()
+
+        # person_grantee is not longer subscribed to bug1.
+        self.assertNotIn(
+            person_grantee, removeSecurityProxy(bug1).getDirectSubscribers())
+        # person_grantee is still subscribed to bug2 because they have access
+        # via a team.
+        self.assertIn(
+            person_grantee, removeSecurityProxy(bug2).getDirectSubscribers())
+
+    def test_unsubscribe_pillar_artifacts_indirect_bugs(self):
+        pillar = self.factory.makeProduct()
+        self._assert_unsubscribe_pillar_artifacts_indirect_bugs(pillar)
+
+    def test_unsubscribe_artifacts_indirect_bugs_unspecified_pillar(self):
+        self._assert_unsubscribe_pillar_artifacts_indirect_bugs()
+
+    def test_unsubscribe_pillar_artifacts_specific_info_types(self):
+        # Only delete pillar artifacts of the specified info type.
+
+        owner = self.factory.makePerson(name='pillarowner')
+        pillar = self.factory.makeProduct(owner=owner)
+        person_grantee = self.factory.makePerson(name='grantee')
+
+        # Make bugs the person_grantee is subscribed to.
+        bug1, ignored = self._make_subscribed_bug(
+            person_grantee, product=pillar,
+            information_type=InformationType.USERDATA)
+
+        bug2, ignored = self._make_subscribed_bug(
+            person_grantee, product=pillar,
+            information_type=InformationType.EMBARGOEDSECURITY)
+
+        # Subscribing grantee to bugs creates an access grant so we
+        # need to revoke those for our test.
+        accessartifact_source = getUtility(IAccessArtifactSource)
+        accessartifact_grant_source = getUtility(IAccessArtifactGrantSource)
+        accessartifact_grant_source.revokeByArtifact(
+            accessartifact_source.find([bug1, bug2]), [person_grantee])
+
+        # Now run the job, removing access to userdata artifacts.
+        getUtility(IRemoveSubscriptionsJobSource).create(
+            pillar, person_grantee, owner, [InformationType.USERDATA])
+        with block_on_job(self):
+            transaction.commit()
+
+        self.assertNotIn(
+            person_grantee, removeSecurityProxy(bug1).getDirectSubscribers())
+        self.assertIn(
+            person_grantee, removeSecurityProxy(bug2).getDirectSubscribers())

=== modified file 'lib/lp/registry/tests/test_teammembership.py'
--- lib/lp/registry/tests/test_teammembership.py	2012-02-27 07:09:17 +0000
+++ lib/lp/registry/tests/test_teammembership.py	2012-05-17 22:13:25 +0000
@@ -23,6 +23,11 @@
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.registry.enums import InformationType
+from lp.registry.interfaces.accesspolicy import (
+    IAccessArtifactSource,
+    IAccessArtifactGrantSource,
+    )
 from lp.registry.interfaces.person import (
     IPersonSet,
     TeamMembershipRenewalPolicy,
@@ -53,7 +58,9 @@
     flush_database_updates,
     sqlvalues,
     )
+from lp.services.features.testing import FeatureFixture
 from lp.services.log.logger import BufferLogger
+from lp.services.job.tests import block_on_job
 from lp.testing import (
     login,
     login_celebrity,
@@ -65,6 +72,7 @@
     )
 from lp.testing.dbuser import dbuser
 from lp.testing.layers import (
+    CeleryJobLayer,
     DatabaseFunctionalLayer,
     DatabaseLayer,
     LaunchpadZopelessLayer,
@@ -984,6 +992,68 @@
         self.assertEqual(TeamMembershipStatus.DEACTIVATED, tm.status)
 
 
+class TestTeamMembershipJobs(TestCaseWithFactory):
+    """Test jobs associated with managing team membership."""
+    layer = CeleryJobLayer
+
+    def setUp(self):
+        self.useFixture(FeatureFixture({
+            'jobs.celery.enabled_classes': 'RemoveSubscriptionsJob',
+        }))
+        super(TestTeamMembershipJobs, self).setUp()
+
+    def _make_subscribed_bug(self, grantee, product=None, distribution=None,
+                             information_type=InformationType.USERDATA):
+        owner = self.factory.makePerson()
+        bug = self.factory.makeBug(
+            owner=owner, product=product, distribution=distribution,
+            information_type=information_type)
+        with person_logged_in(owner):
+            bug.subscribe(grantee, owner)
+        return bug, owner
+
+    def test_retract_unsubscribes_former_member(self):
+        # When a team member is removed, any subscriptions to artifacts they
+        # can no longer see are removed also.
+        person_grantee = self.factory.makePerson()
+        product = self.factory.makeProduct()
+        # Make a bug the person_grantee is subscribed to.
+        bug1, ignored = self._make_subscribed_bug(
+            person_grantee, product=product,
+            information_type=InformationType.USERDATA)
+
+        # Make another bug and grant access to a team.
+        team_grantee = self.factory.makeTeam(
+            subscription_policy=TeamSubscriptionPolicy.RESTRICTED,
+            members=[person_grantee])
+        bug2, bug2_owner = self._make_subscribed_bug(
+            team_grantee, product=product,
+            information_type=InformationType.EMBARGOEDSECURITY)
+        # Add a subscription for the person_grantee.
+        with person_logged_in(bug2_owner):
+            bug2.subscribe(person_grantee, bug2_owner)
+
+        # Subscribing person_grantee to bugs creates an access grant so we
+        # need to revoke the one to bug2 for our test.
+        accessartifact_source = getUtility(IAccessArtifactSource)
+        accessartifact_grant_source = getUtility(IAccessArtifactGrantSource)
+        accessartifact_grant_source.revokeByArtifact(
+            accessartifact_source.find([bug2]), [person_grantee])
+
+        with person_logged_in(person_grantee):
+            person_grantee.retractTeamMembership(team_grantee, person_grantee)
+        with block_on_job(self):
+            transaction.commit()
+
+        # person_grantee is still subscribed to bug1.
+        self.assertIn(
+            person_grantee, removeSecurityProxy(bug1).getDirectSubscribers())
+        # person_grantee is not subscribed to bug2 because they no longer have
+        # access via a team.
+        self.assertNotIn(
+            person_grantee, removeSecurityProxy(bug2).getDirectSubscribers())
+
+
 class TestTeamMembershipSendExpirationWarningEmail(TestCaseWithFactory):
     """Test the behaviour of sendExpirationWarningEmail()."""
     layer = DatabaseFunctionalLayer

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2012-05-03 03:50:57 +0000
+++ lib/lp/services/config/schema-lazr.conf	2012-05-17 22:13:25 +0000
@@ -1558,6 +1558,14 @@
 # datatype: string
 virtual_host: none
 
+[sharing_jobs]
+# The database user which will be used by this process.
+# datatype: string
+dbuser: sharing-jobs
+
+# See [error_reports].
+error_dir: none
+
 [txlongpoll]
 # Should TxLongPoll be launched by default?
 # datatype: boolean

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py	2012-05-08 01:31:10 +0000
+++ lib/lp/services/features/flags.py	2012-05-17 22:13:25 +0000
@@ -281,6 +281,13 @@
      '',
      '',
      ''),
+    ('disclosure.legacy_subscription_visibility.enabled',
+     'boolean',
+     ('If true, the legacy behaviour of unsubscribing from a bug or branch'
+      'revokes access.'),
+     '',
+     '',
+     ''),
     ('disclosure.enhanced_sharing.writable',
      'boolean',
      ('If true, will allow the use of the new sharing view and apis used '
@@ -288,6 +295,13 @@
      '',
      'Sharing management',
      ''),
+    ('disclosure.access_mirror_triggers.removed',
+     'boolean',
+     ('If true, the database triggers which cause subscribing to grant'
+      'access to a bug or branch have been removed.'),
+     '',
+     '',
+     ''),
     ('garbo.workitem_migrator.enabled',
      'boolean',
      ('If true, garbo will try to migrate work items from the whiteboard of '

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2012-05-04 14:52:44 +0000
+++ lib/lp/testing/factory.py	2012-05-17 22:13:25 +0000
@@ -1122,6 +1122,12 @@
         if private:
             removeSecurityProxy(branch).explicitly_private = True
             removeSecurityProxy(branch).transitively_private = True
+            # XXX this is here till branch properly supports information_type
+            [artifact] = getUtility(IAccessArtifactSource).ensure([branch])
+            [policy] = getUtility(IAccessPolicySource).find(
+                [(branch.target.context, InformationType.USERDATA)])
+            getUtility(IAccessPolicyArtifactSource).create([(artifact, policy)])
+
         if stacked_on is not None:
             removeSecurityProxy(branch).stacked_on = stacked_on
         if reviewer is not None:

=== modified file 'lib/lp/testing/fixture.py'
--- lib/lp/testing/fixture.py	2012-05-15 20:47:25 +0000
+++ lib/lp/testing/fixture.py	2012-05-17 22:13:25 +0000
@@ -7,6 +7,7 @@
 __all__ = [
     'CaptureOops',
     'DemoMode',
+    'DisableTriggerFixture',
     'PGBouncerFixture',
     'PGNotReadyError',
     'Urllib2Fixture',
@@ -40,6 +41,7 @@
 from zope.component import (
     adapter,
     getGlobalSiteManager,
+    getUtility,
     provideHandler,
     )
 from zope.interface import Interface
@@ -56,6 +58,12 @@
 from lp.services.messaging.rabbit import connect
 from lp.services.timeline.requesttimeline import get_request_timeline
 from lp.services.webapp.errorlog import ErrorReportEvent
+from lp.services.webapp.interfaces import (
+    DEFAULT_FLAVOR,
+    MAIN_STORE,
+    IStoreSelector,
+    )
+from lp.testing.dbuser import dbuser
 
 
 class PGNotReadyError(Exception):
@@ -385,3 +393,33 @@
 <a href="http://example.com";>File a bug</a>.
             ''')
         self.addCleanup(lambda: config.pop('demo-fixture'))
+
+
+class DisableTriggerFixture(Fixture):
+    """Let tests disable database triggers."""
+
+    def __init__(self, table_triggers=None):
+        self.table_triggers = table_triggers or {}
+
+    def setUp(self):
+        super(DisableTriggerFixture, self).setUp()
+        self._disable_triggers()
+        self.addCleanup(self._enable_triggers)
+
+    def _process_triggers(self, mode):
+        store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
+        with dbuser('postgres'):
+            for table, trigger in self.table_triggers.items():
+                sql = ("ALTER TABLE %(table)s %(mode)s trigger "
+                       "%(trigger)s") % {
+                    'table': table,
+                    'mode': mode,
+                    'trigger': trigger,
+                }
+                store.execute(sql)
+
+    def _disable_triggers(self):
+        self._process_triggers(mode='DISABLE')
+
+    def _enable_triggers(self):
+        self._process_triggers(mode='ENABLE')

=== modified file 'lib/lp/testing/tests/test_fixture.py'
--- lib/lp/testing/tests/test_fixture.py	2012-01-18 07:35:31 +0000
+++ lib/lp/testing/tests/test_fixture.py	2012-05-17 22:13:25 +0000
@@ -36,6 +36,7 @@
 from lp.testing import TestCase
 from lp.testing.fixture import (
     CaptureOops,
+    DisableTriggerFixture,
     PGBouncerFixture,
     ZopeAdapterFixture,
     ZopeUtilityFixture,
@@ -45,6 +46,7 @@
     DatabaseLayer,
     LaunchpadLayer,
     LaunchpadZopelessLayer,
+    ZopelessDatabaseLayer,
     )
 
 
@@ -263,3 +265,62 @@
         capture = self.useFixture(CaptureOops())
         capture.sync()
         capture.sync()
+
+
+class TestDisableTriggerFixture(TestCase):
+    """Test the DisableTriggerFixture class."""
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestDisableTriggerFixture, self).setUp()
+        con_str = dbconfig.rw_main_master + ' user=launchpad_main'
+        con = psycopg2.connect(con_str)
+        con.set_isolation_level(0)
+        self.cursor = con.cursor()
+        # Create a test table and trigger.
+        setup_sql = """
+        CREATE OR REPLACE FUNCTION trig() RETURNS trigger
+        LANGUAGE plpgsql
+        AS
+        'BEGIN
+            update test_trigger set col_b = NEW.col_a
+            where col_a = NEW.col_a;
+            RETURN NULL;
+        END;';
+        DROP TABLE IF EXISTS test_trigger CASCADE;
+        CREATE TABLE test_trigger(col_a integer, col_b integer);
+        CREATE TRIGGER test_trigger_t
+            AFTER INSERT on test_trigger
+            FOR EACH ROW EXECUTE PROCEDURE trig();
+        """
+        self.cursor.execute(setup_sql)
+        self.addCleanup(self._cleanup)
+
+    def _cleanup(self):
+        self.cursor.execute('DROP TABLE test_trigger CASCADE;')
+        self.cursor.close()
+        self.cursor.connection.close()
+
+    def test_triggers_are_disabled(self):
+        # Test that the fixture correctly disables specified triggers.
+        with DisableTriggerFixture({'test_trigger': 'test_trigger_t'}):
+            self.cursor.execute(
+                'INSERT INTO test_trigger(col_a) values (1)')
+            self.cursor.execute(
+                'SELECT col_b FROM test_trigger WHERE col_a = 1')
+            [col_b] = self.cursor.fetchone()
+            self.assertEqual(None, col_b)
+
+    def test_triggers_are_enabled_after(self):
+        # Test that the fixture correctly enables the triggers again when it
+        # is done.
+        with DisableTriggerFixture({'test_trigger': 'test_trigger_t'}):
+            self.cursor.execute(
+                'INSERT INTO test_trigger(col_a) values (1)')
+        self.cursor.execute(
+            'INSERT INTO test_trigger(col_a) values (2)')
+        self.cursor.execute(
+            'SELECT col_b FROM test_trigger WHERE col_a = 2')
+        [col_b] = self.cursor.fetchone()
+        self.assertEqual(2, col_b)