Skip to content

Commit ebe4674

Browse files
committed
Granted scopes can be wildcards
1 parent b6a949e commit ebe4674

File tree

3 files changed

+108
-14
lines changed

3 files changed

+108
-14
lines changed

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
### Enhancements
44

5+
* For authorized clients, the xcube Web API provided by `xcube serve`
6+
now allows granted scopes to contain wildcard characters `*`, `**`,
7+
and `?`. This is useful to give access to groups of datasets, e.g.
8+
the scope `read:dataset:*/S2-*.zarr` permits access to any Zarr
9+
dataset in a subdirectory of the configured data stores and
10+
whose name starts with "S2-". (#632)
11+
512
* `xcube serve` used to shut down with an error message
613
if it encountered datasets it could not open. New behaviour
714
is to emit a warning and ignore such datasets. (#630)

test/webapi/test_auth.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import requests
66

77
from xcube.webapi.auth import AuthMixin
8-
from xcube.webapi.errors import ServiceConfigError, ServiceAuthError
8+
from xcube.webapi.auth import assert_scopes
9+
from xcube.webapi.auth import check_scopes
10+
from xcube.webapi.errors import ServiceAuthError
11+
from xcube.webapi.errors import ServiceConfigError
912

1013

1114
class ServiceContextMock:
@@ -77,7 +80,6 @@ def _fetch_access_token(self):
7780
access_token = token_data['access_token']
7881
return access_token
7982

80-
8183
def test_missing_auth_config(self):
8284
auth_mixin = AuthMixin()
8385
auth_mixin.service_context = ServiceContextMock(config={})
@@ -86,7 +88,7 @@ def test_missing_auth_config(self):
8688
)
8789

8890
with self.assertRaises(ServiceAuthError) as cm:
89-
id_token = auth_mixin.get_id_token(require_auth=True)
91+
auth_mixin.get_id_token(require_auth=True)
9092

9193
self.assertEqual('HTTP 401: Invalid header (Received access token, '
9294
'but this server doesn\'t support authentication.)',
@@ -251,3 +253,80 @@ def test_not_ok(self):
251253
self.assertEqual('HTTP 500: Value for key "Algorithms"'
252254
' in section "Authentication" must not be empty',
253255
f'{cm.exception}')
256+
257+
258+
class ScopesTest(unittest.TestCase):
259+
260+
def test_check_scopes_ok(self):
261+
self.assertEqual(
262+
True,
263+
check_scopes({'read:dataset:test1.zarr'},
264+
set(),
265+
is_substitute=False)
266+
)
267+
self.assertEqual(
268+
True,
269+
check_scopes({'read:dataset:test1.zarr'},
270+
set(),
271+
is_substitute=True)
272+
)
273+
self.assertEqual(
274+
True,
275+
check_scopes({'read:dataset:test1.zarr'},
276+
{'read:dataset:test1.zarr'})
277+
)
278+
self.assertEqual(
279+
True,
280+
check_scopes({'read:dataset:test1.zarr'},
281+
{'read:dataset:test?.zarr'})
282+
)
283+
self.assertEqual(
284+
True,
285+
check_scopes({'read:dataset:test1.zarr'},
286+
{'read:dataset:test1.*'})
287+
)
288+
289+
def test_check_scopes_fails(self):
290+
self.assertEqual(
291+
False,
292+
check_scopes({'read:dataset:test1.zarr'},
293+
{'read:dataset:test1.zarr'},
294+
is_substitute=True)
295+
)
296+
self.assertEqual(
297+
False,
298+
check_scopes({'read:dataset:test2.zarr'},
299+
{'read:dataset:test1.zarr'})
300+
)
301+
self.assertEqual(
302+
False,
303+
check_scopes({'read:dataset:test2.zarr'},
304+
{'read:dataset:test1.zarr'},
305+
is_substitute=True)
306+
)
307+
308+
def test_assert_scopes_ok(self):
309+
assert_scopes({'read:dataset:test1.zarr'},
310+
set())
311+
assert_scopes({'read:dataset:test1.zarr'},
312+
{'read:dataset:test1.zarr'})
313+
assert_scopes({'read:dataset:test1.zarr'},
314+
{'read:dataset:*'})
315+
assert_scopes({'read:dataset:test1.zarr'},
316+
{'read:dataset:test?.zarr'})
317+
assert_scopes({'read:dataset:test1.zarr'},
318+
{'read:dataset:test1.*'})
319+
assert_scopes({'read:dataset:test1.zarr'},
320+
{'read:dataset:test2.zarr',
321+
'read:dataset:test3.zarr',
322+
'read:dataset:test1.zarr'})
323+
324+
def test_assert_scopes_fails(self):
325+
with self.assertRaises(ServiceAuthError) as cm:
326+
assert_scopes({'read:dataset:test1.zarr'},
327+
{'read:dataset:test2.zarr'})
328+
self.assertEquals(
329+
'HTTP 401: Missing permission'
330+
' (Missing permission read:dataset:test1.zarr)',
331+
f'{cm.exception}'
332+
)

xcube/webapi/auth.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1919
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2020
# SOFTWARE.
21-
21+
import fnmatch
2222
import json
2323
from typing import Optional, Mapping, List, Dict, Any, Set
2424

@@ -255,15 +255,23 @@ def check_scopes(required_scopes: Set[str],
255255
def _get_missing_scope(required_scopes: Set[str],
256256
granted_scopes: Set[str],
257257
is_substitute: bool = False) -> Optional[str]:
258-
for required_scope in required_scopes:
259-
if required_scope not in granted_scopes:
260-
# If the required scope is not a granted scope, fail
261-
return required_scope
262-
# If there are granted scopes then the client is authorized,
263-
# hence fail for substitute resources (e.g. demo resources)
264-
# as there is usually a better (non-demo) resource that
265-
# replaces it.
266-
if granted_scopes and is_substitute:
267-
return '<is_substitute>'
258+
if required_scopes and granted_scopes:
259+
for required_scope in required_scopes:
260+
required_permission_given = False
261+
for granted_scope in granted_scopes:
262+
if required_scope == granted_scope \
263+
or fnmatch.fnmatch(required_scope, granted_scope):
264+
# If any granted scope matches, we can stop
265+
required_permission_given = True
266+
break
267+
if not required_permission_given:
268+
# The required scope is not a granted scope --> fail
269+
return required_scope
270+
# If there are granted scopes then the client is authorized,
271+
# hence fail for substitute resources (e.g. demo resources)
272+
# as there is usually a better (non-demo) resource that
273+
# replaces it.
274+
if is_substitute:
275+
return '<is_substitute>'
268276
# All ok.
269277
return None

0 commit comments

Comments
 (0)