← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-permissions-webservice-ref into lp:launchpad with lp:~cjwatson/launchpad/git-grant-limitedview as a prerequisite.

Commit message:
Allow getting and setting grants for a single Git ref over the webservice.

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-webservice-ref/+merge/355608
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-permissions-webservice-ref into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2018-08-23 17:03:05 +0000
+++ lib/lp/_schema_circular_imports.py	2018-09-25 16:33:05 +0000
@@ -68,6 +68,7 @@
 from lp.code.interfaces.diff import IPreviewDiff
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitrule import IGitNascentRuleGrant
 from lp.code.interfaces.gitsubscription import IGitSubscription
 from lp.code.interfaces.hasbranches import (
     IHasBranches,
@@ -150,6 +151,7 @@
 from lp.registry.interfaces.teammembership import ITeamMembership
 from lp.registry.interfaces.wikiname import IWikiName
 from lp.services.comments.interfaces.conversation import IComment
+from lp.services.fields import InlineObject
 from lp.services.messages.interfaces.message import (
     IIndexedMessage,
     IMessage,
@@ -506,6 +508,8 @@
 patch_entry_return_type(IGitRef, 'createMergeProposal', IBranchMergeProposal)
 patch_collection_return_type(
     IGitRef, 'getMergeProposals', IBranchMergeProposal)
+patch_list_parameter_type(
+    IGitRef, 'setGrants', 'grants', InlineObject(schema=IGitNascentRuleGrant))
 
 # IGitRepository
 patch_collection_property(IGitRepository, 'branches', IGitRef)

=== modified file 'lib/lp/app/webservice/marshallers.py'
--- lib/lp/app/webservice/marshallers.py	2012-01-01 02:58:52 +0000
+++ lib/lp/app/webservice/marshallers.py	2018-09-25 16:33:05 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Launchpad-specific field marshallers for the web service."""
@@ -10,10 +10,23 @@
     ]
 
 
+from lazr.restful.interfaces import (
+    IEntry,
+    IFieldMarshaller,
+    )
 from lazr.restful.marshallers import (
+    SimpleFieldMarshaller,
     TextFieldMarshaller as LazrTextFieldMarshaller,
     )
-from zope.component import getUtility
+from zope.component import (
+    getMultiAdapter,
+    getUtility,
+    )
+from zope.component.interfaces import ComponentLookupError
+from zope.schema.interfaces import (
+    IField,
+    RequiredMissing,
+    )
 
 from lp.services.utils import obfuscate_email
 from lp.services.webapp.interfaces import ILaunchBag
@@ -31,3 +44,49 @@
         if (value is not None and getUtility(ILaunchBag).user is None):
             return obfuscate_email(value)
         return value
+
+
+class InlineObjectFieldMarshaller(SimpleFieldMarshaller):
+    """A marshaller that represents an object as a dict.
+
+    lazr.restful represents objects as URL references by default, but that
+    isn't what we want in all cases.
+
+    To use this marshaller to read JSON input data, you must register an
+    adapter from the expected top-level type of the loaded JSON data
+    (usually `dict`) to the `InlineObject` field's schema.  The adapter will
+    be called with the deserialised input data, with all inner fields
+    already converted as indicated by the schema.
+    """
+
+    def unmarshall(self, entry, value):
+        """See `IFieldMarshaller`."""
+        result = {}
+        for name in self.field.schema.names(all=True):
+            field = self.field.schema[name]
+            if IField.providedBy(field):
+                marshaller = getMultiAdapter(
+                    (field, self.request), IFieldMarshaller)
+                sub_value = getattr(value, name, field.default)
+                try:
+                    sub_entry = getMultiAdapter(
+                        (sub_value, self.request), IEntry)
+                except ComponentLookupError:
+                    sub_entry = entry
+                result[name] = marshaller.unmarshall(sub_entry, sub_value)
+        return result
+
+    def _marshall_from_json_data(self, value):
+        """See `SimpleFieldMarshaller`."""
+        template = {}
+        for name in self.field.schema.names(all=True):
+            field = self.field.schema[name]
+            if IField.providedBy(field):
+                if field.required and name not in value:
+                    raise RequiredMissing(name)
+                if name in value:
+                    marshaller = getMultiAdapter(
+                        (field, self.request), IFieldMarshaller)
+                    template[name] = marshaller.marshall_from_json_data(
+                        value[name])
+        return self.field.schema(template)

=== modified file 'lib/lp/app/webservice/tests/test_marshallers.py'
--- lib/lp/app/webservice/tests/test_marshallers.py	2012-01-01 02:58:52 +0000
+++ lib/lp/app/webservice/tests/test_marshallers.py	2018-09-25 16:33:05 +0000
@@ -1,19 +1,40 @@
-# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the webservice marshallers."""
 
 __metaclass__ = type
 
+from testtools.matchers import (
+    Equals,
+    MatchesDict,
+    MatchesStructure,
+    )
 import transaction
+from zope.component import adapter
+from zope.interface import (
+    implementer,
+    Interface,
+    )
+from zope.schema import Choice
 
-from lp.app.webservice.marshallers import TextFieldMarshaller
+from lp.app.webservice.marshallers import (
+    InlineObjectFieldMarshaller,
+    TextFieldMarshaller,
+    )
+from lp.services.fields import (
+    InlineObject,
+    PersonChoice,
+    )
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.webapp.publisher import canonical_url
 from lp.services.webapp.servers import WebServiceTestRequest
 from lp.testing import (
     logout,
     person_logged_in,
     TestCaseWithFactory,
     )
+from lp.testing.fixture import ZopeAdapterFixture
 from lp.testing.layers import DatabaseFunctionalLayer
 from lp.testing.pages import (
     LaunchpadWebServiceCaller,
@@ -37,7 +58,7 @@
         self.assertEqual(u"<email address hidden>", result)
 
     def test_unmarshall_not_obfuscated(self):
-        # Data is not obfuccated if the user is authenticated.
+        # Data is not obfuscated if the user is authenticated.
         marshaller = TextFieldMarshaller(None, WebServiceTestRequest())
         with person_logged_in(self.factory.makePerson()):
             result = marshaller.unmarshall(None, u"foo@xxxxxxxxxxx")
@@ -128,3 +149,56 @@
         webservice = LaunchpadWebServiceCaller()
         etag_logged_out = webservice(ws_url(bug)).getheader('etag')
         self.assertNotEqual(etag_logged_in, etag_logged_out)
+
+
+class IInlineExample(Interface):
+
+    person = PersonChoice(vocabulary="ValidPersonOrTeam")
+
+    status = Choice(vocabulary=JobStatus)
+
+
+@implementer(IInlineExample)
+class InlineExample:
+
+    def __init__(self, person, status):
+        self.person = person
+        self.status = status
+
+
+@adapter(dict)
+@implementer(IInlineExample)
+def inline_example_from_dict(template):
+    return InlineExample(**template)
+
+
+class TestInlineObjectFieldMarshaller(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_unmarshall(self):
+        field = InlineObject(schema=IInlineExample)
+        request = WebServiceTestRequest()
+        request.setVirtualHostRoot(names=["devel"])
+        marshaller = InlineObjectFieldMarshaller(field, request)
+        obj = InlineExample(self.factory.makePerson(), JobStatus.WAITING)
+        result = marshaller.unmarshall(None, obj)
+        self.assertThat(result, MatchesDict({
+            "person": Equals(canonical_url(obj.person, request=request)),
+            "status": Equals("Waiting"),
+            }))
+
+    def test_marshall_from_json_data(self):
+        self.useFixture(ZopeAdapterFixture(inline_example_from_dict))
+        field = InlineObject(schema=IInlineExample)
+        request = WebServiceTestRequest()
+        request.setVirtualHostRoot(names=["devel"])
+        marshaller = InlineObjectFieldMarshaller(field, request)
+        person = self.factory.makePerson()
+        data = {
+            "person": canonical_url(person, request=request),
+            "status": "Running",
+            }
+        obj = marshaller.marshall_from_json_data(data)
+        self.assertThat(obj, MatchesStructure.byEquality(
+            person=person, status=JobStatus.RUNNING))

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2018-09-25 16:33:04 +0000
+++ lib/lp/code/configure.zcml	2018-09-25 16:33:05 +0000
@@ -896,22 +896,34 @@
   <class class="lp.code.model.gitref.GitRef">
     <require
         permission="launchpad.View"
-        interface="lp.code.interfaces.gitref.IGitRef" />
+        interface="lp.code.interfaces.gitref.IGitRefView" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.gitref.IGitRefEdit" />
   </class>
   <class class="lp.code.model.gitref.GitRefDefault">
     <require
         permission="launchpad.View"
-        interface="lp.code.interfaces.gitref.IGitRef" />
+        interface="lp.code.interfaces.gitref.IGitRefView" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.gitref.IGitRefEdit" />
   </class>
   <class class="lp.code.model.gitref.GitRefFrozen">
     <require
         permission="launchpad.View"
-        interface="lp.code.interfaces.gitref.IGitRef" />
+        interface="lp.code.interfaces.gitref.IGitRefView" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.gitref.IGitRefEdit" />
   </class>
   <class class="lp.code.model.gitref.GitRefRemote">
     <require
         permission="launchpad.View"
-        interface="lp.code.interfaces.gitref.IGitRef" />
+        interface="lp.code.interfaces.gitref.IGitRefView" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.gitref.IGitRefEdit" />
   </class>
   <securedutility
       component="lp.code.model.gitref.GitRefRemote"
@@ -943,10 +955,15 @@
         permission="launchpad.Edit"
         interface="lp.code.interfaces.gitrule.IGitRuleGrantEdit"
         set_schema="lp.code.interfaces.gitrule.IGitRuleGrantEditableAttributes" />
+    <allow interface="lazr.restful.interfaces.IJSONPublishable" />
   </class>
   <subscriber
       for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
       handler="lp.code.model.gitrule.git_rule_grant_modified"/>
+  <class class="lp.code.model.gitrule.GitNascentRuleGrant">
+    <allow interface="lp.code.interfaces.gitrule.IGitNascentRuleGrant" />
+  </class>
+  <adapter factory="lp.code.model.gitrule.nascent_rule_grant_from_dict" />
 
   <!-- GitActivity -->
 

=== modified file 'lib/lp/code/interfaces/gitref.py'
--- lib/lp/code/interfaces/gitref.py	2018-08-20 23:33:01 +0000
+++ lib/lp/code/interfaces/gitref.py	2018-09-25 16:33:05 +0000
@@ -16,6 +16,7 @@
     export_as_webservice_entry,
     export_factory_operation,
     export_read_operation,
+    export_write_operation,
     exported,
     operation_for_version,
     operation_parameters,
@@ -50,16 +51,12 @@
 from lp.code.interfaces.hasbranches import IHasMergeProposals
 from lp.code.interfaces.hasrecipes import IHasRecipes
 from lp.registry.interfaces.person import IPerson
+from lp.services.fields import InlineObject
 from lp.services.webapp.interfaces import ITableBatchNavigator
 
 
-class IGitRef(IHasMergeProposals, IHasRecipes, IPrivacy, IInformationType):
-    """A reference in a Git repository."""
-
-    # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
-    # generation working.  Individual attributes must set their version to
-    # "devel".
-    export_as_webservice_entry(as_of="beta")
+class IGitRefView(IHasMergeProposals, IHasRecipes, IPrivacy, IInformationType):
+    """IGitRef attributes that require launchpad.View permission."""
 
     repository = exported(ReferenceChoice(
         title=_("Repository"), required=True, readonly=True,
@@ -119,7 +116,7 @@
 
     commit_message_first_line = TextLine(
         title=_("The first line of the commit message."),
-        required=True, readonly=True)
+        required=False, readonly=True)
 
     identity = Attribute(
         "The identity of this reference.  This will be the shortened path to "
@@ -392,6 +389,36 @@
         """
 
 
+class IGitRefEdit(Interface):
+    """IGitRef methods that require launchpad.Edit permission."""
+
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getGrants():
+        """Get the access grants for this reference."""
+
+    @operation_parameters(
+        grants=List(
+            title=_("Grants"),
+            # Really IGitNascentRuleGrant, patched in
+            # _schema_circular_imports.py.
+            value_type=InlineObject(schema=Interface)))
+    @call_with(user=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version("devel")
+    def setGrants(grants, user):
+        """Set the access grants for this reference."""
+
+
+class IGitRef(IGitRefView, IGitRefEdit):
+    """A reference in a Git repository."""
+
+    # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
+    # generation working.  Individual attributes must set their version to
+    # "devel".
+    export_as_webservice_entry(as_of="beta")
+
+
 class IGitRefBatchNavigator(ITableBatchNavigator):
     pass
 

=== modified file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py	2018-09-25 16:33:04 +0000
+++ lib/lp/code/interfaces/gitrule.py	2018-09-25 16:33:05 +0000
@@ -7,11 +7,13 @@
 
 __metaclass__ = type
 __all__ = [
+    'IGitNascentRuleGrant',
     'IGitRule',
     'IGitRuleGrant',
     ]
 
 from lazr.restful.fields import Reference
+from lazr.restful.interface import copy_field
 from zope.interface import (
     Attribute,
     Interface,
@@ -100,6 +102,9 @@
             matching this rule.
         """
 
+    def setGrants(grants, user):
+        """Set the access grants for this rule."""
+
     def destroySelf(user):
         """Delete this rule.
 
@@ -183,3 +188,24 @@
 class IGitRuleGrant(IGitRuleGrantView, IGitRuleGrantEditableAttributes,
                     IGitRuleGrantEdit):
     """An access grant for a Git repository rule."""
+
+
+class IGitNascentRuleGrant(Interface):
+    """An access grant in the process of being created.
+
+    This represents parameters for a grant that have been deserialised from
+    a webservice request, but that have not yet been attached to a rule.
+    """
+
+    grantee_type = copy_field(IGitRuleGrant["grantee_type"])
+
+    grantee = copy_field(IGitRuleGrant["grantee"])
+
+    can_create = copy_field(
+        IGitRuleGrant["can_create"], required=False, default=False)
+
+    can_push = copy_field(
+        IGitRuleGrant["can_push"], required=False, default=False)
+
+    can_force_push = copy_field(
+        IGitRuleGrant["can_force_push"], required=False, default=False)

=== modified file 'lib/lp/code/model/gitref.py'
--- lib/lp/code/model/gitref.py	2018-08-23 12:34:24 +0000
+++ lib/lp/code/model/gitref.py	2018-09-25 16:33:05 +0000
@@ -70,6 +70,10 @@
     BranchMergeProposal,
     BranchMergeProposalGetter,
     )
+from lp.code.model.gitrule import (
+    GitRule,
+    GitRuleGrant,
+    )
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.decoratedresultset import DecoratedResultSet
@@ -422,6 +426,24 @@
         hook = SourcePackageRecipe.preLoadDataForSourcePackageRecipes
         return DecoratedResultSet(recipes, pre_iter_hook=hook)
 
+    def getGrants(self):
+        """See `IGitRef`."""
+        return list(Store.of(self).find(
+            GitRuleGrant, GitRuleGrant.rule_id == GitRule.id,
+            GitRule.repository_id == self.repository_id,
+            GitRule.ref_pattern == self.path))
+
+    def setGrants(self, grants, user):
+        """See `IGitRef`."""
+        rule = Store.of(self).find(
+            GitRule, GitRule.repository_id == self.repository_id,
+            GitRule.ref_pattern == self.path).one()
+        if rule is None:
+            # We don't need to worry about position, since this is an
+            # exact-match rule and therefore has a canonical position.
+            rule = self.repository.addRule(self.path, user)
+        rule.setGrants(grants, user)
+
 
 @implementer(IGitRef)
 class GitRef(StormBase, GitRefMixin):
@@ -453,7 +475,10 @@
 
     @property
     def commit_message_first_line(self):
-        return self.commit_message.split("\n", 1)[0]
+        if self.commit_message is not None:
+            return self.commit_message.split("\n", 1)[0]
+        else:
+            return None
 
     @property
     def has_commits(self):
@@ -796,6 +821,12 @@
         """See `IHasRecipes`."""
         return []
 
+    def getGrants(self):
+        """See `IGitRef`."""
+        return []
+
+    setGrants = _unimplemented
+
     def __eq__(self, other):
         return (
             self.repository_url == other.repository_url and

=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py	2018-09-25 16:33:04 +0000
+++ lib/lp/code/model/gitrule.py	2018-09-25 16:33:05 +0000
@@ -11,9 +11,14 @@
     'GitRuleGrant',
     ]
 
+from collections import OrderedDict
 import re
 
 from lazr.enum import DBItem
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
+from lazr.restful.interfaces import IJSONPublishable
+from lazr.restful.utils import get_current_browser_request
 import pytz
 from storm.locals import (
     Bool,
@@ -23,13 +28,21 @@
     Store,
     Unicode,
     )
-from zope.component import getUtility
-from zope.interface import implementer
+from zope.component import (
+    adapter,
+    getUtility,
+    )
+from zope.event import notify
+from zope.interface import (
+    implementer,
+    providedBy,
+    )
 from zope.security.proxy import removeSecurityProxy
 
 from lp.code.enums import GitGranteeType
 from lp.code.interfaces.gitactivity import IGitActivitySet
 from lp.code.interfaces.gitrule import (
+    IGitNascentRuleGrant,
     IGitRule,
     IGitRuleGrant,
     )
@@ -44,6 +57,7 @@
     )
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.stormbase import StormBase
+from lp.services.webapp.publisher import canonical_url
 
 
 def git_rule_modified(rule, event):
@@ -120,6 +134,54 @@
         getUtility(IGitActivitySet).logGrantAdded(grant, grantor)
         return grant
 
+    def _validateGrants(self, grants):
+        """Validate a new iterable of access grants."""
+        for grant in grants:
+            if grant.grantee_type == GitGranteeType.PERSON:
+                if grant.grantee is None:
+                    raise ValueError(
+                        "Permission grant for %s has grantee_type 'Person' "
+                        "but no grantee" % self.ref_pattern)
+            else:
+                if grant.grantee is not None:
+                    raise ValueError(
+                        "Permission grant for %s has grantee_type '%s', "
+                        "contradicting grantee ~%s" %
+                        (self.ref_pattern, grant.grantee_type,
+                         grant.grantee.name))
+
+    def setGrants(self, grants, user):
+        """See `IGitRule`."""
+        self._validateGrants(grants)
+        existing_grants = {
+            (grant.grantee_type, grant.grantee): grant
+            for grant in self.grants}
+        new_grants = OrderedDict(
+            ((grant.grantee_type, grant.grantee), grant)
+            for grant in grants)
+
+        for grant_key, grant in existing_grants.items():
+            if grant_key not in new_grants:
+                grant.destroySelf(user)
+
+        for grant_key, new_grant in new_grants.items():
+            grant = existing_grants.get(grant_key)
+            if grant is None:
+                new_grantee = (
+                    new_grant.grantee
+                    if new_grant.grantee_type == GitGranteeType.PERSON
+                    else new_grant.grantee_type)
+                grant = self.addGrant(new_grantee, user)
+            grant_before_modification = Snapshot(
+                grant, providing=providedBy(grant))
+            edited_fields = []
+            for field in ("can_create", "can_push", "can_force_push"):
+                if getattr(grant, field) != getattr(new_grant, field):
+                    setattr(grant, field, getattr(new_grant, field))
+                    edited_fields.append(field)
+            notify(ObjectModifiedEvent(
+                grant, grant_before_modification, edited_fields))
+
     def destroySelf(self, user):
         """See `IGitRule`."""
         getUtility(IGitActivitySet).logRuleRemoved(self, user)
@@ -144,7 +206,7 @@
         removeSecurityProxy(grant).date_last_modified = UTC_NOW
 
 
-@implementer(IGitRuleGrant)
+@implementer(IGitRuleGrant, IJSONPublishable)
 class GitRuleGrant(StormBase):
     """See `IGitRuleGrant`."""
 
@@ -216,8 +278,41 @@
         return "<GitRuleGrant [%s] to %s> for %r" % (
             ", ".join(permissions), grantee_name, self.rule)
 
+    def toDataForJSON(self, media_type):
+        """See `IJSONPublishable`."""
+        if media_type != "application/json":
+            raise ValueError("Unhandled media type %s" % media_type)
+        request = get_current_browser_request()
+        return {
+            "grantee_type": self.grantee_type,
+            "grantee": (
+                canonical_url(self.grantee, request=request)
+                if self.grantee is not None else None),
+            "can_create": self.can_create,
+            "can_push": self.can_push,
+            "can_force_push": self.can_force_push,
+            }
+
     def destroySelf(self, user=None):
         """See `IGitRuleGrant`."""
         if user is not None:
             getUtility(IGitActivitySet).logGrantRemoved(self, user)
         Store.of(self).remove(self)
+
+
+@implementer(IGitNascentRuleGrant)
+class GitNascentRuleGrant:
+
+    def __init__(self, grantee_type, grantee=None, can_create=False,
+                 can_push=False, can_force_push=False):
+        self.grantee_type = grantee_type
+        self.grantee = grantee
+        self.can_create = can_create
+        self.can_push = can_push
+        self.can_force_push = can_force_push
+
+
+@adapter(dict)
+@implementer(IGitNascentRuleGrant)
+def nascent_rule_grant_from_dict(template):
+    return GitNascentRuleGrant(**template)

=== modified file 'lib/lp/code/model/tests/test_gitref.py'
--- lib/lp/code/model/tests/test_gitref.py	2018-08-23 12:34:24 +0000
+++ lib/lp/code/model/tests/test_gitref.py	2018-09-25 16:33:05 +0000
@@ -17,20 +17,25 @@
 from bzrlib import urlutils
 import pytz
 import responses
+from storm.store import Store
 from testtools.matchers import (
     ContainsDict,
     EndsWith,
     Equals,
     Is,
     LessThan,
+    MatchesDict,
     MatchesListwise,
+    MatchesSetwise,
     MatchesStructure,
     )
+import transaction
 from zope.component import getUtility
 
 from lp.app.enums import InformationType
 from lp.app.interfaces.informationtype import IInformationType
 from lp.app.interfaces.launchpad import IPrivacy
+from lp.code.enums import GitGranteeType
 from lp.code.errors import (
     GitRepositoryBlobNotFound,
     GitRepositoryBlobUnsupportedRemote,
@@ -38,8 +43,10 @@
     InvalidBranchMergeProposal,
     )
 from lp.code.interfaces.gitrepository import IGitRepositorySet
+from lp.code.interfaces.gitrule import IGitNascentRuleGrant
 from lp.code.tests.helpers import GitHostingFixture
 from lp.services.config import config
+from lp.services.database.sqlbase import get_transaction_timestamp
 from lp.services.features.testing import FeatureFixture
 from lp.services.memcache.interfaces import IMemcacheClient
 from lp.services.webapp.interfaces import OAuthPermission
@@ -570,6 +577,106 @@
         self.assertEqual({(person1, "review1"), (person2, "review2")}, votes)
 
 
+class TestGitRefGrants(TestCaseWithFactory):
+    """Test handling of access grants for refs.
+
+    Most of the hard work here is done by GitRule, but we ensure that
+    getting and setting grants via GitRef operates only on the appropriate
+    exact-match rule.
+    """
+
+    layer = DatabaseFunctionalLayer
+
+    def test_getGrants(self):
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern=ref.path)
+        grants = [
+            self.factory.makeGitRuleGrant(
+                rule=rule, can_create=True, can_force_push=True),
+            self.factory.makeGitRuleGrant(rule=rule, can_push=True),
+            ]
+        wildcard_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        self.factory.makeGitRuleGrant(rule=wildcard_rule)
+        self.assertThat(ref.getGrants(), MatchesSetwise(
+            MatchesStructure(
+                rule=Equals(rule),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grants[0].grantee),
+                can_create=Is(True),
+                can_push=Is(False),
+                can_force_push=Is(True)),
+            MatchesStructure(
+                rule=Equals(rule),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grants[1].grantee),
+                can_create=Is(False),
+                can_push=Is(True),
+                can_force_push=Is(False))))
+
+    def test_setGrants_no_matching_rule(self):
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        other_repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=other_repository, ref_pattern=ref.path)
+        with person_logged_in(repository.owner):
+            ref.setGrants([
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                    "can_force_push": True,
+                    }),
+                ], repository.owner)
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals(ref.path),
+                grants=MatchesSetwise(
+                    MatchesStructure(
+                        grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                        grantee=Is(None),
+                        can_create=Is(False),
+                        can_push=Is(False),
+                        can_force_push=Is(True)))),
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/*"),
+                grants=MatchesSetwise()),
+            ]))
+
+    def test_setGrants_matching_rule(self):
+        repository = self.factory.makeGitRepository()
+        [ref] = self.factory.makeGitRefs(repository=repository)
+        rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern=ref.path)
+        date_created = get_transaction_timestamp(Store.of(rule))
+        transaction.commit()
+        with person_logged_in(repository.owner):
+            ref.setGrants([
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                    "can_force_push": True,
+                    }),
+                ], repository.owner)
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals(ref.path),
+                date_created=Equals(date_created),
+                grants=MatchesSetwise(
+                    MatchesStructure(
+                        grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                        grantee=Is(None),
+                        can_create=Is(False),
+                        can_push=Is(False),
+                        can_force_push=Is(True)))),
+            ]))
+
+
 class TestGitRefWebservice(TestCaseWithFactory):
     """Tests for the webservice."""
 
@@ -686,3 +793,85 @@
         self.assertEqual(1, len(dependent_landings["entries"]))
         self.assertThat(
             dependent_landings["entries"][0]["self_link"], EndsWith(bmp_url))
+
+    def test_getGrants(self):
+        [ref] = self.factory.makeGitRefs()
+        rule = self.factory.makeGitRule(
+            repository=ref.repository, ref_pattern=ref.path)
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+            can_create=True, can_force_push=True)
+        grantee = self.factory.makePerson()
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=grantee, can_push=True)
+        with person_logged_in(ref.owner):
+            ref_url = api_url(ref)
+            grantee_url = api_url(grantee)
+        webservice = webservice_for_person(
+            ref.owner, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_get(ref_url, "getGrants")
+        self.assertThat(json.loads(response.body), MatchesSetwise(
+            MatchesDict({
+                "grantee_type": Equals("Repository owner"),
+                "grantee": Is(None),
+                "can_create": Is(True),
+                "can_push": Is(False),
+                "can_force_push": Is(True),
+                }),
+            MatchesDict({
+                "grantee_type": Equals("Person"),
+                "grantee": Equals(webservice.getAbsoluteUrl(grantee_url)),
+                "can_create": Is(False),
+                "can_push": Is(True),
+                "can_force_push": Is(False),
+                })))
+
+    def test_setGrants(self):
+        [ref] = self.factory.makeGitRefs()
+        owner = ref.owner
+        grantee = self.factory.makePerson()
+        with person_logged_in(owner):
+            ref_url = api_url(ref)
+            grantee_url = api_url(grantee)
+        webservice = webservice_for_person(
+            owner, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            ref_url, "setGrants",
+            grants=[
+                {
+                    "grantee_type": "Repository owner",
+                    "can_create": True,
+                    "can_force_push": True,
+                    },
+                {
+                    "grantee_type": "Person",
+                    "grantee": grantee_url,
+                    "can_push": True,
+                    },
+                ])
+        self.assertEqual(200, response.status)
+        with person_logged_in(owner):
+            self.assertThat(list(ref.repository.rules), MatchesListwise([
+                MatchesStructure(
+                    repository=Equals(ref.repository),
+                    ref_pattern=Equals(ref.path),
+                    creator=Equals(owner),
+                    grants=MatchesSetwise(
+                        MatchesStructure(
+                            grantor=Equals(owner),
+                            grantee_type=Equals(
+                                GitGranteeType.REPOSITORY_OWNER),
+                            grantee=Is(None),
+                            can_create=Is(True),
+                            can_push=Is(False),
+                            can_force_push=Is(True)),
+                        MatchesStructure(
+                            grantor=Equals(owner),
+                            grantee_type=Equals(GitGranteeType.PERSON),
+                            grantee=Equals(grantee),
+                            can_create=Is(False),
+                            can_push=Is(True),
+                            can_force_push=Is(False)))),
+                ]))

=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py	2018-09-25 16:33:04 +0000
+++ lib/lp/code/model/tests/test_gitrule.py	2018-09-25 16:33:05 +0000
@@ -17,14 +17,17 @@
     MatchesSetwise,
     MatchesStructure,
     )
+import transaction
 from zope.event import notify
 from zope.interface import providedBy
+from zope.security.proxy import removeSecurityProxy
 
 from lp.code.enums import (
     GitActivityType,
     GitGranteeType,
     )
 from lp.code.interfaces.gitrule import (
+    IGitNascentRuleGrant,
     IGitRule,
     IGitRuleGrant,
     )
@@ -118,6 +121,185 @@
                 can_push=Is(False),
                 can_force_push=Is(True))))
 
+    def test__validateGrants_ok(self):
+        rule = self.factory.makeGitRule()
+        grants = [
+            IGitNascentRuleGrant({
+                "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                "can_force_push": True,
+                }),
+            ]
+        removeSecurityProxy(rule)._validateGrants(grants)
+
+    def test__validateGrants_grantee_type_person_but_no_grantee(self):
+        rule = self.factory.makeGitRule(ref_pattern="refs/heads/*")
+        grants = [
+            IGitNascentRuleGrant({
+                "grantee_type": GitGranteeType.PERSON,
+                "can_force_push": True,
+                }),
+            ]
+        self.assertRaisesWithContent(
+            ValueError,
+            "Permission grant for refs/heads/* has grantee_type 'Person' but "
+            "no grantee",
+            removeSecurityProxy(rule)._validateGrants, grants)
+
+    def test__validateGrants_grantee_but_wrong_grantee_type(self):
+        rule = self.factory.makeGitRule(ref_pattern="refs/heads/*")
+        grantee = self.factory.makePerson()
+        grants = [
+            IGitNascentRuleGrant({
+                "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                "grantee": grantee,
+                "can_force_push": True,
+                }),
+            ]
+        self.assertRaisesWithContent(
+            ValueError,
+            "Permission grant for refs/heads/* has grantee_type "
+            "'Repository owner', contradicting grantee ~%s" % grantee.name,
+            removeSecurityProxy(rule)._validateGrants, grants)
+
+    def test_setGrants_add(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        rule = self.factory.makeGitRule(owner=owner)
+        grantee = self.factory.makePerson()
+        with person_logged_in(member):
+            rule.setGrants([
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                    "can_create": True,
+                    "can_force_push": True,
+                    }),
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.PERSON,
+                    "grantee": grantee,
+                    "can_push": True,
+                    }),
+                ], member)
+        self.assertThat(rule.grants, MatchesSetwise(
+            MatchesStructure(
+                rule=Equals(rule),
+                grantor=Equals(member),
+                grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                grantee=Is(None),
+                can_create=Is(True),
+                can_push=Is(False),
+                can_force_push=Is(True)),
+            MatchesStructure(
+                rule=Equals(rule),
+                grantor=Equals(member),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grantee),
+                can_create=Is(False),
+                can_push=Is(True),
+                can_force_push=Is(False))))
+
+    def test_setGrants_modify(self):
+        owner = self.factory.makeTeam()
+        members = [
+            self.factory.makePerson(member_of=[owner]) for _ in range(2)]
+        rule = self.factory.makeGitRule(owner=owner)
+        grantees = [self.factory.makePerson() for _ in range(2)]
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+            grantor=members[0], can_create=True)
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=grantees[0], grantor=members[0], can_push=True)
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=grantees[1], grantor=members[0],
+            can_force_push=True)
+        date_created = get_transaction_timestamp(Store.of(rule))
+        transaction.commit()
+        with person_logged_in(members[1]):
+            rule.setGrants([
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                    "can_force_push": True,
+                    }),
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.PERSON,
+                    "grantee": grantees[1],
+                    "can_create": True,
+                    }),
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.PERSON,
+                    "grantee": grantees[0],
+                    "can_push": True,
+                    "can_force_push": True,
+                    }),
+                ], members[1])
+            date_modified = get_transaction_timestamp(Store.of(rule))
+        self.assertThat(rule.grants, MatchesSetwise(
+            MatchesStructure(
+                rule=Equals(rule),
+                grantor=Equals(members[0]),
+                grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                grantee=Is(None),
+                can_create=Is(False),
+                can_push=Is(False),
+                can_force_push=Is(True),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_modified)),
+            MatchesStructure(
+                rule=Equals(rule),
+                grantor=Equals(members[0]),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grantees[0]),
+                can_create=Is(False),
+                can_push=Is(True),
+                can_force_push=Is(True),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_modified)),
+            MatchesStructure(
+                rule=Equals(rule),
+                grantor=Equals(members[0]),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grantees[1]),
+                can_create=Is(True),
+                can_push=Is(False),
+                can_force_push=Is(False),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_modified))))
+
+    def test_setGrants_remove(self):
+        owner = self.factory.makeTeam()
+        members = [
+            self.factory.makePerson(member_of=[owner]) for _ in range(2)]
+        rule = self.factory.makeGitRule(owner=owner)
+        grantees = [self.factory.makePerson() for _ in range(2)]
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+            grantor=members[0], can_create=True)
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=grantees[0], grantor=members[0], can_push=True)
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=grantees[1], grantor=members[0],
+            can_force_push=True)
+        date_created = get_transaction_timestamp(Store.of(rule))
+        transaction.commit()
+        with person_logged_in(members[1]):
+            rule.setGrants([
+                IGitNascentRuleGrant({
+                    "grantee_type": GitGranteeType.PERSON,
+                    "grantee": grantees[0],
+                    "can_push": True,
+                    }),
+                ], members[1])
+        self.assertThat(rule.grants, MatchesSetwise(
+            MatchesStructure(
+                rule=Equals(rule),
+                grantor=Equals(members[0]),
+                grantee_type=Equals(GitGranteeType.PERSON),
+                grantee=Equals(grantees[0]),
+                can_create=Is(False),
+                can_push=Is(True),
+                can_force_push=Is(False),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_created))))
+
     def test_activity_rule_added(self):
         owner = self.factory.makeTeam()
         member = self.factory.makePerson(member_of=[owner])

=== modified file 'lib/lp/services/fields/__init__.py'
--- lib/lp/services/fields/__init__.py	2015-09-28 17:38:45 +0000
+++ lib/lp/services/fields/__init__.py	2018-09-25 16:33:05 +0000
@@ -1,10 +1,9 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 __all__ = [
     'AnnouncementDate',
-    'FormattableDate',
     'BaseImageUpload',
     'BlacklistableContentNameField',
     'BugField',
@@ -13,10 +12,12 @@
     'Datetime',
     'DuplicateBug',
     'FieldNotBoundError',
+    'FormattableDate',
     'IAnnouncementDate',
     'IBaseImageUpload',
     'IBugField',
     'IDescription',
+    'IInlineObject',
     'INoneableTextLine',
     'IPersonChoice',
     'IStrippedTextLine',
@@ -26,6 +27,7 @@
     'IURIField',
     'IWhiteboard',
     'IconImageUpload',
+    'InlineObject',
     'KEEP_SAME_IMAGE',
     'LogoImageUpload',
     'MugshotImageUpload',
@@ -71,6 +73,7 @@
     Date,
     Datetime,
     Int,
+    Object,
     Text,
     TextLine,
     Tuple,
@@ -909,3 +912,12 @@
                                            "for the target '%s'." % \
                                                (milestone_name, target.name))
         return milestone
+
+
+class IInlineObject(IObject):
+    """A marker for an object represented as a dict."""
+
+
+@implementer(IInlineObject)
+class InlineObject(Object):
+    """An object that is represented as a dict rather than a URL reference."""

=== modified file 'lib/lp/services/webservice/configure.zcml'
--- lib/lp/services/webservice/configure.zcml	2015-04-28 15:22:46 +0000
+++ lib/lp/services/webservice/configure.zcml	2018-09-25 16:33:05 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2011 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2011-2018 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -84,6 +84,12 @@
        provides="lazr.restful.interfaces.IFieldMarshaller"
        factory="lazr.restful.marshallers.ObjectLookupFieldMarshaller"
        />
+   <adapter
+       for="lp.services.fields.IInlineObject
+            zope.publisher.interfaces.http.IHTTPRequest"
+       provides="lazr.restful.interfaces.IFieldMarshaller"
+       factory="lp.app.webservice.marshallers.InlineObjectFieldMarshaller"
+       />
 
    <!-- The API documentation -->
     <browser:page


Follow ups