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 | 2 | NEWS for lazr.restful | 2 | NEWS for lazr.restful |
6 | 3 | ===================== | 3 | ===================== |
7 | 4 | 4 | ||
8 | 5 | 1.1.0 | ||
9 | 6 | ===== | ||
10 | 7 | |||
11 | 8 | - Add a new ``@scoped`` decorator to ``lazr.restful.declarations``, allowing | ||
12 | 9 | applications to tag methods with scope names and issue authentication | ||
13 | 10 | tokens constrained to only be able to call methods with particular scopes. | ||
14 | 11 | Scoped requests cannot currently use attributes, accessors, or mutators; | ||
15 | 12 | this may change in future. | ||
16 | 13 | |||
17 | 5 | 1.0.4 (2021-09-13) | 14 | 1.0.4 (2021-09-13) |
18 | 6 | ================== | 15 | ================== |
19 | 7 | 16 | ||
20 | 8 | 17 | ||
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 | 40 | 'operation_returns_entry', | 40 | 'operation_returns_entry', |
26 | 41 | 'operation_returns_collection_of', | 41 | 'operation_returns_collection_of', |
27 | 42 | 'rename_parameters_as', | 42 | 'rename_parameters_as', |
28 | 43 | 'scoped', | ||
29 | 43 | 'webservice_error', | 44 | 'webservice_error', |
30 | 44 | ] | 45 | ] |
31 | 45 | 46 | ||
32 | @@ -1059,6 +1060,28 @@ | |||
33 | 1059 | annotations['cache_for'] = self.duration | 1060 | annotations['cache_for'] = self.duration |
34 | 1060 | 1061 | ||
35 | 1061 | 1062 | ||
36 | 1063 | class scoped(_method_annotator): | ||
37 | 1064 | """Decorator assigning scopes to a method. | ||
38 | 1065 | |||
39 | 1066 | This may be used to grant authentication tokens that are only valid for | ||
40 | 1067 | certain webservice operations. | ||
41 | 1068 | |||
42 | 1069 | The decorator takes a collection of scope names as positional arguments. | ||
43 | 1070 | """ | ||
44 | 1071 | |||
45 | 1072 | def __init__(self, *scopes): | ||
46 | 1073 | for scope in scopes: | ||
47 | 1074 | if not isinstance(scope, six.string_types): | ||
48 | 1075 | raise TypeError( | ||
49 | 1076 | 'Scope should be a string type, not %s' % | ||
50 | 1077 | scope.__class__.__name__) | ||
51 | 1078 | self.scopes = scopes | ||
52 | 1079 | |||
53 | 1080 | def annotate_method(self, method, annotations): | ||
54 | 1081 | """See `_method_annotator`.""" | ||
55 | 1082 | annotations['scopes'] = list(self.scopes) | ||
56 | 1083 | |||
57 | 1084 | |||
58 | 1062 | class export_read_operation(_export_operation): | 1085 | class export_read_operation(_export_operation): |
59 | 1063 | """Decorator marking a method for export as a read operation.""" | 1086 | """Decorator marking a method for export as a read operation.""" |
60 | 1064 | type = 'read_operation' | 1087 | type = 'read_operation' |
61 | @@ -1315,7 +1338,7 @@ | |||
62 | 1315 | orig_name, 'context', accessor, | 1338 | orig_name, 'context', accessor, |
63 | 1316 | accessor_annotations, orig_iface) | 1339 | accessor_annotations, orig_iface) |
64 | 1317 | else: | 1340 | else: |
66 | 1318 | prop = Passthrough(orig_name, 'context', orig_iface) | 1341 | prop = _ScopeChecker(orig_name, 'context', orig_iface) |
67 | 1319 | 1342 | ||
68 | 1320 | adapter_dict[tags['as']] = prop | 1343 | adapter_dict[tags['as']] = prop |
69 | 1321 | 1344 | ||
70 | @@ -1359,11 +1382,40 @@ | |||
71 | 1359 | return params | 1382 | return params |
72 | 1360 | 1383 | ||
73 | 1361 | 1384 | ||
74 | 1385 | def _check_request(context, required_scopes): | ||
75 | 1386 | """Check whether the current request may call a particular method. | ||
76 | 1387 | |||
77 | 1388 | See `IWebServiceConfiguration.checkRequest`. | ||
78 | 1389 | """ | ||
79 | 1390 | check_request = getattr( | ||
80 | 1391 | getUtility(IWebServiceConfiguration), 'checkRequest', None) | ||
81 | 1392 | if check_request is not None: | ||
82 | 1393 | check_request(context, required_scopes) | ||
83 | 1394 | |||
84 | 1395 | |||
85 | 1396 | class _ScopeChecker(Passthrough): | ||
86 | 1397 | """Check scopes before allowing access to properties.""" | ||
87 | 1398 | |||
88 | 1399 | def __get__(self, inst, cls=None): | ||
89 | 1400 | context = getattr(inst, self.contextvar) | ||
90 | 1401 | if self.adaptation is not None: | ||
91 | 1402 | context = self.adaptation(context) | ||
92 | 1403 | _check_request(context, None) | ||
93 | 1404 | return super(_ScopeChecker, self).__get__(inst, cls=cls) | ||
94 | 1405 | |||
95 | 1406 | def __set__(self, inst, value): | ||
96 | 1407 | context = getattr(inst, self.contextvar) | ||
97 | 1408 | if self.adaptation is not None: | ||
98 | 1409 | context = self.adaptation(context) | ||
99 | 1410 | _check_request(context, None) | ||
100 | 1411 | return super(_ScopeChecker, self).__set__(inst, value) | ||
101 | 1412 | |||
102 | 1413 | |||
103 | 1362 | class _AccessorWrapper: | 1414 | class _AccessorWrapper: |
104 | 1363 | """A wrapper class for properties with accessors. | 1415 | """A wrapper class for properties with accessors. |
105 | 1364 | 1416 | ||
106 | 1365 | We define this separately from PropertyWithAccessor and | 1417 | We define this separately from PropertyWithAccessor and |
108 | 1366 | PropertyWithAccessorAndMutator to avoid multple inheritance issues. | 1418 | PropertyWithAccessorAndMutator to avoid multiple inheritance issues. |
109 | 1367 | """ | 1419 | """ |
110 | 1368 | 1420 | ||
111 | 1369 | def __get__(self, obj, *args): | 1421 | def __get__(self, obj, *args): |
112 | @@ -1373,6 +1425,7 @@ | |||
113 | 1373 | context = getattr(obj, self.contextvar) | 1425 | context = getattr(obj, self.contextvar) |
114 | 1374 | if self.adaptation is not None: | 1426 | if self.adaptation is not None: |
115 | 1375 | context = self.adaptation(context) | 1427 | context = self.adaptation(context) |
116 | 1428 | _check_request(context, None) | ||
117 | 1376 | # Error checking code in accessor_for() guarantees that there | 1429 | # Error checking code in accessor_for() guarantees that there |
118 | 1377 | # is one and only one non-fixed parameter for the accessor | 1430 | # is one and only one non-fixed parameter for the accessor |
119 | 1378 | # method. | 1431 | # method. |
120 | @@ -1383,7 +1436,7 @@ | |||
121 | 1383 | """A wrapper class for properties with mutators. | 1436 | """A wrapper class for properties with mutators. |
122 | 1384 | 1437 | ||
123 | 1385 | We define this separately from PropertyWithMutator and | 1438 | We define this separately from PropertyWithMutator and |
125 | 1386 | PropertyWithAccessorAndMutator to avoid multple inheritance issues. | 1439 | PropertyWithAccessorAndMutator to avoid multiple inheritance issues. |
126 | 1387 | """ | 1440 | """ |
127 | 1388 | 1441 | ||
128 | 1389 | def __set__(self, obj, new_value): | 1442 | def __set__(self, obj, new_value): |
129 | @@ -1393,13 +1446,14 @@ | |||
130 | 1393 | context = getattr(obj, self.contextvar) | 1446 | context = getattr(obj, self.contextvar) |
131 | 1394 | if self.adaptation is not None: | 1447 | if self.adaptation is not None: |
132 | 1395 | context = self.adaptation(context) | 1448 | context = self.adaptation(context) |
133 | 1449 | _check_request(context, None) | ||
134 | 1396 | # Error checking code in mutator_for() guarantees that there | 1450 | # Error checking code in mutator_for() guarantees that there |
135 | 1397 | # is one and only one non-fixed parameter for the mutator | 1451 | # is one and only one non-fixed parameter for the mutator |
136 | 1398 | # method. | 1452 | # method. |
137 | 1399 | getattr(context, self.mutator)(new_value, **params) | 1453 | getattr(context, self.mutator)(new_value, **params) |
138 | 1400 | 1454 | ||
139 | 1401 | 1455 | ||
141 | 1402 | class PropertyWithAccessor(_AccessorWrapper, Passthrough): | 1456 | class PropertyWithAccessor(_AccessorWrapper, _ScopeChecker): |
142 | 1403 | """A property with a accessor method.""" | 1457 | """A property with a accessor method.""" |
143 | 1404 | 1458 | ||
144 | 1405 | def __init__(self, name, context, accessor, accessor_annotations, | 1459 | def __init__(self, name, context, accessor, accessor_annotations, |
145 | @@ -1409,7 +1463,7 @@ | |||
146 | 1409 | self.accessor_annotations = accessor_annotations | 1463 | self.accessor_annotations = accessor_annotations |
147 | 1410 | 1464 | ||
148 | 1411 | 1465 | ||
150 | 1412 | class PropertyWithMutator(_MutatorWrapper, Passthrough): | 1466 | class PropertyWithMutator(_MutatorWrapper, _ScopeChecker): |
151 | 1413 | """A property with a mutator method.""" | 1467 | """A property with a mutator method.""" |
152 | 1414 | 1468 | ||
153 | 1415 | def __init__(self, name, context, mutator, mutator_annotations, | 1469 | def __init__(self, name, context, mutator, mutator_annotations, |
154 | @@ -1542,6 +1596,7 @@ | |||
155 | 1542 | 'Cache-control', 'max-age=%i' | 1596 | 'Cache-control', 'max-age=%i' |
156 | 1543 | % self._export_info['cache_for']) | 1597 | % self._export_info['cache_for']) |
157 | 1544 | 1598 | ||
158 | 1599 | _check_request(self.context, self._export_info.get('scopes', [])) | ||
159 | 1545 | result = self._getMethod()(**params) | 1600 | result = self._getMethod()(**params) |
160 | 1546 | return self.encodeResult(result) | 1601 | return self.encodeResult(result) |
161 | 1547 | 1602 | ||
162 | @@ -1613,6 +1668,7 @@ | |||
163 | 1613 | raise AssertionError('Unknown method export type: %s' % operation_type) | 1668 | raise AssertionError('Unknown method export type: %s' % operation_type) |
164 | 1614 | 1669 | ||
165 | 1615 | return_type = match['return_type'] | 1670 | return_type = match['return_type'] |
166 | 1671 | scopes = match.get('scopes') or [] | ||
167 | 1616 | 1672 | ||
168 | 1617 | name = _versioned_class_name( | 1673 | name = _versioned_class_name( |
169 | 1618 | '%s_%s_%s' % (prefix, method.interface.__name__, match['as']), | 1674 | '%s_%s_%s' % (prefix, method.interface.__name__, match['as']), |
170 | @@ -1620,6 +1676,7 @@ | |||
171 | 1620 | class_dict = { | 1676 | class_dict = { |
172 | 1621 | 'params': tuple(match['params'].values()), | 1677 | 'params': tuple(match['params'].values()), |
173 | 1622 | 'return_type': return_type, | 1678 | 'return_type': return_type, |
174 | 1679 | 'scopes': tuple(scopes), | ||
175 | 1623 | '_orig_iface': method.interface, | 1680 | '_orig_iface': method.interface, |
176 | 1624 | '_export_info': match, | 1681 | '_export_info': match, |
177 | 1625 | '_method_name': method.__name__, | 1682 | '_method_name': method.__name__, |
178 | 1626 | 1683 | ||
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 | 836 | utilities providing basic information about the web service. This one | 836 | utilities providing basic information about the web service. This one |
184 | 837 | is just a dummy. | 837 | is just a dummy. |
185 | 838 | 838 | ||
186 | 839 | >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration | ||
187 | 840 | >>> from zope.component import provideUtility | 839 | >>> from zope.component import provideUtility |
188 | 840 | >>> from zope.security.interfaces import Unauthorized | ||
189 | 841 | >>> from lazr.restful.interfaces import IWebServiceConfiguration | 841 | >>> from lazr.restful.interfaces import IWebServiceConfiguration |
190 | 842 | >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration | ||
191 | 842 | >>> class MyWebServiceConfiguration(TestWebServiceConfiguration): | 843 | >>> class MyWebServiceConfiguration(TestWebServiceConfiguration): |
192 | 843 | ... active_versions = ["beta", "1.0", "2.0", "3.0"] | 844 | ... active_versions = ["beta", "1.0", "2.0", "3.0"] |
193 | 844 | ... last_version_with_mutator_named_operations = "1.0" | 845 | ... last_version_with_mutator_named_operations = "1.0" |
194 | 845 | ... first_version_with_total_size_link = "2.0" | 846 | ... first_version_with_total_size_link = "2.0" |
195 | 846 | ... code_revision = "1.0b" | 847 | ... code_revision = "1.0b" |
196 | 847 | ... default_batch_size = 50 | 848 | ... default_batch_size = 50 |
197 | 849 | ... _scopes = None | ||
198 | 850 | ... | ||
199 | 851 | ... def checkRequest(self, obj, required_scopes): | ||
200 | 852 | ... if self._scopes is not None: | ||
201 | 853 | ... if not required_scopes: | ||
202 | 854 | ... raise Unauthorized( | ||
203 | 855 | ... 'Current authentication only allows calling ' | ||
204 | 856 | ... 'scoped methods.') | ||
205 | 857 | ... elif not any( | ||
206 | 858 | ... scope in required_scopes | ||
207 | 859 | ... for scope in self._scopes): | ||
208 | 860 | ... raise Unauthorized( | ||
209 | 861 | ... 'Current authentication does not allow calling ' | ||
210 | 862 | ... 'this method (one of these scopes is required: ' | ||
211 | 863 | ... '%s).' % ', '.join( | ||
212 | 864 | ... "'%s'" % scope for scope in required_scopes)) | ||
213 | 848 | >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration) | 865 | >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration) |
214 | 849 | 866 | ||
215 | 850 | We must also set up the ability to create versioned requests. This web | 867 | We must also set up the ability to create versioned requests. This web |
216 | @@ -1540,6 +1557,192 @@ | |||
217 | 1540 | TypeError: A field can only have one mutator method for version | 1557 | TypeError: A field can only have one mutator method for version |
218 | 1541 | (earliest version); set_value_2 makes two. | 1558 | (earliest version); set_value_2 makes two. |
219 | 1542 | 1559 | ||
220 | 1560 | Scopes | ||
221 | 1561 | ------ | ||
222 | 1562 | |||
223 | 1563 | A method can be tagged with a list of scope names. If the user has | ||
224 | 1564 | authenticated in such a way as to limit their access to particular scopes | ||
225 | 1565 | (indicated by `IWebServiceConfiguration.checkRequest()`), then they can only | ||
226 | 1566 | call methods that declare at least one of the corresponding scopes. | ||
227 | 1567 | |||
228 | 1568 | >>> from lazr.restful.declarations import scoped | ||
229 | 1569 | >>> from zope.component import getUtility | ||
230 | 1570 | |||
231 | 1571 | >>> @exported_as_webservice_entry() | ||
232 | 1572 | ... class IScopedEntry(Interface): | ||
233 | 1573 | ... | ||
234 | 1574 | ... value = exported(TextLine(readonly=False)) | ||
235 | 1575 | ... | ||
236 | 1576 | ... @scoped('read') | ||
237 | 1577 | ... @export_read_operation() | ||
238 | 1578 | ... def get_info(): | ||
239 | 1579 | ... pass | ||
240 | 1580 | ... | ||
241 | 1581 | ... @scoped('update') | ||
242 | 1582 | ... @export_write_operation() | ||
243 | 1583 | ... def do_update(): | ||
244 | 1584 | ... pass | ||
245 | 1585 | ... | ||
246 | 1586 | ... @scoped('read', 'update') | ||
247 | 1587 | ... @export_write_operation() | ||
248 | 1588 | ... def multiple_scopes(): | ||
249 | 1589 | ... pass | ||
250 | 1590 | ... | ||
251 | 1591 | ... @export_write_operation() | ||
252 | 1592 | ... def unscoped(): | ||
253 | 1593 | ... pass | ||
254 | 1594 | |||
255 | 1595 | >>> @implementer(IScopedEntry) | ||
256 | 1596 | ... class ScopedEntry(object): | ||
257 | 1597 | ... | ||
258 | 1598 | ... value = 'initial' | ||
259 | 1599 | ... | ||
260 | 1600 | ... def get_info(self): | ||
261 | 1601 | ... print('get_info called') | ||
262 | 1602 | ... | ||
263 | 1603 | ... def do_update(self): | ||
264 | 1604 | ... print('do_update called') | ||
265 | 1605 | ... | ||
266 | 1606 | ... def multiple_scopes(self): | ||
267 | 1607 | ... print('multiple_scopes called') | ||
268 | 1608 | ... | ||
269 | 1609 | ... def unscoped(self): | ||
270 | 1610 | ... print('unscoped called') | ||
271 | 1611 | |||
272 | 1612 | >>> [(version, scoped_entry_interface)] = generate_entry_interfaces( | ||
273 | 1613 | ... IScopedEntry, [], 'beta') | ||
274 | 1614 | >>> scoped_entry_adapter_factory = generate_entry_adapters( | ||
275 | 1615 | ... IScopedEntry, [], [(version, scoped_entry_interface)])[0].object | ||
276 | 1616 | |||
277 | 1617 | >>> get_info_method_adapter_factory = generate_operation_adapter( | ||
278 | 1618 | ... IScopedEntry['get_info']) | ||
279 | 1619 | >>> IResourceGETOperation.implementedBy(get_info_method_adapter_factory) | ||
280 | 1620 | True | ||
281 | 1621 | >>> do_update_method_adapter_factory = generate_operation_adapter( | ||
282 | 1622 | ... IScopedEntry['do_update']) | ||
283 | 1623 | >>> IResourcePOSTOperation.implementedBy(do_update_method_adapter_factory) | ||
284 | 1624 | True | ||
285 | 1625 | >>> multiple_scopes_method_adapter_factory = generate_operation_adapter( | ||
286 | 1626 | ... IScopedEntry['multiple_scopes']) | ||
287 | 1627 | >>> IResourcePOSTOperation.implementedBy( | ||
288 | 1628 | ... multiple_scopes_method_adapter_factory) | ||
289 | 1629 | True | ||
290 | 1630 | >>> unscoped_method_adapter_factory = generate_operation_adapter( | ||
291 | 1631 | ... IScopedEntry['unscoped']) | ||
292 | 1632 | >>> IResourcePOSTOperation.implementedBy(unscoped_method_adapter_factory) | ||
293 | 1633 | True | ||
294 | 1634 | |||
295 | 1635 | >>> obj = ScopedEntry() | ||
296 | 1636 | >>> request = FakeRequest(version='beta') | ||
297 | 1637 | >>> scoped_entry_adapter = scoped_entry_adapter_factory(obj, request) | ||
298 | 1638 | >>> get_info_method_adapter = ( | ||
299 | 1639 | ... get_info_method_adapter_factory(obj, request)) | ||
300 | 1640 | >>> do_update_method_adapter = ( | ||
301 | 1641 | ... do_update_method_adapter_factory(obj, request)) | ||
302 | 1642 | >>> multiple_scopes_method_adapter = ( | ||
303 | 1643 | ... multiple_scopes_method_adapter_factory(obj, request)) | ||
304 | 1644 | >>> unscoped_method_adapter = ( | ||
305 | 1645 | ... unscoped_method_adapter_factory(obj, request)) | ||
306 | 1646 | |||
307 | 1647 | A user with unscoped authentication can call any method, and get or set | ||
308 | 1648 | attributes. | ||
309 | 1649 | |||
310 | 1650 | >>> _ = get_info_method_adapter.call() | ||
311 | 1651 | get_info called | ||
312 | 1652 | >>> _ = do_update_method_adapter.call() | ||
313 | 1653 | do_update called | ||
314 | 1654 | >>> _ = multiple_scopes_method_adapter.call() | ||
315 | 1655 | multiple_scopes called | ||
316 | 1656 | >>> _ = unscoped_method_adapter.call() | ||
317 | 1657 | unscoped called | ||
318 | 1658 | >>> print(scoped_entry_adapter.value) | ||
319 | 1659 | initial | ||
320 | 1660 | >>> scoped_entry_adapter.value = 'set by unscoped user' | ||
321 | 1661 | |||
322 | 1662 | A user with both scopes can call any method tagged with either scope, but | ||
323 | 1663 | can neither get nor set attributes. | ||
324 | 1664 | |||
325 | 1665 | >>> config = getUtility(IWebServiceConfiguration) | ||
326 | 1666 | >>> config._scopes = ['read', 'update'] | ||
327 | 1667 | >>> _ = get_info_method_adapter.call() | ||
328 | 1668 | get_info called | ||
329 | 1669 | >>> _ = do_update_method_adapter.call() | ||
330 | 1670 | do_update called | ||
331 | 1671 | >>> _ = multiple_scopes_method_adapter.call() | ||
332 | 1672 | multiple_scopes called | ||
333 | 1673 | >>> _ = unscoped_method_adapter.call() | ||
334 | 1674 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
335 | 1675 | Traceback (most recent call last): | ||
336 | 1676 | ... | ||
337 | 1677 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
338 | 1678 | >>> print(scoped_entry_adapter.value) | ||
339 | 1679 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
340 | 1680 | Traceback (most recent call last): | ||
341 | 1681 | ... | ||
342 | 1682 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
343 | 1683 | >>> scoped_entry_adapter.value = 'set by scoped user' | ||
344 | 1684 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
345 | 1685 | Traceback (most recent call last): | ||
346 | 1686 | ... | ||
347 | 1687 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
348 | 1688 | |||
349 | 1689 | A user with one scope can only call the methods tagged with that scope, and | ||
350 | 1690 | can neither get nor set attributes. | ||
351 | 1691 | |||
352 | 1692 | >>> config._scopes = ['read'] | ||
353 | 1693 | >>> _ = get_info_method_adapter.call() | ||
354 | 1694 | get_info called | ||
355 | 1695 | >>> _ = do_update_method_adapter.call() | ||
356 | 1696 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
357 | 1697 | Traceback (most recent call last): | ||
358 | 1698 | ... | ||
359 | 1699 | zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'update'). | ||
360 | 1700 | >>> _ = multiple_scopes_method_adapter.call() | ||
361 | 1701 | multiple_scopes called | ||
362 | 1702 | >>> _ = unscoped_method_adapter.call() | ||
363 | 1703 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
364 | 1704 | Traceback (most recent call last): | ||
365 | 1705 | ... | ||
366 | 1706 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
367 | 1707 | >>> print(scoped_entry_adapter.value) | ||
368 | 1708 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
369 | 1709 | Traceback (most recent call last): | ||
370 | 1710 | ... | ||
371 | 1711 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
372 | 1712 | >>> scoped_entry_adapter.value = 'set by scoped user' | ||
373 | 1713 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
374 | 1714 | Traceback (most recent call last): | ||
375 | 1715 | ... | ||
376 | 1716 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
377 | 1717 | |||
378 | 1718 | >>> config._scopes = ['update'] | ||
379 | 1719 | >>> _ = get_info_method_adapter.call() | ||
380 | 1720 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
381 | 1721 | Traceback (most recent call last): | ||
382 | 1722 | ... | ||
383 | 1723 | zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'read'). | ||
384 | 1724 | >>> _ = do_update_method_adapter.call() | ||
385 | 1725 | do_update called | ||
386 | 1726 | >>> _ = multiple_scopes_method_adapter.call() | ||
387 | 1727 | multiple_scopes called | ||
388 | 1728 | >>> _ = unscoped_method_adapter.call() | ||
389 | 1729 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
390 | 1730 | Traceback (most recent call last): | ||
391 | 1731 | ... | ||
392 | 1732 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
393 | 1733 | >>> print(scoped_entry_adapter.value) | ||
394 | 1734 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
395 | 1735 | Traceback (most recent call last): | ||
396 | 1736 | ... | ||
397 | 1737 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
398 | 1738 | >>> scoped_entry_adapter.value = 'set by scoped user' | ||
399 | 1739 | ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2 | ||
400 | 1740 | Traceback (most recent call last): | ||
401 | 1741 | ... | ||
402 | 1742 | zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods. | ||
403 | 1743 | |||
404 | 1744 | >>> config._scopes = None | ||
405 | 1745 | |||
406 | 1543 | Read-only fields | 1746 | Read-only fields |
407 | 1544 | ---------------- | 1747 | ---------------- |
408 | 1545 | 1748 | ||
409 | @@ -2593,7 +2796,7 @@ | |||
410 | 2593 | IResourceOperation adapters named under the exported method names | 2796 | IResourceOperation adapters named under the exported method names |
411 | 2594 | are also available for IBookSetOnSteroids and IBookOnSteroids. | 2797 | are also available for IBookSetOnSteroids and IBookOnSteroids. |
412 | 2595 | 2798 | ||
414 | 2596 | >>> from zope.component import getGlobalSiteManager, getUtility | 2799 | >>> from zope.component import getGlobalSiteManager |
415 | 2597 | >>> adapter_registry = getGlobalSiteManager().adapters | 2800 | >>> adapter_registry = getGlobalSiteManager().adapters |
416 | 2598 | 2801 | ||
417 | 2599 | >>> from lazr.restful.interfaces import IWebServiceClientRequest | 2802 | >>> from lazr.restful.interfaces import IWebServiceClientRequest |
418 | 2600 | 2803 | ||
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 | 652 | value will be fed back into your code. | 652 | value will be fed back into your code. |
424 | 653 | """ | 653 | """ |
425 | 654 | 654 | ||
426 | 655 | def checkRequest(context, required_scopes): | ||
427 | 656 | """Check whether the current request may call a particular method. | ||
428 | 657 | |||
429 | 658 | Authenticated users may be limited to certain scopes, in which case | ||
430 | 659 | they will only be able to use methods with corresponding `@scoped` | ||
431 | 660 | decorators. This method is called to check whether a call to a | ||
432 | 661 | method on `context` tagged with `required_scopes` should be allowed. | ||
433 | 662 | The return value is ignored; it only matters whether this raises a | ||
434 | 663 | `zope.security.interfaces.Unauthorized` exception. | ||
435 | 664 | |||
436 | 665 | For compatibility, if this method is unimplemented, it is treated as | ||
437 | 666 | if it did not raise an exception. | ||
438 | 667 | |||
439 | 668 | :param context: The context object. | ||
440 | 669 | :param required_scopes: A list of scope names for this method, or | ||
441 | 670 | None if the method is unscoped. | ||
442 | 671 | :raises zope.security.interfaces.Unauthorized: if the call should | ||
443 | 672 | not be allowed. | ||
444 | 673 | """ | ||
445 | 674 | |||
446 | 655 | 675 | ||
447 | 656 | class IUnmarshallingDoesntNeedValue(Interface): | 676 | class IUnmarshallingDoesntNeedValue(Interface): |
448 | 657 | """A marker interface for unmarshallers that work without values. | 677 | """A marker interface for unmarshallers that work without values. |
449 | 658 | 678 | ||
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 | 804 | @property | 804 | @property |
455 | 805 | def doc(self): | 805 | def doc(self): |
456 | 806 | """Human-readable documentation for this operation.""" | 806 | """Human-readable documentation for this operation.""" |
458 | 807 | return generate_wadl_doc(self.operation.__doc__) | 807 | docstring = self.operation.__doc__ |
459 | 808 | # Hack scope information into the docstring for now. | ||
460 | 809 | scopes = getattr(self.operation, 'scopes', None) | ||
461 | 810 | if scopes: | ||
462 | 811 | docstring += '\n\nScopes: %s\n' % ( | ||
463 | 812 | ', '.join("``%s``" % scope for scope in scopes)) | ||
464 | 813 | return generate_wadl_doc(docstring) | ||
465 | 808 | 814 | ||
466 | 809 | @property | 815 | @property |
467 | 810 | def has_return_type(self): | 816 | def has_return_type(self): |
468 | 811 | 817 | ||
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 | 47 | from zope.proxy import ProxyBase | 47 | from zope.proxy import ProxyBase |
474 | 48 | from zope.schema import TextLine | 48 | from zope.schema import TextLine |
475 | 49 | from zope.security.checker import ProxyFactory | 49 | from zope.security.checker import ProxyFactory |
476 | 50 | from zope.security.interfaces import Unauthorized | ||
477 | 50 | from zope.testing.cleanup import CleanUp | 51 | from zope.testing.cleanup import CleanUp |
478 | 51 | from zope.traversing.browser.interfaces import IAbsoluteURL | 52 | from zope.traversing.browser.interfaces import IAbsoluteURL |
479 | 52 | 53 | ||
480 | @@ -565,6 +566,7 @@ | |||
481 | 565 | active_versions = ['1.0', '2.0'] | 566 | active_versions = ['1.0', '2.0'] |
482 | 566 | hostname = "webservice_test" | 567 | hostname = "webservice_test" |
483 | 567 | last_version_with_mutator_named_operations = None | 568 | last_version_with_mutator_named_operations = None |
484 | 569 | _scopes = None | ||
485 | 568 | 570 | ||
486 | 569 | def createRequest(self, body_instream, environ): | 571 | def createRequest(self, body_instream, environ): |
487 | 570 | request = Request(body_instream, environ) | 572 | request = Request(body_instream, environ) |
488 | @@ -573,6 +575,19 @@ | |||
489 | 573 | tag_request_with_version_name(request, '2.0') | 575 | tag_request_with_version_name(request, '2.0') |
490 | 574 | return request | 576 | return request |
491 | 575 | 577 | ||
492 | 578 | def checkRequest(self, obj, required_scopes): | ||
493 | 579 | if self._scopes is not None: | ||
494 | 580 | if not required_scopes: | ||
495 | 581 | raise Unauthorized( | ||
496 | 582 | 'Current authentication only allows calling ' | ||
497 | 583 | 'scoped methods.') | ||
498 | 584 | elif not any(scope in required_scopes for scope in self._scopes): | ||
499 | 585 | raise Unauthorized( | ||
500 | 586 | 'Current authentication does not allow calling ' | ||
501 | 587 | 'this method (one of these scopes is required: ' | ||
502 | 588 | '%s).' | ||
503 | 589 | % ', '.join("'%s'" % scope for scope in required_scopes)) | ||
504 | 590 | |||
505 | 576 | 591 | ||
506 | 577 | class IWebServiceTestRequest10(IWebServiceClientRequest): | 592 | class IWebServiceTestRequest10(IWebServiceClientRequest): |
507 | 578 | """A marker interface for requests to the '1.0' web service.""" | 593 | """A marker interface for requests to the '1.0' web service.""" |
508 | 579 | 594 | ||
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 | 28 | MultiChecker, | 28 | MultiChecker, |
514 | 29 | ProxyFactory, | 29 | ProxyFactory, |
515 | 30 | ) | 30 | ) |
516 | 31 | from zope.security.interfaces import Unauthorized | ||
517 | 31 | from zope.security.management import ( | 32 | from zope.security.management import ( |
518 | 32 | endInteraction, | 33 | endInteraction, |
519 | 33 | newInteraction, | 34 | newInteraction, |
520 | @@ -339,6 +340,37 @@ | |||
521 | 339 | self.assertEqual( | 340 | self.assertEqual( |
522 | 340 | 'product', EntryAdapterUtility(adapter.__class__).singular_type) | 341 | 'product', EntryAdapterUtility(adapter.__class__).singular_type) |
523 | 341 | 342 | ||
524 | 343 | def test_accessor_for_with_scopes(self): | ||
525 | 344 | # Users with scopes cannot use accessors. | ||
526 | 345 | self.product._branches = [ | ||
527 | 346 | Branch('A branch'), Branch('Another branch')] | ||
528 | 347 | register_test_module('testmod', IBranch, IProduct, IHasBranches) | ||
529 | 348 | config = getUtility(IWebServiceConfiguration) | ||
530 | 349 | config._scopes = ['scope'] | ||
531 | 350 | self.addCleanup(setattr, config, '_scopes', None) | ||
532 | 351 | adapter = getMultiAdapter( | ||
533 | 352 | (self.product, self.one_zero_request), IEntry) | ||
534 | 353 | exception = self.assertRaises( | ||
535 | 354 | Unauthorized, getattr, adapter, 'branches') | ||
536 | 355 | self.assertEqual( | ||
537 | 356 | 'Current authentication only allows calling scoped methods.', | ||
538 | 357 | str(exception)) | ||
539 | 358 | |||
540 | 359 | def test_mutator_for_with_scopes(self): | ||
541 | 360 | # Users with scopes cannot use mutators. | ||
542 | 361 | self.product._dev_branch = Branch('A product branch') | ||
543 | 362 | register_test_module('testmod', IBranch, IProduct, IHasBranches) | ||
544 | 363 | config = getUtility(IWebServiceConfiguration) | ||
545 | 364 | config._scopes = ['scope'] | ||
546 | 365 | self.addCleanup(setattr, config, '_scopes', None) | ||
547 | 366 | adapter = getMultiAdapter( | ||
548 | 367 | (self.product, self.two_zero_request), IEntry) | ||
549 | 368 | exception = self.assertRaises( | ||
550 | 369 | Unauthorized, setattr, adapter, 'development_branch_20', None) | ||
551 | 370 | self.assertEqual( | ||
552 | 371 | 'Current authentication only allows calling scoped methods.', | ||
553 | 372 | str(exception)) | ||
554 | 373 | |||
555 | 342 | 374 | ||
556 | 343 | class TestExportAsWebserviceEntry(testtools.TestCase): | 375 | class TestExportAsWebserviceEntry(testtools.TestCase): |
557 | 344 | """Tests for export_as_webservice_entry.""" | 376 | """Tests for export_as_webservice_entry.""" |
558 | 345 | 377 | ||
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 | 61 | ResourceGETOperation, | 61 | ResourceGETOperation, |
564 | 62 | ) | 62 | ) |
565 | 63 | from lazr.restful.declarations import ( | 63 | from lazr.restful.declarations import ( |
566 | 64 | export_read_operation, | ||
567 | 64 | exported, | 65 | exported, |
568 | 65 | exported_as_webservice_entry, | 66 | exported_as_webservice_entry, |
569 | 66 | LAZR_WEBSERVICE_NAME, | 67 | LAZR_WEBSERVICE_NAME, |
570 | 68 | scoped, | ||
571 | 67 | ) | 69 | ) |
572 | 68 | from lazr.restful.testing.webservice import ( | 70 | from lazr.restful.testing.webservice import ( |
573 | 69 | create_web_service_request, | 71 | create_web_service_request, |
574 | @@ -667,13 +669,20 @@ | |||
575 | 667 | class WadlAPITestCase(WebServiceTestCase): | 669 | class WadlAPITestCase(WebServiceTestCase): |
576 | 668 | """Test the docstring generation.""" | 670 | """Test the docstring generation.""" |
577 | 669 | 671 | ||
578 | 672 | @exported_as_webservice_entry() | ||
579 | 673 | class IScopedEntry(Interface): | ||
580 | 674 | @scoped('test-scope') | ||
581 | 675 | @export_read_operation() | ||
582 | 676 | def test(): | ||
583 | 677 | """A method with a scope.""" | ||
584 | 678 | |||
585 | 670 | # This one is used to test when docstrings are missing. | 679 | # This one is used to test when docstrings are missing. |
586 | 671 | @exported_as_webservice_entry() | 680 | @exported_as_webservice_entry() |
587 | 672 | class IUndocumentedEntry(Interface): | 681 | class IUndocumentedEntry(Interface): |
588 | 673 | a_field = exported(TextLine()) | 682 | a_field = exported(TextLine()) |
589 | 674 | 683 | ||
590 | 675 | testmodule_objects = [ | 684 | testmodule_objects = [ |
592 | 676 | IGenericEntry, IGenericCollection, IUndocumentedEntry] | 685 | IGenericEntry, IGenericCollection, IScopedEntry, IUndocumentedEntry] |
593 | 677 | 686 | ||
594 | 678 | def test_wadl_field_type(self): | 687 | def test_wadl_field_type(self): |
595 | 679 | """Test the generated XSD field types for various fields.""" | 688 | """Test the generated XSD field types for various fields.""" |
596 | @@ -747,6 +756,23 @@ | |||
597 | 747 | self.assertTrue(len(doclines) > 3, | 756 | self.assertTrue(len(doclines) > 3, |
598 | 748 | 'Missing the parameter table: %s' % "\n".join(doclines)) | 757 | 'Missing the parameter table: %s' % "\n".join(doclines)) |
599 | 749 | 758 | ||
600 | 759 | def test_wadl_operation_with_scopes_doc(self): | ||
601 | 760 | """Test the wadl:doc generated for an operation adapter.""" | ||
602 | 761 | operation = get_operation_factory(self.IScopedEntry, 'test') | ||
603 | 762 | doclines = test_tales( | ||
604 | 763 | 'operation/wadl_operation:doc', operation=operation).splitlines() | ||
605 | 764 | # Only compare the first three lines and the last one. | ||
606 | 765 | # we dont care about the formatting of the parameters table. | ||
607 | 766 | self.assertEqual([ | ||
608 | 767 | '<wadl:doc xmlns="http://www.w3.org/1999/xhtml">', | ||
609 | 768 | '<p>A method with a scope.</p>', | ||
610 | 769 | '<p>Scopes: <tt class="rst-docutils literal"><span class="pre">' | ||
611 | 770 | 'test-scope</span></tt></p>', | ||
612 | 771 | ], doclines[0:3]) | ||
613 | 772 | self.assertEqual('</wadl:doc>', doclines[-1]) | ||
614 | 773 | self.assertTrue(len(doclines) > 3, | ||
615 | 774 | 'Missing the parameter table: %s' % "\n".join(doclines)) | ||
616 | 775 | |||
617 | 750 | 776 | ||
618 | 751 | class DuplicateNameTestCase(WebServiceTestCase): | 777 | class DuplicateNameTestCase(WebServiceTestCase): |
619 | 752 | """Test AssertionError when two resources expose the same name. | 778 | """Test AssertionError when two resources expose the same name. |
Looks good. Also nice to see the documentation included. Good job!