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
=== modified file 'setup.py'
--- setup.py 2016-02-17 01:07:21 +0000
+++ setup.py 2016-03-02 13:25:07 +0000
@@ -69,11 +69,13 @@
69 'van.testing',69 'van.testing',
70 'wsgiref',70 'wsgiref',
71 'zope.app.pagetemplate',71 'zope.app.pagetemplate',
72 'zope.authentication',
72 'zope.component [zcml]',73 'zope.component [zcml]',
73 'zope.configuration',74 'zope.configuration',
74 'zope.event',75 'zope.event',
75 'zope.interface>=3.6.0',76 'zope.interface>=3.6.0',
76 'zope.pagetemplate',77 'zope.pagetemplate',
78 'zope.principalregistry',
77 'zope.processlifetime',79 'zope.processlifetime',
78 'zope.proxy',80 'zope.proxy',
79 'zope.publisher',81 'zope.publisher',
8082
=== modified file 'src/lazr/restful/_operation.py'
--- src/lazr/restful/_operation.py 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/_operation.py 2016-03-02 13:25:07 +0000
@@ -16,10 +16,15 @@
16from lazr.lifecycle.event import ObjectModifiedEvent16from lazr.lifecycle.event import ObjectModifiedEvent
17from lazr.lifecycle.snapshot import Snapshot17from lazr.lifecycle.snapshot import Snapshot
1818
19from lazr.restful.fields import CollectionField
20from lazr.restful.interfaces import (19from lazr.restful.interfaces import (
21 ICollection, IFieldMarshaller, IResourceDELETEOperation,20 ICaveatChecker,
22 IResourceGETOperation, IResourcePOSTOperation, IWebServiceConfiguration)21 ICollection,
22 IFieldMarshaller,
23 IResourceDELETEOperation,
24 IResourceGETOperation,
25 IResourcePOSTOperation,
26 IWebServiceConfiguration,
27 )
23from lazr.restful.interfaces import ICollectionField, IReference28from lazr.restful.interfaces import ICollectionField, IReference
24from lazr.restful.utils import is_total_size_link_active29from lazr.restful.utils import is_total_size_link_active
25from lazr.restful._resource import (30from lazr.restful._resource import (
@@ -46,6 +51,7 @@
46 def __init__(self, context, request):51 def __init__(self, context, request):
47 self.context = context52 self.context = context
48 self.request = request53 self.request = request
54 self.caveat_checker = ICaveatChecker(request)
49 self.total_size_only = False55 self.total_size_only = False
5056
51 def total_size_link(self, navigator):57 def total_size_link(self, navigator):
5258
=== modified file 'src/lazr/restful/_resource.py'
--- src/lazr/restful/_resource.py 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/_resource.py 2016-03-02 13:25:07 +0000
@@ -89,6 +89,7 @@
89from lazr.lifecycle.event import ObjectModifiedEvent89from lazr.lifecycle.event import ObjectModifiedEvent
90from lazr.lifecycle.snapshot import Snapshot90from lazr.lifecycle.snapshot import Snapshot
91from lazr.restful.interfaces import (91from lazr.restful.interfaces import (
92 ICaveatChecker,
92 ICollection,93 ICollection,
93 ICollectionField,94 ICollectionField,
94 ICollectionResource,95 ICollectionResource,
@@ -286,11 +287,41 @@
286 def __init__(self, context, request):287 def __init__(self, context, request):
287 self.context = context288 self.context = context
288 self.request = request289 self.request = request
290 self.caveat_checker = ICaveatChecker(request)
289 self.etags_by_media_type = {}291 self.etags_by_media_type = {}
290292
291 def __call__(self):293 def __call__(self):
292 """See `IHTTPResource`."""294 """See `IHTTPResource`."""
293 pass295 result = ""
296 try:
297 result = self.call()
298 except Exception as e:
299 exception_info = sys.exc_info()
300 view = getMultiAdapter((e, self.request), name="index.html")
301 try:
302 ws_view = IWebServiceExceptionView(view)
303 except TypeError:
304 # There's a view for this exception, but it's not one
305 # we can understand. It was probably designed for a
306 # web application rather than a web service. We'll
307 # re-raise the exception, let the publisher look up
308 # the view, and hopefully handle it better. Note the careful
309 # reraising that ensures the original traceback is preserved.
310 raise exception_info[0], exception_info[1], exception_info[2]
311
312 self.request.response.setStatus(ws_view.status)
313 if ws_view.status / 100 == 4:
314 # This exception corresponds to a client-side error
315 result = ws_view()
316 else:
317 # This exception corresponds to a server-side error
318 # (or some weird attempt to handle redirects or normal
319 # status codes using exceptions). Let the publisher
320 # handle it. (No need for carefully reraising here; since
321 # there is only one exception in flight we just use a bare
322 # raise to reraise the original exception.)
323 raise
324 return result
294325
295 def getRequestMethod(self, request=None):326 def getRequestMethod(self, request=None):
296 """Return the HTTP method of the provided (or current) request.327 """Return the HTTP method of the provided (or current) request.
@@ -667,7 +698,8 @@
667 # obtained from a representation cache.698 # obtained from a representation cache.
668 resources = [EntryResource(entry, request)699 resources = [EntryResource(entry, request)
669 for entry in navigator.batch700 for entry in navigator.batch
670 if checkPermission(view_permission, entry)]701 if checkPermission(view_permission, entry) and
702 self.caveat_checker.checkCaveats(entry)]
671 entry_strings = [703 entry_strings = [
672 resource._representation(HTTPResource.JSON_TYPE)704 resource._representation(HTTPResource.JSON_TYPE)
673 for resource in resources]705 for resource in resources]
@@ -784,6 +816,56 @@
784 super(FieldUnmarshallerMixin, self).__init__(context, request)816 super(FieldUnmarshallerMixin, self).__init__(context, request)
785 self._unmarshalled_field_cache = {}817 self._unmarshalled_field_cache = {}
786818
819 def _checkPermission(self, context, name, field, check_method):
820 # Can we view/change (as appropriate) the field's value? We check
821 # the permission directly using the Zope permission checker for
822 # several reasons. Firstly, doing it indirectly by fetching the
823 # value may have very slow side-effects such as database hits.
824 # Secondly, in the write case we obviously cannot set the value
825 # without side-effects. Thirdly, if caveats are in operation then
826 # we need to check them only for this field, not for anything
827 # internal to the getter/setter.
828 try:
829 tagged_values = field.getTaggedValue(
830 'lazr.restful.exported')
831 original_name = tagged_values['original_name']
832 except KeyError:
833 # This field has no tagged values, or is missing the
834 # 'original_name' value. Its entry class was probably created
835 # by hand rather than by tagging an interface. In that case,
836 # it's the programmer's responsibility to set 'original_name' if
837 # the web service field name differs from the underlying
838 # interface's field name. Since 'original_name' is not present,
839 # assume the names are the same.
840 original_name = name
841 context = self.entry._orig_interfaces[name](context)
842 try:
843 getChecker(context)
844 except TypeError:
845 if check_method == 'check':
846 # This is more expensive than using a Zope checker, but
847 # there is no checker, so either there is no permission
848 # control on this object, or permission control is
849 # implemented some other way. Also note that we use
850 # getattr() on self.entry rather than context because some
851 # of the fields in entry.schema will be provided by adapters
852 # rather than directly by context.
853 getattr(self.entry, name)
854 else:
855 # In the write case, we cannot check permissions in advance
856 # if there is no checker.
857 pass
858 else:
859 getattr(self.caveat_checker, check_method)(context, original_name)
860
861 def checkRead(self, context, name, field):
862 """Raise Unauthorized if the current user cannot read this field."""
863 self._checkPermission(context, name, field, 'check_getattr')
864
865 def checkWrite(self, context, name, field):
866 """Raise Unauthorized if the current user cannot write this field."""
867 self._checkPermission(context, name, field, 'check_setattr')
868
787 def _unmarshallField(self, field_name, field, detail=NORMAL_DETAIL):869 def _unmarshallField(self, field_name, field, detail=NORMAL_DETAIL):
788 """See what a field would look like in a generic representation.870 """See what a field would look like in a generic representation.
789871
@@ -803,7 +885,9 @@
803 value = None885 value = None
804 flag = IUnmarshallingDoesntNeedValue886 flag = IUnmarshallingDoesntNeedValue
805 else:887 else:
888 self.checkRead(self.entry.context, field.__name__, field)
806 value = getattr(self.entry, field.__name__)889 value = getattr(self.entry, field.__name__)
890 self.caveat_checker.enforceCaveats(value)
807 flag = None891 flag = None
808 if detail is NORMAL_DETAIL:892 if detail is NORMAL_DETAIL:
809 repr_value = marshaller.unmarshall(self.entry, value)893 repr_value = marshaller.unmarshall(self.entry, value)
@@ -875,7 +959,7 @@
875 # even though there's no other code in __init__.959 # even though there's no other code in __init__.
876 super(ReadOnlyResource, self).__init__(context, request)960 super(ReadOnlyResource, self).__init__(context, request)
877961
878 def __call__(self):962 def call(self):
879 """Handle a GET or (if implemented) POST request."""963 """Handle a GET or (if implemented) POST request."""
880 result = ""964 result = ""
881 method = self.getRequestMethod()965 method = self.getRequestMethod()
@@ -902,60 +986,33 @@
902 # even though there's no other code in __init__.986 # even though there's no other code in __init__.
903 super(ReadWriteResource, self).__init__(context, request)987 super(ReadWriteResource, self).__init__(context, request)
904988
905 def __call__(self):989 def call(self):
906 """Handle a GET, PUT, or PATCH request."""990 """Handle a GET, PUT, or PATCH request."""
907 result = ""991 result = ""
908 method = self.getRequestMethod()992 method = self.getRequestMethod()
909 try:993 if method == "GET":
910 if method == "GET":994 result = self.do_GET()
911 result = self.do_GET()995 elif method in ["PUT", "PATCH"]:
912 elif method in ["PUT", "PATCH"]:996 media_type = self.handleConditionalWrite()
913 media_type = self.handleConditionalWrite()997 if media_type is not None:
914 if media_type is not None:998 stream = self.request.bodyStream
915 stream = self.request.bodyStream999 representation = stream.getCacheStream().read()
916 representation = stream.getCacheStream().read()1000 if method == "PUT":
917 if method == "PUT":1001 result = self.do_PUT(media_type, representation)
918 result = self.do_PUT(media_type, representation)1002 else:
919 else:1003 result = self.do_PATCH(media_type, representation)
920 result = self.do_PATCH(media_type, representation)1004 elif method == "POST" and self.implementsPOST():
921 elif method == "POST" and self.implementsPOST():1005 result = self.do_POST()
922 result = self.do_POST()1006 elif method == "DELETE" and self.implementsDELETE():
923 elif method == "DELETE" and self.implementsDELETE():1007 result = self.do_DELETE()
924 result = self.do_DELETE()1008 else:
925 else:1009 allow_string = "GET PUT PATCH"
926 allow_string = "GET PUT PATCH"1010 if self.implementsPOST():
927 if self.implementsPOST():1011 allow_string += " POST"
928 allow_string += " POST"1012 if self.implementsDELETE():
929 if self.implementsDELETE():1013 allow_string += " DELETE"
930 allow_string += " DELETE"1014 self.request.response.setStatus(405)
931 self.request.response.setStatus(405)1015 self.request.response.setHeader("Allow", allow_string)
932 self.request.response.setHeader("Allow", allow_string)
933 except Exception as e:
934 exception_info = sys.exc_info()
935 view = getMultiAdapter((e, self.request), name="index.html")
936 try:
937 ws_view = IWebServiceExceptionView(view)
938 except TypeError:
939 # There's a view for this exception, but it's not one
940 # we can understand. It was probably designed for a
941 # web application rather than a web service. We'll
942 # re-raise the exception, let the publisher look up
943 # the view, and hopefully handle it better. Note the careful
944 # reraising that ensures the original trackabck is preserved.
945 raise exception_info[0], exception_info[1], exception_info[2]
946
947 self.request.response.setStatus(ws_view.status)
948 if ws_view.status / 100 == 4:
949 # This exception corresponds to a client-side error
950 result = ws_view()
951 else:
952 # This exception corresponds to a server-side error
953 # (or some weird attempt to handle redirects or normal
954 # status codes using exceptions). Let the publisher
955 # handle it. (No need for carefuly reraising here, since there
956 # is only one exception in flight we just use a bare raise to
957 # reraise the original exception.)
958 raise
959 return result1016 return result
9601017
9611018
@@ -1016,6 +1073,7 @@
1016 changeset = copy.copy(changeset)1073 changeset = copy.copy(changeset)
1017 validated_changeset = []1074 validated_changeset = []
1018 errors = []1075 errors = []
1076 orig_interfaces = self.entry._orig_interfaces
10191077
1020 # Some fields aren't part of the schema, so they're handled1078 # Some fields aren't part of the schema, so they're handled
1021 # separately.1079 # separately.
@@ -1174,6 +1232,8 @@
1174 error = "Validation error"1232 error = "Validation error"
1175 errors.append(u"%s: %s" % (repr_name, error))1233 errors.append(u"%s: %s" % (repr_name, error))
1176 continue1234 continue
1235 self.checkWrite(self.entry.context, name, field)
1236 self.caveat_checker.enforceCaveats(value)
1177 validated_changeset.append((field, value))1237 validated_changeset.append((field, value))
1178 # If there are any fields left in the changeset, they're1238 # If there are any fields left in the changeset, they're
1179 # fields that don't correspond to some field in the1239 # fields that don't correspond to some field in the
@@ -1624,7 +1684,6 @@
1624 """An EntryAdapterUtility for this resource."""1684 """An EntryAdapterUtility for this resource."""
1625 return EntryAdapterUtility(self.entry.__class__)1685 return EntryAdapterUtility(self.entry.__class__)
16261686
1627
1628 @property1687 @property
1629 def redacted_fields(self):1688 def redacted_fields(self):
1630 """Names the fields the current user doesn't have permission to see."""1689 """Names the fields the current user doesn't have permission to see."""
@@ -1632,40 +1691,7 @@
1632 orig_interfaces = self.entry._orig_interfaces1691 orig_interfaces = self.entry._orig_interfaces
1633 for name, field in getFieldsInOrder(self.entry.schema):1692 for name, field in getFieldsInOrder(self.entry.schema):
1634 try:1693 try:
1635 # Can we view the field's value? We check the1694 self.checkRead(self.context, name, field)
1636 # permission directly using the Zope permission
1637 # checker, because doing it indirectly by fetching the
1638 # value may have very slow side effects such as
1639 # database hits.
1640 try:
1641 tagged_values = field.getTaggedValue(
1642 'lazr.restful.exported')
1643 original_name = tagged_values['original_name']
1644 except KeyError:
1645 # This field has no tagged values, or is missing
1646 # the 'original_name' value. Its entry class was
1647 # probably created by hand rather than by tagging
1648 # an interface. In that case, it's the
1649 # programmer's responsibility to set
1650 # 'original_name' if the web service field name
1651 # differs from the underlying interface's field
1652 # name. Since 'original_name' is not present, assume the
1653 # names are the same.
1654 original_name = name
1655 context = orig_interfaces[name](self.context)
1656 try:
1657 checker = getChecker(context)
1658 except TypeError:
1659 # This is more expensive than using a Zope checker, but
1660 # there is no checker, so either there is no permission
1661 # control on this object, or permission control is
1662 # implemented some other way. Also note that we use
1663 # getattr() on self.entry rather than self.context because
1664 # some of the fields in entry.schema will be provided by
1665 # adapters rather than directly by self.context.
1666 getattr(self.entry, name)
1667 else:
1668 checker.check(context, original_name)
1669 except Unauthorized:1695 except Unauthorized:
1670 # This is an expensive operation that will make this1696 # This is an expensive operation that will make this
1671 # request more expensive still, but it happens1697 # request more expensive still, but it happens
@@ -1699,6 +1725,8 @@
1699 if media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]:1725 if media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]:
1700 return self.toWADL().encode("utf-8")1726 return self.toWADL().encode("utf-8")
1701 elif media_type in (self.JSON_TYPE, self.JSON_PLUS_XHTML_TYPE):1727 elif media_type in (self.JSON_TYPE, self.JSON_PLUS_XHTML_TYPE):
1728 self.caveat_checker.enforceCaveats(self.context)
1729
1702 cache = self._representation_cache1730 cache = self._representation_cache
1703 if cache is None:1731 if cache is None:
1704 representation = None1732 representation = None
17051733
=== added file 'src/lazr/restful/caveatchecker.py'
--- src/lazr/restful/caveatchecker.py 1970-01-01 00:00:00 +0000
+++ src/lazr/restful/caveatchecker.py 2016-03-02 13:25:07 +0000
@@ -0,0 +1,121 @@
1# Copyright 2016 Canonical Ltd. All rights reserved.
2#
3# This file is part of lazr.restful.
4#
5# lazr.restful is free software: you can redistribute it and/or modify it
6# under the terms of the GNU Lesser General Public License as published by
7# the Free Software Foundation, version 3 of the License.
8#
9# lazr.restful is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
12# License for more details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with lazr.restful. If not, see <http://www.gnu.org/licenses/>.
16
17"""Caveat checking."""
18
19__metaclass__ = type
20__all__ = [
21 'CaveatChecker',
22 ]
23
24from zope.component import (
25 adapter,
26 getUtility,
27 )
28from zope.interface import implementer
29from zope.publisher.interfaces import IApplicationRequest
30from zope.security.interfaces import Unauthorized
31from zope.security.management import queryInteraction
32from zope.security.proxy import getChecker
33
34from lazr.restful.interfaces import (
35 ICaveatChecker,
36 IInteractionWithUnauthenticatedCheck,
37 IPrincipalWithCaveats,
38 IWebServiceConfiguration,
39 )
40
41
42@implementer(ICaveatChecker)
43@adapter(IApplicationRequest)
44class CaveatChecker:
45 """A class that checks whether objects satisfy caveats in a request."""
46
47 def __init__(self, request):
48 self.request = request
49 self.view_permission = (
50 getUtility(IWebServiceConfiguration).view_permission)
51
52 def checkCaveats(self, object, permission=None, attribute=None):
53 """See `ICaveatChecker`."""
54 if not IPrincipalWithCaveats.providedBy(self.request.principal):
55 return True
56 if permission is None:
57 permission = self.view_permission
58 satisfied = all(
59 caveat.verify(permission, object, attribute=attribute)
60 for caveat in self.request.principal.caveats)
61 if not satisfied:
62 # Caveats are not satisfied, but we'll ignore that if an
63 # unauthenticated principal could perform this operation: that
64 # is, caveats never reduce permissions below those possessed by
65 # an anonymous user.
66 interaction = queryInteraction()
67 if (IInteractionWithUnauthenticatedCheck.providedBy(
68 interaction) and
69 interaction.checkUnauthenticatedPermission(
70 permission, object)):
71 return True
72 return satisfied
73
74 def enforceCaveats(self, object, permission=None, attribute=None):
75 """See `ICaveatChecker`."""
76 if permission is None:
77 permission = self.view_permission
78 if not self.checkCaveats(
79 object, permission=permission, attribute=attribute):
80 if attribute is None:
81 raise Unauthorized(object, permission)
82 else:
83 raise Unauthorized(object, attribute, permission)
84 return object
85
86 def check_setattr(self, object, attribute):
87 """See `ICaveatChecker`."""
88 try:
89 base_checker = getChecker(object)
90 except TypeError:
91 # We cannot check permissions in advance if there is no checker.
92 return
93 if base_checker.set_permissions:
94 permission = base_checker.set_permissions.get(attribute)
95 else:
96 permission = None
97 if permission is not None:
98 self.enforceCaveats(object, permission, attribute)
99 base_checker.check_setattr(object, attribute)
100
101 def check_getattr(self, object, attribute):
102 """See `ICaveatChecker`."""
103 try:
104 base_checker = getChecker(object)
105 except TypeError:
106 # We cannot check permissions in advance if there is no checker.
107 return
108 permission = base_checker.get_permissions.get(attribute)
109 if permission is not None:
110 self.enforceCaveats(object, permission, attribute)
111 base_checker.check_getattr(object, attribute)
112
113 def setattr(self, object, attribute, value):
114 """See `ICaveatChecker`."""
115 self.check_setattr(object, attribute)
116 setattr(object, attribute, value)
117
118 def getattr(self, object, attribute):
119 """See `ICaveatChecker`."""
120 self.check_getattr(object, attribute)
121 return getattr(object, attribute)
0122
=== modified file 'src/lazr/restful/configure.zcml'
--- src/lazr/restful/configure.zcml 2012-03-13 02:03:47 +0000
+++ src/lazr/restful/configure.zcml 2016-03-02 13:25:07 +0000
@@ -40,6 +40,8 @@
4040
41 <adapter factory="lazr.restful.jsoncache.JSONRequestCache" />41 <adapter factory="lazr.restful.jsoncache.JSONRequestCache" />
4242
43 <adapter factory="lazr.restful.caveatchecker.CaveatChecker" />
44
43 <!-- TALES namespace for web service functions available through the45 <!-- TALES namespace for web service functions available through the
44 website. -->46 website. -->
45 <adapter47 <adapter
4648
=== modified file 'src/lazr/restful/declarations.py'
--- src/lazr/restful/declarations.py 2015-04-08 20:11:29 +0000
+++ src/lazr/restful/declarations.py 2016-03-02 13:25:07 +0000
@@ -15,6 +15,7 @@
15 'accessor_for',15 'accessor_for',
16 'cache_for',16 'cache_for',
17 'call_with',17 'call_with',
18 'check_parameter_permissions',
18 'collection_default_content',19 'collection_default_content',
19 'error_status',20 'error_status',
20 'exported',21 'exported',
@@ -55,6 +56,8 @@
55 IText,56 IText,
56 )57 )
57from zope.security.checker import CheckerPublic58from zope.security.checker import CheckerPublic
59from zope.security.interfaces import Unauthorized
60from zope.security.management import checkPermission
58from zope.traversing.browser import absoluteURL61from zope.traversing.browser import absoluteURL
5962
60from lazr.delegates import Passthrough63from lazr.delegates import Passthrough
@@ -65,6 +68,7 @@
65 )68 )
66from lazr.restful.interface import copy_field69from lazr.restful.interface import copy_field
67from lazr.restful.interfaces import (70from lazr.restful.interfaces import (
71 ICaveatChecker,
68 ICollection,72 ICollection,
69 IEntry,73 IEntry,
70 IReference,74 IReference,
@@ -562,10 +566,12 @@
562 if 'as' not in annotations:566 if 'as' not in annotations:
563 annotations['as'] = method.__name__567 annotations['as'] = method.__name__
564568
565 # It's possible that call_with, operation_parameters, and/or569 # It's possible that call_with, operation_parameters,
566 # operation_returns_* weren't used.570 # check_parameter_permissions, and/or operation_returns_*
571 # weren't used.
567 annotations.setdefault('call_with', {})572 annotations.setdefault('call_with', {})
568 annotations.setdefault('params', {})573 annotations.setdefault('params', {})
574 annotations.setdefault('param_permissions', {})
569 annotations.setdefault('return_type', None)575 annotations.setdefault('return_type', None)
570576
571 # Make sure that all parameters exist and that we miss none.577 # Make sure that all parameters exist and that we miss none.
@@ -574,6 +580,7 @@
574 defined_params.update(info['required'])580 defined_params.update(info['required'])
575 exported_params = set(annotations['params'])581 exported_params = set(annotations['params'])
576 exported_params.update(annotations['call_with'])582 exported_params.update(annotations['call_with'])
583 exported_params.update(annotations['param_permissions'])
577 undefined_params = exported_params.difference(defined_params)584 undefined_params = exported_params.difference(defined_params)
578 if undefined_params and info['kwargs'] is None:585 if undefined_params and info['kwargs'] is None:
579 raise TypeError(586 raise TypeError(
@@ -632,6 +639,20 @@
632 annotations['call_with'] = self.params639 annotations['call_with'] = self.params
633640
634641
642class check_parameter_permissions(_method_annotator):
643 """Decorator indicating that calling a method requires specified
644 permissions on parameters.
645 """
646 def __init__(self, **params):
647 _check_called_from_interface_def('%s()' % self.__class__.__name__)
648 self.params = params
649
650 def annotate_method(self, method, annotations):
651 """See `_method_annotator`."""
652 annotations.setdefault('param_permissions', [])
653 annotations['param_permissions'] = self.params
654
655
635class mutator_for(_method_annotator):656class mutator_for(_method_annotator):
636 """Decorator indicating that an exported method mutates a field.657 """Decorator indicating that an exported method mutates a field.
637658
@@ -1225,6 +1246,15 @@
1225 return params1246 return params
12261247
12271248
1249def get_caveat_checker(instance):
1250 """Get a caveat checker appropriate for an instance."""
1251 if instance is None or instance.request is None:
1252 request = get_current_web_service_request()
1253 else:
1254 request = instance.request
1255 return ICaveatChecker(request)
1256
1257
1228class _AccessorWrapper:1258class _AccessorWrapper:
1229 """A wrapper class for properties with accessors.1259 """A wrapper class for properties with accessors.
12301260
@@ -1242,7 +1272,8 @@
1242 # Error checking code in accessor_for() guarantees that there1272 # Error checking code in accessor_for() guarantees that there
1243 # is one and only one non-fixed parameter for the accessor1273 # is one and only one non-fixed parameter for the accessor
1244 # method.1274 # method.
1245 return getattr(context, self.accessor)(**params)1275 caveat_checker = get_caveat_checker(obj)
1276 return caveat_checker.getattr(context, self.accessor)(**params)
12461277
12471278
1248class _MutatorWrapper:1279class _MutatorWrapper:
@@ -1262,7 +1293,8 @@
1262 # Error checking code in mutator_for() guarantees that there1293 # Error checking code in mutator_for() guarantees that there
1263 # is one and only one non-fixed parameter for the mutator1294 # is one and only one non-fixed parameter for the mutator
1264 # method.1295 # method.
1265 getattr(context, self.mutator)(new_value, **params)1296 caveat_checker = get_caveat_checker(obj)
1297 caveat_checker.getattr(context, self.mutator)(new_value, **params)
12661298
12671299
1268class PropertyWithAccessor(_AccessorWrapper, Passthrough):1300class PropertyWithAccessor(_AccessorWrapper, Passthrough):
@@ -1336,7 +1368,8 @@
13361368
1337 def find(self):1369 def find(self):
1338 """See `ICollection`."""1370 """See `ICollection`."""
1339 method = getattr(self.context, self.method_name)1371 caveat_checker = get_caveat_checker(self)
1372 method = caveat_checker.getattr(self.context, self.method_name)
1340 params = params_with_dereferenced_user(self.params)1373 params = params_with_dereferenced_user(self.params)
1341 return method(**params)1374 return method(**params)
13421375
@@ -1371,7 +1404,8 @@
1371 """Base class for generated operation adapters."""1404 """Base class for generated operation adapters."""
13721405
1373 def _getMethod(self):1406 def _getMethod(self):
1374 return getattr(self._orig_iface(self.context), self._method_name)1407 return self.caveat_checker.getattr(
1408 self._orig_iface(self.context), self._method_name)
13751409
1376 def _getMethodParameters(self, kwargs):1410 def _getMethodParameters(self, kwargs):
1377 """Return the method parameters.1411 """Return the method parameters.
@@ -1394,6 +1428,15 @@
1394 # Handle fixed parameters.1428 # Handle fixed parameters.
1395 params.update(params_with_dereferenced_user(1429 params.update(params_with_dereferenced_user(
1396 self._export_info['call_with']))1430 self._export_info['call_with']))
1431
1432 # Handle any requested parameter permission checks.
1433 for name, permission in self._export_info['param_permissions'].items():
1434 param = params.get(name)
1435 if param is not None:
1436 if not checkPermission(permission, param):
1437 raise Unauthorized(param, permission)
1438 self.caveat_checker.enforceCaveats(param, permission)
1439
1397 return params1440 return params
13981441
1399 def call(self, **kwargs):1442 def call(self, **kwargs):
@@ -1409,6 +1452,7 @@
1409 % self._export_info['cache_for'])1452 % self._export_info['cache_for'])
14101453
1411 result = self._getMethod()(**params)1454 result = self._getMethod()(**params)
1455 self.caveat_checker.enforceCaveats(result)
1412 return self.encodeResult(result)1456 return self.encodeResult(result)
14131457
14141458
@@ -1423,6 +1467,7 @@
1423 """1467 """
1424 params = self._getMethodParameters(kwargs)1468 params = self._getMethodParameters(kwargs)
1425 result = self._getMethod()(**params)1469 result = self._getMethod()(**params)
1470 self.caveat_checker.enforceCaveats(result)
1426 response = self.request.response1471 response = self.request.response
1427 response.setStatus(201)1472 response.setStatus(201)
1428 response.setHeader('Location', absoluteURL(result, self.request))1473 response.setHeader('Location', absoluteURL(result, self.request))
14291474
=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
--- src/lazr/restful/docs/webservice-declarations.txt 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/docs/webservice-declarations.txt 2016-03-02 13:25:07 +0000
@@ -427,6 +427,7 @@
427 as: 'create_book'427 as: 'create_book'
428 call_with: {}428 call_with: {}
429 creates: <...IBook...>429 creates: <...IBook...>
430 param_permissions: {}
430 params: {'author': <...TextLine...>,431 params: {'author': <...TextLine...>,
431 'base_price': <...Float...>,432 'base_price': <...Float...>,
432 'title': <...TextLine...>}433 'title': <...TextLine...>}
@@ -439,6 +440,7 @@
439 >>> print_export_tag(IBookSetOnSteroids['searchBookTitles'])440 >>> print_export_tag(IBookSetOnSteroids['searchBookTitles'])
440 as: 'searchBookTitles'441 as: 'searchBookTitles'
441 call_with: {}442 call_with: {}
443 param_permissions: {}
442 params: {'text': <...TextLine...>}444 params: {'text': <...TextLine...>}
443 return_type: <lazr.restful.fields.CollectionField object...>445 return_type: <lazr.restful.fields.CollectionField object...>
444 type: 'read_operation'446 type: 'read_operation'
@@ -448,6 +450,7 @@
448 >>> print_export_tag(IBookSetOnSteroids['bestMatch'])450 >>> print_export_tag(IBookSetOnSteroids['bestMatch'])
449 as: 'bestMatch'451 as: 'bestMatch'
450 call_with: {}452 call_with: {}
453 param_permissions: {}
451 params: {'text': <...TextLine...>}454 params: {'text': <...TextLine...>}
452 return_type: <lazr.restful.fields.Reference object...>455 return_type: <lazr.restful.fields.Reference object...>
453 type: 'read_operation'456 type: 'read_operation'
@@ -457,6 +460,7 @@
457 >>> print_export_tag(IBookOnSteroids['checkout'])460 >>> print_export_tag(IBookOnSteroids['checkout'])
458 as: 'checkout'461 as: 'checkout'
459 call_with: {'kind': 'normal', 'who': <class '...REQUEST_USER'>}462 call_with: {'kind': 'normal', 'who': <class '...REQUEST_USER'>}
463 param_permissions: {}
460 params: {}464 params: {}
461 return_type: None465 return_type: None
462 type: 'write_operation'466 type: 'write_operation'
@@ -486,6 +490,7 @@
486 as: 'create_book'490 as: 'create_book'
487 call_with: {}491 call_with: {}
488 creates: <...IBook...>492 creates: <...IBook...>
493 param_permissions: {}
489 params: {'author': <...TextLine...>,494 params: {'author': <...TextLine...>,
490 'collection': <...TextLine...>,495 'collection': <...TextLine...>,
491 'title': <...TextLine...>}496 'title': <...TextLine...>}
@@ -804,12 +809,14 @@
804 == rename ==809 == rename ==
805 as: 'rename'810 as: 'rename'
806 call_with: {}811 call_with: {}
812 param_permissions: {}
807 params: {'new_name': <...TextLine...>}813 params: {'new_name': <...TextLine...>}
808 return_type: None814 return_type: None
809 type: 'write_operation'815 type: 'write_operation'
810 == talk_to ==816 == talk_to ==
811 as: 'talk_to'817 as: 'talk_to'
812 call_with: {}818 call_with: {}
819 param_permissions: {}
813 params: {'msg': <...TextLine...>,820 params: {'msg': <...TextLine...>,
814 'to': <...Object...>}821 'to': <...Object...>}
815 return_type: None822 return_type: None
@@ -897,6 +904,9 @@
897 ... (ITestServiceRequest30, '3.0')]:904 ... (ITestServiceRequest30, '3.0')]:
898 ... sm.registerUtility(marker, IWebServiceVersion, name=name)905 ... sm.registerUtility(marker, IWebServiceVersion, name=name)
899906
907 >>> from lazr.restful.caveatchecker import CaveatChecker
908 >>> sm.registerAdapter(CaveatChecker)
909
900 >>> from lazr.restful.testing.webservice import FakeRequest910 >>> from lazr.restful.testing.webservice import FakeRequest
901 >>> request = FakeRequest(version='beta')911 >>> request = FakeRequest(version='beta')
902912
@@ -2591,6 +2601,79 @@
2591 zope.security._proxy._Proxy (using zope.security.checker.Checker)2601 zope.security._proxy._Proxy (using zope.security.checker.Checker)
2592 public: __call__, send_modification_event2602 public: __call__, send_modification_event
25932603
2604Additional permission checking
2605------------------------------
2606
2607The @check_parameter_permissions decorator allows stating that additional
2608permissions are required on some parameters.
2609
2610 >>> from zope.security.management import (
2611 ... queryInteraction,
2612 ... setSecurityPolicy,
2613 ... )
2614 >>> from zope.security.permission import Permission
2615 >>> from zope.security.simplepolicies import PermissiveSecurityPolicy
2616 >>> from lazr.restful.caveatchecker import CaveatChecker
2617 >>> from lazr.restful.declarations import check_parameter_permissions
2618 >>> from lazr.restful.security import protect_schema
2619
2620 >>> class CachedOnlySecurityPolicy(PermissiveSecurityPolicy):
2621 ... def __init__(self, *participations):
2622 ... super(CachedOnlySecurityPolicy, self).__init__(
2623 ... *participations)
2624 ... self.cache = {}
2625 ...
2626 ... def checkPermission(self, permission, object):
2627 ... return self.cache.get(object, {}).get(permission, False)
2628
2629 >>> class IParameterPermissions(Interface):
2630 ... export_as_webservice_entry()
2631 ...
2632 ... @check_parameter_permissions(foo='lazr.Edit', bar='lazr.Edit')
2633 ... @operation_parameters(
2634 ... foo=Reference(schema=Interface),
2635 ... bar=Reference(schema=Interface, required=False))
2636 ... @export_write_operation()
2637 ... def test_function(foo, bar=None):
2638 ... pass
2639
2640 >>> @implementer(IParameterPermissions)
2641 ... class ParameterPermissions(object):
2642 ... def test_function(self, foo, bar=None):
2643 ... return foo, bar
2644
2645 >>> sm.registerAdapter(CaveatChecker)
2646 >>> sm.registerUtility(Permission('lazr.Edit'), name='lazr.Edit')
2647 >>> protect_schema(HasText, IHasText, write_permission='lazr.Edit')
2648
2649 >>> pp_test_function_adapter_factory = generate_operation_adapter(
2650 ... IParameterPermissions['test_function'])
2651 >>> setSecurityPolicy(CachedOnlySecurityPolicy)
2652 <class ...>
2653 >>> endInteraction()
2654 >>> newInteraction()
2655 >>> interaction = queryInteraction()
2656 >>> pp_object = ParameterPermissions()
2657 >>> foo = HasText()
2658 >>> bar = HasText()
2659 >>> pp_test_function_adapter = pp_test_function_adapter_factory(
2660 ... pp_object, request)
2661
2662 >>> pp_test_function_adapter.call(foo=foo)
2663 Traceback (most recent call last):
2664 ...
2665 Unauthorized: (<HasText object...>, 'lazr.Edit')
2666 >>> interaction.cache.setdefault(foo, {})['lazr.Edit'] = True
2667 >>> pp_test_function_adapter.call(foo=foo)
2668 (<HasText object...>, None)
2669 >>> pp_test_function_adapter.call(foo=foo, bar=bar)
2670 Traceback (most recent call last):
2671 ...
2672 Unauthorized: (<HasText object...>, 'lazr.Edit')
2673 >>> interaction.cache.setdefault(bar, {})['lazr.Edit'] = True
2674 >>> pp_test_function_adapter.call(foo=foo, bar=bar)
2675 (<HasText object...>, <HasText object...>)
2676
2594ZCML Registration2677ZCML Registration
2595=================2678=================
25962679
25972680
=== modified file 'src/lazr/restful/docs/webservice-marshallers.txt'
--- src/lazr/restful/docs/webservice-marshallers.txt 2016-02-16 13:32:24 +0000
+++ src/lazr/restful/docs/webservice-marshallers.txt 2016-03-02 13:25:07 +0000
@@ -8,10 +8,19 @@
8To test the various marshallers we create a dummy request and8To test the various marshallers we create a dummy request and
9application root.9application root.
1010
11 >>> from zope.component import (
12 ... provideAdapter,
13 ... provideUtility,
14 ... )
15 >>> from lazr.restful.caveatchecker import CaveatChecker
16 >>> from lazr.restful.interfaces import IWebServiceConfiguration
17 >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration
11 >>> from lazr.restful.testing.webservice import WebServiceTestPublication18 >>> from lazr.restful.testing.webservice import WebServiceTestPublication
12 >>> from lazr.restful.simple import Request19 >>> from lazr.restful.simple import Request
13 >>> from lazr.restful.example.base.root import (20 >>> from lazr.restful.example.base.root import (
14 ... CookbookServiceRootResource)21 ... CookbookServiceRootResource)
22 >>> provideUtility(TestWebServiceConfiguration(), IWebServiceConfiguration)
23 >>> provideAdapter(CaveatChecker)
15 >>> request = Request("", {'HTTP_HOST': 'cookbooks.dev'})24 >>> request = Request("", {'HTTP_HOST': 'cookbooks.dev'})
16 >>> request.annotations[request.VERSION_ANNOTATION] = '1.0'25 >>> request.annotations[request.VERSION_ANNOTATION] = '1.0'
17 >>> application = CookbookServiceRootResource()26 >>> application = CookbookServiceRootResource()
1827
=== modified file 'src/lazr/restful/docs/webservice.txt'
--- src/lazr/restful/docs/webservice.txt 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/docs/webservice.txt 2016-03-02 13:25:07 +0000
@@ -50,6 +50,9 @@
50 ... cuisine = TextLine(title=u"Cuisine", required=False, default=None)50 ... cuisine = TextLine(title=u"Cuisine", required=False, default=None)
51 ... recipes = Attribute("List of recipes published in this cookbook.")51 ... recipes = Attribute("List of recipes published in this cookbook.")
52 ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.")52 ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.")
53 ... auth_required = Bool(
54 ... title=u"Whether viewing this requires authentication.",
55 ... default=False)
53 ... def removeRecipe(recipe):56 ... def removeRecipe(recipe):
54 ... """Remove a recipe from this cookbook."""57 ... """Remove a recipe from this cookbook."""
5558
@@ -65,8 +68,11 @@
65 ... cookbook = Reference(schema=ICookbook)68 ... cookbook = Reference(schema=ICookbook)
66 ... instructions = Text(title=u"How to prepare the recipe.",69 ... instructions = Text(title=u"How to prepare the recipe.",
67 ... required=True)70 ... required=True)
68 ... private = Bool(title=u"Whether the public can see this recipe.",71 ... private = Bool(
69 ... default=False)72 ... title=u"Whether this recipe is private.", default=False)
73 ... auth_required = Bool(
74 ... title=u"Whether viewing this recipe requires authentication.",
75 ... default=False)
70 ... def delete():76 ... def delete():
71 ... """Delete this recipe."""77 ... """Delete this recipe."""
7278
@@ -87,6 +93,9 @@
87 ... "Retrieve a single author by name."93 ... "Retrieve a single author by name."
8894
89 >>> class ICookbookSet(ITestDataSet):95 >>> class ICookbookSet(ITestDataSet):
96 ... def newCookbook(self, author_name, title, cuisine):
97 ... """Create a new cookbook."""
98 ...
90 ... def getAll(self):99 ... def getAll(self):
91 ... "Get all cookbooks."100 ... "Get all cookbooks."
92 ...101 ...
@@ -190,6 +199,7 @@
190 ... self.comments = []199 ... self.comments = []
191 ... self.cuisine = cuisine200 ... self.cuisine = cuisine
192 ... self.cover = None201 ... self.cover = None
202 ... self.auth_required = True
193 ...203 ...
194 ... @property204 ... @property
195 ... def path(self):205 ... def path(self):
@@ -251,7 +261,7 @@
251 ... class Recipe:261 ... class Recipe:
252 ... path = ''262 ... path = ''
253 ... def __init__(self, id, cookbook, dish, instructions,263 ... def __init__(self, id, cookbook, dish, instructions,
254 ... private=False):264 ... private=False, auth_required=False):
255 ... self.id = id265 ... self.id = id
256 ... self.cookbook = cookbook266 ... self.cookbook = cookbook
257 ... self.cookbook.recipes.append(self)267 ... self.cookbook.recipes.append(self)
@@ -260,6 +270,7 @@
260 ... self.instructions = instructions270 ... self.instructions = instructions
261 ... self.comments = []271 ... self.comments = []
262 ... self.private = private272 ... self.private = private
273 ... self.auth_required = auth_required
263 ... def delete(self):274 ... def delete(self):
264 ... self.cookbook.removeRecipe(self)275 ... self.cookbook.removeRecipe(self)
265 ... self.dish.removeRecipe(self)276 ... self.dish.removeRecipe(self)
@@ -324,11 +335,18 @@
324 >>> C3_D1 = Recipe(3, C3, D1, u"A perfectly roasted chicken is...")335 >>> C3_D1 = Recipe(3, C3, D1, u"A perfectly roasted chicken is...")
325336
326 >>> D2 = Dish("Baked beans")337 >>> D2 = Dish("Baked beans")
327 >>> C2_D2 = Recipe(4, C2, D2, "Preheat oven to...")338 >>> C1_D2 = Recipe(4, C1, D2, "57 varieties...", auth_required=True)
328 >>> C3_D2 = Recipe(5, C3, D2, "Without doubt the most famous...", True)339 >>> C2_D2 = Recipe(5, C2, D2, "Preheat oven to...")
340 >>> C3_D2 = Recipe(
341 ... 6, C3, D2, "Without doubt the most famous...",
342 ... private=True, auth_required=True)
329343
330 >>> D3 = Dish("Foies de voilaille en aspic")344 >>> D3 = Dish("Foies de voilaille en aspic")
331 >>> C1_D3 = Recipe(6, C1, D3, "Chicken livers sauteed in butter...")345 >>> C1_D3 = Recipe(
346 ... 7, C1, D3, "Chicken livers sauteed in butter...",
347 ... auth_required=True)
348 >>> C2_D3 = Recipe(
349 ... 8, C2, D3, "More chicken livers...", auth_required=True)
332350
333 >>> COM1 = Comment(C2_D1, "Clear and concise.")351 >>> COM1 = Comment(C2_D1, "Clear and concise.")
334 >>> COM2 = Comment(C2, "A kitchen staple.")352 >>> COM2 = Comment(C2, "A kitchen staple.")
@@ -379,7 +397,9 @@
379 ... raise ValueError("No matches for %s" % name)397 ... raise ValueError("No matches for %s" % name)
380 ... return matches398 ... return matches
381399
400 >>> from zope.security.protectclass import protectName
382 >>> protect_schema(CookbookSet, ICookbookSet)401 >>> protect_schema(CookbookSet, ICookbookSet)
402 >>> protectName(CookbookSet, 'newCookbook', 'zope.Edit')
383 >>> sm.registerUtility(CookbookSet(), ICookbookSet)403 >>> sm.registerUtility(CookbookSet(), ICookbookSet)
384404
385Here's a simple AuthorSet with predefined authors.405Here's a simple AuthorSet with predefined authors.
@@ -460,15 +480,24 @@
460 >>> from zope.security.management import setSecurityPolicy480 >>> from zope.security.management import setSecurityPolicy
461 >>> from zope.security.simplepolicies import PermissiveSecurityPolicy481 >>> from zope.security.simplepolicies import PermissiveSecurityPolicy
462 >>> from zope.security.proxy import removeSecurityProxy482 >>> from zope.security.proxy import removeSecurityProxy
483 >>> from lazr.restful.interfaces import (
484 ... IInteractionWithUnauthenticatedCheck,
485 ... )
463486
464 >>> sm.registerUtility(Permission('zope.View'), name='zope.View')487 >>> sm.registerUtility(Permission('zope.View'), name='zope.View')
465488
466 >>> class SimpleSecurityPolicy(PermissiveSecurityPolicy):489 >>> @implementer(IInteractionWithUnauthenticatedCheck)
490 ... class SimpleSecurityPolicy(PermissiveSecurityPolicy):
467 ... def checkPermission(self, permission, object):491 ... def checkPermission(self, permission, object):
468 ... if IRecipe.providedBy(object):492 ... if IRecipe.providedBy(object):
469 ... return not removeSecurityProxy(object).private493 ... return not removeSecurityProxy(object).private
470 ... else:494 ... else:
471 ... return True495 ... return True
496 ... def checkUnauthenticatedPermission(self, permission, object):
497 ... if IRecipe.providedBy(object) or ICookbook.providedBy(object):
498 ... return not removeSecurityProxy(object).auth_required
499 ... else:
500 ... return permission == 'zope.View'
472501
473 >>> setSecurityPolicy(SimpleSecurityPolicy)502 >>> setSecurityPolicy(SimpleSecurityPolicy)
474 <class ...>503 <class ...>
@@ -980,8 +1009,10 @@
980 ... return_type = Reference(schema=IRecipe)1009 ... return_type = Reference(schema=IRecipe)
981 ...1010 ...
982 ... def call(self, author_name, title, cuisine):1011 ... def call(self, author_name, title, cuisine):
983 ... cookbook = CookbookSet().newCookbook(1012 ... method = self.caveat_checker.getattr(
984 ... author_name, title, cuisine)1013 ... self.context, 'newCookbook')
1014 ... cookbook = method(author_name, title, cuisine)
1015 ... self.caveat_checker.enforceCaveats(cookbook)
985 ... self.request.response.setStatus(201)1016 ... self.request.response.setStatus(201)
986 ... self.request.response.setHeader(1017 ... self.request.response.setHeader(
987 ... "Location", absoluteURL(cookbook, self.request))1018 ... "Location", absoluteURL(cookbook, self.request))
@@ -1829,6 +1860,153 @@
1829 >>> print put_request.response.getStatus()1860 >>> print put_request.response.getStatus()
1830 4011861 401
18311862
1863Caveats
1864=======
1865
1866Resource visibility may also be restricted by caveats attached to a
1867principal. We define an example caveat which is only satisfied by
1868non-recipes or by recipes for a particular set of dishes.
1869
1870 >>> from lazr.restful.testing.webservice import (
1871 ... FakeCaveat,
1872 ... FakePrincipal,
1873 ... )
1874
1875 >>> class DishCaveat(FakeCaveat):
1876 ... def __init__(self, argument):
1877 ... super(DishCaveat, self).__init__("dish", argument)
1878 ... def verify(self, permission, object, attribute=None):
1879 ... if not IRecipe.providedBy(object):
1880 ... return True
1881 ... return object.dish.name in self.argument
1882
1883A request without caveats (which our simple security policy will normally
1884pretend is authenticated) can see recipes regardless of whether they require
1885authentication.
1886
1887 >>> public_recipe_url = quote(
1888 ... "/beta/cookbooks/Mastering the Art of French Cooking/recipes/"
1889 ... "Roast chicken")
1890 >>> auth_required_recipe_urls = [
1891 ... quote(
1892 ... "/beta/cookbooks/Mastering the Art of French Cooking/recipes/"
1893 ... "Baked beans"),
1894 ... quote(
1895 ... "/beta/cookbooks/The Joy of Cooking/recipes/"
1896 ... "Foies de voilaille en aspic"),
1897 ... ]
1898 >>> for url in [public_recipe_url] + auth_required_recipe_urls:
1899 ... get_request = create_web_service_request(public_recipe_url)
1900 ... recipe_resource = get_request.traverse(app)
1901
1902A request with a caveat restricting it to a particular dish can still see
1903the public recipe and the one that requires authentication and satisfies the
1904caveat, but not the one that requires authentication and does not satisfy
1905the caveat.
1906
1907 >>> principal = FakePrincipal()
1908 >>> principal.caveats.append(DishCaveat(["Baked beans"]))
1909 >>> for url in [public_recipe_url, auth_required_recipe_urls[0]]:
1910 ... get_request = create_web_service_request(url, principal=principal)
1911 ... recipe_resource = get_request.traverse(app)
1912 >>> get_request = create_web_service_request(
1913 ... auth_required_recipe_urls[1], principal=principal)
1914 >>> recipe_resource = get_request.traverse(app)
1915 >>> print recipe_resource()
1916 (<Recipe object...>, u'zope.View')
1917
1918Links are redacted if they fail to satisfy caveats.
1919
1920 >>> child_url = quote('/beta/authors/Julia Child')
1921 >>> get_request = create_web_service_request(
1922 ... child_url, principal=principal)
1923 >>> author_resource = get_request.traverse(app)
1924 >>> author = load_json(author_resource())
1925 >>> author['name']
1926 u'Julia Child'
1927 >>> author['favorite_recipe_link']
1928 u'.../The%20Joy%20of%20Cooking/recipes/Baked%20beans'
1929
1930 >>> A1.favorite_recipe = C1_D3
1931 >>> get_request = create_web_service_request(
1932 ... child_url, principal=principal)
1933 >>> author_resource = get_request.traverse(app)
1934 >>> author = load_json(author_resource())
1935 >>> author['name']
1936 u'Julia Child'
1937 >>> author['favorite_recipe_link']
1938 u'tag:launchpad.net:2008:redacted'
1939
1940When setting an attribute value, the new value must satisfy caveats.
1941
1942 >>> A1.favorite_recipe = C1_D1
1943 >>> get_request = create_web_service_request(
1944 ... child_url, principal=principal)
1945 >>> author_resource = get_request.traverse(app)
1946 >>> author = load_json(author_resource())
1947 >>> author['favorite_recipe_link'] = (
1948 ... 'http://api.cookbooks.dev' + auth_required_recipe_urls[0])
1949 >>> body = simplejson.dumps(author)
1950 >>> put_request = create_web_service_request(
1951 ... child_url, body=body, environ=headers, method='PUT',
1952 ... principal=principal)
1953 >>> put_request.traverse(app)()
1954 '{..."favorite_recipe_link": ".../Baked%20beans"...}'
1955 >>> put_request.response.getStatus()
1956 209
1957
1958 >>> get_request = create_web_service_request(
1959 ... child_url, principal=principal)
1960 >>> author_resource = get_request.traverse(app)
1961 >>> author = load_json(author_resource())
1962 >>> author['favorite_recipe_link'] = (
1963 ... 'http://api.cookbooks.dev' + auth_required_recipe_urls[1])
1964 >>> body = simplejson.dumps(author)
1965 >>> put_request = create_web_service_request(
1966 ... child_url, body=body, environ=headers, method='PUT',
1967 ... principal=principal)
1968 >>> print put_request.traverse(app)()
1969 (<Recipe object...>, u'zope.View')
1970 >>> print put_request.response.getStatus()
1971 401
1972
1973Caveats can restrict named POST operations.
1974
1975 >>> class CookbookCaveat(FakeCaveat):
1976 ... def __init__(self, argument):
1977 ... super(CookbookCaveat, self).__init__("cookbook", argument)
1978 ... def verify(self, permission, object, attribute=None):
1979 ... if not ICookbook.providedBy(object):
1980 ... return True
1981 ... return object.name in self.argument
1982
1983 >>> principal.caveats = [CookbookCaveat(['Good name'])]
1984
1985 >>> body = ("ws.op=create_cookbook&title=Good%20name&"
1986 ... "author_name=James%20Beard")
1987 >>> request = create_web_service_request(
1988 ... '/beta/cookbooks', 'POST', body,
1989 ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'},
1990 ... principal=principal)
1991 >>> operation_resource = request.traverse(app)
1992 >>> result = operation_resource()
1993 >>> request.response.getStatus()
1994 201
1995 >>> request.response.getHeader('Location')
1996 'http://api.cookbooks.dev/beta/cookbooks/Good%20name'
1997
1998 >>> body = ("ws.op=create_cookbook&title=Bad%20name&"
1999 ... "author_name=James%20Beard")
2000 >>> request = create_web_service_request(
2001 ... '/beta/cookbooks', 'POST', body,
2002 ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'},
2003 ... principal=principal)
2004 >>> operation_resource = request.traverse(app)
2005 >>> print operation_resource()
2006 (<Cookbook object...>, u'zope.View')
2007 >>> request.response.getStatus()
2008 401
2009
1832Stored file resources2010Stored file resources
1833=====================2011=====================
18342012
18352013
=== modified file 'src/lazr/restful/interfaces/_rest.py'
--- src/lazr/restful/interfaces/_rest.py 2011-03-31 01:13:59 +0000
+++ src/lazr/restful/interfaces/_rest.py 2016-03-02 13:25:07 +0000
@@ -22,6 +22,8 @@
22__all__ = [22__all__ = [
23 'IByteStorage',23 'IByteStorage',
24 'IByteStorageResource',24 'IByteStorageResource',
25 'ICaveat',
26 'ICaveatChecker',
25 'ICollection',27 'ICollection',
26 'ICollectionResource',28 'ICollectionResource',
27 'IEntry',29 'IEntry',
@@ -32,9 +34,11 @@
32 'IFieldMarshaller',34 'IFieldMarshaller',
33 'IFileLibrarian',35 'IFileLibrarian',
34 'IHTTPResource',36 'IHTTPResource',
37 'IInteractionWithUnauthenticatedCheck',
35 'INotificationsProvider',38 'INotificationsProvider',
36 'IJSONPublishable',39 'IJSONPublishable',
37 'IJSONRequestCache',40 'IJSONRequestCache',
41 'IPrincipalWithCaveats',
38 'IRepresentationCache',42 'IRepresentationCache',
39 'IResourceOperation',43 'IResourceOperation',
40 'IResourceGETOperation',44 'IResourceGETOperation',
@@ -57,6 +61,7 @@
57 ]61 ]
5862
59from textwrap import dedent63from textwrap import dedent
64from zope.authentication.interfaces import IPrincipal
60from zope.schema import (65from zope.schema import (
61 Bool,66 Bool,
62 Dict,67 Dict,
@@ -78,6 +83,7 @@
78 IBrowserRequest,83 IBrowserRequest,
79 IDefaultBrowserLayer,84 IDefaultBrowserLayer,
80 )85 )
86from zope.security.interfaces import IInteraction
81from lazr.batchnavigator.interfaces import InvalidBatchSizeError87from lazr.batchnavigator.interfaces import InvalidBatchSizeError
8288
83# Constants for periods of time89# Constants for periods of time
@@ -329,6 +335,126 @@
329 """335 """
330 pass336 pass
331337
338
339class ICaveat(Interface):
340 """A caveat that restricts the scope of a principal.
341
342 Caveats extend standard Zope security checks with finer-grained
343 restrictions, but they are typically only applied at the request
344 boundary.
345
346 For example, a webservice method call checks that caveats permit read or
347 write access (depending on its declaration) to the method on the context
348 object and read access to all method parameters; while setting an
349 attribute checks that caveats permit write access to the attribute on
350 the context object and read access to the new value. In either case,
351 caveats are not checked for any internal operations performed by the
352 method call or the attribute setter.
353 """
354
355 condition = TextLine(
356 title=u"Condition", description=u"The condition part of this caveat.")
357 argument = TextLine(
358 title=u"Argument", description=u"The argument part of this caveat.")
359
360 text = TextLine(title=u"A serialised form of this caveat.")
361
362 def verify(permission, object, attribute=None):
363 """Return whether a caveat is satisfied for permission on object.
364
365 :param permission: A permission name.
366 :param object: The object being accessed according to the permission.
367 :param attribute: The attribute of the object being accessed, if
368 any. Since caveats may specify particular method and attribute
369 names that may be accessed, a full caveat check may require this
370 information in addition to the permission name (which is
371 sufficient for other kinds of permission checks on an object).
372 Callers that are merely checking a principal's authorization to
373 view an object rather than to call a method or view/change an
374 attribute on it do not need to pass an attribute name.
375 """
376
377
378class IPrincipalWithCaveats(IPrincipal):
379 """A principal that may have some `ICaveat`s attached to it."""
380
381 caveats = Attribute(
382 "List of `ICaveat`s restricting the scope of this principal.")
383
384
385class ICaveatChecker(Interface):
386 """A class that checks whether objects satisfy caveats in a request."""
387
388 def checkCaveats(object, permission=None, attribute=None):
389 """Check whether `object` satisfies caveats.
390
391 :param object: The object to check.
392 :param permission: The Zope permission indicating the kind of
393 operation being performed. If None, check for view permission
394 according to the webservice configuration.
395 :param attribute: If not None, check caveats that apply to
396 reading/writing this attribute on `object`; if None, only check
397 caveats on `object` itself.
398 """
399
400 def enforceCaveats(object, permission=None, attribute=None):
401 """Raise `Unauthorized` if `object` does not satisfy caveats.
402
403 :param object: The object to check.
404 :param permission: The Zope permission indicating the kind of
405 operation being performed. If None, check for view permission
406 according to the webservice configuration.
407 :param attribute: If not None, check caveats that apply to
408 reading/writing this attribute on `object`; if None, only check
409 caveats on `object` itself.
410 """
411
412 def check_setattr(object, attribute):
413 """Check whether setting an object's attribute is allowed.
414
415 This checks both basic Zope security and caveats.
416
417 :param object: The object to check.
418 :param attribute: The attribute name to check.
419 """
420
421 def check_getattr(object, attribute):
422 """Check whether getting an object's attribute is allowed.
423
424 This checks both basic Zope security and caveats.
425
426 :param object: The object to check.
427 :param attribute: The attribute name to check.
428 """
429
430 def setattr(object, attribute, value):
431 """Set an object's attribute after checking that it is allowed.
432
433 :param object: The object whose attribute is to be set.
434 :param attribute: The attribute name to set.
435 :param value: The new attribute value.
436 """
437
438 def getattr(object, attribute):
439 """Get an object's attribute after checking that it is allowed.
440
441 :param object: The object whose attribute is to be got.
442 :param attribute: The attribute name to get.
443 """
444
445
446class IInteractionWithUnauthenticatedCheck(IInteraction):
447 """An interaction that can do a separate unauthenticated check."""
448
449 def checkUnauthenticatedPermission(permission, object):
450 """Return whether an unauthenticated user has permission on object.
451
452 :param permission: A permission name.
453 :param object: The object being accessed according to the
454 permission.
455 """
456
457
332class IJSONRequestCache(Interface):458class IJSONRequestCache(Interface):
333 """A cache of objects exposed as URLs or JSON representations."""459 """A cache of objects exposed as URLs or JSON representations."""
334460
335461
=== modified file 'src/lazr/restful/marshallers.py'
--- src/lazr/restful/marshallers.py 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/marshallers.py 2016-03-02 13:25:07 +0000
@@ -49,6 +49,7 @@
49 )49 )
5050
51from lazr.restful.interfaces import (51from lazr.restful.interfaces import (
52 ICaveatChecker,
52 IFieldMarshaller,53 IFieldMarshaller,
53 IUnmarshallingDoesntNeedValue,54 IUnmarshallingDoesntNeedValue,
54 IServiceRootResource,55 IServiceRootResource,
@@ -117,7 +118,7 @@
117 request = config.createRequest(StringIO(), {'PATH_INFO': path})118 request = config.createRequest(StringIO(), {'PATH_INFO': path})
118 request.setTraversalStack(path_parts)119 request.setTraversalStack(path_parts)
119 root = request.publication.getApplication(self.request)120 root = request.publication.getApplication(self.request)
120 return request.traverse(root)121 return self.caveat_checker.enforceCaveats(request.traverse(root))
121122
122123
123@implementer(IFieldMarshaller)124@implementer(IFieldMarshaller)
@@ -133,6 +134,7 @@
133 def __init__(self, field, request):134 def __init__(self, field, request):
134 self.field = field135 self.field = field
135 self.request = request136 self.request = request
137 self.caveat_checker = ICaveatChecker(request)
136138
137 def marshall_from_json_data(self, value):139 def marshall_from_json_data(self, value):
138 """See `IFieldMarshaller`.140 """See `IFieldMarshaller`.
@@ -142,7 +144,8 @@
142 """144 """
143 if value is None:145 if value is None:
144 return None146 return None
145 return self._marshall_from_json_data(value)147 return self.caveat_checker.enforceCaveats(
148 self._marshall_from_json_data(value))
146149
147 def marshall_from_request(self, value):150 def marshall_from_request(self, value):
148 """See `IFieldMarshaller`.151 """See `IFieldMarshaller`.
@@ -170,7 +173,8 @@
170 value = v[0]173 value = v[0]
171 if value is None:174 if value is None:
172 return None175 return None
173 return self._marshall_from_request(value)176 return self.caveat_checker.enforceCaveats(
177 self._marshall_from_request(value))
174178
175 def _marshall_from_request(self, value):179 def _marshall_from_request(self, value):
176 """Hook method to marshall a non-null JSON value.180 """Hook method to marshall a non-null JSON value.
@@ -264,6 +268,7 @@
264268
265 Marshall as a link to the byte storage resource.269 Marshall as a link to the byte storage resource.
266 """270 """
271 self.caveat_checker.enforceCaveats(entry.context)
267 return "%s/%s" % (absoluteURL(entry.context, self.request),272 return "%s/%s" % (absoluteURL(entry.context, self.request),
268 self.field.__name__)273 self.field.__name__)
269274
@@ -560,6 +565,7 @@
560565
561 This returns a link to the scoped collection.566 This returns a link to the scoped collection.
562 """567 """
568 self.caveat_checker.enforceCaveats(entry.context)
563 return "%s/%s" % (absoluteURL(entry.context, self.request),569 return "%s/%s" % (absoluteURL(entry.context, self.request),
564 self.field.__name__)570 self.field.__name__)
565571
@@ -628,6 +634,7 @@
628 """634 """
629 repr_value = None635 repr_value = None
630 if value is not None:636 if value is not None:
637 self.caveat_checker.enforceCaveats(value)
631 repr_value = absoluteURL(value, self.request)638 repr_value = absoluteURL(value, self.request)
632 return repr_value639 return repr_value
633640
634641
=== modified file 'src/lazr/restful/testing/webservice.py'
--- src/lazr/restful/testing/webservice.py 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/testing/webservice.py 2016-03-02 13:25:07 +0000
@@ -7,6 +7,8 @@
7 'create_web_service_request',7 'create_web_service_request',
8 'DummyAbsoluteURL',8 'DummyAbsoluteURL',
9 'DummyRootResourceURL',9 'DummyRootResourceURL',
10 'FakeCaveat',
11 'FakePrincipal',
10 'FakeRequest',12 'FakeRequest',
11 'FakeResponse',13 'FakeResponse',
12 'IGenericEntry',14 'IGenericEntry',
@@ -32,21 +34,29 @@
32 adapter, getGlobalSiteManager, getUtility)34 adapter, getGlobalSiteManager, getUtility)
33from zope.configuration import xmlconfig35from zope.configuration import xmlconfig
34from zope.interface import alsoProvides, implementer, Interface36from zope.interface import alsoProvides, implementer, Interface
37from zope.principalregistry.principalregistry import PrincipalBase
38from zope.proxy import ProxyBase
35from zope.publisher.interfaces.http import IHTTPApplicationRequest39from zope.publisher.interfaces.http import IHTTPApplicationRequest
36from zope.publisher.paste import Application40from zope.publisher.paste import Application
37from zope.proxy import ProxyBase
38from zope.schema import TextLine41from zope.schema import TextLine
39from zope.security.checker import ProxyFactory42from zope.security.checker import ProxyFactory
40from zope.testing.cleanup import CleanUp43from zope.testing.cleanup import CleanUp
41from zope.traversing.browser.interfaces import IAbsoluteURL44from zope.traversing.browser.interfaces import IAbsoluteURL
4245
46from lazr.restful.caveatchecker import CaveatChecker
43from lazr.restful.declarations import (47from lazr.restful.declarations import (
44 collection_default_content, exported,48 collection_default_content, exported,
45 export_as_webservice_collection, export_as_webservice_entry,49 export_as_webservice_collection, export_as_webservice_entry,
46 export_read_operation, operation_parameters)50 export_read_operation, operation_parameters)
47from lazr.restful.interfaces import (51from lazr.restful.interfaces import (
48 IServiceRootResource, IWebServiceClientRequest,52 ICaveat,
49 IWebServiceConfiguration, IWebServiceLayer, IWebServiceVersion)53 IPrincipalWithCaveats,
54 IServiceRootResource,
55 IWebServiceClientRequest,
56 IWebServiceConfiguration,
57 IWebServiceLayer,
58 IWebServiceVersion,
59 )
50from lazr.restful.simple import (60from lazr.restful.simple import (
51 BaseWebServiceConfiguration, Publication, Request,61 BaseWebServiceConfiguration, Publication, Request,
52 RootResourceAbsoluteURL, ServiceRootResource)62 RootResourceAbsoluteURL, ServiceRootResource)
@@ -54,7 +64,7 @@
5464
5565
56def create_web_service_request(path_info, method='GET', body=None,66def create_web_service_request(path_info, method='GET', body=None,
57 environ=None, hostname=None):67 environ=None, hostname=None, principal=None):
58 """Create a web service request object with the given parameters.68 """Create a web service request object with the given parameters.
5969
60 :param path_info: The path portion of the requested URL.70 :param path_info: The path portion of the requested URL.
@@ -81,6 +91,8 @@
81 test_environ['CONTENT_LENGTH'] = len(body)91 test_environ['CONTENT_LENGTH'] = len(body)
82 body_instream = StringIO(body)92 body_instream = StringIO(body)
83 request = config.createRequest(body_instream, test_environ)93 request = config.createRequest(body_instream, test_environ)
94 if principal is not None:
95 request.setPrincipal(principal)
8496
85 request.processInputs()97 request.processInputs()
86 # This sets the request as the current interaction.98 # This sets the request as the current interaction.
@@ -168,6 +180,28 @@
168 return default180 return default
169181
170182
183@implementer(ICaveat)
184class FakeCaveat:
185 """Simple caveat object for testing."""
186
187 def __init__(self, condition, argument):
188 self.condition = condition
189 self.argument = argument
190
191 def verify(self, permission, object, attribute=None):
192 return getattr(object, self.condition) in self.argument
193
194
195@implementer(IPrincipalWithCaveats)
196class FakePrincipal(PrincipalBase):
197 """Simple principal object for testing."""
198
199 def __init__(self):
200 super(FakePrincipal, self).__init__(
201 1, "fake-principal", "Fake principal")
202 self.caveats = []
203
204
171def pprint_entry(json_body):205def pprint_entry(json_body):
172 """Pretty-print a webservice entry JSON representation.206 """Pretty-print a webservice entry JSON representation.
173207
@@ -451,6 +485,7 @@
451 webservice_configuration = WebServiceTestConfiguration()485 webservice_configuration = WebServiceTestConfiguration()
452 sm = getGlobalSiteManager()486 sm = getGlobalSiteManager()
453 sm.registerUtility(webservice_configuration)487 sm.registerUtility(webservice_configuration)
488 sm.registerAdapter(CaveatChecker)
454489
455 # Register IWebServiceVersions for the490 # Register IWebServiceVersions for the
456 # '1.0' and '2.0' web service versions.491 # '1.0' and '2.0' web service versions.
457492
=== modified file 'src/lazr/restful/tests/test_etag.py'
--- src/lazr/restful/tests/test_etag.py 2016-02-17 00:49:58 +0000
+++ src/lazr/restful/tests/test_etag.py 2016-03-02 13:25:07 +0000
@@ -5,11 +5,18 @@
55
6import unittest6import unittest
77
8from zope.component import provideUtility8from zope.component import (
9 provideAdapter,
10 provideUtility,
11 )
912
13from lazr.restful.caveatchecker import CaveatChecker
10from lazr.restful.interfaces import IWebServiceConfiguration14from lazr.restful.interfaces import IWebServiceConfiguration
11from lazr.restful.testing.helpers import TestWebServiceConfiguration15from lazr.restful.testing.helpers import TestWebServiceConfiguration
12from lazr.restful.testing.webservice import create_web_service_request16from lazr.restful.testing.webservice import (
17 create_web_service_request,
18 FakeRequest,
19 )
13from lazr.restful._resource import (20from lazr.restful._resource import (
14 EntryFieldResource,21 EntryFieldResource,
15 HTTPResource,22 HTTPResource,
@@ -81,11 +88,17 @@
8188
82class TestHTTPResourceETags(unittest.TestCase):89class TestHTTPResourceETags(unittest.TestCase):
8390
91 def setUp(self):
92 self.config = TestWebServiceConfiguration()
93 provideUtility(self.config, IWebServiceConfiguration)
94 provideAdapter(CaveatChecker)
95
84 def test_getETag_is_a_noop(self):96 def test_getETag_is_a_noop(self):
85 # The HTTPResource class implements a do-nothing _getETagCores in order to97 # The HTTPResource class implements a do-nothing _getETagCores in order to
86 # be conservative (because it's not aware of the nature of all possible98 # be conservative (because it's not aware of the nature of all possible
87 # subclasses).99 # subclasses).
88 self.assertEquals(HTTPResource(None, None)._getETagCores(), None)100 self.assertEqual(
101 None, HTTPResource(None, FakeRequest())._getETagCores())
89102
90103
91class FauxEntryField:104class FauxEntryField:
@@ -105,7 +118,8 @@
105 def setUp(self):118 def setUp(self):
106 self.config = TestWebServiceConfiguration()119 self.config = TestWebServiceConfiguration()
107 provideUtility(self.config, IWebServiceConfiguration)120 provideUtility(self.config, IWebServiceConfiguration)
108 self.resource = EntryFieldResource(FauxEntryField(), None)121 provideAdapter(CaveatChecker)
122 self.resource = EntryFieldResource(FauxEntryField(), FakeRequest())
109123
110 def set_field_value(self, value):124 def set_field_value(self, value):
111 """Set the value of the fake field the EntryFieldResource references.125 """Set the value of the fake field the EntryFieldResource references.
@@ -162,6 +176,7 @@
162 def setUp(self):176 def setUp(self):
163 self.config = TestWebServiceConfiguration()177 self.config = TestWebServiceConfiguration()
164 provideUtility(self.config, IWebServiceConfiguration)178 provideUtility(self.config, IWebServiceConfiguration)
179 provideAdapter(CaveatChecker)
165 self.resource = ServiceRootResource()180 self.resource = ServiceRootResource()
166181
167 def test_cores_change_with_revno(self):182 def test_cores_change_with_revno(self):
@@ -195,6 +210,7 @@
195 def setUp(self):210 def setUp(self):
196 self.config = TestWebServiceConfiguration()211 self.config = TestWebServiceConfiguration()
197 provideUtility(self.config, IWebServiceConfiguration)212 provideUtility(self.config, IWebServiceConfiguration)
213 provideAdapter(CaveatChecker)
198 self.request = create_web_service_request('/1.0')214 self.request = create_web_service_request('/1.0')
199 self.resource = TestableHTTPResource(None, self.request)215 self.resource = TestableHTTPResource(None, self.request)
200216
@@ -222,6 +238,7 @@
222 def setUp(self):238 def setUp(self):
223 self.config = TestWebServiceConfiguration()239 self.config = TestWebServiceConfiguration()
224 provideUtility(self.config, IWebServiceConfiguration)240 provideUtility(self.config, IWebServiceConfiguration)
241 provideAdapter(CaveatChecker)
225 self.request = create_web_service_request('/1.0')242 self.request = create_web_service_request('/1.0')
226 self.resource = TestableHTTPResource(None, self.request)243 self.resource = TestableHTTPResource(None, self.request)
227244
228245
=== modified file 'src/lazr/restful/tests/test_webservice.py'
--- src/lazr/restful/tests/test_webservice.py 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/tests/test_webservice.py 2016-03-02 13:25:07 +0000
@@ -60,9 +60,11 @@
60from lazr.restful.testing.webservice import (60from lazr.restful.testing.webservice import (
61 create_web_service_request,61 create_web_service_request,
62 DummyAbsoluteURL,62 DummyAbsoluteURL,
63 FakeRequest,
63 IGenericCollection,64 IGenericCollection,
64 IGenericEntry,65 IGenericEntry,
65 simple_renderer,66 simple_renderer,
67 TestCaseWithWebServiceFixtures,
66 WebServiceTestCase,68 WebServiceTestCase,
67 )69 )
68from lazr.restful.testing.tales import test_tales70from lazr.restful.testing.tales import test_tales
@@ -111,7 +113,7 @@
111 return "wibble"113 return "wibble"
112114
113115
114class ResourceOperationTestCase(unittest.TestCase):116class ResourceOperationTestCase(TestCaseWithWebServiceFixtures):
115 """A test case for resource operations."""117 """A test case for resource operations."""
116118
117 def test_object_with_getitem_should_not_batch(self):119 def test_object_with_getitem_should_not_batch(self):
@@ -124,7 +126,7 @@
124 return_type = Reference(IHas_getitem)126 return_type = Reference(IHas_getitem)
125 result = Has_getitem()127 result = Has_getitem()
126128
127 operation = ResourceGETOperation("fake context", "fake request")129 operation = ResourceGETOperation("fake context", FakeRequest())
128 operation.return_type = return_type130 operation.return_type = return_type
129131
130 self.assertFalse(132 self.assertFalse(

Subscribers

People subscribed via source and target branches