Merge lp:~cjwatson/launchpad/git-permissions-webservice-ref into lp:launchpad
- git-permissions-webservice-ref
- Merge into devel
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 | ||||
Related bugs: |
|
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 : | # |
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 |
I've applied most of your suggestions; thanks.