Merge lp:~cjwatson/lazr.restful/caveats into lp:lazr.restful

Proposed by Colin Watson on 2016-02-22
Status: Needs review
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
Reviewer Review Type Date Requested Status
LAZR Developers 2016-02-22 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://research.google.com/pubs/pub41892.html) and will be used to implement support for them, but the support here is more generic; the concrete binding to the macaroon wire format and implementation of specific predicates will be left to the Launchpad webapp code, and some caveats are expected to be stored in the database rather than supplied in macaroons.

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.interfaces.IChecker, but I didn't think it quite worked out for it to implement it directly.

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.

To post a comment you must log in.
lp:~cjwatson/lazr.restful/caveats updated on 2016-03-02
215. By Colin Watson on 2016-02-22

Only check caveats if the principal provides IPrincipalWithCaveats.

216. By Colin Watson on 2016-02-22

It's the interaction that supports checkUnauthenticatedPermission, not the security policy.

217. By Colin Watson on 2016-02-22

Fix doctest.

218. By Colin Watson on 2016-02-25

Add IPrincipalCaveat.text.

219. By Colin Watson on 2016-02-29

Rename IPrincipalCaveat to ICaveat.

220. By Colin Watson on 2016-03-01

Add a @check_parameter_permissions decorator.

221. By Colin Watson on 2016-03-01

Fix lazr.restful.caveatchecker.__all__.

222. By Colin Watson on 2016-03-02

Check caveats in accessors, mutators, and collections.

Unmerged revisions

222. By Colin Watson on 2016-03-02

Check caveats in accessors, mutators, and collections.

221. By Colin Watson on 2016-03-01

Fix lazr.restful.caveatchecker.__all__.

220. By Colin Watson on 2016-03-01

Add a @check_parameter_permissions decorator.

219. By Colin Watson on 2016-02-29

Rename IPrincipalCaveat to ICaveat.

218. By Colin Watson on 2016-02-25

Add IPrincipalCaveat.text.

217. By Colin Watson on 2016-02-22

Fix doctest.

216. By Colin Watson on 2016-02-22

It's the interaction that supports checkUnauthenticatedPermission, not the security policy.

215. By Colin Watson on 2016-02-22

Only check caveats if the principal provides IPrincipalWithCaveats.

214. By Colin Watson on 2016-02-22

Implement caveat checking at the request boundary.

213. By Colin Watson on 2016-02-22

Lift exception handling from ReadWriteResource.__call__ into HTTPResource.__call__.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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(

Subscribers

People subscribed via source and target branches