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

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
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
Cristian Gonzalez (cristiangsp) wrote :

Looks good. Also nice to see the documentation included. Good job!

review: Approve
Revision history for this message
Ioana Lasc (ilasc) wrote :

LGTM!

review: Approve
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
=== modified file 'NEWS.rst'
--- NEWS.rst 2021-09-13 15:23:15 +0000
+++ NEWS.rst 2021-10-06 10:26:55 +0000
@@ -2,6 +2,15 @@
2NEWS for lazr.restful2NEWS for lazr.restful
3=====================3=====================
44
51.1.0
6=====
7
8- Add a new ``@scoped`` decorator to ``lazr.restful.declarations``, allowing
9 applications to tag methods with scope names and issue authentication
10 tokens constrained to only be able to call methods with particular scopes.
11 Scoped requests cannot currently use attributes, accessors, or mutators;
12 this may change in future.
13
51.0.4 (2021-09-13)141.0.4 (2021-09-13)
6==================15==================
716
817
=== modified file 'src/lazr/restful/declarations.py'
--- src/lazr/restful/declarations.py 2021-02-16 16:51:35 +0000
+++ src/lazr/restful/declarations.py 2021-10-06 10:26:55 +0000
@@ -40,6 +40,7 @@
40 'operation_returns_entry',40 'operation_returns_entry',
41 'operation_returns_collection_of',41 'operation_returns_collection_of',
42 'rename_parameters_as',42 'rename_parameters_as',
43 'scoped',
43 'webservice_error',44 'webservice_error',
44 ]45 ]
4546
@@ -1059,6 +1060,28 @@
1059 annotations['cache_for'] = self.duration1060 annotations['cache_for'] = self.duration
10601061
10611062
1063class scoped(_method_annotator):
1064 """Decorator assigning scopes to a method.
1065
1066 This may be used to grant authentication tokens that are only valid for
1067 certain webservice operations.
1068
1069 The decorator takes a collection of scope names as positional arguments.
1070 """
1071
1072 def __init__(self, *scopes):
1073 for scope in scopes:
1074 if not isinstance(scope, six.string_types):
1075 raise TypeError(
1076 'Scope should be a string type, not %s' %
1077 scope.__class__.__name__)
1078 self.scopes = scopes
1079
1080 def annotate_method(self, method, annotations):
1081 """See `_method_annotator`."""
1082 annotations['scopes'] = list(self.scopes)
1083
1084
1062class export_read_operation(_export_operation):1085class export_read_operation(_export_operation):
1063 """Decorator marking a method for export as a read operation."""1086 """Decorator marking a method for export as a read operation."""
1064 type = 'read_operation'1087 type = 'read_operation'
@@ -1315,7 +1338,7 @@
1315 orig_name, 'context', accessor,1338 orig_name, 'context', accessor,
1316 accessor_annotations, orig_iface)1339 accessor_annotations, orig_iface)
1317 else:1340 else:
1318 prop = Passthrough(orig_name, 'context', orig_iface)1341 prop = _ScopeChecker(orig_name, 'context', orig_iface)
13191342
1320 adapter_dict[tags['as']] = prop1343 adapter_dict[tags['as']] = prop
13211344
@@ -1359,11 +1382,40 @@
1359 return params1382 return params
13601383
13611384
1385def _check_request(context, required_scopes):
1386 """Check whether the current request may call a particular method.
1387
1388 See `IWebServiceConfiguration.checkRequest`.
1389 """
1390 check_request = getattr(
1391 getUtility(IWebServiceConfiguration), 'checkRequest', None)
1392 if check_request is not None:
1393 check_request(context, required_scopes)
1394
1395
1396class _ScopeChecker(Passthrough):
1397 """Check scopes before allowing access to properties."""
1398
1399 def __get__(self, inst, cls=None):
1400 context = getattr(inst, self.contextvar)
1401 if self.adaptation is not None:
1402 context = self.adaptation(context)
1403 _check_request(context, None)
1404 return super(_ScopeChecker, self).__get__(inst, cls=cls)
1405
1406 def __set__(self, inst, value):
1407 context = getattr(inst, self.contextvar)
1408 if self.adaptation is not None:
1409 context = self.adaptation(context)
1410 _check_request(context, None)
1411 return super(_ScopeChecker, self).__set__(inst, value)
1412
1413
1362class _AccessorWrapper:1414class _AccessorWrapper:
1363 """A wrapper class for properties with accessors.1415 """A wrapper class for properties with accessors.
13641416
1365 We define this separately from PropertyWithAccessor and1417 We define this separately from PropertyWithAccessor and
1366 PropertyWithAccessorAndMutator to avoid multple inheritance issues.1418 PropertyWithAccessorAndMutator to avoid multiple inheritance issues.
1367 """1419 """
13681420
1369 def __get__(self, obj, *args):1421 def __get__(self, obj, *args):
@@ -1373,6 +1425,7 @@
1373 context = getattr(obj, self.contextvar)1425 context = getattr(obj, self.contextvar)
1374 if self.adaptation is not None:1426 if self.adaptation is not None:
1375 context = self.adaptation(context)1427 context = self.adaptation(context)
1428 _check_request(context, None)
1376 # Error checking code in accessor_for() guarantees that there1429 # Error checking code in accessor_for() guarantees that there
1377 # is one and only one non-fixed parameter for the accessor1430 # is one and only one non-fixed parameter for the accessor
1378 # method.1431 # method.
@@ -1383,7 +1436,7 @@
1383 """A wrapper class for properties with mutators.1436 """A wrapper class for properties with mutators.
13841437
1385 We define this separately from PropertyWithMutator and1438 We define this separately from PropertyWithMutator and
1386 PropertyWithAccessorAndMutator to avoid multple inheritance issues.1439 PropertyWithAccessorAndMutator to avoid multiple inheritance issues.
1387 """1440 """
13881441
1389 def __set__(self, obj, new_value):1442 def __set__(self, obj, new_value):
@@ -1393,13 +1446,14 @@
1393 context = getattr(obj, self.contextvar)1446 context = getattr(obj, self.contextvar)
1394 if self.adaptation is not None:1447 if self.adaptation is not None:
1395 context = self.adaptation(context)1448 context = self.adaptation(context)
1449 _check_request(context, None)
1396 # Error checking code in mutator_for() guarantees that there1450 # Error checking code in mutator_for() guarantees that there
1397 # is one and only one non-fixed parameter for the mutator1451 # is one and only one non-fixed parameter for the mutator
1398 # method.1452 # method.
1399 getattr(context, self.mutator)(new_value, **params)1453 getattr(context, self.mutator)(new_value, **params)
14001454
14011455
1402class PropertyWithAccessor(_AccessorWrapper, Passthrough):1456class PropertyWithAccessor(_AccessorWrapper, _ScopeChecker):
1403 """A property with a accessor method."""1457 """A property with a accessor method."""
14041458
1405 def __init__(self, name, context, accessor, accessor_annotations,1459 def __init__(self, name, context, accessor, accessor_annotations,
@@ -1409,7 +1463,7 @@
1409 self.accessor_annotations = accessor_annotations1463 self.accessor_annotations = accessor_annotations
14101464
14111465
1412class PropertyWithMutator(_MutatorWrapper, Passthrough):1466class PropertyWithMutator(_MutatorWrapper, _ScopeChecker):
1413 """A property with a mutator method."""1467 """A property with a mutator method."""
14141468
1415 def __init__(self, name, context, mutator, mutator_annotations,1469 def __init__(self, name, context, mutator, mutator_annotations,
@@ -1542,6 +1596,7 @@
1542 'Cache-control', 'max-age=%i'1596 'Cache-control', 'max-age=%i'
1543 % self._export_info['cache_for'])1597 % self._export_info['cache_for'])
15441598
1599 _check_request(self.context, self._export_info.get('scopes', []))
1545 result = self._getMethod()(**params)1600 result = self._getMethod()(**params)
1546 return self.encodeResult(result)1601 return self.encodeResult(result)
15471602
@@ -1613,6 +1668,7 @@
1613 raise AssertionError('Unknown method export type: %s' % operation_type)1668 raise AssertionError('Unknown method export type: %s' % operation_type)
16141669
1615 return_type = match['return_type']1670 return_type = match['return_type']
1671 scopes = match.get('scopes') or []
16161672
1617 name = _versioned_class_name(1673 name = _versioned_class_name(
1618 '%s_%s_%s' % (prefix, method.interface.__name__, match['as']),1674 '%s_%s_%s' % (prefix, method.interface.__name__, match['as']),
@@ -1620,6 +1676,7 @@
1620 class_dict = {1676 class_dict = {
1621 'params': tuple(match['params'].values()),1677 'params': tuple(match['params'].values()),
1622 'return_type': return_type,1678 'return_type': return_type,
1679 'scopes': tuple(scopes),
1623 '_orig_iface': method.interface,1680 '_orig_iface': method.interface,
1624 '_export_info': match,1681 '_export_info': match,
1625 '_method_name': method.__name__,1682 '_method_name': method.__name__,
16261683
=== modified file 'src/lazr/restful/docs/webservice-declarations.rst'
--- src/lazr/restful/docs/webservice-declarations.rst 2021-02-16 16:51:35 +0000
+++ src/lazr/restful/docs/webservice-declarations.rst 2021-10-06 10:26:55 +0000
@@ -836,15 +836,32 @@
836utilities providing basic information about the web service. This one836utilities providing basic information about the web service. This one
837is just a dummy.837is just a dummy.
838838
839 >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration
840 >>> from zope.component import provideUtility839 >>> from zope.component import provideUtility
840 >>> from zope.security.interfaces import Unauthorized
841 >>> from lazr.restful.interfaces import IWebServiceConfiguration841 >>> from lazr.restful.interfaces import IWebServiceConfiguration
842 >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration
842 >>> class MyWebServiceConfiguration(TestWebServiceConfiguration):843 >>> class MyWebServiceConfiguration(TestWebServiceConfiguration):
843 ... active_versions = ["beta", "1.0", "2.0", "3.0"]844 ... active_versions = ["beta", "1.0", "2.0", "3.0"]
844 ... last_version_with_mutator_named_operations = "1.0"845 ... last_version_with_mutator_named_operations = "1.0"
845 ... first_version_with_total_size_link = "2.0"846 ... first_version_with_total_size_link = "2.0"
846 ... code_revision = "1.0b"847 ... code_revision = "1.0b"
847 ... default_batch_size = 50848 ... default_batch_size = 50
849 ... _scopes = None
850 ...
851 ... def checkRequest(self, obj, required_scopes):
852 ... if self._scopes is not None:
853 ... if not required_scopes:
854 ... raise Unauthorized(
855 ... 'Current authentication only allows calling '
856 ... 'scoped methods.')
857 ... elif not any(
858 ... scope in required_scopes
859 ... for scope in self._scopes):
860 ... raise Unauthorized(
861 ... 'Current authentication does not allow calling '
862 ... 'this method (one of these scopes is required: '
863 ... '%s).' % ', '.join(
864 ... "'%s'" % scope for scope in required_scopes))
848 >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration)865 >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration)
849866
850We must also set up the ability to create versioned requests. This web867We must also set up the ability to create versioned requests. This web
@@ -1540,6 +1557,192 @@
1540 TypeError: A field can only have one mutator method for version1557 TypeError: A field can only have one mutator method for version
1541 (earliest version); set_value_2 makes two.1558 (earliest version); set_value_2 makes two.
15421559
1560Scopes
1561------
1562
1563A method can be tagged with a list of scope names. If the user has
1564authenticated in such a way as to limit their access to particular scopes
1565(indicated by `IWebServiceConfiguration.checkRequest()`), then they can only
1566call methods that declare at least one of the corresponding scopes.
1567
1568 >>> from lazr.restful.declarations import scoped
1569 >>> from zope.component import getUtility
1570
1571 >>> @exported_as_webservice_entry()
1572 ... class IScopedEntry(Interface):
1573 ...
1574 ... value = exported(TextLine(readonly=False))
1575 ...
1576 ... @scoped('read')
1577 ... @export_read_operation()
1578 ... def get_info():
1579 ... pass
1580 ...
1581 ... @scoped('update')
1582 ... @export_write_operation()
1583 ... def do_update():
1584 ... pass
1585 ...
1586 ... @scoped('read', 'update')
1587 ... @export_write_operation()
1588 ... def multiple_scopes():
1589 ... pass
1590 ...
1591 ... @export_write_operation()
1592 ... def unscoped():
1593 ... pass
1594
1595 >>> @implementer(IScopedEntry)
1596 ... class ScopedEntry(object):
1597 ...
1598 ... value = 'initial'
1599 ...
1600 ... def get_info(self):
1601 ... print('get_info called')
1602 ...
1603 ... def do_update(self):
1604 ... print('do_update called')
1605 ...
1606 ... def multiple_scopes(self):
1607 ... print('multiple_scopes called')
1608 ...
1609 ... def unscoped(self):
1610 ... print('unscoped called')
1611
1612 >>> [(version, scoped_entry_interface)] = generate_entry_interfaces(
1613 ... IScopedEntry, [], 'beta')
1614 >>> scoped_entry_adapter_factory = generate_entry_adapters(
1615 ... IScopedEntry, [], [(version, scoped_entry_interface)])[0].object
1616
1617 >>> get_info_method_adapter_factory = generate_operation_adapter(
1618 ... IScopedEntry['get_info'])
1619 >>> IResourceGETOperation.implementedBy(get_info_method_adapter_factory)
1620 True
1621 >>> do_update_method_adapter_factory = generate_operation_adapter(
1622 ... IScopedEntry['do_update'])
1623 >>> IResourcePOSTOperation.implementedBy(do_update_method_adapter_factory)
1624 True
1625 >>> multiple_scopes_method_adapter_factory = generate_operation_adapter(
1626 ... IScopedEntry['multiple_scopes'])
1627 >>> IResourcePOSTOperation.implementedBy(
1628 ... multiple_scopes_method_adapter_factory)
1629 True
1630 >>> unscoped_method_adapter_factory = generate_operation_adapter(
1631 ... IScopedEntry['unscoped'])
1632 >>> IResourcePOSTOperation.implementedBy(unscoped_method_adapter_factory)
1633 True
1634
1635 >>> obj = ScopedEntry()
1636 >>> request = FakeRequest(version='beta')
1637 >>> scoped_entry_adapter = scoped_entry_adapter_factory(obj, request)
1638 >>> get_info_method_adapter = (
1639 ... get_info_method_adapter_factory(obj, request))
1640 >>> do_update_method_adapter = (
1641 ... do_update_method_adapter_factory(obj, request))
1642 >>> multiple_scopes_method_adapter = (
1643 ... multiple_scopes_method_adapter_factory(obj, request))
1644 >>> unscoped_method_adapter = (
1645 ... unscoped_method_adapter_factory(obj, request))
1646
1647A user with unscoped authentication can call any method, and get or set
1648attributes.
1649
1650 >>> _ = get_info_method_adapter.call()
1651 get_info called
1652 >>> _ = do_update_method_adapter.call()
1653 do_update called
1654 >>> _ = multiple_scopes_method_adapter.call()
1655 multiple_scopes called
1656 >>> _ = unscoped_method_adapter.call()
1657 unscoped called
1658 >>> print(scoped_entry_adapter.value)
1659 initial
1660 >>> scoped_entry_adapter.value = 'set by unscoped user'
1661
1662A user with both scopes can call any method tagged with either scope, but
1663can neither get nor set attributes.
1664
1665 >>> config = getUtility(IWebServiceConfiguration)
1666 >>> config._scopes = ['read', 'update']
1667 >>> _ = get_info_method_adapter.call()
1668 get_info called
1669 >>> _ = do_update_method_adapter.call()
1670 do_update called
1671 >>> _ = multiple_scopes_method_adapter.call()
1672 multiple_scopes called
1673 >>> _ = unscoped_method_adapter.call()
1674 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1675 Traceback (most recent call last):
1676 ...
1677 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1678 >>> print(scoped_entry_adapter.value)
1679 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1680 Traceback (most recent call last):
1681 ...
1682 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1683 >>> scoped_entry_adapter.value = 'set by scoped user'
1684 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1685 Traceback (most recent call last):
1686 ...
1687 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1688
1689A user with one scope can only call the methods tagged with that scope, and
1690can neither get nor set attributes.
1691
1692 >>> config._scopes = ['read']
1693 >>> _ = get_info_method_adapter.call()
1694 get_info called
1695 >>> _ = do_update_method_adapter.call()
1696 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1697 Traceback (most recent call last):
1698 ...
1699 zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'update').
1700 >>> _ = multiple_scopes_method_adapter.call()
1701 multiple_scopes called
1702 >>> _ = unscoped_method_adapter.call()
1703 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1704 Traceback (most recent call last):
1705 ...
1706 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1707 >>> print(scoped_entry_adapter.value)
1708 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1709 Traceback (most recent call last):
1710 ...
1711 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1712 >>> scoped_entry_adapter.value = 'set by scoped user'
1713 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1714 Traceback (most recent call last):
1715 ...
1716 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1717
1718 >>> config._scopes = ['update']
1719 >>> _ = get_info_method_adapter.call()
1720 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1721 Traceback (most recent call last):
1722 ...
1723 zope.security.interfaces.Unauthorized: Current authentication does not allow calling this method (one of these scopes is required: 'read').
1724 >>> _ = do_update_method_adapter.call()
1725 do_update called
1726 >>> _ = multiple_scopes_method_adapter.call()
1727 multiple_scopes called
1728 >>> _ = unscoped_method_adapter.call()
1729 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1730 Traceback (most recent call last):
1731 ...
1732 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1733 >>> print(scoped_entry_adapter.value)
1734 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1735 Traceback (most recent call last):
1736 ...
1737 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1738 >>> scoped_entry_adapter.value = 'set by scoped user'
1739 ... # doctest: +IGNORE_EXCEPTION_MODULE_IN_PYTHON2
1740 Traceback (most recent call last):
1741 ...
1742 zope.security.interfaces.Unauthorized: Current authentication only allows calling scoped methods.
1743
1744 >>> config._scopes = None
1745
1543Read-only fields1746Read-only fields
1544----------------1747----------------
15451748
@@ -2593,7 +2796,7 @@
2593IResourceOperation adapters named under the exported method names2796IResourceOperation adapters named under the exported method names
2594are also available for IBookSetOnSteroids and IBookOnSteroids.2797are also available for IBookSetOnSteroids and IBookOnSteroids.
25952798
2596 >>> from zope.component import getGlobalSiteManager, getUtility2799 >>> from zope.component import getGlobalSiteManager
2597 >>> adapter_registry = getGlobalSiteManager().adapters2800 >>> adapter_registry = getGlobalSiteManager().adapters
25982801
2599 >>> from lazr.restful.interfaces import IWebServiceClientRequest2802 >>> from lazr.restful.interfaces import IWebServiceClientRequest
26002803
=== modified file 'src/lazr/restful/interfaces/_rest.py'
--- src/lazr/restful/interfaces/_rest.py 2020-02-04 11:52:59 +0000
+++ src/lazr/restful/interfaces/_rest.py 2021-10-06 10:26:55 +0000
@@ -652,6 +652,26 @@
652 value will be fed back into your code.652 value will be fed back into your code.
653 """653 """
654654
655 def checkRequest(context, required_scopes):
656 """Check whether the current request may call a particular method.
657
658 Authenticated users may be limited to certain scopes, in which case
659 they will only be able to use methods with corresponding `@scoped`
660 decorators. This method is called to check whether a call to a
661 method on `context` tagged with `required_scopes` should be allowed.
662 The return value is ignored; it only matters whether this raises a
663 `zope.security.interfaces.Unauthorized` exception.
664
665 For compatibility, if this method is unimplemented, it is treated as
666 if it did not raise an exception.
667
668 :param context: The context object.
669 :param required_scopes: A list of scope names for this method, or
670 None if the method is unscoped.
671 :raises zope.security.interfaces.Unauthorized: if the call should
672 not be allowed.
673 """
674
655675
656class IUnmarshallingDoesntNeedValue(Interface):676class IUnmarshallingDoesntNeedValue(Interface):
657 """A marker interface for unmarshallers that work without values.677 """A marker interface for unmarshallers that work without values.
658678
=== modified file 'src/lazr/restful/tales.py'
--- src/lazr/restful/tales.py 2020-07-22 23:22:26 +0000
+++ src/lazr/restful/tales.py 2021-10-06 10:26:55 +0000
@@ -804,7 +804,13 @@
804 @property804 @property
805 def doc(self):805 def doc(self):
806 """Human-readable documentation for this operation."""806 """Human-readable documentation for this operation."""
807 return generate_wadl_doc(self.operation.__doc__)807 docstring = self.operation.__doc__
808 # Hack scope information into the docstring for now.
809 scopes = getattr(self.operation, 'scopes', None)
810 if scopes:
811 docstring += '\n\nScopes: %s\n' % (
812 ', '.join("``%s``" % scope for scope in scopes))
813 return generate_wadl_doc(docstring)
808814
809 @property815 @property
810 def has_return_type(self):816 def has_return_type(self):
811817
=== modified file 'src/lazr/restful/testing/webservice.py'
--- src/lazr/restful/testing/webservice.py 2021-05-20 20:44:54 +0000
+++ src/lazr/restful/testing/webservice.py 2021-10-06 10:26:55 +0000
@@ -47,6 +47,7 @@
47from zope.proxy import ProxyBase47from zope.proxy import ProxyBase
48from zope.schema import TextLine48from zope.schema import TextLine
49from zope.security.checker import ProxyFactory49from zope.security.checker import ProxyFactory
50from zope.security.interfaces import Unauthorized
50from zope.testing.cleanup import CleanUp51from zope.testing.cleanup import CleanUp
51from zope.traversing.browser.interfaces import IAbsoluteURL52from zope.traversing.browser.interfaces import IAbsoluteURL
5253
@@ -565,6 +566,7 @@
565 active_versions = ['1.0', '2.0']566 active_versions = ['1.0', '2.0']
566 hostname = "webservice_test"567 hostname = "webservice_test"
567 last_version_with_mutator_named_operations = None568 last_version_with_mutator_named_operations = None
569 _scopes = None
568570
569 def createRequest(self, body_instream, environ):571 def createRequest(self, body_instream, environ):
570 request = Request(body_instream, environ)572 request = Request(body_instream, environ)
@@ -573,6 +575,19 @@
573 tag_request_with_version_name(request, '2.0')575 tag_request_with_version_name(request, '2.0')
574 return request576 return request
575577
578 def checkRequest(self, obj, required_scopes):
579 if self._scopes is not None:
580 if not required_scopes:
581 raise Unauthorized(
582 'Current authentication only allows calling '
583 'scoped methods.')
584 elif not any(scope in required_scopes for scope in self._scopes):
585 raise Unauthorized(
586 'Current authentication does not allow calling '
587 'this method (one of these scopes is required: '
588 '%s).'
589 % ', '.join("'%s'" % scope for scope in required_scopes))
590
576591
577class IWebServiceTestRequest10(IWebServiceClientRequest):592class IWebServiceTestRequest10(IWebServiceClientRequest):
578 """A marker interface for requests to the '1.0' web service."""593 """A marker interface for requests to the '1.0' web service."""
579594
=== modified file 'src/lazr/restful/tests/test_declarations.py'
--- src/lazr/restful/tests/test_declarations.py 2020-07-22 23:22:26 +0000
+++ src/lazr/restful/tests/test_declarations.py 2021-10-06 10:26:55 +0000
@@ -28,6 +28,7 @@
28 MultiChecker,28 MultiChecker,
29 ProxyFactory,29 ProxyFactory,
30 )30 )
31from zope.security.interfaces import Unauthorized
31from zope.security.management import (32from zope.security.management import (
32 endInteraction,33 endInteraction,
33 newInteraction,34 newInteraction,
@@ -339,6 +340,37 @@
339 self.assertEqual(340 self.assertEqual(
340 'product', EntryAdapterUtility(adapter.__class__).singular_type)341 'product', EntryAdapterUtility(adapter.__class__).singular_type)
341342
343 def test_accessor_for_with_scopes(self):
344 # Users with scopes cannot use accessors.
345 self.product._branches = [
346 Branch('A branch'), Branch('Another branch')]
347 register_test_module('testmod', IBranch, IProduct, IHasBranches)
348 config = getUtility(IWebServiceConfiguration)
349 config._scopes = ['scope']
350 self.addCleanup(setattr, config, '_scopes', None)
351 adapter = getMultiAdapter(
352 (self.product, self.one_zero_request), IEntry)
353 exception = self.assertRaises(
354 Unauthorized, getattr, adapter, 'branches')
355 self.assertEqual(
356 'Current authentication only allows calling scoped methods.',
357 str(exception))
358
359 def test_mutator_for_with_scopes(self):
360 # Users with scopes cannot use mutators.
361 self.product._dev_branch = Branch('A product branch')
362 register_test_module('testmod', IBranch, IProduct, IHasBranches)
363 config = getUtility(IWebServiceConfiguration)
364 config._scopes = ['scope']
365 self.addCleanup(setattr, config, '_scopes', None)
366 adapter = getMultiAdapter(
367 (self.product, self.two_zero_request), IEntry)
368 exception = self.assertRaises(
369 Unauthorized, setattr, adapter, 'development_branch_20', None)
370 self.assertEqual(
371 'Current authentication only allows calling scoped methods.',
372 str(exception))
373
342374
343class TestExportAsWebserviceEntry(testtools.TestCase):375class TestExportAsWebserviceEntry(testtools.TestCase):
344 """Tests for export_as_webservice_entry."""376 """Tests for export_as_webservice_entry."""
345377
=== modified file 'src/lazr/restful/tests/test_webservice.py'
--- src/lazr/restful/tests/test_webservice.py 2021-01-21 00:36:11 +0000
+++ src/lazr/restful/tests/test_webservice.py 2021-10-06 10:26:55 +0000
@@ -61,9 +61,11 @@
61 ResourceGETOperation,61 ResourceGETOperation,
62 )62 )
63from lazr.restful.declarations import (63from lazr.restful.declarations import (
64 export_read_operation,
64 exported,65 exported,
65 exported_as_webservice_entry,66 exported_as_webservice_entry,
66 LAZR_WEBSERVICE_NAME,67 LAZR_WEBSERVICE_NAME,
68 scoped,
67 )69 )
68from lazr.restful.testing.webservice import (70from lazr.restful.testing.webservice import (
69 create_web_service_request,71 create_web_service_request,
@@ -667,13 +669,20 @@
667class WadlAPITestCase(WebServiceTestCase):669class WadlAPITestCase(WebServiceTestCase):
668 """Test the docstring generation."""670 """Test the docstring generation."""
669671
672 @exported_as_webservice_entry()
673 class IScopedEntry(Interface):
674 @scoped('test-scope')
675 @export_read_operation()
676 def test():
677 """A method with a scope."""
678
670 # This one is used to test when docstrings are missing.679 # This one is used to test when docstrings are missing.
671 @exported_as_webservice_entry()680 @exported_as_webservice_entry()
672 class IUndocumentedEntry(Interface):681 class IUndocumentedEntry(Interface):
673 a_field = exported(TextLine())682 a_field = exported(TextLine())
674683
675 testmodule_objects = [684 testmodule_objects = [
676 IGenericEntry, IGenericCollection, IUndocumentedEntry]685 IGenericEntry, IGenericCollection, IScopedEntry, IUndocumentedEntry]
677686
678 def test_wadl_field_type(self):687 def test_wadl_field_type(self):
679 """Test the generated XSD field types for various fields."""688 """Test the generated XSD field types for various fields."""
@@ -747,6 +756,23 @@
747 self.assertTrue(len(doclines) > 3,756 self.assertTrue(len(doclines) > 3,
748 'Missing the parameter table: %s' % "\n".join(doclines))757 'Missing the parameter table: %s' % "\n".join(doclines))
749758
759 def test_wadl_operation_with_scopes_doc(self):
760 """Test the wadl:doc generated for an operation adapter."""
761 operation = get_operation_factory(self.IScopedEntry, 'test')
762 doclines = test_tales(
763 'operation/wadl_operation:doc', operation=operation).splitlines()
764 # Only compare the first three lines and the last one.
765 # we dont care about the formatting of the parameters table.
766 self.assertEqual([
767 '<wadl:doc xmlns="http://www.w3.org/1999/xhtml">',
768 '<p>A method with a scope.</p>',
769 '<p>Scopes: <tt class="rst-docutils literal"><span class="pre">'
770 'test-scope</span></tt></p>',
771 ], doclines[0:3])
772 self.assertEqual('</wadl:doc>', doclines[-1])
773 self.assertTrue(len(doclines) > 3,
774 'Missing the parameter table: %s' % "\n".join(doclines))
775
750776
751class DuplicateNameTestCase(WebServiceTestCase):777class DuplicateNameTestCase(WebServiceTestCase):
752 """Test AssertionError when two resources expose the same name.778 """Test AssertionError when two resources expose the same name.

Subscribers

People subscribed via source and target branches