Merge lp:~cjwatson/lazr.restful/caveats into lp:lazr.restful
- caveats
- Merge into trunk
Status: | Rejected |
---|---|
Rejected by: | Colin Watson |
Proposed branch: | lp:~cjwatson/lazr.restful/caveats |
Merge into: | lp:lazr.restful |
Diff against target: |
1565 lines (+781/-120) 14 files modified
setup.py (+2/-0) src/lazr/restful/_operation.py (+9/-3) src/lazr/restful/_resource.py (+117/-89) src/lazr/restful/caveatchecker.py (+121/-0) src/lazr/restful/configure.zcml (+2/-0) src/lazr/restful/declarations.py (+51/-6) src/lazr/restful/docs/webservice-declarations.txt (+83/-0) src/lazr/restful/docs/webservice-marshallers.txt (+9/-0) src/lazr/restful/docs/webservice.txt (+187/-9) src/lazr/restful/interfaces/_rest.py (+126/-0) src/lazr/restful/marshallers.py (+10/-3) src/lazr/restful/testing/webservice.py (+39/-4) src/lazr/restful/tests/test_etag.py (+21/-4) src/lazr/restful/tests/test_webservice.py (+4/-2) |
To merge this branch: | bzr merge lp:~cjwatson/lazr.restful/caveats |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
LAZR Developers | Pending | ||
Review via email: mp+286796@code.launchpad.net |
Commit message
Implement caveat checking at the request boundary.
Description of the change
Implement basic support for "caveats": predicates that are enforced at the webservice request boundary, but not inside it. The data structures used here are inspired by Google's "macaroons" (http://
Caveats are enforced around marshalling and unmarshalling of objects, and in some cases in advance of turning them into their wire format in order to be able to redact fields; they are also enforced on calls to methods declared as part of the webservice. In order to avoid a large amount of verbiage in macaroons related to reading public objects, caveats can never reduce permissions below those possessed by an unauthenticated user, which requires some extra support in the security policy.
The ICaveatChecker interface is somewhat based on zope.security.
I have work in progress to implement the corresponding pieces of this in Launchpad. I don't intend to land the lazr.restful side until the Launchpad side is also ready, but I'm sending this for earlier review to make sure that I'm heading in the right direction.
- 215. By Colin Watson
-
Only check caveats if the principal provides IPrincipalWithC
aveats. - 216. By Colin Watson
-
It's the interaction that supports checkUnauthenti
catedPermission , not the security policy. - 217. By Colin Watson
-
Fix doctest.
- 218. By Colin Watson
-
Add IPrincipalCavea
t.text. - 219. By Colin Watson
-
Rename IPrincipalCaveat to ICaveat.
- 220. By Colin Watson
-
Add a @check_
parameter_ permissions decorator. - 221. By Colin Watson
-
Fix lazr.restful.
caveatchecker. __all__ . - 222. By Colin Watson
-
Check caveats in accessors, mutators, and collections.
Colin Watson (cjwatson) wrote : | # |
Unmerged revisions
- 222. By Colin Watson
-
Check caveats in accessors, mutators, and collections.
- 221. By Colin Watson
-
Fix lazr.restful.
caveatchecker. __all__ . - 220. By Colin Watson
-
Add a @check_
parameter_ permissions decorator. - 219. By Colin Watson
-
Rename IPrincipalCaveat to ICaveat.
- 218. By Colin Watson
-
Add IPrincipalCavea
t.text. - 217. By Colin Watson
-
Fix doctest.
- 216. By Colin Watson
-
It's the interaction that supports checkUnauthenti
catedPermission , not the security policy. - 215. By Colin Watson
-
Only check caveats if the principal provides IPrincipalWithC
aveats. - 214. By Colin Watson
-
Implement caveat checking at the request boundary.
- 213. By Colin Watson
-
Lift exception handling from ReadWriteResour
ce.__call_ _ into HTTPResource. __call_ _.
Preview Diff
1 | === modified file 'setup.py' |
2 | --- setup.py 2016-02-17 01:07:21 +0000 |
3 | +++ setup.py 2016-03-02 13:25:07 +0000 |
4 | @@ -69,11 +69,13 @@ |
5 | 'van.testing', |
6 | 'wsgiref', |
7 | 'zope.app.pagetemplate', |
8 | + 'zope.authentication', |
9 | 'zope.component [zcml]', |
10 | 'zope.configuration', |
11 | 'zope.event', |
12 | 'zope.interface>=3.6.0', |
13 | 'zope.pagetemplate', |
14 | + 'zope.principalregistry', |
15 | 'zope.processlifetime', |
16 | 'zope.proxy', |
17 | 'zope.publisher', |
18 | |
19 | === modified file 'src/lazr/restful/_operation.py' |
20 | --- src/lazr/restful/_operation.py 2016-02-17 01:07:21 +0000 |
21 | +++ src/lazr/restful/_operation.py 2016-03-02 13:25:07 +0000 |
22 | @@ -16,10 +16,15 @@ |
23 | from lazr.lifecycle.event import ObjectModifiedEvent |
24 | from lazr.lifecycle.snapshot import Snapshot |
25 | |
26 | -from lazr.restful.fields import CollectionField |
27 | from lazr.restful.interfaces import ( |
28 | - ICollection, IFieldMarshaller, IResourceDELETEOperation, |
29 | - IResourceGETOperation, IResourcePOSTOperation, IWebServiceConfiguration) |
30 | + ICaveatChecker, |
31 | + ICollection, |
32 | + IFieldMarshaller, |
33 | + IResourceDELETEOperation, |
34 | + IResourceGETOperation, |
35 | + IResourcePOSTOperation, |
36 | + IWebServiceConfiguration, |
37 | + ) |
38 | from lazr.restful.interfaces import ICollectionField, IReference |
39 | from lazr.restful.utils import is_total_size_link_active |
40 | from lazr.restful._resource import ( |
41 | @@ -46,6 +51,7 @@ |
42 | def __init__(self, context, request): |
43 | self.context = context |
44 | self.request = request |
45 | + self.caveat_checker = ICaveatChecker(request) |
46 | self.total_size_only = False |
47 | |
48 | def total_size_link(self, navigator): |
49 | |
50 | === modified file 'src/lazr/restful/_resource.py' |
51 | --- src/lazr/restful/_resource.py 2016-02-17 01:07:21 +0000 |
52 | +++ src/lazr/restful/_resource.py 2016-03-02 13:25:07 +0000 |
53 | @@ -89,6 +89,7 @@ |
54 | from lazr.lifecycle.event import ObjectModifiedEvent |
55 | from lazr.lifecycle.snapshot import Snapshot |
56 | from lazr.restful.interfaces import ( |
57 | + ICaveatChecker, |
58 | ICollection, |
59 | ICollectionField, |
60 | ICollectionResource, |
61 | @@ -286,11 +287,41 @@ |
62 | def __init__(self, context, request): |
63 | self.context = context |
64 | self.request = request |
65 | + self.caveat_checker = ICaveatChecker(request) |
66 | self.etags_by_media_type = {} |
67 | |
68 | def __call__(self): |
69 | """See `IHTTPResource`.""" |
70 | - pass |
71 | + result = "" |
72 | + try: |
73 | + result = self.call() |
74 | + except Exception as e: |
75 | + exception_info = sys.exc_info() |
76 | + view = getMultiAdapter((e, self.request), name="index.html") |
77 | + try: |
78 | + ws_view = IWebServiceExceptionView(view) |
79 | + except TypeError: |
80 | + # There's a view for this exception, but it's not one |
81 | + # we can understand. It was probably designed for a |
82 | + # web application rather than a web service. We'll |
83 | + # re-raise the exception, let the publisher look up |
84 | + # the view, and hopefully handle it better. Note the careful |
85 | + # reraising that ensures the original traceback is preserved. |
86 | + raise exception_info[0], exception_info[1], exception_info[2] |
87 | + |
88 | + self.request.response.setStatus(ws_view.status) |
89 | + if ws_view.status / 100 == 4: |
90 | + # This exception corresponds to a client-side error |
91 | + result = ws_view() |
92 | + else: |
93 | + # This exception corresponds to a server-side error |
94 | + # (or some weird attempt to handle redirects or normal |
95 | + # status codes using exceptions). Let the publisher |
96 | + # handle it. (No need for carefully reraising here; since |
97 | + # there is only one exception in flight we just use a bare |
98 | + # raise to reraise the original exception.) |
99 | + raise |
100 | + return result |
101 | |
102 | def getRequestMethod(self, request=None): |
103 | """Return the HTTP method of the provided (or current) request. |
104 | @@ -667,7 +698,8 @@ |
105 | # obtained from a representation cache. |
106 | resources = [EntryResource(entry, request) |
107 | for entry in navigator.batch |
108 | - if checkPermission(view_permission, entry)] |
109 | + if checkPermission(view_permission, entry) and |
110 | + self.caveat_checker.checkCaveats(entry)] |
111 | entry_strings = [ |
112 | resource._representation(HTTPResource.JSON_TYPE) |
113 | for resource in resources] |
114 | @@ -784,6 +816,56 @@ |
115 | super(FieldUnmarshallerMixin, self).__init__(context, request) |
116 | self._unmarshalled_field_cache = {} |
117 | |
118 | + def _checkPermission(self, context, name, field, check_method): |
119 | + # Can we view/change (as appropriate) the field's value? We check |
120 | + # the permission directly using the Zope permission checker for |
121 | + # several reasons. Firstly, doing it indirectly by fetching the |
122 | + # value may have very slow side-effects such as database hits. |
123 | + # Secondly, in the write case we obviously cannot set the value |
124 | + # without side-effects. Thirdly, if caveats are in operation then |
125 | + # we need to check them only for this field, not for anything |
126 | + # internal to the getter/setter. |
127 | + try: |
128 | + tagged_values = field.getTaggedValue( |
129 | + 'lazr.restful.exported') |
130 | + original_name = tagged_values['original_name'] |
131 | + except KeyError: |
132 | + # This field has no tagged values, or is missing the |
133 | + # 'original_name' value. Its entry class was probably created |
134 | + # by hand rather than by tagging an interface. In that case, |
135 | + # it's the programmer's responsibility to set 'original_name' if |
136 | + # the web service field name differs from the underlying |
137 | + # interface's field name. Since 'original_name' is not present, |
138 | + # assume the names are the same. |
139 | + original_name = name |
140 | + context = self.entry._orig_interfaces[name](context) |
141 | + try: |
142 | + getChecker(context) |
143 | + except TypeError: |
144 | + if check_method == 'check': |
145 | + # This is more expensive than using a Zope checker, but |
146 | + # there is no checker, so either there is no permission |
147 | + # control on this object, or permission control is |
148 | + # implemented some other way. Also note that we use |
149 | + # getattr() on self.entry rather than context because some |
150 | + # of the fields in entry.schema will be provided by adapters |
151 | + # rather than directly by context. |
152 | + getattr(self.entry, name) |
153 | + else: |
154 | + # In the write case, we cannot check permissions in advance |
155 | + # if there is no checker. |
156 | + pass |
157 | + else: |
158 | + getattr(self.caveat_checker, check_method)(context, original_name) |
159 | + |
160 | + def checkRead(self, context, name, field): |
161 | + """Raise Unauthorized if the current user cannot read this field.""" |
162 | + self._checkPermission(context, name, field, 'check_getattr') |
163 | + |
164 | + def checkWrite(self, context, name, field): |
165 | + """Raise Unauthorized if the current user cannot write this field.""" |
166 | + self._checkPermission(context, name, field, 'check_setattr') |
167 | + |
168 | def _unmarshallField(self, field_name, field, detail=NORMAL_DETAIL): |
169 | """See what a field would look like in a generic representation. |
170 | |
171 | @@ -803,7 +885,9 @@ |
172 | value = None |
173 | flag = IUnmarshallingDoesntNeedValue |
174 | else: |
175 | + self.checkRead(self.entry.context, field.__name__, field) |
176 | value = getattr(self.entry, field.__name__) |
177 | + self.caveat_checker.enforceCaveats(value) |
178 | flag = None |
179 | if detail is NORMAL_DETAIL: |
180 | repr_value = marshaller.unmarshall(self.entry, value) |
181 | @@ -875,7 +959,7 @@ |
182 | # even though there's no other code in __init__. |
183 | super(ReadOnlyResource, self).__init__(context, request) |
184 | |
185 | - def __call__(self): |
186 | + def call(self): |
187 | """Handle a GET or (if implemented) POST request.""" |
188 | result = "" |
189 | method = self.getRequestMethod() |
190 | @@ -902,60 +986,33 @@ |
191 | # even though there's no other code in __init__. |
192 | super(ReadWriteResource, self).__init__(context, request) |
193 | |
194 | - def __call__(self): |
195 | + def call(self): |
196 | """Handle a GET, PUT, or PATCH request.""" |
197 | result = "" |
198 | method = self.getRequestMethod() |
199 | - try: |
200 | - if method == "GET": |
201 | - result = self.do_GET() |
202 | - elif method in ["PUT", "PATCH"]: |
203 | - media_type = self.handleConditionalWrite() |
204 | - if media_type is not None: |
205 | - stream = self.request.bodyStream |
206 | - representation = stream.getCacheStream().read() |
207 | - if method == "PUT": |
208 | - result = self.do_PUT(media_type, representation) |
209 | - else: |
210 | - result = self.do_PATCH(media_type, representation) |
211 | - elif method == "POST" and self.implementsPOST(): |
212 | - result = self.do_POST() |
213 | - elif method == "DELETE" and self.implementsDELETE(): |
214 | - result = self.do_DELETE() |
215 | - else: |
216 | - allow_string = "GET PUT PATCH" |
217 | - if self.implementsPOST(): |
218 | - allow_string += " POST" |
219 | - if self.implementsDELETE(): |
220 | - allow_string += " DELETE" |
221 | - self.request.response.setStatus(405) |
222 | - self.request.response.setHeader("Allow", allow_string) |
223 | - except Exception as e: |
224 | - exception_info = sys.exc_info() |
225 | - view = getMultiAdapter((e, self.request), name="index.html") |
226 | - try: |
227 | - ws_view = IWebServiceExceptionView(view) |
228 | - except TypeError: |
229 | - # There's a view for this exception, but it's not one |
230 | - # we can understand. It was probably designed for a |
231 | - # web application rather than a web service. We'll |
232 | - # re-raise the exception, let the publisher look up |
233 | - # the view, and hopefully handle it better. Note the careful |
234 | - # reraising that ensures the original trackabck is preserved. |
235 | - raise exception_info[0], exception_info[1], exception_info[2] |
236 | - |
237 | - self.request.response.setStatus(ws_view.status) |
238 | - if ws_view.status / 100 == 4: |
239 | - # This exception corresponds to a client-side error |
240 | - result = ws_view() |
241 | - else: |
242 | - # This exception corresponds to a server-side error |
243 | - # (or some weird attempt to handle redirects or normal |
244 | - # status codes using exceptions). Let the publisher |
245 | - # handle it. (No need for carefuly reraising here, since there |
246 | - # is only one exception in flight we just use a bare raise to |
247 | - # reraise the original exception.) |
248 | - raise |
249 | + if method == "GET": |
250 | + result = self.do_GET() |
251 | + elif method in ["PUT", "PATCH"]: |
252 | + media_type = self.handleConditionalWrite() |
253 | + if media_type is not None: |
254 | + stream = self.request.bodyStream |
255 | + representation = stream.getCacheStream().read() |
256 | + if method == "PUT": |
257 | + result = self.do_PUT(media_type, representation) |
258 | + else: |
259 | + result = self.do_PATCH(media_type, representation) |
260 | + elif method == "POST" and self.implementsPOST(): |
261 | + result = self.do_POST() |
262 | + elif method == "DELETE" and self.implementsDELETE(): |
263 | + result = self.do_DELETE() |
264 | + else: |
265 | + allow_string = "GET PUT PATCH" |
266 | + if self.implementsPOST(): |
267 | + allow_string += " POST" |
268 | + if self.implementsDELETE(): |
269 | + allow_string += " DELETE" |
270 | + self.request.response.setStatus(405) |
271 | + self.request.response.setHeader("Allow", allow_string) |
272 | return result |
273 | |
274 | |
275 | @@ -1016,6 +1073,7 @@ |
276 | changeset = copy.copy(changeset) |
277 | validated_changeset = [] |
278 | errors = [] |
279 | + orig_interfaces = self.entry._orig_interfaces |
280 | |
281 | # Some fields aren't part of the schema, so they're handled |
282 | # separately. |
283 | @@ -1174,6 +1232,8 @@ |
284 | error = "Validation error" |
285 | errors.append(u"%s: %s" % (repr_name, error)) |
286 | continue |
287 | + self.checkWrite(self.entry.context, name, field) |
288 | + self.caveat_checker.enforceCaveats(value) |
289 | validated_changeset.append((field, value)) |
290 | # If there are any fields left in the changeset, they're |
291 | # fields that don't correspond to some field in the |
292 | @@ -1624,7 +1684,6 @@ |
293 | """An EntryAdapterUtility for this resource.""" |
294 | return EntryAdapterUtility(self.entry.__class__) |
295 | |
296 | - |
297 | @property |
298 | def redacted_fields(self): |
299 | """Names the fields the current user doesn't have permission to see.""" |
300 | @@ -1632,40 +1691,7 @@ |
301 | orig_interfaces = self.entry._orig_interfaces |
302 | for name, field in getFieldsInOrder(self.entry.schema): |
303 | try: |
304 | - # Can we view the field's value? We check the |
305 | - # permission directly using the Zope permission |
306 | - # checker, because doing it indirectly by fetching the |
307 | - # value may have very slow side effects such as |
308 | - # database hits. |
309 | - try: |
310 | - tagged_values = field.getTaggedValue( |
311 | - 'lazr.restful.exported') |
312 | - original_name = tagged_values['original_name'] |
313 | - except KeyError: |
314 | - # This field has no tagged values, or is missing |
315 | - # the 'original_name' value. Its entry class was |
316 | - # probably created by hand rather than by tagging |
317 | - # an interface. In that case, it's the |
318 | - # programmer's responsibility to set |
319 | - # 'original_name' if the web service field name |
320 | - # differs from the underlying interface's field |
321 | - # name. Since 'original_name' is not present, assume the |
322 | - # names are the same. |
323 | - original_name = name |
324 | - context = orig_interfaces[name](self.context) |
325 | - try: |
326 | - checker = getChecker(context) |
327 | - except TypeError: |
328 | - # This is more expensive than using a Zope checker, but |
329 | - # there is no checker, so either there is no permission |
330 | - # control on this object, or permission control is |
331 | - # implemented some other way. Also note that we use |
332 | - # getattr() on self.entry rather than self.context because |
333 | - # some of the fields in entry.schema will be provided by |
334 | - # adapters rather than directly by self.context. |
335 | - getattr(self.entry, name) |
336 | - else: |
337 | - checker.check(context, original_name) |
338 | + self.checkRead(self.context, name, field) |
339 | except Unauthorized: |
340 | # This is an expensive operation that will make this |
341 | # request more expensive still, but it happens |
342 | @@ -1699,6 +1725,8 @@ |
343 | if media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]: |
344 | return self.toWADL().encode("utf-8") |
345 | elif media_type in (self.JSON_TYPE, self.JSON_PLUS_XHTML_TYPE): |
346 | + self.caveat_checker.enforceCaveats(self.context) |
347 | + |
348 | cache = self._representation_cache |
349 | if cache is None: |
350 | representation = None |
351 | |
352 | === added file 'src/lazr/restful/caveatchecker.py' |
353 | --- src/lazr/restful/caveatchecker.py 1970-01-01 00:00:00 +0000 |
354 | +++ src/lazr/restful/caveatchecker.py 2016-03-02 13:25:07 +0000 |
355 | @@ -0,0 +1,121 @@ |
356 | +# Copyright 2016 Canonical Ltd. All rights reserved. |
357 | +# |
358 | +# This file is part of lazr.restful. |
359 | +# |
360 | +# lazr.restful is free software: you can redistribute it and/or modify it |
361 | +# under the terms of the GNU Lesser General Public License as published by |
362 | +# the Free Software Foundation, version 3 of the License. |
363 | +# |
364 | +# lazr.restful is distributed in the hope that it will be useful, but WITHOUT |
365 | +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or |
366 | +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public |
367 | +# License for more details. |
368 | +# |
369 | +# You should have received a copy of the GNU Lesser General Public License |
370 | +# along with lazr.restful. If not, see <http://www.gnu.org/licenses/>. |
371 | + |
372 | +"""Caveat checking.""" |
373 | + |
374 | +__metaclass__ = type |
375 | +__all__ = [ |
376 | + 'CaveatChecker', |
377 | + ] |
378 | + |
379 | +from zope.component import ( |
380 | + adapter, |
381 | + getUtility, |
382 | + ) |
383 | +from zope.interface import implementer |
384 | +from zope.publisher.interfaces import IApplicationRequest |
385 | +from zope.security.interfaces import Unauthorized |
386 | +from zope.security.management import queryInteraction |
387 | +from zope.security.proxy import getChecker |
388 | + |
389 | +from lazr.restful.interfaces import ( |
390 | + ICaveatChecker, |
391 | + IInteractionWithUnauthenticatedCheck, |
392 | + IPrincipalWithCaveats, |
393 | + IWebServiceConfiguration, |
394 | + ) |
395 | + |
396 | + |
397 | +@implementer(ICaveatChecker) |
398 | +@adapter(IApplicationRequest) |
399 | +class CaveatChecker: |
400 | + """A class that checks whether objects satisfy caveats in a request.""" |
401 | + |
402 | + def __init__(self, request): |
403 | + self.request = request |
404 | + self.view_permission = ( |
405 | + getUtility(IWebServiceConfiguration).view_permission) |
406 | + |
407 | + def checkCaveats(self, object, permission=None, attribute=None): |
408 | + """See `ICaveatChecker`.""" |
409 | + if not IPrincipalWithCaveats.providedBy(self.request.principal): |
410 | + return True |
411 | + if permission is None: |
412 | + permission = self.view_permission |
413 | + satisfied = all( |
414 | + caveat.verify(permission, object, attribute=attribute) |
415 | + for caveat in self.request.principal.caveats) |
416 | + if not satisfied: |
417 | + # Caveats are not satisfied, but we'll ignore that if an |
418 | + # unauthenticated principal could perform this operation: that |
419 | + # is, caveats never reduce permissions below those possessed by |
420 | + # an anonymous user. |
421 | + interaction = queryInteraction() |
422 | + if (IInteractionWithUnauthenticatedCheck.providedBy( |
423 | + interaction) and |
424 | + interaction.checkUnauthenticatedPermission( |
425 | + permission, object)): |
426 | + return True |
427 | + return satisfied |
428 | + |
429 | + def enforceCaveats(self, object, permission=None, attribute=None): |
430 | + """See `ICaveatChecker`.""" |
431 | + if permission is None: |
432 | + permission = self.view_permission |
433 | + if not self.checkCaveats( |
434 | + object, permission=permission, attribute=attribute): |
435 | + if attribute is None: |
436 | + raise Unauthorized(object, permission) |
437 | + else: |
438 | + raise Unauthorized(object, attribute, permission) |
439 | + return object |
440 | + |
441 | + def check_setattr(self, object, attribute): |
442 | + """See `ICaveatChecker`.""" |
443 | + try: |
444 | + base_checker = getChecker(object) |
445 | + except TypeError: |
446 | + # We cannot check permissions in advance if there is no checker. |
447 | + return |
448 | + if base_checker.set_permissions: |
449 | + permission = base_checker.set_permissions.get(attribute) |
450 | + else: |
451 | + permission = None |
452 | + if permission is not None: |
453 | + self.enforceCaveats(object, permission, attribute) |
454 | + base_checker.check_setattr(object, attribute) |
455 | + |
456 | + def check_getattr(self, object, attribute): |
457 | + """See `ICaveatChecker`.""" |
458 | + try: |
459 | + base_checker = getChecker(object) |
460 | + except TypeError: |
461 | + # We cannot check permissions in advance if there is no checker. |
462 | + return |
463 | + permission = base_checker.get_permissions.get(attribute) |
464 | + if permission is not None: |
465 | + self.enforceCaveats(object, permission, attribute) |
466 | + base_checker.check_getattr(object, attribute) |
467 | + |
468 | + def setattr(self, object, attribute, value): |
469 | + """See `ICaveatChecker`.""" |
470 | + self.check_setattr(object, attribute) |
471 | + setattr(object, attribute, value) |
472 | + |
473 | + def getattr(self, object, attribute): |
474 | + """See `ICaveatChecker`.""" |
475 | + self.check_getattr(object, attribute) |
476 | + return getattr(object, attribute) |
477 | |
478 | === modified file 'src/lazr/restful/configure.zcml' |
479 | --- src/lazr/restful/configure.zcml 2012-03-13 02:03:47 +0000 |
480 | +++ src/lazr/restful/configure.zcml 2016-03-02 13:25:07 +0000 |
481 | @@ -40,6 +40,8 @@ |
482 | |
483 | <adapter factory="lazr.restful.jsoncache.JSONRequestCache" /> |
484 | |
485 | + <adapter factory="lazr.restful.caveatchecker.CaveatChecker" /> |
486 | + |
487 | <!-- TALES namespace for web service functions available through the |
488 | website. --> |
489 | <adapter |
490 | |
491 | === modified file 'src/lazr/restful/declarations.py' |
492 | --- src/lazr/restful/declarations.py 2015-04-08 20:11:29 +0000 |
493 | +++ src/lazr/restful/declarations.py 2016-03-02 13:25:07 +0000 |
494 | @@ -15,6 +15,7 @@ |
495 | 'accessor_for', |
496 | 'cache_for', |
497 | 'call_with', |
498 | + 'check_parameter_permissions', |
499 | 'collection_default_content', |
500 | 'error_status', |
501 | 'exported', |
502 | @@ -55,6 +56,8 @@ |
503 | IText, |
504 | ) |
505 | from zope.security.checker import CheckerPublic |
506 | +from zope.security.interfaces import Unauthorized |
507 | +from zope.security.management import checkPermission |
508 | from zope.traversing.browser import absoluteURL |
509 | |
510 | from lazr.delegates import Passthrough |
511 | @@ -65,6 +68,7 @@ |
512 | ) |
513 | from lazr.restful.interface import copy_field |
514 | from lazr.restful.interfaces import ( |
515 | + ICaveatChecker, |
516 | ICollection, |
517 | IEntry, |
518 | IReference, |
519 | @@ -562,10 +566,12 @@ |
520 | if 'as' not in annotations: |
521 | annotations['as'] = method.__name__ |
522 | |
523 | - # It's possible that call_with, operation_parameters, and/or |
524 | - # operation_returns_* weren't used. |
525 | + # It's possible that call_with, operation_parameters, |
526 | + # check_parameter_permissions, and/or operation_returns_* |
527 | + # weren't used. |
528 | annotations.setdefault('call_with', {}) |
529 | annotations.setdefault('params', {}) |
530 | + annotations.setdefault('param_permissions', {}) |
531 | annotations.setdefault('return_type', None) |
532 | |
533 | # Make sure that all parameters exist and that we miss none. |
534 | @@ -574,6 +580,7 @@ |
535 | defined_params.update(info['required']) |
536 | exported_params = set(annotations['params']) |
537 | exported_params.update(annotations['call_with']) |
538 | + exported_params.update(annotations['param_permissions']) |
539 | undefined_params = exported_params.difference(defined_params) |
540 | if undefined_params and info['kwargs'] is None: |
541 | raise TypeError( |
542 | @@ -632,6 +639,20 @@ |
543 | annotations['call_with'] = self.params |
544 | |
545 | |
546 | +class check_parameter_permissions(_method_annotator): |
547 | + """Decorator indicating that calling a method requires specified |
548 | + permissions on parameters. |
549 | + """ |
550 | + def __init__(self, **params): |
551 | + _check_called_from_interface_def('%s()' % self.__class__.__name__) |
552 | + self.params = params |
553 | + |
554 | + def annotate_method(self, method, annotations): |
555 | + """See `_method_annotator`.""" |
556 | + annotations.setdefault('param_permissions', []) |
557 | + annotations['param_permissions'] = self.params |
558 | + |
559 | + |
560 | class mutator_for(_method_annotator): |
561 | """Decorator indicating that an exported method mutates a field. |
562 | |
563 | @@ -1225,6 +1246,15 @@ |
564 | return params |
565 | |
566 | |
567 | +def get_caveat_checker(instance): |
568 | + """Get a caveat checker appropriate for an instance.""" |
569 | + if instance is None or instance.request is None: |
570 | + request = get_current_web_service_request() |
571 | + else: |
572 | + request = instance.request |
573 | + return ICaveatChecker(request) |
574 | + |
575 | + |
576 | class _AccessorWrapper: |
577 | """A wrapper class for properties with accessors. |
578 | |
579 | @@ -1242,7 +1272,8 @@ |
580 | # Error checking code in accessor_for() guarantees that there |
581 | # is one and only one non-fixed parameter for the accessor |
582 | # method. |
583 | - return getattr(context, self.accessor)(**params) |
584 | + caveat_checker = get_caveat_checker(obj) |
585 | + return caveat_checker.getattr(context, self.accessor)(**params) |
586 | |
587 | |
588 | class _MutatorWrapper: |
589 | @@ -1262,7 +1293,8 @@ |
590 | # Error checking code in mutator_for() guarantees that there |
591 | # is one and only one non-fixed parameter for the mutator |
592 | # method. |
593 | - getattr(context, self.mutator)(new_value, **params) |
594 | + caveat_checker = get_caveat_checker(obj) |
595 | + caveat_checker.getattr(context, self.mutator)(new_value, **params) |
596 | |
597 | |
598 | class PropertyWithAccessor(_AccessorWrapper, Passthrough): |
599 | @@ -1336,7 +1368,8 @@ |
600 | |
601 | def find(self): |
602 | """See `ICollection`.""" |
603 | - method = getattr(self.context, self.method_name) |
604 | + caveat_checker = get_caveat_checker(self) |
605 | + method = caveat_checker.getattr(self.context, self.method_name) |
606 | params = params_with_dereferenced_user(self.params) |
607 | return method(**params) |
608 | |
609 | @@ -1371,7 +1404,8 @@ |
610 | """Base class for generated operation adapters.""" |
611 | |
612 | def _getMethod(self): |
613 | - return getattr(self._orig_iface(self.context), self._method_name) |
614 | + return self.caveat_checker.getattr( |
615 | + self._orig_iface(self.context), self._method_name) |
616 | |
617 | def _getMethodParameters(self, kwargs): |
618 | """Return the method parameters. |
619 | @@ -1394,6 +1428,15 @@ |
620 | # Handle fixed parameters. |
621 | params.update(params_with_dereferenced_user( |
622 | self._export_info['call_with'])) |
623 | + |
624 | + # Handle any requested parameter permission checks. |
625 | + for name, permission in self._export_info['param_permissions'].items(): |
626 | + param = params.get(name) |
627 | + if param is not None: |
628 | + if not checkPermission(permission, param): |
629 | + raise Unauthorized(param, permission) |
630 | + self.caveat_checker.enforceCaveats(param, permission) |
631 | + |
632 | return params |
633 | |
634 | def call(self, **kwargs): |
635 | @@ -1409,6 +1452,7 @@ |
636 | % self._export_info['cache_for']) |
637 | |
638 | result = self._getMethod()(**params) |
639 | + self.caveat_checker.enforceCaveats(result) |
640 | return self.encodeResult(result) |
641 | |
642 | |
643 | @@ -1423,6 +1467,7 @@ |
644 | """ |
645 | params = self._getMethodParameters(kwargs) |
646 | result = self._getMethod()(**params) |
647 | + self.caveat_checker.enforceCaveats(result) |
648 | response = self.request.response |
649 | response.setStatus(201) |
650 | response.setHeader('Location', absoluteURL(result, self.request)) |
651 | |
652 | === modified file 'src/lazr/restful/docs/webservice-declarations.txt' |
653 | --- src/lazr/restful/docs/webservice-declarations.txt 2016-02-17 01:07:21 +0000 |
654 | +++ src/lazr/restful/docs/webservice-declarations.txt 2016-03-02 13:25:07 +0000 |
655 | @@ -427,6 +427,7 @@ |
656 | as: 'create_book' |
657 | call_with: {} |
658 | creates: <...IBook...> |
659 | + param_permissions: {} |
660 | params: {'author': <...TextLine...>, |
661 | 'base_price': <...Float...>, |
662 | 'title': <...TextLine...>} |
663 | @@ -439,6 +440,7 @@ |
664 | >>> print_export_tag(IBookSetOnSteroids['searchBookTitles']) |
665 | as: 'searchBookTitles' |
666 | call_with: {} |
667 | + param_permissions: {} |
668 | params: {'text': <...TextLine...>} |
669 | return_type: <lazr.restful.fields.CollectionField object...> |
670 | type: 'read_operation' |
671 | @@ -448,6 +450,7 @@ |
672 | >>> print_export_tag(IBookSetOnSteroids['bestMatch']) |
673 | as: 'bestMatch' |
674 | call_with: {} |
675 | + param_permissions: {} |
676 | params: {'text': <...TextLine...>} |
677 | return_type: <lazr.restful.fields.Reference object...> |
678 | type: 'read_operation' |
679 | @@ -457,6 +460,7 @@ |
680 | >>> print_export_tag(IBookOnSteroids['checkout']) |
681 | as: 'checkout' |
682 | call_with: {'kind': 'normal', 'who': <class '...REQUEST_USER'>} |
683 | + param_permissions: {} |
684 | params: {} |
685 | return_type: None |
686 | type: 'write_operation' |
687 | @@ -486,6 +490,7 @@ |
688 | as: 'create_book' |
689 | call_with: {} |
690 | creates: <...IBook...> |
691 | + param_permissions: {} |
692 | params: {'author': <...TextLine...>, |
693 | 'collection': <...TextLine...>, |
694 | 'title': <...TextLine...>} |
695 | @@ -804,12 +809,14 @@ |
696 | == rename == |
697 | as: 'rename' |
698 | call_with: {} |
699 | + param_permissions: {} |
700 | params: {'new_name': <...TextLine...>} |
701 | return_type: None |
702 | type: 'write_operation' |
703 | == talk_to == |
704 | as: 'talk_to' |
705 | call_with: {} |
706 | + param_permissions: {} |
707 | params: {'msg': <...TextLine...>, |
708 | 'to': <...Object...>} |
709 | return_type: None |
710 | @@ -897,6 +904,9 @@ |
711 | ... (ITestServiceRequest30, '3.0')]: |
712 | ... sm.registerUtility(marker, IWebServiceVersion, name=name) |
713 | |
714 | + >>> from lazr.restful.caveatchecker import CaveatChecker |
715 | + >>> sm.registerAdapter(CaveatChecker) |
716 | + |
717 | >>> from lazr.restful.testing.webservice import FakeRequest |
718 | >>> request = FakeRequest(version='beta') |
719 | |
720 | @@ -2591,6 +2601,79 @@ |
721 | zope.security._proxy._Proxy (using zope.security.checker.Checker) |
722 | public: __call__, send_modification_event |
723 | |
724 | +Additional permission checking |
725 | +------------------------------ |
726 | + |
727 | +The @check_parameter_permissions decorator allows stating that additional |
728 | +permissions are required on some parameters. |
729 | + |
730 | + >>> from zope.security.management import ( |
731 | + ... queryInteraction, |
732 | + ... setSecurityPolicy, |
733 | + ... ) |
734 | + >>> from zope.security.permission import Permission |
735 | + >>> from zope.security.simplepolicies import PermissiveSecurityPolicy |
736 | + >>> from lazr.restful.caveatchecker import CaveatChecker |
737 | + >>> from lazr.restful.declarations import check_parameter_permissions |
738 | + >>> from lazr.restful.security import protect_schema |
739 | + |
740 | + >>> class CachedOnlySecurityPolicy(PermissiveSecurityPolicy): |
741 | + ... def __init__(self, *participations): |
742 | + ... super(CachedOnlySecurityPolicy, self).__init__( |
743 | + ... *participations) |
744 | + ... self.cache = {} |
745 | + ... |
746 | + ... def checkPermission(self, permission, object): |
747 | + ... return self.cache.get(object, {}).get(permission, False) |
748 | + |
749 | + >>> class IParameterPermissions(Interface): |
750 | + ... export_as_webservice_entry() |
751 | + ... |
752 | + ... @check_parameter_permissions(foo='lazr.Edit', bar='lazr.Edit') |
753 | + ... @operation_parameters( |
754 | + ... foo=Reference(schema=Interface), |
755 | + ... bar=Reference(schema=Interface, required=False)) |
756 | + ... @export_write_operation() |
757 | + ... def test_function(foo, bar=None): |
758 | + ... pass |
759 | + |
760 | + >>> @implementer(IParameterPermissions) |
761 | + ... class ParameterPermissions(object): |
762 | + ... def test_function(self, foo, bar=None): |
763 | + ... return foo, bar |
764 | + |
765 | + >>> sm.registerAdapter(CaveatChecker) |
766 | + >>> sm.registerUtility(Permission('lazr.Edit'), name='lazr.Edit') |
767 | + >>> protect_schema(HasText, IHasText, write_permission='lazr.Edit') |
768 | + |
769 | + >>> pp_test_function_adapter_factory = generate_operation_adapter( |
770 | + ... IParameterPermissions['test_function']) |
771 | + >>> setSecurityPolicy(CachedOnlySecurityPolicy) |
772 | + <class ...> |
773 | + >>> endInteraction() |
774 | + >>> newInteraction() |
775 | + >>> interaction = queryInteraction() |
776 | + >>> pp_object = ParameterPermissions() |
777 | + >>> foo = HasText() |
778 | + >>> bar = HasText() |
779 | + >>> pp_test_function_adapter = pp_test_function_adapter_factory( |
780 | + ... pp_object, request) |
781 | + |
782 | + >>> pp_test_function_adapter.call(foo=foo) |
783 | + Traceback (most recent call last): |
784 | + ... |
785 | + Unauthorized: (<HasText object...>, 'lazr.Edit') |
786 | + >>> interaction.cache.setdefault(foo, {})['lazr.Edit'] = True |
787 | + >>> pp_test_function_adapter.call(foo=foo) |
788 | + (<HasText object...>, None) |
789 | + >>> pp_test_function_adapter.call(foo=foo, bar=bar) |
790 | + Traceback (most recent call last): |
791 | + ... |
792 | + Unauthorized: (<HasText object...>, 'lazr.Edit') |
793 | + >>> interaction.cache.setdefault(bar, {})['lazr.Edit'] = True |
794 | + >>> pp_test_function_adapter.call(foo=foo, bar=bar) |
795 | + (<HasText object...>, <HasText object...>) |
796 | + |
797 | ZCML Registration |
798 | ================= |
799 | |
800 | |
801 | === modified file 'src/lazr/restful/docs/webservice-marshallers.txt' |
802 | --- src/lazr/restful/docs/webservice-marshallers.txt 2016-02-16 13:32:24 +0000 |
803 | +++ src/lazr/restful/docs/webservice-marshallers.txt 2016-03-02 13:25:07 +0000 |
804 | @@ -8,10 +8,19 @@ |
805 | To test the various marshallers we create a dummy request and |
806 | application root. |
807 | |
808 | + >>> from zope.component import ( |
809 | + ... provideAdapter, |
810 | + ... provideUtility, |
811 | + ... ) |
812 | + >>> from lazr.restful.caveatchecker import CaveatChecker |
813 | + >>> from lazr.restful.interfaces import IWebServiceConfiguration |
814 | + >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration |
815 | >>> from lazr.restful.testing.webservice import WebServiceTestPublication |
816 | >>> from lazr.restful.simple import Request |
817 | >>> from lazr.restful.example.base.root import ( |
818 | ... CookbookServiceRootResource) |
819 | + >>> provideUtility(TestWebServiceConfiguration(), IWebServiceConfiguration) |
820 | + >>> provideAdapter(CaveatChecker) |
821 | >>> request = Request("", {'HTTP_HOST': 'cookbooks.dev'}) |
822 | >>> request.annotations[request.VERSION_ANNOTATION] = '1.0' |
823 | >>> application = CookbookServiceRootResource() |
824 | |
825 | === modified file 'src/lazr/restful/docs/webservice.txt' |
826 | --- src/lazr/restful/docs/webservice.txt 2016-02-17 01:07:21 +0000 |
827 | +++ src/lazr/restful/docs/webservice.txt 2016-03-02 13:25:07 +0000 |
828 | @@ -50,6 +50,9 @@ |
829 | ... cuisine = TextLine(title=u"Cuisine", required=False, default=None) |
830 | ... recipes = Attribute("List of recipes published in this cookbook.") |
831 | ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.") |
832 | + ... auth_required = Bool( |
833 | + ... title=u"Whether viewing this requires authentication.", |
834 | + ... default=False) |
835 | ... def removeRecipe(recipe): |
836 | ... """Remove a recipe from this cookbook.""" |
837 | |
838 | @@ -65,8 +68,11 @@ |
839 | ... cookbook = Reference(schema=ICookbook) |
840 | ... instructions = Text(title=u"How to prepare the recipe.", |
841 | ... required=True) |
842 | - ... private = Bool(title=u"Whether the public can see this recipe.", |
843 | - ... default=False) |
844 | + ... private = Bool( |
845 | + ... title=u"Whether this recipe is private.", default=False) |
846 | + ... auth_required = Bool( |
847 | + ... title=u"Whether viewing this recipe requires authentication.", |
848 | + ... default=False) |
849 | ... def delete(): |
850 | ... """Delete this recipe.""" |
851 | |
852 | @@ -87,6 +93,9 @@ |
853 | ... "Retrieve a single author by name." |
854 | |
855 | >>> class ICookbookSet(ITestDataSet): |
856 | + ... def newCookbook(self, author_name, title, cuisine): |
857 | + ... """Create a new cookbook.""" |
858 | + ... |
859 | ... def getAll(self): |
860 | ... "Get all cookbooks." |
861 | ... |
862 | @@ -190,6 +199,7 @@ |
863 | ... self.comments = [] |
864 | ... self.cuisine = cuisine |
865 | ... self.cover = None |
866 | + ... self.auth_required = True |
867 | ... |
868 | ... @property |
869 | ... def path(self): |
870 | @@ -251,7 +261,7 @@ |
871 | ... class Recipe: |
872 | ... path = '' |
873 | ... def __init__(self, id, cookbook, dish, instructions, |
874 | - ... private=False): |
875 | + ... private=False, auth_required=False): |
876 | ... self.id = id |
877 | ... self.cookbook = cookbook |
878 | ... self.cookbook.recipes.append(self) |
879 | @@ -260,6 +270,7 @@ |
880 | ... self.instructions = instructions |
881 | ... self.comments = [] |
882 | ... self.private = private |
883 | + ... self.auth_required = auth_required |
884 | ... def delete(self): |
885 | ... self.cookbook.removeRecipe(self) |
886 | ... self.dish.removeRecipe(self) |
887 | @@ -324,11 +335,18 @@ |
888 | >>> C3_D1 = Recipe(3, C3, D1, u"A perfectly roasted chicken is...") |
889 | |
890 | >>> D2 = Dish("Baked beans") |
891 | - >>> C2_D2 = Recipe(4, C2, D2, "Preheat oven to...") |
892 | - >>> C3_D2 = Recipe(5, C3, D2, "Without doubt the most famous...", True) |
893 | + >>> C1_D2 = Recipe(4, C1, D2, "57 varieties...", auth_required=True) |
894 | + >>> C2_D2 = Recipe(5, C2, D2, "Preheat oven to...") |
895 | + >>> C3_D2 = Recipe( |
896 | + ... 6, C3, D2, "Without doubt the most famous...", |
897 | + ... private=True, auth_required=True) |
898 | |
899 | >>> D3 = Dish("Foies de voilaille en aspic") |
900 | - >>> C1_D3 = Recipe(6, C1, D3, "Chicken livers sauteed in butter...") |
901 | + >>> C1_D3 = Recipe( |
902 | + ... 7, C1, D3, "Chicken livers sauteed in butter...", |
903 | + ... auth_required=True) |
904 | + >>> C2_D3 = Recipe( |
905 | + ... 8, C2, D3, "More chicken livers...", auth_required=True) |
906 | |
907 | >>> COM1 = Comment(C2_D1, "Clear and concise.") |
908 | >>> COM2 = Comment(C2, "A kitchen staple.") |
909 | @@ -379,7 +397,9 @@ |
910 | ... raise ValueError("No matches for %s" % name) |
911 | ... return matches |
912 | |
913 | + >>> from zope.security.protectclass import protectName |
914 | >>> protect_schema(CookbookSet, ICookbookSet) |
915 | + >>> protectName(CookbookSet, 'newCookbook', 'zope.Edit') |
916 | >>> sm.registerUtility(CookbookSet(), ICookbookSet) |
917 | |
918 | Here's a simple AuthorSet with predefined authors. |
919 | @@ -460,15 +480,24 @@ |
920 | >>> from zope.security.management import setSecurityPolicy |
921 | >>> from zope.security.simplepolicies import PermissiveSecurityPolicy |
922 | >>> from zope.security.proxy import removeSecurityProxy |
923 | + >>> from lazr.restful.interfaces import ( |
924 | + ... IInteractionWithUnauthenticatedCheck, |
925 | + ... ) |
926 | |
927 | >>> sm.registerUtility(Permission('zope.View'), name='zope.View') |
928 | |
929 | - >>> class SimpleSecurityPolicy(PermissiveSecurityPolicy): |
930 | + >>> @implementer(IInteractionWithUnauthenticatedCheck) |
931 | + ... class SimpleSecurityPolicy(PermissiveSecurityPolicy): |
932 | ... def checkPermission(self, permission, object): |
933 | ... if IRecipe.providedBy(object): |
934 | ... return not removeSecurityProxy(object).private |
935 | ... else: |
936 | ... return True |
937 | + ... def checkUnauthenticatedPermission(self, permission, object): |
938 | + ... if IRecipe.providedBy(object) or ICookbook.providedBy(object): |
939 | + ... return not removeSecurityProxy(object).auth_required |
940 | + ... else: |
941 | + ... return permission == 'zope.View' |
942 | |
943 | >>> setSecurityPolicy(SimpleSecurityPolicy) |
944 | <class ...> |
945 | @@ -980,8 +1009,10 @@ |
946 | ... return_type = Reference(schema=IRecipe) |
947 | ... |
948 | ... def call(self, author_name, title, cuisine): |
949 | - ... cookbook = CookbookSet().newCookbook( |
950 | - ... author_name, title, cuisine) |
951 | + ... method = self.caveat_checker.getattr( |
952 | + ... self.context, 'newCookbook') |
953 | + ... cookbook = method(author_name, title, cuisine) |
954 | + ... self.caveat_checker.enforceCaveats(cookbook) |
955 | ... self.request.response.setStatus(201) |
956 | ... self.request.response.setHeader( |
957 | ... "Location", absoluteURL(cookbook, self.request)) |
958 | @@ -1829,6 +1860,153 @@ |
959 | >>> print put_request.response.getStatus() |
960 | 401 |
961 | |
962 | +Caveats |
963 | +======= |
964 | + |
965 | +Resource visibility may also be restricted by caveats attached to a |
966 | +principal. We define an example caveat which is only satisfied by |
967 | +non-recipes or by recipes for a particular set of dishes. |
968 | + |
969 | + >>> from lazr.restful.testing.webservice import ( |
970 | + ... FakeCaveat, |
971 | + ... FakePrincipal, |
972 | + ... ) |
973 | + |
974 | + >>> class DishCaveat(FakeCaveat): |
975 | + ... def __init__(self, argument): |
976 | + ... super(DishCaveat, self).__init__("dish", argument) |
977 | + ... def verify(self, permission, object, attribute=None): |
978 | + ... if not IRecipe.providedBy(object): |
979 | + ... return True |
980 | + ... return object.dish.name in self.argument |
981 | + |
982 | +A request without caveats (which our simple security policy will normally |
983 | +pretend is authenticated) can see recipes regardless of whether they require |
984 | +authentication. |
985 | + |
986 | + >>> public_recipe_url = quote( |
987 | + ... "/beta/cookbooks/Mastering the Art of French Cooking/recipes/" |
988 | + ... "Roast chicken") |
989 | + >>> auth_required_recipe_urls = [ |
990 | + ... quote( |
991 | + ... "/beta/cookbooks/Mastering the Art of French Cooking/recipes/" |
992 | + ... "Baked beans"), |
993 | + ... quote( |
994 | + ... "/beta/cookbooks/The Joy of Cooking/recipes/" |
995 | + ... "Foies de voilaille en aspic"), |
996 | + ... ] |
997 | + >>> for url in [public_recipe_url] + auth_required_recipe_urls: |
998 | + ... get_request = create_web_service_request(public_recipe_url) |
999 | + ... recipe_resource = get_request.traverse(app) |
1000 | + |
1001 | +A request with a caveat restricting it to a particular dish can still see |
1002 | +the public recipe and the one that requires authentication and satisfies the |
1003 | +caveat, but not the one that requires authentication and does not satisfy |
1004 | +the caveat. |
1005 | + |
1006 | + >>> principal = FakePrincipal() |
1007 | + >>> principal.caveats.append(DishCaveat(["Baked beans"])) |
1008 | + >>> for url in [public_recipe_url, auth_required_recipe_urls[0]]: |
1009 | + ... get_request = create_web_service_request(url, principal=principal) |
1010 | + ... recipe_resource = get_request.traverse(app) |
1011 | + >>> get_request = create_web_service_request( |
1012 | + ... auth_required_recipe_urls[1], principal=principal) |
1013 | + >>> recipe_resource = get_request.traverse(app) |
1014 | + >>> print recipe_resource() |
1015 | + (<Recipe object...>, u'zope.View') |
1016 | + |
1017 | +Links are redacted if they fail to satisfy caveats. |
1018 | + |
1019 | + >>> child_url = quote('/beta/authors/Julia Child') |
1020 | + >>> get_request = create_web_service_request( |
1021 | + ... child_url, principal=principal) |
1022 | + >>> author_resource = get_request.traverse(app) |
1023 | + >>> author = load_json(author_resource()) |
1024 | + >>> author['name'] |
1025 | + u'Julia Child' |
1026 | + >>> author['favorite_recipe_link'] |
1027 | + u'.../The%20Joy%20of%20Cooking/recipes/Baked%20beans' |
1028 | + |
1029 | + >>> A1.favorite_recipe = C1_D3 |
1030 | + >>> get_request = create_web_service_request( |
1031 | + ... child_url, principal=principal) |
1032 | + >>> author_resource = get_request.traverse(app) |
1033 | + >>> author = load_json(author_resource()) |
1034 | + >>> author['name'] |
1035 | + u'Julia Child' |
1036 | + >>> author['favorite_recipe_link'] |
1037 | + u'tag:launchpad.net:2008:redacted' |
1038 | + |
1039 | +When setting an attribute value, the new value must satisfy caveats. |
1040 | + |
1041 | + >>> A1.favorite_recipe = C1_D1 |
1042 | + >>> get_request = create_web_service_request( |
1043 | + ... child_url, principal=principal) |
1044 | + >>> author_resource = get_request.traverse(app) |
1045 | + >>> author = load_json(author_resource()) |
1046 | + >>> author['favorite_recipe_link'] = ( |
1047 | + ... 'http://api.cookbooks.dev' + auth_required_recipe_urls[0]) |
1048 | + >>> body = simplejson.dumps(author) |
1049 | + >>> put_request = create_web_service_request( |
1050 | + ... child_url, body=body, environ=headers, method='PUT', |
1051 | + ... principal=principal) |
1052 | + >>> put_request.traverse(app)() |
1053 | + '{..."favorite_recipe_link": ".../Baked%20beans"...}' |
1054 | + >>> put_request.response.getStatus() |
1055 | + 209 |
1056 | + |
1057 | + >>> get_request = create_web_service_request( |
1058 | + ... child_url, principal=principal) |
1059 | + >>> author_resource = get_request.traverse(app) |
1060 | + >>> author = load_json(author_resource()) |
1061 | + >>> author['favorite_recipe_link'] = ( |
1062 | + ... 'http://api.cookbooks.dev' + auth_required_recipe_urls[1]) |
1063 | + >>> body = simplejson.dumps(author) |
1064 | + >>> put_request = create_web_service_request( |
1065 | + ... child_url, body=body, environ=headers, method='PUT', |
1066 | + ... principal=principal) |
1067 | + >>> print put_request.traverse(app)() |
1068 | + (<Recipe object...>, u'zope.View') |
1069 | + >>> print put_request.response.getStatus() |
1070 | + 401 |
1071 | + |
1072 | +Caveats can restrict named POST operations. |
1073 | + |
1074 | + >>> class CookbookCaveat(FakeCaveat): |
1075 | + ... def __init__(self, argument): |
1076 | + ... super(CookbookCaveat, self).__init__("cookbook", argument) |
1077 | + ... def verify(self, permission, object, attribute=None): |
1078 | + ... if not ICookbook.providedBy(object): |
1079 | + ... return True |
1080 | + ... return object.name in self.argument |
1081 | + |
1082 | + >>> principal.caveats = [CookbookCaveat(['Good name'])] |
1083 | + |
1084 | + >>> body = ("ws.op=create_cookbook&title=Good%20name&" |
1085 | + ... "author_name=James%20Beard") |
1086 | + >>> request = create_web_service_request( |
1087 | + ... '/beta/cookbooks', 'POST', body, |
1088 | + ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'}, |
1089 | + ... principal=principal) |
1090 | + >>> operation_resource = request.traverse(app) |
1091 | + >>> result = operation_resource() |
1092 | + >>> request.response.getStatus() |
1093 | + 201 |
1094 | + >>> request.response.getHeader('Location') |
1095 | + 'http://api.cookbooks.dev/beta/cookbooks/Good%20name' |
1096 | + |
1097 | + >>> body = ("ws.op=create_cookbook&title=Bad%20name&" |
1098 | + ... "author_name=James%20Beard") |
1099 | + >>> request = create_web_service_request( |
1100 | + ... '/beta/cookbooks', 'POST', body, |
1101 | + ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'}, |
1102 | + ... principal=principal) |
1103 | + >>> operation_resource = request.traverse(app) |
1104 | + >>> print operation_resource() |
1105 | + (<Cookbook object...>, u'zope.View') |
1106 | + >>> request.response.getStatus() |
1107 | + 401 |
1108 | + |
1109 | Stored file resources |
1110 | ===================== |
1111 | |
1112 | |
1113 | === modified file 'src/lazr/restful/interfaces/_rest.py' |
1114 | --- src/lazr/restful/interfaces/_rest.py 2011-03-31 01:13:59 +0000 |
1115 | +++ src/lazr/restful/interfaces/_rest.py 2016-03-02 13:25:07 +0000 |
1116 | @@ -22,6 +22,8 @@ |
1117 | __all__ = [ |
1118 | 'IByteStorage', |
1119 | 'IByteStorageResource', |
1120 | + 'ICaveat', |
1121 | + 'ICaveatChecker', |
1122 | 'ICollection', |
1123 | 'ICollectionResource', |
1124 | 'IEntry', |
1125 | @@ -32,9 +34,11 @@ |
1126 | 'IFieldMarshaller', |
1127 | 'IFileLibrarian', |
1128 | 'IHTTPResource', |
1129 | + 'IInteractionWithUnauthenticatedCheck', |
1130 | 'INotificationsProvider', |
1131 | 'IJSONPublishable', |
1132 | 'IJSONRequestCache', |
1133 | + 'IPrincipalWithCaveats', |
1134 | 'IRepresentationCache', |
1135 | 'IResourceOperation', |
1136 | 'IResourceGETOperation', |
1137 | @@ -57,6 +61,7 @@ |
1138 | ] |
1139 | |
1140 | from textwrap import dedent |
1141 | +from zope.authentication.interfaces import IPrincipal |
1142 | from zope.schema import ( |
1143 | Bool, |
1144 | Dict, |
1145 | @@ -78,6 +83,7 @@ |
1146 | IBrowserRequest, |
1147 | IDefaultBrowserLayer, |
1148 | ) |
1149 | +from zope.security.interfaces import IInteraction |
1150 | from lazr.batchnavigator.interfaces import InvalidBatchSizeError |
1151 | |
1152 | # Constants for periods of time |
1153 | @@ -329,6 +335,126 @@ |
1154 | """ |
1155 | pass |
1156 | |
1157 | + |
1158 | +class ICaveat(Interface): |
1159 | + """A caveat that restricts the scope of a principal. |
1160 | + |
1161 | + Caveats extend standard Zope security checks with finer-grained |
1162 | + restrictions, but they are typically only applied at the request |
1163 | + boundary. |
1164 | + |
1165 | + For example, a webservice method call checks that caveats permit read or |
1166 | + write access (depending on its declaration) to the method on the context |
1167 | + object and read access to all method parameters; while setting an |
1168 | + attribute checks that caveats permit write access to the attribute on |
1169 | + the context object and read access to the new value. In either case, |
1170 | + caveats are not checked for any internal operations performed by the |
1171 | + method call or the attribute setter. |
1172 | + """ |
1173 | + |
1174 | + condition = TextLine( |
1175 | + title=u"Condition", description=u"The condition part of this caveat.") |
1176 | + argument = TextLine( |
1177 | + title=u"Argument", description=u"The argument part of this caveat.") |
1178 | + |
1179 | + text = TextLine(title=u"A serialised form of this caveat.") |
1180 | + |
1181 | + def verify(permission, object, attribute=None): |
1182 | + """Return whether a caveat is satisfied for permission on object. |
1183 | + |
1184 | + :param permission: A permission name. |
1185 | + :param object: The object being accessed according to the permission. |
1186 | + :param attribute: The attribute of the object being accessed, if |
1187 | + any. Since caveats may specify particular method and attribute |
1188 | + names that may be accessed, a full caveat check may require this |
1189 | + information in addition to the permission name (which is |
1190 | + sufficient for other kinds of permission checks on an object). |
1191 | + Callers that are merely checking a principal's authorization to |
1192 | + view an object rather than to call a method or view/change an |
1193 | + attribute on it do not need to pass an attribute name. |
1194 | + """ |
1195 | + |
1196 | + |
1197 | +class IPrincipalWithCaveats(IPrincipal): |
1198 | + """A principal that may have some `ICaveat`s attached to it.""" |
1199 | + |
1200 | + caveats = Attribute( |
1201 | + "List of `ICaveat`s restricting the scope of this principal.") |
1202 | + |
1203 | + |
1204 | +class ICaveatChecker(Interface): |
1205 | + """A class that checks whether objects satisfy caveats in a request.""" |
1206 | + |
1207 | + def checkCaveats(object, permission=None, attribute=None): |
1208 | + """Check whether `object` satisfies caveats. |
1209 | + |
1210 | + :param object: The object to check. |
1211 | + :param permission: The Zope permission indicating the kind of |
1212 | + operation being performed. If None, check for view permission |
1213 | + according to the webservice configuration. |
1214 | + :param attribute: If not None, check caveats that apply to |
1215 | + reading/writing this attribute on `object`; if None, only check |
1216 | + caveats on `object` itself. |
1217 | + """ |
1218 | + |
1219 | + def enforceCaveats(object, permission=None, attribute=None): |
1220 | + """Raise `Unauthorized` if `object` does not satisfy caveats. |
1221 | + |
1222 | + :param object: The object to check. |
1223 | + :param permission: The Zope permission indicating the kind of |
1224 | + operation being performed. If None, check for view permission |
1225 | + according to the webservice configuration. |
1226 | + :param attribute: If not None, check caveats that apply to |
1227 | + reading/writing this attribute on `object`; if None, only check |
1228 | + caveats on `object` itself. |
1229 | + """ |
1230 | + |
1231 | + def check_setattr(object, attribute): |
1232 | + """Check whether setting an object's attribute is allowed. |
1233 | + |
1234 | + This checks both basic Zope security and caveats. |
1235 | + |
1236 | + :param object: The object to check. |
1237 | + :param attribute: The attribute name to check. |
1238 | + """ |
1239 | + |
1240 | + def check_getattr(object, attribute): |
1241 | + """Check whether getting an object's attribute is allowed. |
1242 | + |
1243 | + This checks both basic Zope security and caveats. |
1244 | + |
1245 | + :param object: The object to check. |
1246 | + :param attribute: The attribute name to check. |
1247 | + """ |
1248 | + |
1249 | + def setattr(object, attribute, value): |
1250 | + """Set an object's attribute after checking that it is allowed. |
1251 | + |
1252 | + :param object: The object whose attribute is to be set. |
1253 | + :param attribute: The attribute name to set. |
1254 | + :param value: The new attribute value. |
1255 | + """ |
1256 | + |
1257 | + def getattr(object, attribute): |
1258 | + """Get an object's attribute after checking that it is allowed. |
1259 | + |
1260 | + :param object: The object whose attribute is to be got. |
1261 | + :param attribute: The attribute name to get. |
1262 | + """ |
1263 | + |
1264 | + |
1265 | +class IInteractionWithUnauthenticatedCheck(IInteraction): |
1266 | + """An interaction that can do a separate unauthenticated check.""" |
1267 | + |
1268 | + def checkUnauthenticatedPermission(permission, object): |
1269 | + """Return whether an unauthenticated user has permission on object. |
1270 | + |
1271 | + :param permission: A permission name. |
1272 | + :param object: The object being accessed according to the |
1273 | + permission. |
1274 | + """ |
1275 | + |
1276 | + |
1277 | class IJSONRequestCache(Interface): |
1278 | """A cache of objects exposed as URLs or JSON representations.""" |
1279 | |
1280 | |
1281 | === modified file 'src/lazr/restful/marshallers.py' |
1282 | --- src/lazr/restful/marshallers.py 2016-02-17 01:07:21 +0000 |
1283 | +++ src/lazr/restful/marshallers.py 2016-03-02 13:25:07 +0000 |
1284 | @@ -49,6 +49,7 @@ |
1285 | ) |
1286 | |
1287 | from lazr.restful.interfaces import ( |
1288 | + ICaveatChecker, |
1289 | IFieldMarshaller, |
1290 | IUnmarshallingDoesntNeedValue, |
1291 | IServiceRootResource, |
1292 | @@ -117,7 +118,7 @@ |
1293 | request = config.createRequest(StringIO(), {'PATH_INFO': path}) |
1294 | request.setTraversalStack(path_parts) |
1295 | root = request.publication.getApplication(self.request) |
1296 | - return request.traverse(root) |
1297 | + return self.caveat_checker.enforceCaveats(request.traverse(root)) |
1298 | |
1299 | |
1300 | @implementer(IFieldMarshaller) |
1301 | @@ -133,6 +134,7 @@ |
1302 | def __init__(self, field, request): |
1303 | self.field = field |
1304 | self.request = request |
1305 | + self.caveat_checker = ICaveatChecker(request) |
1306 | |
1307 | def marshall_from_json_data(self, value): |
1308 | """See `IFieldMarshaller`. |
1309 | @@ -142,7 +144,8 @@ |
1310 | """ |
1311 | if value is None: |
1312 | return None |
1313 | - return self._marshall_from_json_data(value) |
1314 | + return self.caveat_checker.enforceCaveats( |
1315 | + self._marshall_from_json_data(value)) |
1316 | |
1317 | def marshall_from_request(self, value): |
1318 | """See `IFieldMarshaller`. |
1319 | @@ -170,7 +173,8 @@ |
1320 | value = v[0] |
1321 | if value is None: |
1322 | return None |
1323 | - return self._marshall_from_request(value) |
1324 | + return self.caveat_checker.enforceCaveats( |
1325 | + self._marshall_from_request(value)) |
1326 | |
1327 | def _marshall_from_request(self, value): |
1328 | """Hook method to marshall a non-null JSON value. |
1329 | @@ -264,6 +268,7 @@ |
1330 | |
1331 | Marshall as a link to the byte storage resource. |
1332 | """ |
1333 | + self.caveat_checker.enforceCaveats(entry.context) |
1334 | return "%s/%s" % (absoluteURL(entry.context, self.request), |
1335 | self.field.__name__) |
1336 | |
1337 | @@ -560,6 +565,7 @@ |
1338 | |
1339 | This returns a link to the scoped collection. |
1340 | """ |
1341 | + self.caveat_checker.enforceCaveats(entry.context) |
1342 | return "%s/%s" % (absoluteURL(entry.context, self.request), |
1343 | self.field.__name__) |
1344 | |
1345 | @@ -628,6 +634,7 @@ |
1346 | """ |
1347 | repr_value = None |
1348 | if value is not None: |
1349 | + self.caveat_checker.enforceCaveats(value) |
1350 | repr_value = absoluteURL(value, self.request) |
1351 | return repr_value |
1352 | |
1353 | |
1354 | === modified file 'src/lazr/restful/testing/webservice.py' |
1355 | --- src/lazr/restful/testing/webservice.py 2016-02-17 01:07:21 +0000 |
1356 | +++ src/lazr/restful/testing/webservice.py 2016-03-02 13:25:07 +0000 |
1357 | @@ -7,6 +7,8 @@ |
1358 | 'create_web_service_request', |
1359 | 'DummyAbsoluteURL', |
1360 | 'DummyRootResourceURL', |
1361 | + 'FakeCaveat', |
1362 | + 'FakePrincipal', |
1363 | 'FakeRequest', |
1364 | 'FakeResponse', |
1365 | 'IGenericEntry', |
1366 | @@ -32,21 +34,29 @@ |
1367 | adapter, getGlobalSiteManager, getUtility) |
1368 | from zope.configuration import xmlconfig |
1369 | from zope.interface import alsoProvides, implementer, Interface |
1370 | +from zope.principalregistry.principalregistry import PrincipalBase |
1371 | +from zope.proxy import ProxyBase |
1372 | from zope.publisher.interfaces.http import IHTTPApplicationRequest |
1373 | from zope.publisher.paste import Application |
1374 | -from zope.proxy import ProxyBase |
1375 | from zope.schema import TextLine |
1376 | from zope.security.checker import ProxyFactory |
1377 | from zope.testing.cleanup import CleanUp |
1378 | from zope.traversing.browser.interfaces import IAbsoluteURL |
1379 | |
1380 | +from lazr.restful.caveatchecker import CaveatChecker |
1381 | from lazr.restful.declarations import ( |
1382 | collection_default_content, exported, |
1383 | export_as_webservice_collection, export_as_webservice_entry, |
1384 | export_read_operation, operation_parameters) |
1385 | from lazr.restful.interfaces import ( |
1386 | - IServiceRootResource, IWebServiceClientRequest, |
1387 | - IWebServiceConfiguration, IWebServiceLayer, IWebServiceVersion) |
1388 | + ICaveat, |
1389 | + IPrincipalWithCaveats, |
1390 | + IServiceRootResource, |
1391 | + IWebServiceClientRequest, |
1392 | + IWebServiceConfiguration, |
1393 | + IWebServiceLayer, |
1394 | + IWebServiceVersion, |
1395 | + ) |
1396 | from lazr.restful.simple import ( |
1397 | BaseWebServiceConfiguration, Publication, Request, |
1398 | RootResourceAbsoluteURL, ServiceRootResource) |
1399 | @@ -54,7 +64,7 @@ |
1400 | |
1401 | |
1402 | def create_web_service_request(path_info, method='GET', body=None, |
1403 | - environ=None, hostname=None): |
1404 | + environ=None, hostname=None, principal=None): |
1405 | """Create a web service request object with the given parameters. |
1406 | |
1407 | :param path_info: The path portion of the requested URL. |
1408 | @@ -81,6 +91,8 @@ |
1409 | test_environ['CONTENT_LENGTH'] = len(body) |
1410 | body_instream = StringIO(body) |
1411 | request = config.createRequest(body_instream, test_environ) |
1412 | + if principal is not None: |
1413 | + request.setPrincipal(principal) |
1414 | |
1415 | request.processInputs() |
1416 | # This sets the request as the current interaction. |
1417 | @@ -168,6 +180,28 @@ |
1418 | return default |
1419 | |
1420 | |
1421 | +@implementer(ICaveat) |
1422 | +class FakeCaveat: |
1423 | + """Simple caveat object for testing.""" |
1424 | + |
1425 | + def __init__(self, condition, argument): |
1426 | + self.condition = condition |
1427 | + self.argument = argument |
1428 | + |
1429 | + def verify(self, permission, object, attribute=None): |
1430 | + return getattr(object, self.condition) in self.argument |
1431 | + |
1432 | + |
1433 | +@implementer(IPrincipalWithCaveats) |
1434 | +class FakePrincipal(PrincipalBase): |
1435 | + """Simple principal object for testing.""" |
1436 | + |
1437 | + def __init__(self): |
1438 | + super(FakePrincipal, self).__init__( |
1439 | + 1, "fake-principal", "Fake principal") |
1440 | + self.caveats = [] |
1441 | + |
1442 | + |
1443 | def pprint_entry(json_body): |
1444 | """Pretty-print a webservice entry JSON representation. |
1445 | |
1446 | @@ -451,6 +485,7 @@ |
1447 | webservice_configuration = WebServiceTestConfiguration() |
1448 | sm = getGlobalSiteManager() |
1449 | sm.registerUtility(webservice_configuration) |
1450 | + sm.registerAdapter(CaveatChecker) |
1451 | |
1452 | # Register IWebServiceVersions for the |
1453 | # '1.0' and '2.0' web service versions. |
1454 | |
1455 | === modified file 'src/lazr/restful/tests/test_etag.py' |
1456 | --- src/lazr/restful/tests/test_etag.py 2016-02-17 00:49:58 +0000 |
1457 | +++ src/lazr/restful/tests/test_etag.py 2016-03-02 13:25:07 +0000 |
1458 | @@ -5,11 +5,18 @@ |
1459 | |
1460 | import unittest |
1461 | |
1462 | -from zope.component import provideUtility |
1463 | +from zope.component import ( |
1464 | + provideAdapter, |
1465 | + provideUtility, |
1466 | + ) |
1467 | |
1468 | +from lazr.restful.caveatchecker import CaveatChecker |
1469 | from lazr.restful.interfaces import IWebServiceConfiguration |
1470 | from lazr.restful.testing.helpers import TestWebServiceConfiguration |
1471 | -from lazr.restful.testing.webservice import create_web_service_request |
1472 | +from lazr.restful.testing.webservice import ( |
1473 | + create_web_service_request, |
1474 | + FakeRequest, |
1475 | + ) |
1476 | from lazr.restful._resource import ( |
1477 | EntryFieldResource, |
1478 | HTTPResource, |
1479 | @@ -81,11 +88,17 @@ |
1480 | |
1481 | class TestHTTPResourceETags(unittest.TestCase): |
1482 | |
1483 | + def setUp(self): |
1484 | + self.config = TestWebServiceConfiguration() |
1485 | + provideUtility(self.config, IWebServiceConfiguration) |
1486 | + provideAdapter(CaveatChecker) |
1487 | + |
1488 | def test_getETag_is_a_noop(self): |
1489 | # The HTTPResource class implements a do-nothing _getETagCores in order to |
1490 | # be conservative (because it's not aware of the nature of all possible |
1491 | # subclasses). |
1492 | - self.assertEquals(HTTPResource(None, None)._getETagCores(), None) |
1493 | + self.assertEqual( |
1494 | + None, HTTPResource(None, FakeRequest())._getETagCores()) |
1495 | |
1496 | |
1497 | class FauxEntryField: |
1498 | @@ -105,7 +118,8 @@ |
1499 | def setUp(self): |
1500 | self.config = TestWebServiceConfiguration() |
1501 | provideUtility(self.config, IWebServiceConfiguration) |
1502 | - self.resource = EntryFieldResource(FauxEntryField(), None) |
1503 | + provideAdapter(CaveatChecker) |
1504 | + self.resource = EntryFieldResource(FauxEntryField(), FakeRequest()) |
1505 | |
1506 | def set_field_value(self, value): |
1507 | """Set the value of the fake field the EntryFieldResource references. |
1508 | @@ -162,6 +176,7 @@ |
1509 | def setUp(self): |
1510 | self.config = TestWebServiceConfiguration() |
1511 | provideUtility(self.config, IWebServiceConfiguration) |
1512 | + provideAdapter(CaveatChecker) |
1513 | self.resource = ServiceRootResource() |
1514 | |
1515 | def test_cores_change_with_revno(self): |
1516 | @@ -195,6 +210,7 @@ |
1517 | def setUp(self): |
1518 | self.config = TestWebServiceConfiguration() |
1519 | provideUtility(self.config, IWebServiceConfiguration) |
1520 | + provideAdapter(CaveatChecker) |
1521 | self.request = create_web_service_request('/1.0') |
1522 | self.resource = TestableHTTPResource(None, self.request) |
1523 | |
1524 | @@ -222,6 +238,7 @@ |
1525 | def setUp(self): |
1526 | self.config = TestWebServiceConfiguration() |
1527 | provideUtility(self.config, IWebServiceConfiguration) |
1528 | + provideAdapter(CaveatChecker) |
1529 | self.request = create_web_service_request('/1.0') |
1530 | self.resource = TestableHTTPResource(None, self.request) |
1531 | |
1532 | |
1533 | === modified file 'src/lazr/restful/tests/test_webservice.py' |
1534 | --- src/lazr/restful/tests/test_webservice.py 2016-02-17 01:07:21 +0000 |
1535 | +++ src/lazr/restful/tests/test_webservice.py 2016-03-02 13:25:07 +0000 |
1536 | @@ -60,9 +60,11 @@ |
1537 | from lazr.restful.testing.webservice import ( |
1538 | create_web_service_request, |
1539 | DummyAbsoluteURL, |
1540 | + FakeRequest, |
1541 | IGenericCollection, |
1542 | IGenericEntry, |
1543 | simple_renderer, |
1544 | + TestCaseWithWebServiceFixtures, |
1545 | WebServiceTestCase, |
1546 | ) |
1547 | from lazr.restful.testing.tales import test_tales |
1548 | @@ -111,7 +113,7 @@ |
1549 | return "wibble" |
1550 | |
1551 | |
1552 | -class ResourceOperationTestCase(unittest.TestCase): |
1553 | +class ResourceOperationTestCase(TestCaseWithWebServiceFixtures): |
1554 | """A test case for resource operations.""" |
1555 | |
1556 | def test_object_with_getitem_should_not_batch(self): |
1557 | @@ -124,7 +126,7 @@ |
1558 | return_type = Reference(IHas_getitem) |
1559 | result = Has_getitem() |
1560 | |
1561 | - operation = ResourceGETOperation("fake context", "fake request") |
1562 | + operation = ResourceGETOperation("fake context", FakeRequest()) |
1563 | operation.return_type = return_type |
1564 | |
1565 | self.assertFalse( |
I'm withdrawing this as it seems unlikely that we're going to proceed with this work.