Merge lp:~cjwatson/lazr.restful/scopes into lp:lazr.restful
- scopes
- Merge into trunk
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | 305 |
Proposed branch: | lp:~cjwatson/lazr.restful/scopes |
Merge into: | lp:lazr.restful |
Diff against target: |
619 lines (+377/-9) 8 files modified
NEWS.rst (+9/-0) src/lazr/restful/declarations.py (+62/-5) src/lazr/restful/docs/webservice-declarations.rst (+205/-2) src/lazr/restful/interfaces/_rest.py (+20/-0) src/lazr/restful/tales.py (+7/-1) src/lazr/restful/testing/webservice.py (+15/-0) src/lazr/restful/tests/test_declarations.py (+32/-0) src/lazr/restful/tests/test_webservice.py (+27/-1) |
To merge this branch: | bzr merge lp:~cjwatson/lazr.restful/scopes |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Ioana Lasc (community) | Approve | ||
Cristian Gonzalez (community) | Approve | ||
Review via email: mp+409735@code.launchpad.net |
Commit message
Add a new @scoped decorator.
Description of the change
This allows applications to tag methods with scope names and issue authentication tokens constrained to only be able to call methods with particular scopes. Scoped requests cannot currently use attributes, accessors, or mutators; this may change in future.
Launchpad will use this in conjunction with its new `AccessToken` table.
To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'NEWS.rst' |
2 | --- NEWS.rst 2021-09-13 15:23:15 +0000 |
3 | +++ NEWS.rst 2021-10-06 10:26:55 +0000 |
4 | @@ -2,6 +2,15 @@ |
5 | NEWS for lazr.restful |
6 | ===================== |
7 | |
8 | +1.1.0 |
9 | +===== |
10 | + |
11 | +- Add a new ``@scoped`` decorator to ``lazr.restful.declarations``, allowing |
12 | + applications to tag methods with scope names and issue authentication |
13 | + tokens constrained to only be able to call methods with particular scopes. |
14 | + Scoped requests cannot currently use attributes, accessors, or mutators; |
15 | + this may change in future. |
16 | + |
17 | 1.0.4 (2021-09-13) |
18 | ================== |
19 | |
20 | |
21 | === modified file 'src/lazr/restful/declarations.py' |
22 | --- src/lazr/restful/declarations.py 2021-02-16 16:51:35 +0000 |
23 | +++ src/lazr/restful/declarations.py 2021-10-06 10:26:55 +0000 |
24 | @@ -40,6 +40,7 @@ |
25 | 'operation_returns_entry', |
26 | 'operation_returns_collection_of', |
27 | 'rename_parameters_as', |
28 | + 'scoped', |
29 | 'webservice_error', |
30 | ] |
31 | |
32 | @@ -1059,6 +1060,28 @@ |
33 | annotations['cache_for'] = self.duration |
34 | |
35 | |
36 | +class scoped(_method_annotator): |
37 | + """Decorator assigning scopes to a method. |
38 | + |
39 | + This may be used to grant authentication tokens that are only valid for |
40 | + certain webservice operations. |
41 | + |
42 | + The decorator takes a collection of scope names as positional arguments. |
43 | + """ |
44 | + |
45 | + def __init__(self, *scopes): |
46 | + for scope in scopes: |
47 | + if not isinstance(scope, six.string_types): |
48 | + raise TypeError( |
49 | + 'Scope should be a string type, not %s' % |
50 | + scope.__class__.__name__) |
51 | + self.scopes = scopes |
52 | + |
53 | + def annotate_method(self, method, annotations): |
54 | + """See `_method_annotator`.""" |
55 | + annotations['scopes'] = list(self.scopes) |
56 | + |
57 | + |
58 | class export_read_operation(_export_operation): |
59 | """Decorator marking a method for export as a read operation.""" |
60 | type = 'read_operation' |
61 | @@ -1315,7 +1338,7 @@ |
62 | orig_name, 'context', accessor, |
63 | accessor_annotations, orig_iface) |
64 | else: |
65 | - prop = Passthrough(orig_name, 'context', orig_iface) |
66 | + prop = _ScopeChecker(orig_name, 'context', orig_iface) |
67 | |
68 | adapter_dict[tags['as']] = prop |
69 | |
70 | @@ -1359,11 +1382,40 @@ |
71 | return params |
72 | |
73 | |
74 | +def _check_request(context, required_scopes): |
75 | + """Check whether the current request may call a particular method. |
76 | + |
77 | + See `IWebServiceConfiguration.checkRequest`. |
78 | + """ |
79 | + check_request = getattr( |
80 | + getUtility(IWebServiceConfiguration), 'checkRequest', None) |
81 | + if check_request is not None: |
82 | + check_request(context, required_scopes) |
83 | + |
84 | + |
85 | +class _ScopeChecker(Passthrough): |
86 | + """Check scopes before allowing access to properties.""" |
87 | + |
88 | + def __get__(self, inst, cls=None): |
89 | + context = getattr(inst, self.contextvar) |
90 | + if self.adaptation is not None: |
91 | + context = self.adaptation(context) |
92 | + _check_request(context, None) |
93 | + return super(_ScopeChecker, self).__get__(inst, cls=cls) |
94 | + |
95 | + def __set__(self, inst, value): |
96 | + context = getattr(inst, self.contextvar) |
97 | + if self.adaptation is not None: |
98 | + context = self.adaptation(context) |
99 | + _check_request(context, None) |
100 | + return super(_ScopeChecker, self).__set__(inst, value) |
101 | + |
102 | + |
103 | class _AccessorWrapper: |
104 | """A wrapper class for properties with accessors. |
105 | |
106 | We define this separately from PropertyWithAccessor and |
107 | - PropertyWithAccessorAndMutator to avoid multple inheritance issues. |
108 | + PropertyWithAccessorAndMutator to avoid multiple inheritance issues. |
109 | """ |
110 | |
111 | def __get__(self, obj, *args): |
112 | @@ -1373,6 +1425,7 @@ |
113 | context = getattr(obj, self.contextvar) |
114 | if self.adaptation is not None: |
115 | context = self.adaptation(context) |
116 | + _check_request(context, None) |
117 | # Error checking code in accessor_for() guarantees that there |
118 | # is one and only one non-fixed parameter for the accessor |
119 | # method. |
120 | @@ -1383,7 +1436,7 @@ |
121 | """A wrapper class for properties with mutators. |
122 | |
123 | We define this separately from PropertyWithMutator and |
124 | - PropertyWithAccessorAndMutator to avoid multple inheritance issues. |
125 | + PropertyWithAccessorAndMutator to avoid multiple inheritance issues. |
126 | """ |
127 | |
128 | def __set__(self, obj, new_value): |
129 | @@ -1393,13 +1446,14 @@ |
130 | context = getattr(obj, self.contextvar) |
131 | if self.adaptation is not None: |
132 | context = self.adaptation(context) |
133 | + _check_request(context, None) |
134 | # Error checking code in mutator_for() guarantees that there |
135 | # is one and only one non-fixed parameter for the mutator |
136 | # method. |
137 | getattr(context, self.mutator)(new_value, **params) |
138 | |
139 | |
140 | -class PropertyWithAccessor(_AccessorWrapper, Passthrough): |
141 | +class PropertyWithAccessor(_AccessorWrapper, _ScopeChecker): |
142 | """A property with a accessor method.""" |
143 | |
144 | def __init__(self, name, context, accessor, accessor_annotations, |
145 | @@ -1409,7 +1463,7 @@ |
146 | self.accessor_annotations = accessor_annotations |
147 | |
148 | |
149 | -class PropertyWithMutator(_MutatorWrapper, Passthrough): |
150 | +class PropertyWithMutator(_MutatorWrapper, _ScopeChecker): |
151 | """A property with a mutator method.""" |
152 | |
153 | def __init__(self, name, context, mutator, mutator_annotations, |
154 | @@ -1542,6 +1596,7 @@ |
155 | 'Cache-control', 'max-age=%i' |
156 | % self._export_info['cache_for']) |
157 | |
158 | + _check_request(self.context, self._export_info.get('scopes', [])) |
159 | result = self._getMethod()(**params) |
160 | return self.encodeResult(result) |
161 | |
162 | @@ -1613,6 +1668,7 @@ |
163 | raise AssertionError('Unknown method export type: %s' % operation_type) |
164 | |
165 | return_type = match['return_type'] |
166 | + scopes = match.get('scopes') or [] |
167 | |
168 | name = _versioned_class_name( |
169 | '%s_%s_%s' % (prefix, method.interface.__name__, match['as']), |
170 | @@ -1620,6 +1676,7 @@ |
171 | class_dict = { |
172 | 'params': tuple(match['params'].values()), |
173 | 'return_type': return_type, |
174 | + 'scopes': tuple(scopes), |
175 | '_orig_iface': method.interface, |
176 | '_export_info': match, |
177 | '_method_name': method.__name__, |
178 | |
179 | === modified file 'src/lazr/restful/docs/webservice-declarations.rst' |
180 | --- src/lazr/restful/docs/webservice-declarations.rst 2021-02-16 16:51:35 +0000 |
181 | +++ src/lazr/restful/docs/webservice-declarations.rst 2021-10-06 10:26:55 +0000 |
182 | @@ -836,15 +836,32 @@ |
183 | utilities providing basic information about the web service. This one |
184 | is just a dummy. |
185 | |
186 | - >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration |
187 | >>> from zope.component import provideUtility |
188 | + >>> from zope.security.interfaces import Unauthorized |
189 | >>> from lazr.restful.interfaces import IWebServiceConfiguration |
190 | + >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration |
191 | >>> class MyWebServiceConfiguration(TestWebServiceConfiguration): |
192 | ... active_versions = ["beta", "1.0", "2.0", "3.0"] |
193 | ... last_version_with_mutator_named_operations = "1.0" |
194 | ... first_version_with_total_size_link = "2.0" |
195 | ... code_revision = "1.0b" |
196 | ... default_batch_size = 50 |
197 | + ... _scopes = None |
198 | + ... |
199 | + ... def checkRequest(self, obj, required_scopes): |
200 | + ... if self._scopes is not None: |
201 | + ... if not required_scopes: |
202 | + ... raise Unauthorized( |
203 | + ... 'Current authentication only allows calling ' |
204 | + ... 'scoped methods.') |
205 | + ... elif not any( |
206 | + ... scope in required_scopes |
207 | + ... for scope in self._scopes): |
208 | + ... raise Unauthorized( |
209 | + ... 'Current authentication does not allow calling ' |
210 | + ... 'this method (one of these scopes is required: ' |
211 | + ... '%s).' % ', '.join( |
212 | + ... "'%s'" % scope for scope in required_scopes)) |
213 | >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration) |
214 | |
215 | We must also set up the ability to create versioned requests. This web |
216 | @@ -1540,6 +1557,192 @@ |
217 | TypeError: A field can only have one mutator method for version |
218 | (earliest version); set_value_2 makes two. |
219 | |
220 | +Scopes |
221 | +------ |
222 | + |
223 | +A method can be tagged with a list of scope names. If the user has |
224 | +authenticated in such a way as to limit their access to particular scopes |
225 | +(indicated by `IWebServiceConfiguration.checkRequest()`), then they can only |
226 | +call methods that declare at least one of the corresponding scopes. |
227 | + |
228 | + >>> from lazr.restful.declarations import scoped |
229 | + >>> from zope.component import getUtility |
230 | + |
231 | + >>> @exported_as_webservice_entry() |
232 | + ... class IScopedEntry(Interface): |
233 | + ... |
234 | + ... value = exported(TextLine(readonly=False)) |
235 | + ... |
236 | + ... @scoped('read') |
237 | + ... @export_read_operation() |
238 | + ... def get_info(): |
239 | + ... pass |
240 | + ... |
241 | + ... @scoped('update') |
242 | + ... @export_write_operation() |
243 | + ... def do_update(): |
244 | + ... pass |
245 | + ... |
246 | + ... @scoped('read', 'update') |
247 | + ... @export_write_operation() |
248 | + ... def multiple_scopes(): |
249 | + ... pass |
250 | + ... |
251 | + ... @export_write_operation() |
252 | + ... def unscoped(): |
253 | + ... pass |
254 | + |
255 | + >>> @implementer(IScopedEntry) |
256 | + ... class ScopedEntry(object): |
257 | + ... |
258 | + ... value = 'initial' |
259 | + ... |
260 | + ... def get_info(self): |
261 | + ... print('get_info called') |
262 | + ... |
263 | + ... def do_update(self): |
264 | + ... print('do_update called') |
265 | + ... |
266 | + ... def multiple_scopes(self): |
267 | + ... print('multiple_scopes called') |
268 | + ... |
269 | + ... def unscoped(self): |
270 | + ... print('unscoped called') |
271 | + |
272 | + >>> [(version, scoped_entry_interface)] = generate_entry_interfaces( |
273 | + ... IScopedEntry, [], 'beta') |
274 | + >>> scoped_entry_adapter_factory = generate_entry_adapters( |
275 | + ... IScopedEntry, [], [(version, scoped_entry_interface)])[0].object |
276 | + |
277 | + >>> get_info_method_adapter_factory = generate_operation_adapter( |
278 | + ... IScopedEntry['get_info']) |
279 | + >>> IResourceGETOperation.implementedBy(get_info_method_adapter_factory) |
280 | + True |
281 | + >>> do_update_method_adapter_factory = generate_operation_adapter( |
282 | + ... IScopedEntry['do_update']) |
283 | + >>> IResourcePOSTOperation.implementedBy(do_update_method_adapter_factory) |
284 | + True |
285 | + >>> multiple_scopes_method_adapter_factory = generate_operation_adapter( |
286 | + ... IScopedEntry['multiple_scopes']) |
287 | + >>> IResourcePOSTOperation.implementedBy( |
288 | + ... multiple_scopes_method_adapter_factory) |
289 | + True |
290 | + >>> unscoped_method_adapter_factory = generate_operation_adapter( |
291 | + ... IScopedEntry['unscoped']) |
292 | + >>> IResourcePOSTOperation.implementedBy(unscoped_method_adapter_factory) |
293 | + True |
294 | + |
295 | + >>> obj = ScopedEntry() |
296 | + >>> request = FakeRequest(version='beta') |
297 | + >>> scoped_entry_adapter = scoped_entry_adapter_factory(obj, request) |
298 | + >>> get_info_method_adapter = ( |
299 | + ... get_info_method_adapter_factory(obj, request)) |
300 | + >>> do_update_method_adapter = ( |
301 | + ... do_update_method_adapter_factory(obj, request)) |
302 | + >>> multiple_scopes_method_adapter = ( |
303 | + ... multiple_scopes_method_adapter_factory(obj, request)) |
304 | + >>> unscoped_method_adapter = ( |
305 | + ... unscoped_method_adapter_factory(obj, request)) |
306 | + |
307 | +A user with unscoped authentication can call any method, and get or set |
308 | +attributes. |
309 | + |
310 | + >>> _ = get_info_method_adapter.call() |
311 | + get_info called |
312 | + >>> _ = do_update_method_adapter.call() |
313 | + do_update called |
314 | + >>> _ = multiple_scopes_method_adapter.call() |
315 | + multiple_scopes called |
316 | + >>> _ = unscoped_method_adapter.call() |
317 | + unscoped called |
318 | + >>> print(scoped_entry_adapter.value) |
319 | + initial |
320 | + >>> scoped_entry_adapter.value = 'set by unscoped user' |
321 | + |
322 | +A user with both scopes can call any method tagged with either scope, but |
323 | +can neither get nor set attributes. |
324 | + |
325 | + >>> config = getUtility(IWebServiceConfiguration) |
326 | + >>> config._scopes = ['read', 'update'] |
327 | + >>> _ = get_info_method_adapter.call() |
328 | + get_info called |
329 | + >>> _ = do_update_method_adapter.call() |
330 | + do_update called |
331 | + >>> _ = multiple_scopes_method_adapter.call() |
332 | + multiple_scopes called |
333 | + >>> _ = unscoped_method_adapter.call() |
334 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
335 | + Traceback (most recent call last): |
336 | + ... |
337 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
338 | + >>> print(scoped_entry_adapter.value) |
339 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
340 | + Traceback (most recent call last): |
341 | + ... |
342 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
343 | + >>> scoped_entry_adapter.value = 'set by scoped user' |
344 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
345 | + Traceback (most recent call last): |
346 | + ... |
347 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
348 | + |
349 | +A user with one scope can only call the methods tagged with that scope, and |
350 | +can neither get nor set attributes. |
351 | + |
352 | + >>> config._scopes = ['read'] |
353 | + >>> _ = get_info_method_adapter.call() |
354 | + get_info called |
355 | + >>> _ = do_update_method_adapter.call() |
356 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
357 | + Traceback (most recent call last): |
358 | + ... |
359 | + zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'update'). |
360 | + >>> _ = multiple_scopes_method_adapter.call() |
361 | + multiple_scopes called |
362 | + >>> _ = unscoped_method_adapter.call() |
363 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
364 | + Traceback (most recent call last): |
365 | + ... |
366 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
367 | + >>> print(scoped_entry_adapter.value) |
368 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
369 | + Traceback (most recent call last): |
370 | + ... |
371 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
372 | + >>> scoped_entry_adapter.value = 'set by scoped user' |
373 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
374 | + Traceback (most recent call last): |
375 | + ... |
376 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
377 | + |
378 | + >>> config._scopes = ['update'] |
379 | + >>> _ = get_info_method_adapter.call() |
380 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
381 | + Traceback (most recent call last): |
382 | + ... |
383 | + zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'read'). |
384 | + >>> _ = do_update_method_adapter.call() |
385 | + do_update called |
386 | + >>> _ = multiple_scopes_method_adapter.call() |
387 | + multiple_scopes called |
388 | + >>> _ = unscoped_method_adapter.call() |
389 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
390 | + Traceback (most recent call last): |
391 | + ... |
392 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
393 | + >>> print(scoped_entry_adapter.value) |
394 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
395 | + Traceback (most recent call last): |
396 | + ... |
397 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
398 | + >>> scoped_entry_adapter.value = 'set by scoped user' |
399 | + ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 |
400 | + Traceback (most recent call last): |
401 | + ... |
402 | + zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. |
403 | + |
404 | + >>> config._scopes = None |
405 | + |
406 | Read-only fields |
407 | ---------------- |
408 | |
409 | @@ -2593,7 +2796,7 @@ |
410 | IResourceOperation adapters named under the exported method names |
411 | are also available for IBookSetOnSteroids and IBookOnSteroids. |
412 | |
413 | - >>> from zope.component import getGlobalSiteManager, getUtility |
414 | + >>> from zope.component import getGlobalSiteManager |
415 | >>> adapter_registry = getGlobalSiteManager().adapters |
416 | |
417 | >>> from lazr.restful.interfaces import IWebServiceClientRequest |
418 | |
419 | === modified file 'src/lazr/restful/interfaces/_rest.py' |
420 | --- src/lazr/restful/interfaces/_rest.py 2020-02-04 11:52:59 +0000 |
421 | +++ src/lazr/restful/interfaces/_rest.py 2021-10-06 10:26:55 +0000 |
422 | @@ -652,6 +652,26 @@ |
423 | value will be fed back into your code. |
424 | """ |
425 | |
426 | + def checkRequest(context, required_scopes): |
427 | + """Check whether the current request may call a particular method. |
428 | + |
429 | + Authenticated users may be limited to certain scopes, in which case |
430 | + they will only be able to use methods with corresponding `@scoped` |
431 | + decorators. This method is called to check whether a call to a |
432 | + method on `context` tagged with `required_scopes` should be allowed. |
433 | + The return value is ignored; it only matters whether this raises a |
434 | + `zope.security.interfaces.Unauthorized` exception. |
435 | + |
436 | + For compatibility, if this method is unimplemented, it is treated as |
437 | + if it did not raise an exception. |
438 | + |
439 | + :param context: The context object. |
440 | + :param required_scopes: A list of scope names for this method, or |
441 | + None if the method is unscoped. |
442 | + :raises zope.security.interfaces.Unauthorized: if the call should |
443 | + not be allowed. |
444 | + """ |
445 | + |
446 | |
447 | class IUnmarshallingDoesntNeedValue(Interface): |
448 | """A marker interface for unmarshallers that work without values. |
449 | |
450 | === modified file 'src/lazr/restful/tales.py' |
451 | --- src/lazr/restful/tales.py 2020-07-22 23:22:26 +0000 |
452 | +++ src/lazr/restful/tales.py 2021-10-06 10:26:55 +0000 |
453 | @@ -804,7 +804,13 @@ |
454 | @property |
455 | def doc(self): |
456 | """Human-readable documentation for this operation.""" |
457 | - return generate_wadl_doc(self.operation.__doc__) |
458 | + docstring = self.operation.__doc__ |
459 | + # Hack scope information into the docstring for now. |
460 | + scopes = getattr(self.operation, 'scopes', None) |
461 | + if scopes: |
462 | + docstring += '\n\nScopes: %s\n' % ( |
463 | + ', '.join("``%s``" % scope for scope in scopes)) |
464 | + return generate_wadl_doc(docstring) |
465 | |
466 | @property |
467 | def has_return_type(self): |
468 | |
469 | === modified file 'src/lazr/restful/testing/webservice.py' |
470 | --- src/lazr/restful/testing/webservice.py 2021-05-20 20:44:54 +0000 |
471 | +++ src/lazr/restful/testing/webservice.py 2021-10-06 10:26:55 +0000 |
472 | @@ -47,6 +47,7 @@ |
473 | from zope.proxy import ProxyBase |
474 | from zope.schema import TextLine |
475 | from zope.security.checker import ProxyFactory |
476 | +from zope.security.interfaces import Unauthorized |
477 | from zope.testing.cleanup import CleanUp |
478 | from zope.traversing.browser.interfaces import IAbsoluteURL |
479 | |
480 | @@ -565,6 +566,7 @@ |
481 | active_versions = ['1.0', '2.0'] |
482 | hostname = "webservice_test" |
483 | last_version_with_mutator_named_operations = None |
484 | + _scopes = None |
485 | |
486 | def createRequest(self, body_instream, environ): |
487 | request = Request(body_instream, environ) |
488 | @@ -573,6 +575,19 @@ |
489 | tag_request_with_version_name(request, '2.0') |
490 | return request |
491 | |
492 | + def checkRequest(self, obj, required_scopes): |
493 | + if self._scopes is not None: |
494 | + if not required_scopes: |
495 | + raise Unauthorized( |
496 | + 'Current authentication only allows calling ' |
497 | + 'scoped methods.') |
498 | + elif not any(scope in required_scopes for scope in self._scopes): |
499 | + raise Unauthorized( |
500 | + 'Current authentication does not allow calling ' |
501 | + 'this method (one of these scopes is required: ' |
502 | + '%s).' |
503 | + % ', '.join("'%s'" % scope for scope in required_scopes)) |
504 | + |
505 | |
506 | class IWebServiceTestRequest10(IWebServiceClientRequest): |
507 | """A marker interface for requests to the '1.0' web service.""" |
508 | |
509 | === modified file 'src/lazr/restful/tests/test_declarations.py' |
510 | --- src/lazr/restful/tests/test_declarations.py 2020-07-22 23:22:26 +0000 |
511 | +++ src/lazr/restful/tests/test_declarations.py 2021-10-06 10:26:55 +0000 |
512 | @@ -28,6 +28,7 @@ |
513 | MultiChecker, |
514 | ProxyFactory, |
515 | ) |
516 | +from zope.security.interfaces import Unauthorized |
517 | from zope.security.management import ( |
518 | endInteraction, |
519 | newInteraction, |
520 | @@ -339,6 +340,37 @@ |
521 | self.assertEqual( |
522 | 'product', EntryAdapterUtility(adapter.__class__).singular_type) |
523 | |
524 | + def test_accessor_for_with_scopes(self): |
525 | + # Users with scopes cannot use accessors. |
526 | + self.product._branches = [ |
527 | + Branch('A branch'), Branch('Another branch')] |
528 | + register_test_module('testmod', IBranch, IProduct, IHasBranches) |
529 | + config = getUtility(IWebServiceConfiguration) |
530 | + config._scopes = ['scope'] |
531 | + self.addCleanup(setattr, config, '_scopes', None) |
532 | + adapter = getMultiAdapter( |
533 | + (self.product, self.one_zero_request), IEntry) |
534 | + exception = self.assertRaises( |
535 | + Unauthorized, getattr, adapter, 'branches') |
536 | + self.assertEqual( |
537 | + 'Current authentication only allows calling scoped methods.', |
538 | + str(exception)) |
539 | + |
540 | + def test_mutator_for_with_scopes(self): |
541 | + # Users with scopes cannot use mutators. |
542 | + self.product._dev_branch = Branch('A product branch') |
543 | + register_test_module('testmod', IBranch, IProduct, IHasBranches) |
544 | + config = getUtility(IWebServiceConfiguration) |
545 | + config._scopes = ['scope'] |
546 | + self.addCleanup(setattr, config, '_scopes', None) |
547 | + adapter = getMultiAdapter( |
548 | + (self.product, self.two_zero_request), IEntry) |
549 | + exception = self.assertRaises( |
550 | + Unauthorized, setattr, adapter, 'development_branch_20', None) |
551 | + self.assertEqual( |
552 | + 'Current authentication only allows calling scoped methods.', |
553 | + str(exception)) |
554 | + |
555 | |
556 | class TestExportAsWebserviceEntry(testtools.TestCase): |
557 | """Tests for export_as_webservice_entry.""" |
558 | |
559 | === modified file 'src/lazr/restful/tests/test_webservice.py' |
560 | --- src/lazr/restful/tests/test_webservice.py 2021-01-21 00:36:11 +0000 |
561 | +++ src/lazr/restful/tests/test_webservice.py 2021-10-06 10:26:55 +0000 |
562 | @@ -61,9 +61,11 @@ |
563 | ResourceGETOperation, |
564 | ) |
565 | from lazr.restful.declarations import ( |
566 | + export_read_operation, |
567 | exported, |
568 | exported_as_webservice_entry, |
569 | LAZR_WEBSERVICE_NAME, |
570 | + scoped, |
571 | ) |
572 | from lazr.restful.testing.webservice import ( |
573 | create_web_service_request, |
574 | @@ -667,13 +669,20 @@ |
575 | class WadlAPITestCase(WebServiceTestCase): |
576 | """Test the docstring generation.""" |
577 | |
578 | + @exported_as_webservice_entry() |
579 | + class IScopedEntry(Interface): |
580 | + @scoped('test-scope') |
581 | + @export_read_operation() |
582 | + def test(): |
583 | + """A method with a scope.""" |
584 | + |
585 | # This one is used to test when docstrings are missing. |
586 | @exported_as_webservice_entry() |
587 | class IUndocumentedEntry(Interface): |
588 | a_field = exported(TextLine()) |
589 | |
590 | testmodule_objects = [ |
591 | - IGenericEntry, IGenericCollection, IUndocumentedEntry] |
592 | + IGenericEntry, IGenericCollection, IScopedEntry, IUndocumentedEntry] |
593 | |
594 | def test_wadl_field_type(self): |
595 | """Test the generated XSD field types for various fields.""" |
596 | @@ -747,6 +756,23 @@ |
597 | self.assertTrue(len(doclines) > 3, |
598 | 'Missing the parameter table: %s' % "\n".join(doclines)) |
599 | |
600 | + def test_wadl_operation_with_scopes_doc(self): |
601 | + """Test the wadl:doc generated for an operation adapter.""" |
602 | + operation = get_operation_factory(self.IScopedEntry, 'test') |
603 | + doclines = test_tales( |
604 | + 'operation/wadl_operation:doc', operation=operation).splitlines() |
605 | + # Only compare the first three lines and the last one. |
606 | + # we dont care about the formatting of the parameters table. |
607 | + self.assertEqual([ |
608 | + '<wadl:doc xmlns="http://www.w3.org/1999/xhtml">', |
609 | + '<p>A method with a scope.</p>', |
610 | + '<p>Scopes: <tt class="rst-docutils literal"><span class="pre">' |
611 | + 'test-scope</span></tt></p>', |
612 | + ], doclines[0:3]) |
613 | + self.assertEqual('</wadl:doc>', doclines[-1]) |
614 | + self.assertTrue(len(doclines) > 3, |
615 | + 'Missing the parameter table: %s' % "\n".join(doclines)) |
616 | + |
617 | |
618 | class DuplicateNameTestCase(WebServiceTestCase): |
619 | """Test AssertionError when two resources expose the same name. |
Looks good. Also nice to see the documentation included. Good job!