Repository: dcs4cop/xcube

Issue:
Allow granted permissions to be wildcards
**Is your feature request related to a problem? Please describe.**

For authorized clients, we require the xcube Web API to grant access to generic groups of datasets, rather than to lists of individual datasets only.

**Describe the solution you'd like**

A simple first solution is to allow for wildcard characters in the granted scopes configured by the authorisation service (auth0, KeyCloak). For example "read:dataset:*/S3_*.zarr".



Discussion:

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 20eef50e..a10bf9d7 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,13 @@
 
 ### Enhancements
 
+* For authorized clients, the xcube Web API provided by `xcube serve`
+  now allows granted scopes to contain wildcard characters `*`, `**`,
+  and `?`. This is useful to give access to groups of datasets, e.g.
+  the scope `read:dataset:*/S2-*.zarr` permits access to any Zarr 
+  dataset in a subdirectory of the configured data stores and 
+  whose name starts with "S2-". (#632)
+
 * `xcube serve` used to shut down with an error message 
   if it encountered datasets it could not open. New behaviour 
   is to emit a warning and ignore such datasets. (#630)
diff --git a/xcube/webapi/auth.py b/xcube/webapi/auth.py
index 97a6fbb9..1e2bd8e9 100644
--- a/xcube/webapi/auth.py
+++ b/xcube/webapi/auth.py
@@ -18,7 +18,7 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-
+import fnmatch
 import json
 from typing import Optional, Mapping, List, Dict, Any, Set
 
@@ -255,15 +255,23 @@ def check_scopes(required_scopes: Set[str],
 def _get_missing_scope(required_scopes: Set[str],
                        granted_scopes: Set[str],
                        is_substitute: bool = False) -> Optional[str]:
-    for required_scope in required_scopes:
-        if required_scope not in granted_scopes:
-            # If the required scope is not a granted scope, fail
-            return required_scope
-    # If there are granted scopes then the client is authorized,
-    # hence fail for substitute resources (e.g. demo resources)
-    # as there is usually a better (non-demo) resource that
-    # replaces it.
-    if granted_scopes and is_substitute:
-        return '<is_substitute>'
+    if required_scopes and granted_scopes:
+        for required_scope in required_scopes:
+            required_permission_given = False
+            for granted_scope in granted_scopes:
+                if required_scope == granted_scope \
+                        or fnmatch.fnmatch(required_scope, granted_scope):
+                    # If any granted scope matches, we can stop
+                    required_permission_given = True
+                    break
+            if not required_permission_given:
+                # The required scope is not a granted scope --> fail
+                return required_scope
+        # If there are granted scopes then the client is authorized,
+        # hence fail for substitute resources (e.g. demo resources)
+        # as there is usually a better (non-demo) resource that
+        # replaces it.
+        if is_substitute:
+            return '<is_substitute>'
     # All ok.
     return None


Test patch:
diff --git a/test/webapi/test_auth.py b/test/webapi/test_auth.py
index f664cede..628d49f1 100644
--- a/test/webapi/test_auth.py
+++ b/test/webapi/test_auth.py
@@ -5,7 +5,10 @@ import unittest
 import requests
 
 from xcube.webapi.auth import AuthMixin
-from xcube.webapi.errors import ServiceConfigError, ServiceAuthError
+from xcube.webapi.auth import assert_scopes
+from xcube.webapi.auth import check_scopes
+from xcube.webapi.errors import ServiceAuthError
+from xcube.webapi.errors import ServiceConfigError
 
 
 class ServiceContextMock:
@@ -77,7 +80,6 @@ class AuthMixinIdTokenTest(unittest.TestCase):
         access_token = token_data['access_token']
         return access_token
 
-
     def test_missing_auth_config(self):
         auth_mixin = AuthMixin()
         auth_mixin.service_context = ServiceContextMock(config={})
@@ -86,7 +88,7 @@ class AuthMixinIdTokenTest(unittest.TestCase):
         )
 
         with self.assertRaises(ServiceAuthError) as cm:
-            id_token = auth_mixin.get_id_token(require_auth=True)
+            auth_mixin.get_id_token(require_auth=True)
 
         self.assertEqual('HTTP 401: Invalid header (Received access token, '
                          'but this server doesn\'t support authentication.)',
@@ -251,3 +253,80 @@ class AuthMixinAuthConfigTest(unittest.TestCase):
         self.assertEqual('HTTP 500: Value for key "Algorithms"'
                          ' in section "Authentication" must not be empty',
                          f'{cm.exception}')
+
+
+class ScopesTest(unittest.TestCase):
+
+    def test_check_scopes_ok(self):
+        self.assertEqual(
+            True,
+            check_scopes({'read:dataset:test1.zarr'},
+                         set(),
+                         is_substitute=False)
+        )
+        self.assertEqual(
+            True,
+            check_scopes({'read:dataset:test1.zarr'},
+                         set(),
+                         is_substitute=True)
+        )
+        self.assertEqual(
+            True,
+            check_scopes({'read:dataset:test1.zarr'},
+                         {'read:dataset:test1.zarr'})
+        )
+        self.assertEqual(
+            True,
+            check_scopes({'read:dataset:test1.zarr'},
+                         {'read:dataset:test?.zarr'})
+        )
+        self.assertEqual(
+            True,
+            check_scopes({'read:dataset:test1.zarr'},
+                         {'read:dataset:test1.*'})
+        )
+
+    def test_check_scopes_fails(self):
+        self.assertEqual(
+            False,
+            check_scopes({'read:dataset:test1.zarr'},
+                         {'read:dataset:test1.zarr'},
+                         is_substitute=True)
+        )
+        self.assertEqual(
+            False,
+            check_scopes({'read:dataset:test2.zarr'},
+                         {'read:dataset:test1.zarr'})
+        )
+        self.assertEqual(
+            False,
+            check_scopes({'read:dataset:test2.zarr'},
+                         {'read:dataset:test1.zarr'},
+                         is_substitute=True)
+        )
+
+    def test_assert_scopes_ok(self):
+        assert_scopes({'read:dataset:test1.zarr'},
+                      set())
+        assert_scopes({'read:dataset:test1.zarr'},
+                      {'read:dataset:test1.zarr'})
+        assert_scopes({'read:dataset:test1.zarr'},
+                      {'read:dataset:*'})
+        assert_scopes({'read:dataset:test1.zarr'},
+                      {'read:dataset:test?.zarr'})
+        assert_scopes({'read:dataset:test1.zarr'},
+                      {'read:dataset:test1.*'})
+        assert_scopes({'read:dataset:test1.zarr'},
+                      {'read:dataset:test2.zarr',
+                       'read:dataset:test3.zarr',
+                       'read:dataset:test1.zarr'})
+
+    def test_assert_scopes_fails(self):
+        with self.assertRaises(ServiceAuthError) as cm:
+            assert_scopes({'read:dataset:test1.zarr'},
+                          {'read:dataset:test2.zarr'})
+        self.assertEquals(
+            'HTTP 401: Missing permission'
+            ' (Missing permission read:dataset:test1.zarr)',
+            f'{cm.exception}'
+        )