← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-permissions-model into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-permissions-model into lp:launchpad.

Commit message:
Add basic GitRule and GitGrant models.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1517559 in Launchpad itself: "git fine-grained permissions"
  https://bugs.launchpad.net/launchpad/+bug/1517559

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-permissions-model/+merge/354201

See https://docs.google.com/document/d/1JW_D_Tgo4X2-vPMZtShSbi3cm1iOsGcNIzeOpa5E_wA for the design and https://code.launchpad.net/~cjwatson/launchpad/db-git-permissions/+merge/354200 for schema changes.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-permissions-model into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2018-07-31 12:41:29 +0000
+++ database/schema/security.cfg	2018-09-03 17:08:57 +0000
@@ -187,9 +187,11 @@
 public.featureflag                      = SELECT, INSERT, UPDATE, DELETE
 public.featureflagchangelogentry        = SELECT, INSERT, UPDATE
 public.flatpackagesetinclusion          = SELECT, INSERT, UPDATE, DELETE
+public.gitgrant                         = SELECT, INSERT, UPDATE, DELETE
 public.gitjob                           = SELECT, INSERT, UPDATE, DELETE
 public.gitref                           = SELECT, INSERT, UPDATE, DELETE
 public.gitrepository                    = SELECT, INSERT, UPDATE, DELETE
+public.gitrule                          = SELECT, INSERT, UPDATE, DELETE
 public.gitsubscription                  = SELECT, INSERT, UPDATE, DELETE
 public.hwdevice                         = SELECT
 public.hwdeviceclass                    = SELECT, INSERT, DELETE

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2018-05-16 17:33:18 +0000
+++ lib/lp/code/configure.zcml	2018-09-03 17:08:57 +0000
@@ -919,6 +919,33 @@
     <allow interface="lp.code.interfaces.gitref.IGitRefRemoteSet" />
   </securedutility>
 
+  <!-- Git repository access rules -->
+
+  <class class="lp.code.model.gitrule.GitRule">
+    <require
+        permission="launchpad.View"
+        interface="lp.code.interfaces.gitrule.IGitRuleView" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.gitrule.IGitRuleEdit" />
+  </class>
+  <subscriber
+      for="lp.code.interfaces.gitrule.IGitRule zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+      handler="lp.code.model.gitrule.git_rule_modified"/>
+  <class class="lp.code.model.gitgrant.GitGrant">
+    <require
+        permission="launchpad.View"
+        interface="lp.code.interfaces.gitgrant.IGitGrantView
+                   lp.code.interfaces.gitgrant.IGitGrantEditableAttributes" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.gitgrant.IGitGrantEdit"
+        set_schema="lp.code.interfaces.gitgrant.IGitGrantEditableAttributes" />
+  </class>
+  <subscriber
+      for="lp.code.interfaces.gitgrant.IGitGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+      handler="lp.code.model.gitgrant.git_grant_modified"/>
+
   <!-- GitCollection -->
 
   <class class="lp.code.model.gitcollection.GenericGitCollection">

=== modified file 'lib/lp/code/enums.py'
--- lib/lp/code/enums.py	2017-06-15 01:02:11 +0000
+++ lib/lp/code/enums.py	2018-09-03 17:08:57 +0000
@@ -21,6 +21,7 @@
     'CodeImportReviewStatus',
     'CodeReviewNotificationLevel',
     'CodeReviewVote',
+    'GitGranteeType',
     'GitObjectType',
     'GitRepositoryType',
     'NON_CVS_RCS_TYPES',
@@ -175,6 +176,26 @@
         """)
 
 
+class GitGranteeType(DBEnumeratedType):
+    """Git Grantee Type
+
+    Access grants for Git repositories can be made to various kinds of
+    grantees.
+    """
+
+    REPOSITORY_OWNER = DBItem(1, """
+        Repository owner
+
+        A grant to the owner of the associated repository.
+        """)
+
+    PERSON = DBItem(2, """
+        Person
+
+        A grant to a particular person or team.
+        """)
+
+
 class BranchLifecycleStatusFilter(EnumeratedType):
     """Branch Lifecycle Status Filter
 

