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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18798
Proposed branch: lp:~cjwatson/launchpad/git-permissions-webservice-ref
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-grant-limitedview
Diff against target: 1291 lines (+875/-24)
12 files modified
lib/lp/_schema_circular_imports.py (+4/-0)
lib/lp/app/webservice/marshallers.py (+62/-2)
lib/lp/app/webservice/tests/test_marshallers.py (+77/-3)
lib/lp/code/configure.zcml (+21/-4)
lib/lp/code/interfaces/gitref.py (+41/-8)
lib/lp/code/interfaces/gitrule.py (+26/-0)
lib/lp/code/model/gitref.py (+32/-1)
lib/lp/code/model/gitrule.py (+101/-3)
lib/lp/code/model/tests/test_gitref.py (+189/-0)
lib/lp/code/model/tests/test_gitrule.py (+301/-0)
lib/lp/services/fields/__init__.py (+14/-2)
lib/lp/services/webservice/configure.zcml (+7/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-permissions-webservice-ref
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+355608@code.launchpad.net

Commit message

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

Description of the change

Getting permissions is limited to people who can edit the repository; this is perhaps not a strictly necessary restriction, but it avoids needing to grant excessive LimitedView if somebody grants a private team access to a public repository.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

I've applied most of your suggestions; thanks.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/_schema_circular_imports.py'
2--- lib/lp/_schema_circular_imports.py 2018-08-23 17:03:05 +0000
3+++ lib/lp/_schema_circular_imports.py 2018-10-16 15:29:23 +0000
4@@ -68,6 +68,7 @@
5 from lp.code.interfaces.diff import IPreviewDiff
6 from lp.code.interfaces.gitref import IGitRef
7 from lp.code.interfaces.gitrepository import IGitRepository
8+from lp.code.interfaces.gitrule import IGitNascentRuleGrant
9 from lp.code.interfaces.gitsubscription import IGitSubscription
10 from lp.code.interfaces.hasbranches import (
11 IHasBranches,
12@@ -150,6 +151,7 @@
13 from lp.registry.interfaces.teammembership import ITeamMembership
14 from lp.registry.interfaces.wikiname import IWikiName
15 from lp.services.comments.interfaces.conversation import IComment
16+from lp.services.fields import InlineObject
17 from lp.services.messages.interfaces.message import (
18 IIndexedMessage,
19 IMessage,
20@@ -506,6 +508,8 @@
21 patch_entry_return_type(IGitRef, 'createMergeProposal', IBranchMergeProposal)
22 patch_collection_return_type(
23 IGitRef, 'getMergeProposals', IBranchMergeProposal)
24+patch_list_parameter_type(
25+ IGitRef, 'setGrants', 'grants', InlineObject(schema=IGitNascentRuleGrant))
26
27 # IGitRepository
28 patch_collection_property(IGitRepository, 'branches', IGitRef)
29
30=== modified file 'lib/lp/app/webservice/marshallers.py'
31--- lib/lp/app/webservice/marshallers.py 2012-01-01 02:58:52 +0000
32+++ lib/lp/app/webservice/marshallers.py 2018-10-16 15:29:23 +0000
33@@ -1,4 +1,4 @@
34-# Copyright 2011 Canonical Ltd. This software is licensed under the
35+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
36 # GNU Affero General Public License version 3 (see the file LICENSE).
37
38 """Launchpad-specific field marshallers for the web service."""
39@@ -10,10 +10,23 @@
40 ]
41
42
43+from lazr.restful.interfaces import (
44+ IEntry,
45+ IFieldMarshaller,
46+ )
47 from lazr.restful.marshallers import (
48+ SimpleFieldMarshaller,
49 TextFieldMarshaller as LazrTextFieldMarshaller,
50 )
51-from zope.component import getUtility
52+from zope.component import (
53+ getMultiAdapter,
54+ getUtility,
55+ )
56+from zope.component.interfaces import ComponentLookupError
57+from zope.schema.interfaces import (
58+ IField,
59+ RequiredMissing,
60+ )
61
62 from lp.services.utils import obfuscate_email
63 from lp.services.webapp.interfaces import ILaunchBag
64@@ -31,3 +44,50 @@
65 if (value is not None and getUtility(ILaunchBag).user is None):
66 return obfuscate_email(value)
67 return value
68+
69+
70+class InlineObjectFieldMarshaller(SimpleFieldMarshaller):
71+ """A marshaller that represents an object as a dict.
72+
73+ lazr.restful represents objects as URL references by default, but that
74+ isn't what we want in all cases.
75+
76+ To use this marshaller to read JSON input data, you must register an
77+ adapter from the expected top-level type of the loaded JSON data
78+ (usually `dict`) to the `InlineObject` field's schema. The adapter will
79+ be called with the deserialised input data, with all inner fields
80+ already converted as indicated by the schema.
81+ """
82+
83+ def unmarshall(self, entry, value):
84+ """See `IFieldMarshaller`."""
85+ result = {}
86+ for name in self.field.schema.names(all=True):
87+ field = self.field.schema[name]
88+ if IField.providedBy(field):
89+ marshaller = getMultiAdapter(
90+ (field, self.request), IFieldMarshaller)
91+ sub_value = getattr(value, name, field.default)
92+ try:
93+ sub_entry = getMultiAdapter(
94+ (sub_value, self.request), IEntry)
95+ except ComponentLookupError:
96+ sub_entry = entry
97+ result[marshaller.representation_name] = marshaller.unmarshall(
98+ sub_entry, sub_value)
99+ return result
100+
101+ def _marshall_from_json_data(self, value):
102+ """See `SimpleFieldMarshaller`."""
103+ template = {}
104+ for name in self.field.schema.names(all=True):
105+ field = self.field.schema[name]
106+ if IField.providedBy(field):
107+ marshaller = getMultiAdapter(
108+ (field, self.request), IFieldMarshaller)
109+ if marshaller.representation_name in value:
110+ template[name] = marshaller.marshall_from_json_data(
111+ value[marshaller.representation_name])
112+ elif field.required:
113+ raise RequiredMissing(name)
114+ return self.field.schema(template)
115
116=== modified file 'lib/lp/app/webservice/tests/test_marshallers.py'
117--- lib/lp/app/webservice/tests/test_marshallers.py 2012-01-01 02:58:52 +0000
118+++ lib/lp/app/webservice/tests/test_marshallers.py 2018-10-16 15:29:23 +0000
119@@ -1,19 +1,40 @@
120-# Copyright 2011 Canonical Ltd. This software is licensed under the
121+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
122 # GNU Affero General Public License version 3 (see the file LICENSE).
123
124 """Tests for the webservice marshallers."""
125
126 __metaclass__ = type
127
128+from testtools.matchers import (
129+ Equals,
130+ MatchesDict,
131+ MatchesStructure,
132+ )
133 import transaction
134+from zope.component import adapter
135+from zope.interface import (
136+ implementer,
137+ Interface,
138+ )
139+from zope.schema import Choice
140
141-from lp.app.webservice.marshallers import TextFieldMarshaller
142+from lp.app.webservice.marshallers import (
143+ InlineObjectFieldMarshaller,
144+ TextFieldMarshaller,
145+ )
146+from lp.services.fields import (
147+ InlineObject,
148+ PersonChoice,
149+ )
150+from lp.services.job.interfaces.job import JobStatus
151+from lp.services.webapp.publisher import canonical_url
152 from lp.services.webapp.servers import WebServiceTestRequest
153 from lp.testing import (
154 logout,
155 person_logged_in,
156 TestCaseWithFactory,
157 )
158+from lp.testing.fixture import ZopeAdapterFixture
159 from lp.testing.layers import DatabaseFunctionalLayer
160 from lp.testing.pages import (
161 LaunchpadWebServiceCaller,
162@@ -37,7 +58,7 @@
163 self.assertEqual(u"<email address hidden>", result)
164
165 def test_unmarshall_not_obfuscated(self):
166- # Data is not obfuccated if the user is authenticated.
167+ # Data is not obfuscated if the user is authenticated.
168 marshaller = TextFieldMarshaller(None, WebServiceTestRequest())
169 with person_logged_in(self.factory.makePerson()):
170 result = marshaller.unmarshall(None, u"foo@example.com")
171@@ -128,3 +149,56 @@
172 webservice = LaunchpadWebServiceCaller()
173 etag_logged_out = webservice(ws_url(bug)).getheader('etag')
174 self.assertNotEqual(etag_logged_in, etag_logged_out)
175+
176+
177+class IInlineExample(Interface):
178+
179+ person = PersonChoice(vocabulary="ValidPersonOrTeam")
180+
181+ status = Choice(vocabulary=JobStatus)
182+
183+
184+@implementer(IInlineExample)
185+class InlineExample:
186+
187+ def __init__(self, person, status):
188+ self.person = person
189+ self.status = status
190+
191+
192+@adapter(dict)
193+@implementer(IInlineExample)
194+def inline_example_from_dict(template):
195+ return InlineExample(**template)
196+
197+
198+class TestInlineObjectFieldMarshaller(TestCaseWithFactory):
199+
200+ layer = DatabaseFunctionalLayer
201+
202+ def test_unmarshall(self):
203+ field = InlineObject(schema=IInlineExample)
204+ request = WebServiceTestRequest()
205+ request.setVirtualHostRoot(names=["devel"])
206+ marshaller = InlineObjectFieldMarshaller(field, request)
207+ obj = InlineExample(self.factory.makePerson(), JobStatus.WAITING)
208+ result = marshaller.unmarshall(None, obj)
209+ self.assertThat(result, MatchesDict({
210+ "person_link": Equals(canonical_url(obj.person, request=request)),
211+ "status": Equals("Waiting"),
212+ }))
213+
214+ def test_marshall_from_json_data(self):
215+ self.useFixture(ZopeAdapterFixture(inline_example_from_dict))
216+ field = InlineObject(schema=IInlineExample)
217+ request = WebServiceTestRequest()
218+ request.setVirtualHostRoot(names=["devel"])
219+ marshaller = InlineObjectFieldMarshaller(field, request)
220+ person = self.factory.makePerson()
221+ data = {
222+ "person_link": canonical_url(person, request=request),
223+ "status": "Running",
224+ }
225+ obj = marshaller.marshall_from_json_data(data)
226+ self.assertThat(obj, MatchesStructure.byEquality(
227+ person=person, status=JobStatus.RUNNING))
228
229=== modified file 'lib/lp/code/configure.zcml'
230--- lib/lp/code/configure.zcml 2018-10-15 14:44:25 +0000
231+++ lib/lp/code/configure.zcml 2018-10-16 15:29:23 +0000
232@@ -896,22 +896,34 @@
233 <class class="lp.code.model.gitref.GitRef">
234 <require
235 permission="launchpad.View"
236- interface="lp.code.interfaces.gitref.IGitRef" />
237+ interface="lp.code.interfaces.gitref.IGitRefView" />
238+ <require
239+ permission="launchpad.Edit"
240+ interface="lp.code.interfaces.gitref.IGitRefEdit" />
241 </class>
242 <class class="lp.code.model.gitref.GitRefDefault">
243 <require
244 permission="launchpad.View"
245- interface="lp.code.interfaces.gitref.IGitRef" />
246+ interface="lp.code.interfaces.gitref.IGitRefView" />
247+ <require
248+ permission="launchpad.Edit"
249+ interface="lp.code.interfaces.gitref.IGitRefEdit" />
250 </class>
251 <class class="lp.code.model.gitref.GitRefFrozen">
252 <require
253 permission="launchpad.View"
254- interface="lp.code.interfaces.gitref.IGitRef" />
255+ interface="lp.code.interfaces.gitref.IGitRefView" />
256+ <require
257+ permission="launchpad.Edit"
258+ interface="lp.code.interfaces.gitref.IGitRefEdit" />
259 </class>
260 <class class="lp.code.model.gitref.GitRefRemote">
261 <require
262 permission="launchpad.View"
263- interface="lp.code.interfaces.gitref.IGitRef" />
264+ interface="lp.code.interfaces.gitref.IGitRefView" />
265+ <require
266+ permission="launchpad.Edit"
267+ interface="lp.code.interfaces.gitref.IGitRefEdit" />
268 </class>
269 <securedutility
270 component="lp.code.model.gitref.GitRefRemote"
271@@ -943,10 +955,15 @@
272 permission="launchpad.Edit"
273 interface="lp.code.interfaces.gitrule.IGitRuleGrantEdit"
274 set_schema="lp.code.interfaces.gitrule.IGitRuleGrantEditableAttributes" />
275+ <allow interface="lazr.restful.interfaces.IJSONPublishable" />
276 </class>
277 <subscriber
278 for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
279 handler="lp.code.model.gitrule.git_rule_grant_modified"/>
280+ <class class="lp.code.model.gitrule.GitNascentRuleGrant">
281+ <allow interface="lp.code.interfaces.gitrule.IGitNascentRuleGrant" />
282+ </class>
283+ <adapter factory="lp.code.model.gitrule.nascent_rule_grant_from_dict" />
284
285 <!-- GitActivity -->
286
287
288=== modified file 'lib/lp/code/interfaces/gitref.py'
289--- lib/lp/code/interfaces/gitref.py 2018-08-20 23:33:01 +0000
290+++ lib/lp/code/interfaces/gitref.py 2018-10-16 15:29:23 +0000
291@@ -16,6 +16,7 @@
292 export_as_webservice_entry,
293 export_factory_operation,
294 export_read_operation,
295+ export_write_operation,
296 exported,
297 operation_for_version,
298 operation_parameters,
299@@ -50,16 +51,12 @@
300 from lp.code.interfaces.hasbranches import IHasMergeProposals
301 from lp.code.interfaces.hasrecipes import IHasRecipes
302 from lp.registry.interfaces.person import IPerson
303+from lp.services.fields import InlineObject
304 from lp.services.webapp.interfaces import ITableBatchNavigator
305
306
307-class IGitRef(IHasMergeProposals, IHasRecipes, IPrivacy, IInformationType):
308- """A reference in a Git repository."""
309-
310- # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
311- # generation working. Individual attributes must set their version to
312- # "devel".
313- export_as_webservice_entry(as_of="beta")
314+class IGitRefView(IHasMergeProposals, IHasRecipes, IPrivacy, IInformationType):
315+ """IGitRef attributes that require launchpad.View permission."""
316
317 repository = exported(ReferenceChoice(
318 title=_("Repository"), required=True, readonly=True,
319@@ -119,7 +116,7 @@
320
321 commit_message_first_line = TextLine(
322 title=_("The first line of the commit message."),
323- required=True, readonly=True)
324+ required=False, readonly=True)
325
326 identity = Attribute(
327 "The identity of this reference. This will be the shortened path to "
328@@ -392,6 +389,42 @@
329 """
330
331
332+class IGitRefEdit(Interface):
333+ """IGitRef methods that require launchpad.Edit permission."""
334+
335+ @export_read_operation()
336+ @operation_for_version("devel")
337+ def getGrants():
338+ """Get the access grants specific to this reference.
339+
340+ Other grants may apply via wildcard rules.
341+ """
342+
343+ @operation_parameters(
344+ grants=List(
345+ title=_("Grants"),
346+ # Really IGitNascentRuleGrant, patched in
347+ # _schema_circular_imports.py.
348+ value_type=InlineObject(schema=Interface)))
349+ @call_with(user=REQUEST_USER)
350+ @export_write_operation()
351+ @operation_for_version("devel")
352+ def setGrants(grants, user):
353+ """Set the access grants specific to this reference.
354+
355+ Other grants may apply via wildcard rules.
356+ """
357+
358+
359+class IGitRef(IGitRefView, IGitRefEdit):
360+ """A reference in a Git repository."""
361+
362+ # XXX cjwatson 2015-01-19 bug=760849: "beta" is a lie to get WADL
363+ # generation working. Individual attributes must set their version to
364+ # "devel".
365+ export_as_webservice_entry(as_of="beta")
366+
367+
368 class IGitRefBatchNavigator(ITableBatchNavigator):
369 pass
370
371
372=== modified file 'lib/lp/code/interfaces/gitrule.py'
373--- lib/lp/code/interfaces/gitrule.py 2018-10-12 16:41:14 +0000
374+++ lib/lp/code/interfaces/gitrule.py 2018-10-16 15:29:23 +0000
375@@ -7,11 +7,13 @@
376
377 __metaclass__ = type
378 __all__ = [
379+ 'IGitNascentRuleGrant',
380 'IGitRule',
381 'IGitRuleGrant',
382 ]
383
384 from lazr.restful.fields import Reference
385+from lazr.restful.interface import copy_field
386 from zope.interface import (
387 Attribute,
388 Interface,
389@@ -100,6 +102,9 @@
390 matching this rule.
391 """
392
393+ def setGrants(grants, user):
394+ """Set the access grants for this rule."""
395+
396 def destroySelf(user):
397 """Delete this rule.
398
399@@ -183,3 +188,24 @@
400 class IGitRuleGrant(IGitRuleGrantView, IGitRuleGrantEditableAttributes,
401 IGitRuleGrantEdit):
402 """An access grant for a Git repository rule."""
403+
404+
405+class IGitNascentRuleGrant(Interface):
406+ """An access grant in the process of being created.
407+
408+ This represents parameters for a grant that have been deserialised from
409+ a webservice request, but that have not yet been attached to a rule.
410+ """
411+
412+ grantee_type = copy_field(IGitRuleGrant["grantee_type"])
413+
414+ grantee = copy_field(IGitRuleGrant["grantee"])
415+
416+ can_create = copy_field(
417+ IGitRuleGrant["can_create"], required=False, default=False)
418+
419+ can_push = copy_field(
420+ IGitRuleGrant["can_push"], required=False, default=False)
421+
422+ can_force_push = copy_field(
423+ IGitRuleGrant["can_force_push"], required=False, default=False)
424
425=== modified file 'lib/lp/code/model/gitref.py'
426--- lib/lp/code/model/gitref.py 2018-09-27 13:50:06 +0000
427+++ lib/lp/code/model/gitref.py 2018-10-16 15:29:23 +0000
428@@ -69,6 +69,10 @@
429 BranchMergeProposal,
430 BranchMergeProposalGetter,
431 )
432+from lp.code.model.gitrule import (
433+ GitRule,
434+ GitRuleGrant,
435+ )
436 from lp.services.config import config
437 from lp.services.database.constants import UTC_NOW
438 from lp.services.database.decoratedresultset import DecoratedResultSet
439@@ -421,6 +425,24 @@
440 hook = SourcePackageRecipe.preLoadDataForSourcePackageRecipes
441 return DecoratedResultSet(recipes, pre_iter_hook=hook)
442
443+ def getGrants(self):
444+ """See `IGitRef`."""
445+ return list(Store.of(self).find(
446+ GitRuleGrant, GitRuleGrant.rule_id == GitRule.id,
447+ GitRule.repository_id == self.repository_id,
448+ GitRule.ref_pattern == self.path))
449+
450+ def setGrants(self, grants, user):
451+ """See `IGitRef`."""
452+ rule = Store.of(self).find(
453+ GitRule, GitRule.repository_id == self.repository_id,
454+ GitRule.ref_pattern == self.path).one()
455+ if rule is None:
456+ # We don't need to worry about position, since this is an
457+ # exact-match rule and therefore has a canonical position.
458+ rule = self.repository.addRule(self.path, user)
459+ rule.setGrants(grants, user)
460+
461
462 @implementer(IGitRef)
463 class GitRef(StormBase, GitRefMixin):
464@@ -452,7 +474,10 @@
465
466 @property
467 def commit_message_first_line(self):
468- return self.commit_message.split("\n", 1)[0]
469+ if self.commit_message is not None:
470+ return self.commit_message.split("\n", 1)[0]
471+ else:
472+ return None
473
474 @property
475 def has_commits(self):
476@@ -795,6 +820,12 @@
477 """See `IHasRecipes`."""
478 return []
479
480+ def getGrants(self):
481+ """See `IGitRef`."""
482+ return []
483+
484+ setGrants = _unimplemented
485+
486 def __eq__(self, other):
487 return (
488 self.repository_url == other.repository_url and
489
490=== modified file 'lib/lp/code/model/gitrule.py'
491--- lib/lp/code/model/gitrule.py 2018-10-12 16:41:14 +0000
492+++ lib/lp/code/model/gitrule.py 2018-10-16 15:29:23 +0000
493@@ -11,7 +11,16 @@
494 'GitRuleGrant',
495 ]
496
497+from collections import OrderedDict
498+
499 from lazr.enum import DBItem
500+from lazr.lifecycle.event import ObjectModifiedEvent
501+from lazr.lifecycle.snapshot import Snapshot
502+from lazr.restful.interfaces import (
503+ IFieldMarshaller,
504+ IJSONPublishable,
505+ )
506+from lazr.restful.utils import get_current_browser_request
507 import pytz
508 from storm.locals import (
509 Bool,
510@@ -21,13 +30,22 @@
511 Store,
512 Unicode,
513 )
514-from zope.component import getUtility
515-from zope.interface import implementer
516+from zope.component import (
517+ adapter,
518+ getMultiAdapter,
519+ getUtility,
520+ )
521+from zope.event import notify
522+from zope.interface import (
523+ implementer,
524+ providedBy,
525+ )
526 from zope.security.proxy import removeSecurityProxy
527
528 from lp.code.enums import GitGranteeType
529 from lp.code.interfaces.gitactivity import IGitActivitySet
530 from lp.code.interfaces.gitrule import (
531+ IGitNascentRuleGrant,
532 IGitRule,
533 IGitRuleGrant,
534 )
535@@ -42,6 +60,7 @@
536 )
537 from lp.services.database.enumcol import DBEnum
538 from lp.services.database.stormbase import StormBase
539+from lp.services.fields import InlineObject
540
541
542 def git_rule_modified(rule, event):
543@@ -118,6 +137,58 @@
544 getUtility(IGitActivitySet).logGrantAdded(grant, grantor)
545 return grant
546
547+ def _validateGrants(self, grants):
548+ """Validate a new iterable of access grants."""
549+ for grant in grants:
550+ if grant.grantee_type == GitGranteeType.PERSON:
551+ if grant.grantee is None:
552+ raise ValueError(
553+ "Permission grant for %s has grantee_type 'Person' "
554+ "but no grantee" % self.ref_pattern)
555+ else:
556+ if grant.grantee is not None:
557+ raise ValueError(
558+ "Permission grant for %s has grantee_type '%s', "
559+ "contradicting grantee ~%s" %
560+ (self.ref_pattern, grant.grantee_type,
561+ grant.grantee.name))
562+
563+ def setGrants(self, grants, user):
564+ """See `IGitRule`."""
565+ self._validateGrants(grants)
566+ existing_grants = {
567+ (grant.grantee_type, grant.grantee): grant
568+ for grant in self.grants}
569+ new_grants = OrderedDict(
570+ ((grant.grantee_type, grant.grantee), grant)
571+ for grant in grants)
572+
573+ for grant_key, grant in existing_grants.items():
574+ if grant_key not in new_grants:
575+ grant.destroySelf(user)
576+
577+ for grant_key, new_grant in new_grants.items():
578+ grant = existing_grants.get(grant_key)
579+ if grant is None:
580+ new_grantee = (
581+ new_grant.grantee
582+ if new_grant.grantee_type == GitGranteeType.PERSON
583+ else new_grant.grantee_type)
584+ grant = self.addGrant(
585+ new_grantee, user, can_create=new_grant.can_create,
586+ can_push=new_grant.can_push,
587+ can_force_push=new_grant.can_force_push)
588+ else:
589+ grant_before_modification = Snapshot(
590+ grant, providing=providedBy(grant))
591+ edited_fields = []
592+ for field in ("can_create", "can_push", "can_force_push"):
593+ if getattr(grant, field) != getattr(new_grant, field):
594+ setattr(grant, field, getattr(new_grant, field))
595+ edited_fields.append(field)
596+ notify(ObjectModifiedEvent(
597+ grant, grant_before_modification, edited_fields))
598+
599 def destroySelf(self, user):
600 """See `IGitRule`."""
601 getUtility(IGitActivitySet).logRuleRemoved(self, user)
602@@ -142,7 +213,7 @@
603 removeSecurityProxy(grant).date_last_modified = UTC_NOW
604
605
606-@implementer(IGitRuleGrant)
607+@implementer(IGitRuleGrant, IJSONPublishable)
608 class GitRuleGrant(StormBase):
609 """See `IGitRuleGrant`."""
610
611@@ -215,8 +286,35 @@
612 ", ".join(permissions), grantee_name, self.repository.unique_name,
613 self.rule.ref_pattern)
614
615+ def toDataForJSON(self, media_type):
616+ """See `IJSONPublishable`."""
617+ if media_type != "application/json":
618+ raise ValueError("Unhandled media type %s" % media_type)
619+ request = get_current_browser_request()
620+ field = InlineObject(schema=IGitNascentRuleGrant).bind(self)
621+ marshaller = getMultiAdapter((field, request), IFieldMarshaller)
622+ return marshaller.unmarshall(None, self)
623+
624 def destroySelf(self, user=None):
625 """See `IGitRuleGrant`."""
626 if user is not None:
627 getUtility(IGitActivitySet).logGrantRemoved(self, user)
628 Store.of(self).remove(self)
629+
630+
631+@implementer(IGitNascentRuleGrant)
632+class GitNascentRuleGrant:
633+
634+ def __init__(self, grantee_type, grantee=None, can_create=False,
635+ can_push=False, can_force_push=False):
636+ self.grantee_type = grantee_type
637+ self.grantee = grantee
638+ self.can_create = can_create
639+ self.can_push = can_push
640+ self.can_force_push = can_force_push
641+
642+
643+@adapter(dict)
644+@implementer(IGitNascentRuleGrant)
645+def nascent_rule_grant_from_dict(template):
646+ return GitNascentRuleGrant(**template)
647
648=== modified file 'lib/lp/code/model/tests/test_gitref.py'
649--- lib/lp/code/model/tests/test_gitref.py 2018-09-27 13:50:06 +0000
650+++ lib/lp/code/model/tests/test_gitref.py 2018-10-16 15:29:23 +0000
651@@ -17,20 +17,25 @@
652 from bzrlib import urlutils
653 import pytz
654 import responses
655+from storm.store import Store
656 from testtools.matchers import (
657 ContainsDict,
658 EndsWith,
659 Equals,
660 Is,
661 LessThan,
662+ MatchesDict,
663 MatchesListwise,
664+ MatchesSetwise,
665 MatchesStructure,
666 )
667+import transaction
668 from zope.component import getUtility
669
670 from lp.app.enums import InformationType
671 from lp.app.interfaces.informationtype import IInformationType
672 from lp.app.interfaces.launchpad import IPrivacy
673+from lp.code.enums import GitGranteeType
674 from lp.code.errors import (
675 GitRepositoryBlobNotFound,
676 GitRepositoryBlobUnsupportedRemote,
677@@ -38,8 +43,10 @@
678 InvalidBranchMergeProposal,
679 )
680 from lp.code.interfaces.gitrepository import IGitRepositorySet
681+from lp.code.interfaces.gitrule import IGitNascentRuleGrant
682 from lp.code.tests.helpers import GitHostingFixture
683 from lp.services.config import config
684+from lp.services.database.sqlbase import get_transaction_timestamp
685 from lp.services.features.testing import FeatureFixture
686 from lp.services.memcache.interfaces import IMemcacheClient
687 from lp.services.utils import seconds_since_epoch
688@@ -570,6 +577,106 @@
689 self.assertEqual({(person1, "review1"), (person2, "review2")}, votes)
690
691
692+class TestGitRefGrants(TestCaseWithFactory):
693+ """Test handling of access grants for refs.
694+
695+ Most of the hard work here is done by GitRule, but we ensure that
696+ getting and setting grants via GitRef operates only on the appropriate
697+ exact-match rule.
698+ """
699+
700+ layer = DatabaseFunctionalLayer
701+
702+ def test_getGrants(self):
703+ repository = self.factory.makeGitRepository()
704+ [ref] = self.factory.makeGitRefs(repository=repository)
705+ rule = self.factory.makeGitRule(
706+ repository=repository, ref_pattern=ref.path)
707+ grants = [
708+ self.factory.makeGitRuleGrant(
709+ rule=rule, can_create=True, can_force_push=True),
710+ self.factory.makeGitRuleGrant(rule=rule, can_push=True),
711+ ]
712+ wildcard_rule = self.factory.makeGitRule(
713+ repository=repository, ref_pattern="refs/heads/*")
714+ self.factory.makeGitRuleGrant(rule=wildcard_rule)
715+ self.assertThat(ref.getGrants(), MatchesSetwise(
716+ MatchesStructure(
717+ rule=Equals(rule),
718+ grantee_type=Equals(GitGranteeType.PERSON),
719+ grantee=Equals(grants[0].grantee),
720+ can_create=Is(True),
721+ can_push=Is(False),
722+ can_force_push=Is(True)),
723+ MatchesStructure(
724+ rule=Equals(rule),
725+ grantee_type=Equals(GitGranteeType.PERSON),
726+ grantee=Equals(grants[1].grantee),
727+ can_create=Is(False),
728+ can_push=Is(True),
729+ can_force_push=Is(False))))
730+
731+ def test_setGrants_no_matching_rule(self):
732+ repository = self.factory.makeGitRepository()
733+ [ref] = self.factory.makeGitRefs(repository=repository)
734+ self.factory.makeGitRule(
735+ repository=repository, ref_pattern="refs/heads/*")
736+ other_repository = self.factory.makeGitRepository()
737+ self.factory.makeGitRule(
738+ repository=other_repository, ref_pattern=ref.path)
739+ with person_logged_in(repository.owner):
740+ ref.setGrants([
741+ IGitNascentRuleGrant({
742+ "grantee_type": GitGranteeType.REPOSITORY_OWNER,
743+ "can_force_push": True,
744+ }),
745+ ], repository.owner)
746+ self.assertThat(list(repository.rules), MatchesListwise([
747+ MatchesStructure(
748+ repository=Equals(repository),
749+ ref_pattern=Equals(ref.path),
750+ grants=MatchesSetwise(
751+ MatchesStructure(
752+ grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
753+ grantee=Is(None),
754+ can_create=Is(False),
755+ can_push=Is(False),
756+ can_force_push=Is(True)))),
757+ MatchesStructure(
758+ repository=Equals(repository),
759+ ref_pattern=Equals("refs/heads/*"),
760+ grants=MatchesSetwise()),
761+ ]))
762+
763+ def test_setGrants_matching_rule(self):
764+ repository = self.factory.makeGitRepository()
765+ [ref] = self.factory.makeGitRefs(repository=repository)
766+ rule = self.factory.makeGitRule(
767+ repository=repository, ref_pattern=ref.path)
768+ date_created = get_transaction_timestamp(Store.of(rule))
769+ transaction.commit()
770+ with person_logged_in(repository.owner):
771+ ref.setGrants([
772+ IGitNascentRuleGrant({
773+ "grantee_type": GitGranteeType.REPOSITORY_OWNER,
774+ "can_force_push": True,
775+ }),
776+ ], repository.owner)
777+ self.assertThat(list(repository.rules), MatchesListwise([
778+ MatchesStructure(
779+ repository=Equals(repository),
780+ ref_pattern=Equals(ref.path),
781+ date_created=Equals(date_created),
782+ grants=MatchesSetwise(
783+ MatchesStructure(
784+ grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
785+ grantee=Is(None),
786+ can_create=Is(False),
787+ can_push=Is(False),
788+ can_force_push=Is(True)))),
789+ ]))
790+
791+
792 class TestGitRefWebservice(TestCaseWithFactory):
793 """Tests for the webservice."""
794
795@@ -686,3 +793,85 @@
796 self.assertEqual(1, len(dependent_landings["entries"]))
797 self.assertThat(
798 dependent_landings["entries"][0]["self_link"], EndsWith(bmp_url))
799+
800+ def test_getGrants(self):
801+ [ref] = self.factory.makeGitRefs()
802+ rule = self.factory.makeGitRule(
803+ repository=ref.repository, ref_pattern=ref.path)
804+ self.factory.makeGitRuleGrant(
805+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
806+ can_create=True, can_force_push=True)
807+ grantee = self.factory.makePerson()
808+ self.factory.makeGitRuleGrant(
809+ rule=rule, grantee=grantee, can_push=True)
810+ with person_logged_in(ref.owner):
811+ ref_url = api_url(ref)
812+ grantee_url = api_url(grantee)
813+ webservice = webservice_for_person(
814+ ref.owner, permission=OAuthPermission.WRITE_PUBLIC)
815+ webservice.default_api_version = "devel"
816+ response = webservice.named_get(ref_url, "getGrants")
817+ self.assertThat(json.loads(response.body), MatchesSetwise(
818+ MatchesDict({
819+ "grantee_type": Equals("Repository owner"),
820+ "grantee_link": Is(None),
821+ "can_create": Is(True),
822+ "can_push": Is(False),
823+ "can_force_push": Is(True),
824+ }),
825+ MatchesDict({
826+ "grantee_type": Equals("Person"),
827+ "grantee_link": Equals(webservice.getAbsoluteUrl(grantee_url)),
828+ "can_create": Is(False),
829+ "can_push": Is(True),
830+ "can_force_push": Is(False),
831+ })))
832+
833+ def test_setGrants(self):
834+ [ref] = self.factory.makeGitRefs()
835+ owner = ref.owner
836+ grantee = self.factory.makePerson()
837+ with person_logged_in(owner):
838+ ref_url = api_url(ref)
839+ grantee_url = api_url(grantee)
840+ webservice = webservice_for_person(
841+ owner, permission=OAuthPermission.WRITE_PUBLIC)
842+ webservice.default_api_version = "devel"
843+ response = webservice.named_post(
844+ ref_url, "setGrants",
845+ grants=[
846+ {
847+ "grantee_type": "Repository owner",
848+ "can_create": True,
849+ "can_force_push": True,
850+ },
851+ {
852+ "grantee_type": "Person",
853+ "grantee_link": grantee_url,
854+ "can_push": True,
855+ },
856+ ])
857+ self.assertEqual(200, response.status)
858+ with person_logged_in(owner):
859+ self.assertThat(list(ref.repository.rules), MatchesListwise([
860+ MatchesStructure(
861+ repository=Equals(ref.repository),
862+ ref_pattern=Equals(ref.path),
863+ creator=Equals(owner),
864+ grants=MatchesSetwise(
865+ MatchesStructure(
866+ grantor=Equals(owner),
867+ grantee_type=Equals(
868+ GitGranteeType.REPOSITORY_OWNER),
869+ grantee=Is(None),
870+ can_create=Is(True),
871+ can_push=Is(False),
872+ can_force_push=Is(True)),
873+ MatchesStructure(
874+ grantor=Equals(owner),
875+ grantee_type=Equals(GitGranteeType.PERSON),
876+ grantee=Equals(grantee),
877+ can_create=Is(False),
878+ can_push=Is(True),
879+ can_force_push=Is(False)))),
880+ ]))
881
882=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
883--- lib/lp/code/model/tests/test_gitrule.py 2018-10-12 16:41:14 +0000
884+++ lib/lp/code/model/tests/test_gitrule.py 2018-10-16 15:29:23 +0000
885@@ -14,17 +14,21 @@
886 Equals,
887 Is,
888 MatchesDict,
889+ MatchesListwise,
890 MatchesSetwise,
891 MatchesStructure,
892 )
893+import transaction
894 from zope.event import notify
895 from zope.interface import providedBy
896+from zope.security.proxy import removeSecurityProxy
897
898 from lp.code.enums import (
899 GitActivityType,
900 GitGranteeType,
901 )
902 from lp.code.interfaces.gitrule import (
903+ IGitNascentRuleGrant,
904 IGitRule,
905 IGitRuleGrant,
906 )
907@@ -122,6 +126,303 @@
908 can_push=Is(False),
909 can_force_push=Is(True))))
910
911+ def test__validateGrants_ok(self):
912+ rule = self.factory.makeGitRule()
913+ grants = [
914+ IGitNascentRuleGrant({
915+ "grantee_type": GitGranteeType.REPOSITORY_OWNER,
916+ "can_force_push": True,
917+ }),
918+ ]
919+ removeSecurityProxy(rule)._validateGrants(grants)
920+
921+ def test__validateGrants_grantee_type_person_but_no_grantee(self):
922+ rule = self.factory.makeGitRule(ref_pattern="refs/heads/*")
923+ grants = [
924+ IGitNascentRuleGrant({
925+ "grantee_type": GitGranteeType.PERSON,
926+ "can_force_push": True,
927+ }),
928+ ]
929+ self.assertRaisesWithContent(
930+ ValueError,
931+ "Permission grant for refs/heads/* has grantee_type 'Person' but "
932+ "no grantee",
933+ removeSecurityProxy(rule)._validateGrants, grants)
934+
935+ def test__validateGrants_grantee_but_wrong_grantee_type(self):
936+ rule = self.factory.makeGitRule(ref_pattern="refs/heads/*")
937+ grantee = self.factory.makePerson()
938+ grants = [
939+ IGitNascentRuleGrant({
940+ "grantee_type": GitGranteeType.REPOSITORY_OWNER,
941+ "grantee": grantee,
942+ "can_force_push": True,
943+ }),
944+ ]
945+ self.assertRaisesWithContent(
946+ ValueError,
947+ "Permission grant for refs/heads/* has grantee_type "
948+ "'Repository owner', contradicting grantee ~%s" % grantee.name,
949+ removeSecurityProxy(rule)._validateGrants, grants)
950+
951+ def test_setGrants_add(self):
952+ owner = self.factory.makeTeam()
953+ member = self.factory.makePerson(member_of=[owner])
954+ rule = self.factory.makeGitRule(owner=owner)
955+ grantee = self.factory.makePerson()
956+ removeSecurityProxy(rule.repository.getActivity()).remove()
957+ with person_logged_in(member):
958+ rule.setGrants([
959+ IGitNascentRuleGrant({
960+ "grantee_type": GitGranteeType.REPOSITORY_OWNER,
961+ "can_create": True,
962+ "can_force_push": True,
963+ }),
964+ IGitNascentRuleGrant({
965+ "grantee_type": GitGranteeType.PERSON,
966+ "grantee": grantee,
967+ "can_push": True,
968+ }),
969+ ], member)
970+ self.assertThat(rule.grants, MatchesSetwise(
971+ MatchesStructure(
972+ rule=Equals(rule),
973+ grantor=Equals(member),
974+ grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
975+ grantee=Is(None),
976+ can_create=Is(True),
977+ can_push=Is(False),
978+ can_force_push=Is(True)),
979+ MatchesStructure(
980+ rule=Equals(rule),
981+ grantor=Equals(member),
982+ grantee_type=Equals(GitGranteeType.PERSON),
983+ grantee=Equals(grantee),
984+ can_create=Is(False),
985+ can_push=Is(True),
986+ can_force_push=Is(False))))
987+ self.assertThat(list(rule.repository.getActivity()), MatchesListwise([
988+ MatchesStructure(
989+ repository=Equals(rule.repository),
990+ changer=Equals(member),
991+ changee=Equals(grantee),
992+ what_changed=Equals(GitActivityType.GRANT_ADDED),
993+ old_value=Is(None),
994+ new_value=MatchesDict({
995+ "changee_type": Equals("Person"),
996+ "ref_pattern": Equals(rule.ref_pattern),
997+ "can_create": Is(False),
998+ "can_push": Is(True),
999+ "can_force_push": Is(False),
1000+ })),
1001+ MatchesStructure(
1002+ repository=Equals(rule.repository),
1003+ changer=Equals(member),
1004+ changee=Is(None),
1005+ what_changed=Equals(GitActivityType.GRANT_ADDED),
1006+ old_value=Is(None),
1007+ new_value=MatchesDict({
1008+ "changee_type": Equals("Repository owner"),
1009+ "ref_pattern": Equals(rule.ref_pattern),
1010+ "can_create": Is(True),
1011+ "can_push": Is(False),
1012+ "can_force_push": Is(True),
1013+ })),
1014+ ]))
1015+
1016+ def test_setGrants_modify(self):
1017+ owner = self.factory.makeTeam()
1018+ members = [
1019+ self.factory.makePerson(member_of=[owner]) for _ in range(2)]
1020+ rule = self.factory.makeGitRule(owner=owner)
1021+ grantees = [self.factory.makePerson() for _ in range(2)]
1022+ self.factory.makeGitRuleGrant(
1023+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1024+ grantor=members[0], can_create=True)
1025+ self.factory.makeGitRuleGrant(
1026+ rule=rule, grantee=grantees[0], grantor=members[0], can_push=True)
1027+ self.factory.makeGitRuleGrant(
1028+ rule=rule, grantee=grantees[1], grantor=members[0],
1029+ can_force_push=True)
1030+ date_created = get_transaction_timestamp(Store.of(rule))
1031+ transaction.commit()
1032+ removeSecurityProxy(rule.repository.getActivity()).remove()
1033+ with person_logged_in(members[1]):
1034+ rule.setGrants([
1035+ IGitNascentRuleGrant({
1036+ "grantee_type": GitGranteeType.REPOSITORY_OWNER,
1037+ "can_force_push": True,
1038+ }),
1039+ IGitNascentRuleGrant({
1040+ "grantee_type": GitGranteeType.PERSON,
1041+ "grantee": grantees[1],
1042+ "can_create": True,
1043+ }),
1044+ IGitNascentRuleGrant({
1045+ "grantee_type": GitGranteeType.PERSON,
1046+ "grantee": grantees[0],
1047+ "can_push": True,
1048+ "can_force_push": True,
1049+ }),
1050+ ], members[1])
1051+ date_modified = get_transaction_timestamp(Store.of(rule))
1052+ self.assertThat(rule.grants, MatchesSetwise(
1053+ MatchesStructure(
1054+ rule=Equals(rule),
1055+ grantor=Equals(members[0]),
1056+ grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
1057+ grantee=Is(None),
1058+ can_create=Is(False),
1059+ can_push=Is(False),
1060+ can_force_push=Is(True),
1061+ date_created=Equals(date_created),
1062+ date_last_modified=Equals(date_modified)),
1063+ MatchesStructure(
1064+ rule=Equals(rule),
1065+ grantor=Equals(members[0]),
1066+ grantee_type=Equals(GitGranteeType.PERSON),
1067+ grantee=Equals(grantees[0]),
1068+ can_create=Is(False),
1069+ can_push=Is(True),
1070+ can_force_push=Is(True),
1071+ date_created=Equals(date_created),
1072+ date_last_modified=Equals(date_modified)),
1073+ MatchesStructure(
1074+ rule=Equals(rule),
1075+ grantor=Equals(members[0]),
1076+ grantee_type=Equals(GitGranteeType.PERSON),
1077+ grantee=Equals(grantees[1]),
1078+ can_create=Is(True),
1079+ can_push=Is(False),
1080+ can_force_push=Is(False),
1081+ date_created=Equals(date_created),
1082+ date_last_modified=Equals(date_modified))))
1083+ self.assertThat(list(rule.repository.getActivity()), MatchesListwise([
1084+ MatchesStructure(
1085+ repository=Equals(rule.repository),
1086+ changer=Equals(members[1]),
1087+ changee=Equals(grantees[0]),
1088+ what_changed=Equals(GitActivityType.GRANT_CHANGED),
1089+ old_value=MatchesDict({
1090+ "changee_type": Equals("Person"),
1091+ "ref_pattern": Equals(rule.ref_pattern),
1092+ "can_create": Is(False),
1093+ "can_push": Is(True),
1094+ "can_force_push": Is(False),
1095+ }),
1096+ new_value=MatchesDict({
1097+ "changee_type": Equals("Person"),
1098+ "ref_pattern": Equals(rule.ref_pattern),
1099+ "can_create": Is(False),
1100+ "can_push": Is(True),
1101+ "can_force_push": Is(True),
1102+ })),
1103+ MatchesStructure(
1104+ repository=Equals(rule.repository),
1105+ changer=Equals(members[1]),
1106+ changee=Equals(grantees[1]),
1107+ what_changed=Equals(GitActivityType.GRANT_CHANGED),
1108+ old_value=MatchesDict({
1109+ "changee_type": Equals("Person"),
1110+ "ref_pattern": Equals(rule.ref_pattern),
1111+ "can_create": Is(False),
1112+ "can_push": Is(False),
1113+ "can_force_push": Is(True),
1114+ }),
1115+ new_value=MatchesDict({
1116+ "changee_type": Equals("Person"),
1117+ "ref_pattern": Equals(rule.ref_pattern),
1118+ "can_create": Is(True),
1119+ "can_push": Is(False),
1120+ "can_force_push": Is(False),
1121+ })),
1122+ MatchesStructure(
1123+ repository=Equals(rule.repository),
1124+ changer=Equals(members[1]),
1125+ changee=Is(None),
1126+ what_changed=Equals(GitActivityType.GRANT_CHANGED),
1127+ old_value=MatchesDict({
1128+ "changee_type": Equals("Repository owner"),
1129+ "ref_pattern": Equals(rule.ref_pattern),
1130+ "can_create": Is(True),
1131+ "can_push": Is(False),
1132+ "can_force_push": Is(False),
1133+ }),
1134+ new_value=MatchesDict({
1135+ "changee_type": Equals("Repository owner"),
1136+ "ref_pattern": Equals(rule.ref_pattern),
1137+ "can_create": Is(False),
1138+ "can_push": Is(False),
1139+ "can_force_push": Is(True),
1140+ })),
1141+ ]))
1142+
1143+ def test_setGrants_remove(self):
1144+ owner = self.factory.makeTeam()
1145+ members = [
1146+ self.factory.makePerson(member_of=[owner]) for _ in range(2)]
1147+ rule = self.factory.makeGitRule(owner=owner)
1148+ grantees = [self.factory.makePerson() for _ in range(2)]
1149+ self.factory.makeGitRuleGrant(
1150+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1151+ grantor=members[0], can_create=True)
1152+ self.factory.makeGitRuleGrant(
1153+ rule=rule, grantee=grantees[0], grantor=members[0], can_push=True)
1154+ self.factory.makeGitRuleGrant(
1155+ rule=rule, grantee=grantees[1], grantor=members[0],
1156+ can_force_push=True)
1157+ date_created = get_transaction_timestamp(Store.of(rule))
1158+ transaction.commit()
1159+ removeSecurityProxy(rule.repository.getActivity()).remove()
1160+ with person_logged_in(members[1]):
1161+ rule.setGrants([
1162+ IGitNascentRuleGrant({
1163+ "grantee_type": GitGranteeType.PERSON,
1164+ "grantee": grantees[0],
1165+ "can_push": True,
1166+ }),
1167+ ], members[1])
1168+ self.assertThat(rule.grants, MatchesSetwise(
1169+ MatchesStructure(
1170+ rule=Equals(rule),
1171+ grantor=Equals(members[0]),
1172+ grantee_type=Equals(GitGranteeType.PERSON),
1173+ grantee=Equals(grantees[0]),
1174+ can_create=Is(False),
1175+ can_push=Is(True),
1176+ can_force_push=Is(False),
1177+ date_created=Equals(date_created),
1178+ date_last_modified=Equals(date_created))))
1179+ self.assertThat(list(rule.repository.getActivity()), MatchesSetwise(
1180+ MatchesStructure(
1181+ repository=Equals(rule.repository),
1182+ changer=Equals(members[1]),
1183+ changee=Is(None),
1184+ what_changed=Equals(GitActivityType.GRANT_REMOVED),
1185+ old_value=MatchesDict({
1186+ "changee_type": Equals("Repository owner"),
1187+ "ref_pattern": Equals(rule.ref_pattern),
1188+ "can_create": Is(True),
1189+ "can_push": Is(False),
1190+ "can_force_push": Is(False),
1191+ }),
1192+ new_value=Is(None)),
1193+ MatchesStructure(
1194+ repository=Equals(rule.repository),
1195+ changer=Equals(members[1]),
1196+ changee=Equals(grantees[1]),
1197+ what_changed=Equals(GitActivityType.GRANT_REMOVED),
1198+ old_value=MatchesDict({
1199+ "changee_type": Equals("Person"),
1200+ "ref_pattern": Equals(rule.ref_pattern),
1201+ "can_create": Is(False),
1202+ "can_push": Is(False),
1203+ "can_force_push": Is(True),
1204+ }),
1205+ new_value=Is(None)),
1206+ ))
1207+
1208 def test_activity_rule_added(self):
1209 owner = self.factory.makeTeam()
1210 member = self.factory.makePerson(member_of=[owner])
1211
1212=== modified file 'lib/lp/services/fields/__init__.py'
1213--- lib/lp/services/fields/__init__.py 2015-09-28 17:38:45 +0000
1214+++ lib/lp/services/fields/__init__.py 2018-10-16 15:29:23 +0000
1215@@ -1,10 +1,9 @@
1216-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
1217+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
1218 # GNU Affero General Public License version 3 (see the file LICENSE).
1219
1220 __metaclass__ = type
1221 __all__ = [
1222 'AnnouncementDate',
1223- 'FormattableDate',
1224 'BaseImageUpload',
1225 'BlacklistableContentNameField',
1226 'BugField',
1227@@ -13,10 +12,12 @@
1228 'Datetime',
1229 'DuplicateBug',
1230 'FieldNotBoundError',
1231+ 'FormattableDate',
1232 'IAnnouncementDate',
1233 'IBaseImageUpload',
1234 'IBugField',
1235 'IDescription',
1236+ 'IInlineObject',
1237 'INoneableTextLine',
1238 'IPersonChoice',
1239 'IStrippedTextLine',
1240@@ -26,6 +27,7 @@
1241 'IURIField',
1242 'IWhiteboard',
1243 'IconImageUpload',
1244+ 'InlineObject',
1245 'KEEP_SAME_IMAGE',
1246 'LogoImageUpload',
1247 'MugshotImageUpload',
1248@@ -71,6 +73,7 @@
1249 Date,
1250 Datetime,
1251 Int,
1252+ Object,
1253 Text,
1254 TextLine,
1255 Tuple,
1256@@ -909,3 +912,12 @@
1257 "for the target '%s'." % \
1258 (milestone_name, target.name))
1259 return milestone
1260+
1261+
1262+class IInlineObject(IObject):
1263+ """A marker for an object represented as a dict."""
1264+
1265+
1266+@implementer(IInlineObject)
1267+class InlineObject(Object):
1268+ """An object that is represented as a dict rather than a URL reference."""
1269
1270=== modified file 'lib/lp/services/webservice/configure.zcml'
1271--- lib/lp/services/webservice/configure.zcml 2015-04-28 15:22:46 +0000
1272+++ lib/lp/services/webservice/configure.zcml 2018-10-16 15:29:23 +0000
1273@@ -1,4 +1,4 @@
1274-<!-- Copyright 2011 Canonical Ltd. This software is licensed under the
1275+<!-- Copyright 2011-2018 Canonical Ltd. This software is licensed under the
1276 GNU Affero General Public License version 3 (see the file LICENSE).
1277 -->
1278
1279@@ -84,6 +84,12 @@
1280 provides="lazr.restful.interfaces.IFieldMarshaller"
1281 factory="lazr.restful.marshallers.ObjectLookupFieldMarshaller"
1282 />
1283+ <adapter
1284+ for="lp.services.fields.IInlineObject
1285+ zope.publisher.interfaces.http.IHTTPRequest"
1286+ provides="lazr.restful.interfaces.IFieldMarshaller"
1287+ factory="lp.app.webservice.marshallers.InlineObjectFieldMarshaller"
1288+ />
1289
1290 <!-- The API documentation -->
1291 <browser:page