=== added file 'lib/lp/code/interfaces/gitgrant.py'
--- lib/lp/code/interfaces/gitgrant.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitgrant.py	2018-09-03 17:08:57 +0000
@@ -0,0 +1,95 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository access grants."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IGitGrant',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import Interface
+from zope.schema import (
+    Bool,
+    Choice,
+    Datetime,
+    Int,
+    )
+
+from lp import _
+from lp.code.enums import GitGranteeType
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitrule import IGitRule
+from lp.registry.interfaces.person import IPerson
+
+
+class IGitGrantView(Interface):
+    """`IGitGrant` attributes that require launchpad.View."""
+
+    id = Int(title=_("ID"), readonly=True, required=True)
+
+    repository = Reference(
+        title=_("Repository"), required=True, readonly=True,
+        schema=IGitRepository,
+        description=_("The repository that this grant is for."))
+
+    rule = Reference(
+        title=_("Rule"), required=True, readonly=True,
+        schema=IGitRule,
+        description=_("The rule that this grant is for."))
+
+    grantor = Reference(
+        title=_("Grantor"), required=True, readonly=True,
+        schema=IPerson,
+        description=_("The user who created this grant."))
+
+    date_created = Datetime(
+        title=_("Date created"), required=True, readonly=True,
+        description=_("The time when this grant was created."))
+
+
+class IGitGrantEditableAttributes(Interface):
+    """`IGitGrant` attributes that can be edited.
+
+    These attributes need launchpad.View to see, and launchpad.Edit to change.
+    """
+
+    grantee_type = Choice(
+        title=_("Grantee type"), required=True, readonly=False,
+        vocabulary=GitGranteeType,
+        description=_("The type of grantee for this grant."))
+
+    grantee = Reference(
+        title=_("Grantee"), required=False, readonly=False,
+        schema=IPerson,
+        description=_("The person being granted access."))
+
+    can_create = Bool(
+        title=_("Can create"), required=True, readonly=False,
+        description=_("Whether creating references is allowed."))
+
+    can_push = Bool(
+        title=_("Can push"), required=True, readonly=False,
+        description=_("Whether pushing references is allowed."))
+
+    can_force_push = Bool(
+        title=_("Can force-push"), required=True, readonly=False,
+        description=_("Whether force-pushing references is allowed."))
+
+    date_last_modified = Datetime(
+        title=_("Date last modified"), required=True, readonly=True,
+        description=_("The time when this grant was last modified."))
+
+
+class IGitGrantEdit(Interface):
+    """`IGitGrant` attributes that require launchpad.Edit."""
+
+    def destroySelf():
+        """Delete this access grant."""
+
+
+class IGitGrant(IGitGrantView, IGitGrantEditableAttributes, IGitGrantEdit):
+    """An access grant for a Git repository rule."""

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2018-08-23 17:03:05 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2018-09-03 17:08:57 +0000
@@ -266,6 +266,10 @@
         # Really ICodeImport, patched in _schema_circular_imports.py.
         schema=Interface))
 
+    rules = Attribute("The access rules for this repository.")
+
+    grants = Attribute("The access grants for this repository.")
+
     @operation_parameters(
         path=TextLine(title=_("A string to look up as a path.")))
     # Really IGitRef, patched in _schema_circular_imports.py.
@@ -715,6 +719,16 @@
         This may be helpful in cases where a previous scan crashed.
         """
 
+    def addRule(ref_pattern, creator, position=None):
+        """Add an access rule to this repository.
+
+        :param ref_pattern: The reference pattern that the new rule should
+            match.
+        :param creator: The `IPerson` who is adding the rule.
+        :param position: The list position at which to insert the rule, or
+            None to append it.
+        """
+
     @export_read_operation()
     @operation_for_version("devel")
     def canBeDeleted():

=== added file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitrule.py	2018-09-03 17:08:57 +0000
@@ -0,0 +1,83 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository access rules."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'IGitRule',
+    ]
+
+from lazr.restful.fields import Reference
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Datetime,
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.registry.interfaces.person import IPerson
+
+
+class IGitRuleView(Interface):
+    """`IGitRule` attributes that require launchpad.View."""
+
+    id = Int(title=_("ID"), readonly=True, required=True)
+
+    repository = Reference(
+        title=_("Repository"), required=True, readonly=True,
+        schema=IGitRepository,
+        description=_("The repository that this rule is for."))
+
+    ref_pattern = TextLine(
+        title=_("Pattern"), required=True, readonly=False,
+        description=_("The pattern of references matched by this rule."))
+
+    creator = Reference(
+        title=_("Creator"), required=True, readonly=True,
+        schema=IPerson,
+        description=_("The user who created this rule."))
+
+    date_created = Datetime(
+        title=_("Date created"), required=True, readonly=True,
+        description=_("The time when this rule was created."))
+
+    date_last_modified = Datetime(
+        title=_("Date last modified"), required=True, readonly=True,
+        description=_("The time when this rule was last modified."))
+
+    grants = Attribute("The access grants for this rule.")
+
+
+class IGitRuleEdit(Interface):
+    """`IGitRule` attributes that require launchpad.Edit."""
+
+    def addGrant(grantee, grantor, can_create=False, can_push=False,
+                 can_force_push=False):
+        """Add an access grant to this rule.
+
+        :param grantee: The `IPerson` who is being granted permission, or an
+            item of `GitGranteeType` other than `GitGranteeType.PERSON` to
+            grant permission to some other kind of entity.
+        :param grantor: The `IPerson` who is granting permission.
+        :param can_create: Whether the grantee can create references
+            matching this rule.
+        :param can_push: Whether the grantee can push references matching
+            this rule.
+        :param can_force_push: Whether the grantee can force-push references
+            matching this rule.
+        """
+
+    def destroySelf():
+        """Delete this rule."""
+
+
+class IGitRule(IGitRuleView, IGitRuleEdit):
+    """An access rule for a Git repository."""

=== added file 'lib/lp/code/model/gitgrant.py'
--- lib/lp/code/model/gitgrant.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitgrant.py	2018-09-03 17:08:57 +0000
@@ -0,0 +1,113 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository access grants."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'GitGrant',
+    ]
+
+from lazr.enum import DBItem
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Int,
+    Reference,
+    Store,
+    )
+from zope.interface import implementer
+
+from lp.code.enums import GitGranteeType
+from lp.code.interfaces.gitgrant import IGitGrant
+from lp.services.database.constants import UTC_NOW
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.stormbase import StormBase
+
+
+def git_grant_modified(grant, event):
+    """Update date_last_modified when a GitGrant is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on Git repository grants.
+    """
+    if event.edited_fields:
+        grant.date_last_modified = UTC_NOW
+
+
+@implementer(IGitGrant)
+class GitGrant(StormBase):
+    """See `IGitGrant`."""
+
+    __storm_table__ = 'GitGrant'
+
+    id = Int(primary=True)
+
+    repository_id = Int(name='repository', allow_none=False)
+    repository = Reference(repository_id, 'GitRepository.id')
+
+    rule_id = Int(name='rule', allow_none=False)
+    rule = Reference(rule_id, 'GitRule.id')
+
+    grantee_type = EnumCol(
+        dbName='grantee_type', enum=GitGranteeType, notNull=True)
+
+    grantee_id = Int(name='grantee', allow_none=True)
+    grantee = Reference(grantee_id, 'Person.id')
+
+    can_create = Bool(name='can_create', allow_none=False)
+    can_push = Bool(name='can_push', allow_none=False)
+    can_force_push = Bool(name='can_force_push', allow_none=False)
+
+    grantor_id = Int(name='grantor', allow_none=False)
+    grantor = Reference(grantor_id, 'Person.id')
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+    date_last_modified = DateTime(
+        name='date_last_modified', tzinfo=pytz.UTC, allow_none=False)
+
+    def __init__(self, rule, grantee, can_create, can_push, can_force_push,
+                 grantor, date_created):
+        if isinstance(grantee, DBItem) and grantee.enum == GitGranteeType:
+            if grantee == GitGranteeType.PERSON:
+                raise ValueError(
+                    "grantee may not be GitGranteeType.PERSON; pass a person "
+                    "object instead")
+            grantee_type = grantee
+            grantee = None
+        else:
+            grantee_type = GitGranteeType.PERSON
+
+        self.repository = rule.repository
+        self.rule = rule
+        self.grantee_type = grantee_type
+        self.grantee = grantee
+        self.can_create = can_create
+        self.can_push = can_push or can_force_push
+        self.can_force_push = can_force_push
+        self.grantor = grantor
+        self.date_created = date_created
+        self.date_last_modified = date_created
+
+    def __repr__(self):
+        permissions = []
+        if self.can_create:
+            permissions.append("create")
+        if self.can_push:
+            permissions.append("push")
+        if self.can_force_push:
+            permissions.append("force-push")
+        if self.grantee_type == GitGranteeType.PERSON:
+            grantee_name = "~%s" % self.grantee.name
+        else:
+            grantee_name = self.grantee_type.title.lower()
+        return "<GitGrant [%s] to %s> for %r" % (
+            ", ".join(permissions), grantee_name, self.rule)
+
+    def destroySelf(self):
+        """See `IGitGrant`."""
+        Store.of(self).remove(self)

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2018-08-31 13:14:39 +0000
+++ lib/lp/code/model/gitrepository.py	2018-09-03 17:08:57 +0000
@@ -41,6 +41,7 @@
     Bool,
     DateTime,
     Int,
+    List,
     Reference,
     Unicode,
     )
@@ -108,10 +109,12 @@
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.mail.branch import send_git_repository_modified_notifications
 from lp.code.model.branchmergeproposal import BranchMergeProposal
+from lp.code.model.gitgrant import GitGrant
 from lp.code.model.gitref import (
     GitRef,
     GitRefDefault,
     )
+from lp.code.model.gitrule import GitRule
 from lp.code.model.gitsubscription import GitSubscription
 from lp.registry.enums import PersonVisibility
 from lp.registry.errors import CannotChangeInformationType
@@ -280,6 +283,8 @@
 
     _default_branch = Unicode(name='default_branch', allow_none=True)
 
+    rule_order = List(type=Int())
+
     def __init__(self, repository_type, registrant, owner, target, name,
                  information_type, date_created, reviewer=None,
                  description=None):
@@ -1118,6 +1123,42 @@
     def code_import(self):
         return getUtility(ICodeImportSet).getByGitRepository(self)
 
+    @property
+    def rules(self):
+        """See `IGitRepository`."""
+        if not self.rule_order:
+            return []
+        rules = {
+            rule.id: rule
+            for rule in Store.of(self).find(
+                GitRule, GitRule.id.is_in(self.rule_order))
+            }
+        for rule_id in self.rule_order:
+            if rule_id not in rules:
+                raise AssertionError(
+                    "GitRule ID %d is in rule_order for %r but does not "
+                    "exist" % (rule_id, self))
+        return [rules[rule_id] for rule_id in self.rule_order]
+
+    def addRule(self, ref_pattern, creator, position=None):
+        """See `IGitRepository`."""
+        rule = GitRule(
+            repository=self, ref_pattern=ref_pattern, creator=creator,
+            date_created=DEFAULT)
+        Store.of(rule).flush()
+        if self.rule_order is None:
+            self.rule_order = []
+        if position is None:
+            self.rule_order.append(rule.id)
+        else:
+            self.rule_order.insert(position, rule.id)
+        return rule
+
+    @property
+    def grants(self):
+        """See `IGitRepository`."""
+        return Store.of(self).find(GitGrant, GitGrant.repository_id == self.id)
+
     def canBeDeleted(self):
         """See `IGitRepository`."""
         # Can't delete if the repository is associated with anything.

=== added file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitrule.py	2018-09-03 17:08:57 +0000
@@ -0,0 +1,90 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository access rules."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    'GitRule',
+    ]
+
+import pytz
+from storm.locals import (
+    DateTime,
+    Int,
+    Reference,
+    Store,
+    Unicode,
+    )
+from zope.interface import implementer
+
+from lp.code.interfaces.gitrule import IGitRule
+from lp.code.model.gitgrant import GitGrant
+from lp.services.database.constants import (
+    DEFAULT,
+    UTC_NOW,
+    )
+from lp.services.database.stormbase import StormBase
+
+
+def git_rule_modified(rule, event):
+    """Update date_last_modified when a GitRule is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on Git repository rules.
+    """
+    if event.edited_fields:
+        rule.date_last_modified = UTC_NOW
+
+
+@implementer(IGitRule)
+class GitRule(StormBase):
+    """See `IGitRule`."""
+
+    __storm_table__ = 'GitRule'
+
+    id = Int(primary=True)
+
+    repository_id = Int(name='repository', allow_none=False)
+    repository = Reference(repository_id, 'GitRepository.id')
+
+    ref_pattern = Unicode(name='ref_pattern', allow_none=False)
+
+    creator_id = Int(name='creator', allow_none=False)
+    creator = Reference(creator_id, 'Person.id')
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+    date_last_modified = DateTime(
+        name='date_last_modified', tzinfo=pytz.UTC, allow_none=False)
+
+    def __init__(self, repository, ref_pattern, creator, date_created):
+        super(GitRule, self).__init__()
+        self.repository = repository
+        self.ref_pattern = ref_pattern
+        self.creator = creator
+        self.date_created = date_created
+        self.date_last_modified = date_created
+
+    def __repr__(self):
+        return "<GitRule '%s'> for %r" % (self.ref_pattern, self.repository)
+
+    @property
+    def grants(self):
+        """See `IGitRule`."""
+        return Store.of(self).find(GitGrant, GitGrant.rule_id == self.id)
+
+    def addGrant(self, grantee, grantor, can_create=False, can_push=False,
+                 can_force_push=False):
+        """See `IGitRule`."""
+        return GitGrant(
+            rule=self, grantee=grantee, can_create=can_create,
+            can_push=can_push, can_force_push=can_force_push, grantor=grantor,
+            date_created=DEFAULT)
+
+    def destroySelf(self):
+        """See `IGitRule`."""
+        self.repository.rule_order.remove(self.id)
+        Store.of(self).remove(self)

=== added file 'lib/lp/code/model/tests/test_gitgrant.py'
--- lib/lp/code/model/tests/test_gitgrant.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_gitgrant.py	2018-09-03 17:08:57 +0000
@@ -0,0 +1,106 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Git repository access grants."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from storm.store import Store
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesSetwise,
+    MatchesStructure,
+    )
+
+from lp.code.enums import GitGranteeType
+from lp.code.interfaces.gitgrant import IGitGrant
+from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestGitGrant(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_implements_IGitGrant(self):
+        grant = self.factory.makeGitGrant()
+        verifyObject(IGitGrant, grant)
+
+    def test_properties_owner(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        rule = self.factory.makeGitRule(owner=owner)
+        grant = self.factory.makeGitGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER, grantor=member,
+            can_create=True, can_force_push=True)
+        now = get_transaction_timestamp(Store.of(grant))
+        self.assertThat(grant, MatchesStructure(
+            repository=Equals(rule.repository),
+            rule=Equals(rule),
+            grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+            grantee=Is(None),
+            can_create=Is(True),
+            # can_force_push implies can_push.
+            can_push=Is(True),
+            can_force_push=Is(True),
+            grantor=Equals(member),
+            date_created=Equals(now),
+            date_last_modified=Equals(now)))
+
+    def test_properties_person(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        rule = self.factory.makeGitRule(owner=owner)
+        grantee = self.factory.makePerson()
+        grant = self.factory.makeGitGrant(
+            rule=rule, grantee=grantee, grantor=member, can_push=True)
+        now = get_transaction_timestamp(Store.of(rule))
+        self.assertThat(grant, MatchesStructure(
+            repository=Equals(rule.repository),
+            rule=Equals(rule),
+            grantee_type=Equals(GitGranteeType.PERSON),
+            grantee=Equals(grantee),
+            can_create=Is(False),
+            can_push=Is(True),
+            can_force_push=Is(False),
+            grantor=Equals(member),
+            date_created=Equals(now),
+            date_last_modified=Equals(now)))
+
+    def test_repr_owner(self):
+        rule = self.factory.makeGitRule()
+        grant = self.factory.makeGitGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+            can_create=True, can_push=True)
+        self.assertEqual(
+            "<GitGrant [create, push] to repository owner> for %r" % rule,
+            repr(grant))
+
+    def test_repr_person(self):
+        rule = self.factory.makeGitRule()
+        grantee = self.factory.makePerson()
+        grant = self.factory.makeGitGrant(
+            rule=rule, grantee=grantee, can_push=True)
+        self.assertEqual(
+            "<GitGrant [push] to ~%s> for %r" % (grantee.name, rule),
+            repr(grant))
+
+    def test_destroySelf(self):
+        rule = self.factory.makeGitRule()
+        grants = [
+            self.factory.makeGitGrant(
+                rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+                can_create=True),
+            self.factory.makeGitGrant(rule=rule, can_push=True),
+            ]
+        with person_logged_in(rule.repository.owner):
+            grants[1].destroySelf()
+        self.assertThat(rule.grants, MatchesSetwise(Equals(grants[0])))

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2018-08-31 14:25:40 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2018-09-03 17:08:57 +0000
@@ -23,6 +23,7 @@
 from testtools.matchers import (
     EndsWith,
     LessThan,
+    MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
     )
@@ -2179,6 +2180,72 @@
         self.assertEqual('Some text', ret)
 
 
+class TestGitRepositoryRules(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_rules(self):
+        repository = self.factory.makeGitRepository()
+        other_repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        self.factory.makeGitRule(
+            repository=other_repository, ref_pattern="refs/heads/*")
+        self.assertThat(repository.rules, MatchesListwise([
+            MatchesStructure.byEquality(
+                repository=repository,
+                ref_pattern="refs/heads/*"),
+            MatchesStructure.byEquality(
+                repository=repository,
+                ref_pattern="refs/heads/stable/*"),
+            ]))
+
+    def test_addRule_append(self):
+        repository = self.factory.makeGitRepository()
+        initial_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        with person_logged_in(repository.owner):
+            new_rule = repository.addRule(
+                "refs/heads/stable/*", repository.owner)
+        self.assertEqual(
+            [initial_rule.id, new_rule.id],
+            removeSecurityProxy(repository).rule_order)
+        self.assertEqual([initial_rule, new_rule], repository.rules)
+
+    def test_addRule_insert(self):
+        repository = self.factory.makeGitRepository()
+        initial_rules = [
+            self.factory.makeGitRule(
+                repository=repository, ref_pattern="refs/heads/*"),
+            self.factory.makeGitRule(
+                repository=repository, ref_pattern="refs/heads/protected"),
+            ]
+        with person_logged_in(repository.owner):
+            new_rule = repository.addRule(
+                "refs/heads/stable/*", repository.owner, position=1)
+        self.assertEqual(
+            [initial_rules[0].id, new_rule.id, initial_rules[1].id],
+            removeSecurityProxy(repository).rule_order)
+        self.assertEqual(
+            [initial_rules[0], new_rule, initial_rules[1]], repository.rules)
+
+    def test_grants(self):
+        repository = self.factory.makeGitRepository()
+        other_repository = self.factory.makeGitRepository()
+        rule = self.factory.makeGitRule(repository=repository)
+        other_rule = self.factory.makeGitRule(repository=other_repository)
+        grants = [
+            self.factory.makeGitGrant(
+                rule=rule, grantee=self.factory.makePerson())
+            for _ in range(2)
+            ]
+        self.factory.makeGitGrant(
+            rule=other_rule, grantee=self.factory.makePerson())
+        self.assertContentEqual(grants, repository.grants)
+
+
 class TestGitRepositorySet(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer

=== added file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_gitrule.py	2018-09-03 17:08:57 +0000
@@ -0,0 +1,93 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Git repository access rules."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from storm.store import Store
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesSetwise,
+    MatchesStructure,
+    )
+
+from lp.code.enums import GitGranteeType
+from lp.code.interfaces.gitrule import IGitRule
+from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestGitRule(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_implements_IGitRule(self):
+        rule = self.factory.makeGitRule()
+        verifyObject(IGitRule, rule)
+
+    def test_properties(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*",
+            creator=member)
+        now = get_transaction_timestamp(Store.of(rule))
+        self.assertThat(rule, MatchesStructure.byEquality(
+            repository=repository,
+            ref_pattern="refs/heads/stable/*",
+            creator=member,
+            date_created=now,
+            date_last_modified=now))
+
+    def test_repr(self):
+        repository = self.factory.makeGitRepository()
+        rule = self.factory.makeGitRule(repository=repository)
+        self.assertEqual(
+            "<GitRule 'refs/heads/*'> for %r" % repository, repr(rule))
+
+    def test_grants(self):
+        rule = self.factory.makeGitRule()
+        other_rule = self.factory.makeGitRule(
+            repository=rule.repository, ref_pattern="refs/heads/stable/*")
+        grantees = [self.factory.makePerson() for _ in range(2)]
+        self.factory.makeGitGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+            can_create=True)
+        self.factory.makeGitGrant(
+            rule=rule, grantee=grantees[0], can_push=True)
+        self.factory.makeGitGrant(
+            rule=rule, grantee=grantees[1], can_force_push=True)
+        self.factory.makeGitGrant(
+            rule=other_rule, grantee=grantees[0], can_push=True)
+        self.assertThat(rule.grants, MatchesSetwise(
+            MatchesStructure(
+                rule=Equals(rule),
+                grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                grantee=Is(None),
+                can_create=Is(True),
+                can_push=Is(False),
+                can_force_push=Is(False)),
+            MatchesStructure(
+                rule=Equals(rule),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grantees[0]),
+                can_create=Is(False),
+                can_push=Is(True),
+                can_force_push=Is(False)),
+            MatchesStructure(
+                rule=Equals(rule),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grantees[1]),
+                can_create=Is(False),
+                # can_force_push implies can_push.
+                can_push=Is(True),
+                can_force_push=Is(True))))

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2018-06-15 13:21:14 +0000
+++ lib/lp/security.py	2018-09-03 17:08:57 +0000
@@ -82,11 +82,13 @@
 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
 from lp.code.interfaces.diff import IPreviewDiff
 from lp.code.interfaces.gitcollection import IGitCollection
+from lp.code.interfaces.gitgrant import IGitGrant
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import (
     IGitRepository,
     user_has_special_git_repository_access,
     )
+from lp.code.interfaces.gitrule import IGitRule
 from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
 from lp.code.interfaces.sourcepackagerecipebuild import (
     ISourcePackageRecipeBuild,
@@ -2343,6 +2345,42 @@
         super(EditGitRef, self).__init__(obj, obj.repository)
 
 
+class ViewGitRule(DelegatedAuthorization):
+    """Anyone who can see a Git repository can see its access rules."""
+    permission = 'launchpad.View'
+    usedfor = IGitRule
+
+    def __init__(self, obj):
+        super(ViewGitRule, self).__init__(obj, obj.repository)
+
+
+class EditGitRule(DelegatedAuthorization):
+    """Anyone who can edit a Git repository can edit its access rules."""
+    permission = 'launchpad.Edit'
+    usedfor = IGitRule
+
+    def __init__(self, obj):
+        super(EditGitRule, self).__init__(obj, obj.repository)
+
+
+class ViewGitGrant(DelegatedAuthorization):
+    """Anyone who can see a Git repository can see its access grants."""
+    permission = 'launchpad.View'
+    usedfor = IGitGrant
+
+    def __init__(self, obj):
+        super(ViewGitGrant, self).__init__(obj, obj.repository)
+
+
+class EditGitGrant(DelegatedAuthorization):
+    """Anyone who can edit a Git repository can edit its access grants."""
+    permission = 'launchpad.Edit'
+    usedfor = IGitGrant
+
+    def __init__(self, obj):
+        super(EditGitGrant, self).__init__(obj, obj.repository)
+
+
 class AdminDistroSeriesTranslations(AuthorizationBase):
     permission = 'launchpad.TranslationsAdmin'
     usedfor = IDistroSeries

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2018-08-23 09:30:24 +0000
+++ lib/lp/testing/factory.py	2018-09-03 17:08:57 +0000
@@ -1828,6 +1828,30 @@
             path = self.getUniqueString('refs/heads/path').decode('utf-8')
         return getUtility(IGitRefRemoteSet).new(repository_url, path)
 
+    def makeGitRule(self, repository=None, ref_pattern=u"refs/heads/*",
+                    creator=None, position=None, **repository_kwargs):
+        """Create a Git repository access rule."""
+        if repository is None:
+            repository = self.makeGitRepository(**repository_kwargs)
+        if creator is None:
+            creator = repository.owner
+        with person_logged_in(creator):
+            return repository.addRule(ref_pattern, creator, position=position)
+
+    def makeGitGrant(self, rule=None, grantee=None, grantor=None,
+                     can_create=False, can_push=False, can_force_push=False):
+        """Create a Git repository access grant."""
+        if rule is None:
+            rule = self.makeGitRule()
+        if grantee is None:
+            grantee = self.makePerson()
+        if grantor is None:
+            grantor = rule.repository.owner
+        with person_logged_in(grantor):
+            return rule.addGrant(
+                grantee, grantor, can_create=can_create, can_push=can_push,
+                can_force_push=can_force_push)
+
     def makeBug(self, target=None, owner=None, bug_watch_url=None,
                 information_type=None, date_closed=None, title=None,
                 date_created=None, description=None, comment=None,


Follow ups