-
Notifications
You must be signed in to change notification settings - Fork 5
/
services.py
1775 lines (1531 loc) · 82.4 KB
/
services.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import abc
import re
from typing import TYPE_CHECKING
import six
from beaker.cache import Cache, cache_region, cache_regions, region_invalidate
from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNotImplemented
from pyramid.security import ALL_PERMISSIONS, DENY_ALL
from sqlalchemy.inspection import inspect as sa_inspect
from ziggurat_foundations.models.base import get_db_session
from ziggurat_foundations.models.services.group import GroupService
from ziggurat_foundations.models.services.resource import ResourceService
from ziggurat_foundations.models.services.user import UserService
from ziggurat_foundations.permissions import permission_to_pyramid_acls
from magpie import models
from magpie.api import exception as ax
from magpie.constants import get_constant
from magpie.db import get_connected_session
from magpie.owsrequest import ows_parser_factory
from magpie.permissions import (
PERMISSION_REASON_ADMIN,
PERMISSION_REASON_DEFAULT,
Access,
Permission,
PermissionSet,
PermissionType,
Scope
)
from magpie.utils import classproperty, fully_qualified_name, get_logger, get_request_user
LOGGER = get_logger(__name__)
if TYPE_CHECKING:
# pylint: disable=W0611,unused-import
from typing import Collection, Dict, List, Optional, Set, Tuple, Type, Union
from pyramid.request import Request
from magpie.typedefs import (
AccessControlListType,
MultiResourceRequested,
PermissionRequested,
ResourceTypePermissions,
ServiceConfiguration,
ServiceOrResourceType,
Str,
TargetResourceRequested
)
class ServiceMeta(type):
@property
def resource_types(cls):
# type: (Type[ServiceInterface]) -> List[Type[models.Resource]]
"""
Allowed resources type classes under the service.
"""
return list(cls.resource_types_permissions)
@property
def resource_type_names(cls):
# type: (Type[ServiceInterface]) -> List[Str]
"""
Allowed resources type names under the service.
"""
# pylint: disable=E1133 # is iterable but detected as not like one
return [res.resource_type_name for res in cls.resource_types]
@property
def child_resource_allowed(cls):
# type: (Type[ServiceInterface]) -> bool
"""
Lists all resources allowed *somewhere* within its resource hierarchy under the service.
.. note::
Resources are not necessarily all allowed *directly* under the service.
This depends on whether :attr:`ServiceInterface.child_structure_allowed` is defined or not.
If not defined, resources are applicable anywhere.
Otherwise, they must respect the explicit structure definitions.
.. seealso::
Use :meth:`ServiceInterface.nested_resource_allowed` to obtain only scoped types allowed under a
given resource considering allowed path structures.
"""
return len(cls.resource_types) > 0
@six.add_metaclass(ServiceMeta)
class ServiceInterface(object):
@property
@abc.abstractmethod
def service_type(self):
# type: () -> Optional[Str]
"""
Service type identifier (required, unique across implementation).
"""
raise NotImplementedError
permissions = [] # type: List[Permission]
"""
Permission allowed directly on the service as top-level resource.
"""
resource_types_permissions = {} # type: ResourceTypePermissions
"""
Mapping of resource types to lists of permissions defining allowed children resource permissions under the service.
"""
child_structure_allowed = {} # type: Dict[Type[ServiceOrResourceType], List[Type[models.Resource]]]
"""
Control mapping of resource types limiting the allowed structure of nested children resources.
When not defined, any nested resource type combination is allowed if they themselves allow children resources.
Otherwise, nested child resource under the service can only be created at specific positions within the hierarchy
that matches exactly one of the defined control conditions.
For example, the below definition allows only resources typed ``route`` directly under the service.
The following nested resource under that first-level ``route`` can then be either another ``route`` followed
by a child ``process`` or directly a ``process``. Because ``process`` type doesn't allow any children resource
(see :attr:`models.Process.child_resource_allowed`), those are the only allowed combinations (cannot further nest
resources under the final ``process`` resource).
.. code-block:: python
child_structure_allowed = {
models.Service: [models.Route],
models.Route: [models.Route, models.Process],
models.Process: [],
}
.. seealso::
- Validation of allowed nested children resource insertion of a given type under a parent resource is provided
by :meth:`ServiceInterface.validate_nested_resource_type` that employs :attr:`child_structure_allowed`.
- Listing of allowed resource types scoped under a given child resource within the hierarchy is provided
by :meth:`ServiceInterface.nested_resource_allowed`.
"""
_config = None # type: Optional[ServiceConfiguration] # for optimization to avoid reload and parsing each time
configurable = False
"""
Indicates if the service supports custom configuration.
"""
def __init__(self, service, request):
# type: (models.Service, Optional[Request]) -> None
"""
Initialize the service.
:param service: Base service resource that must be handled by this service implementation.
:param request: Active request to handle requested resources, permissions and effective access.
The request can be omitted if basic service definition details are to be retrieved.
It is mandatory for any ``requested`` or ``effective`` component that should be resolved.
"""
self.service = service # type: models.Service
self.request = request # type: Request
self._flag_acl_cached = {} # type: Dict[Tuple[Str, Str, Str, Optional[int]], bool]
def __str__(self):
return "<Service [{}] name={} type={} id={}>".format(
type(self).__name__, self.service_type, self.service.resource_name, self.service.resource_id
)
@abc.abstractmethod
def permission_requested(self):
# type: () -> PermissionRequested
"""
Defines how to interpret the incoming request into :class:`Permission` definitions for the given service.
Each service must implement its own definition.
The method must specifically define how to convert generic request path, query, etc. elements into permissions
that match the service and its children resources.
If ``None`` is returned, the :term:`ACL` will effectively be resolved to denied access.
Otherwise, one or more returned :class:`Permission` will indicate which permissions should be looked for to
resolve the :term:`ACL` of the authenticated user and its groups.
If the request cannot be parsed for any reason to retrieve needed parameters (e.g.: Bad Request),
the :exception:`HTTPBadRequest` can be raised to indicate specifically the cause, which will
help :class:`magpie.adapter.magpieowssecurity.MagpieOWSSecurity` create a better response with
the relevant error details.
"""
raise NotImplementedError("missing implementation of request permission converter")
@abc.abstractmethod
def resource_requested(self):
# type: () -> MultiResourceRequested
"""
Defines how to interpret the incoming request into the targeted :class:`model.Resource` for the given service.
Each service must implement its own definition.
The expected return value must be either of the following::
- List<(target-resource, target?)> When multiple resources need validation ('target?' as below for each).
- (target-resource, True) when the exact resource is found according to request parsing.
- (parent-resource, False) when any parent of the resource is found according to request parsing.
- None when invalid request or not found resource.
The ``parent-resource`` should indicate the *closest* higher-level resource in the hierarchy that would nest
the otherwise desired ``target-resource``. The idea behind this is that `Magpie` will be able to resolve the
effective recursive scoped permission even if not all corresponding resources were explicitly defined in the
database.
For example, if the request *would* be interpreted with the following hierarchy after service-specific
resolution::
ServiceA
Resource1 <== closest *existing* parent resource
[Resource2] <== target (according to service/request resolution), but not existing in database
A permission defined as Allow/Recursive on ``Resource1`` should *normally* allow access to ``Resource2``. If
``Resource2`` is not present in the database though, it cannot be looked for, and the corresponding ACL cannot
be generated. Because the (real) protected service using `Magpie` can have a large and dynamic hierarchy, it
is not convenient to enforce perpetual sync between it and its resource representation in `Magpie`. Using
``(parent-resource, False)`` will allow resolution of permission from the closest available parent.
.. note::
In case of ``parent-resource`` returned, only `recursive`-scoped permissions will be considered, since the
missing ``target-resource`` is the only one that should be checked for `match`-scoped permissions. For this
reason, the service-specific implementation should preferably return the explicit `target` resource whenever
possible.
If the returned resource is ``None``, the ACL will effectively be resolved to denied access. This can be used
to indicate failure to retrieve the expected resource or that corresponding resource does not exist. Otherwise,
this method implementation should convert any request path, query parameters, etc. into an existing resource.
If a list of ``(target-resource, target?)`` is returned, all of those resources should individually perform
:term:`Effective Resolution` and should **ALL** simultaneously be granted access to let the request through.
This can be used to resolve ambiguous or equivalent parameter combinations from parsing the request, or to
validate access to parameters that allow multi-resource references using some kind of list value representation.
.. seealso::
- :meth:`_get_acl` for :term:`Effective Resolution` of over multiple :term:`Resource` references.
- :meth:`effective_permissions` for :term:`Effective Resolution` of a single :term:`Resource`.
:returns:
One or many tuple of reference resource (target/parent),
and explicit match status of the corresponding resource (True/False)
"""
raise NotImplementedError
def user_requested(self):
"""
Obtain the :term:`User` that was identified to obtain protected :term:`Resource` access.
"""
found = user = self.request.user
# fallback lookup in case cookies exists but request method did not evaluate user
# this can happen in situations where 'request.user' was pre-resolved when interacting with Twitcher
if user is None:
user = get_request_user(self.request)
if not user:
session = get_connected_session(self.request)
anonymous = get_constant("MAGPIE_ANONYMOUS_USER", self.request)
user = UserService.by_user_name(anonymous, db_session=session)
if user is None:
raise RuntimeError("No Anonymous user in the database")
# 'request.user' expected None (and should remain as such) when anonymous (unauthenticated)
elif found is None and user is not None:
try:
self.request.user = user # fix for future references using the expected location
except AttributeError as exc:
LOGGER.warning("Failed attempt to fix invalid request user reference.", exc_info=exc)
return user
@property
def __acl__(self):
# type: () -> AccessControlListType
"""
Access Control List (:term:`ACL`) formed of :term:`ACE` defining combinations rules to grant or refuse access.
Each :term:`ACE` is defined as ``(outcome, user/group, permission)`` tuples.
Called by the configured Pyramid :class:`pyramid.authorization.ACLAuthorizationPolicy`.
Caching is automatically handled according to configured application settings and whether the specific ACL
combination being requested was already processed recently.
"""
if "acl" not in cache_regions:
cache_regions["acl"] = {"enabled": False}
user_id = None if self.request.user is None else self.request.user.id
cache_keys = (self.service.resource_name, self.request.method, self.request.path_qs, user_id)
LOGGER.debug("Cache keys: %s", list(cache_keys))
self._flag_acl_cached[cache_keys] = True # remains true if not reset by run '_get_acl_cached', hence cached
if self.request.headers.get("Cache-Control") == "no-cache":
LOGGER.debug("Cache invalidation requested. Removing items from ACL region: %s", list(cache_keys))
region_invalidate(self._get_acl_cached, "acl", *cache_keys)
acl = self._get_acl_cached(*cache_keys)
if self._flag_acl_cached[cache_keys]:
LOGGER.warning("Using cached ACL")
return acl
@cache_region("acl")
def _get_acl_cached(self, service_name, request_method, request_path, user_id):
# type: (Str, Str, Str, Optional[int]) -> AccessControlListType
"""
Cache this method with :py:mod:`beaker` based on the provided caching key parameters.
If the cache is not hit (expired timeout or new key entry), calls :meth:`ServiceInterface.get_acl` to retrieve
effective permissions of the requested resource and specific permission for the applicable service and user
executing the request.
.. note::
Function arguments are required to generate caching keys by which cached elements will be retrieved.
Actual arguments are not needed as we employ stored objects in the instance.
.. warning::
Anything within this method or any underlying calls that can potentially retrieve database contents,
whether for direct object or dynamically generated relationships (eg: ``user.groups``) must attempt
to reestablish any detached or invalid session/transaction due to the potentially desynchronized
references between objects before/after both incoming ``service`` and this ``acl`` cache regions.
.. seealso::
- :meth:`ServiceInterface.permission_requested`
- :meth:`ServiceInterface.resource_requested`
- :meth:`ServiceInterface.user_requested`
"""
self._flag_acl_cached[(service_name, request_method, request_path, user_id)] = False
# attempt to catch any missing reconnect or detect closed transaction if needed for following steps
# store in 'request.db' reference since service implementations don't always use 'get_connected_session'
self.request.db = get_connected_session(self.request)
permissions = self.permission_requested()
if permissions is None:
return [DENY_ALL]
target_resources = self.resource_requested()
if not target_resources:
return [DENY_ALL]
if not isinstance(target_resources, list):
if not isinstance(target_resources, tuple):
resource = target_resources
is_target = False
else:
resource, is_target = target_resources
target_resources = [(resource, is_target)]
if any(res[0] is None for res in target_resources):
return [DENY_ALL]
if not isinstance(permissions, (list, set, tuple)):
permissions = {permissions}
user = self.user_requested()
return self._get_acl(user, target_resources, permissions)
def _get_acl(self, user, resources, permissions):
# type: (models.User, MultiResourceRequested, Collection[Permission]) -> AccessControlListType
"""
Resolves resource-tree and user/group inherited permissions into simplified :term:`ACL` of requested resources.
Contrary to :meth:`effective_permissions` that can be resolved only for individual :term:`Resource`, :term:`ACL`
is involved during actual request access, which could refer to multiple :term:`Resource` references or distinct
:term:`Permission` names if the identified parent :term:`Service` supports it.
When more than one item is specified for validation (any combination of :term:`Resource` or :term:`Permission`),
**ALL** of them must be granted access to resolve as :data:`Access.ALLOW`. Any denied access blocks the whole
set of requested elements.
.. seealso::
- Core :term:`Permission` resolution rules of a single :term:`Resource` using :meth:`effective_permissions`.
- Support of multi-:term:`Resource` references defined by returned values of :meth:`resource_requested`
for individual :class:`ServiceInterface` implementations.
"""
# avoid useless effective resolution re-computation if duplicates are found
target_resources = list(dict.fromkeys(resources)) # set(), but preserving order
allowed_ace = []
for resource, is_target in target_resources:
# only 1 entry per (permission-name, resource) possible after effective resolution for each resource
# since distinct resources are being processed, user/group precedence and group priority doesn't matter
# resulting Allow/Deny for each case is the "highest priority" for each applicable resource individually
res_access_perms = self.effective_permissions(user, resource, permissions, is_target)
for perm in res_access_perms:
# if any resource or any permission indicates deny,
# break out to avoid useless computation (block all)
if perm.access == Access.DENY:
return [perm.ace(self.request.user)]
allowed_ace.append(perm.ace(self.request.user))
if not allowed_ace:
return [DENY_ALL]
return allowed_ace
def _get_connected_object(self, obj):
# type: (Union[ServiceOrResourceType, models.User]) -> Optional[ServiceOrResourceType]
"""
Retrieve the object with an active session and attached state by refreshing connection with request session.
This operation is required mostly in cases of mismatching references between cached and active objects obtained
according to timing of requests and whether caching took placed between them, and for different caching region
levels (service, ACL or both). It also attempts to correct and encountered problems due to concurrent requests.
"""
db_session = get_connected_session(self.request)
# Reconnect the referenced object to active database session if it is detached or inactive.
# - This can happen during mismatching sources of cached objects for service/ACL region combinations,
# where service/resource data is available from cache but not associated with an appropriate session state.
# - Since this operation is being computed, ACL is not yet cached (or was reset before service cache was).
# The service/resource must be refreshed regardless of cache to resolve it with other object references.
# - Because of possibly reconnected objects from previous calls to this method, other objects might also need
# to be synced with the same database session.
# In case the DB session was inactive and a new one was recreated above, also ensure that the resource did not
# already have an handle referring to the old session.
obj_session = get_db_session(session=None, obj=obj)
if isinstance(obj, models.User):
obj_type = "user"
elif isinstance(obj, models.Service):
obj_type = "service"
else:
obj_type = "resource"
if obj_session is None or not obj_session.is_active:
if obj_session is None:
LOGGER.debug("Reconnect cached %s [%s] with active request session (missing session).", obj_type, obj)
else:
LOGGER.debug("Reconnect cached %s [%s] with active request session (inactive session).", obj_type, obj)
if obj_type == "user":
obj_connect = UserService.by_id(obj.id, db_session=db_session)
else:
obj_connect = ResourceService.by_resource_id(obj.resource_id, db_session=db_session)
if obj_connect is None:
LOGGER.warning("Reconnect cached %s to active session failed!", obj_type)
LOGGER.debug("Session: %s, Resource: %s, Type: %s", db_session, obj, obj_type)
return None
obj = obj_connect
# Merge retrieved resource to the active session if not already attached.
state = sa_inspect(obj)
if state.detached:
LOGGER.debug("Reconnect cached %s [%s] with active request session (detached state).", obj_type, obj)
obj = db_session.merge(obj)
state = sa_inspect(obj)
LOGGER.debug("Object [%s] is [%s] %s session [%s, active=%s, id=%s].",
obj, "detached" if state.detached else "attached", "from" if state.detached else "to",
state.session, state.session.is_active, state.session_id)
return obj
def _get_request_path_parts(self):
# type: () -> Optional[List[Str]]
"""
Obtain the :attr:`request` path parts stripped of anything prior to the referenced :attr:`service` name.
"""
path_parts = self.request.path.rstrip("/").split("/")
svc_name = self.service.resource_name
if svc_name not in path_parts:
return None
svc_idx = path_parts.index(svc_name)
return path_parts[svc_idx + 1:]
def get_config(self):
# type: () -> ServiceConfiguration
"""
Obtains the custom configuration of the registered service.
"""
return self.service.configuration
@classmethod
def get_resource_permissions(cls, resource_type_name):
# type: (Str) -> List[Permission]
"""
Obtains the allowed permissions of the service's child resource fetched by resource type name.
"""
for res, res_perms in cls.resource_types_permissions.items():
if res.resource_type_name == resource_type_name:
return res_perms
return []
@classmethod
def validate_nested_resource_type(cls, parent_resource, child_resource_type):
# type: (ServiceOrResourceType, Str) -> bool
"""
Validate whether a new child resource type is allowed under the parent resource under the service.
:param parent_resource: Parent under which the new resource must be validated. This can be the service itself.
:param child_resource_type: Type to validate at the position defined under the parent resource.
:return: status indicating if insertion is allowed for this type and at this parent position.
"""
child_resources = cls.nested_resource_allowed(parent_resource)
if not child_resources:
return False
child_allow_types = [res.resource_type_name for res in child_resources]
return child_resource_type in child_allow_types
@classmethod
def nested_resource_allowed(cls, parent_resource):
# type: (ServiceOrResourceType) -> List[Type[models.Resource]]
"""
Obtain the nested resource types allowed as children resource within structure definitions.
"""
if not cls.child_resource_allowed:
return []
if not get_resource_child_allowed(parent_resource):
return []
# if undefined control structures, any combination is allowed (original behaviour)
if not cls.child_structure_allowed:
return cls.resource_types
child_allow_types = [res.resource_type_name for res in cls.child_structure_allowed]
if parent_resource.resource_type_name not in child_allow_types:
return []
return cls.child_structure_allowed[type(parent_resource)]
def allowed_permissions(self, resource):
# type: (ServiceOrResourceType) -> List[Permission]
"""
Obtains the allowed permissions for or under the service according to provided service or resource.
"""
if resource.resource_type == "service" and resource.type == self.service_type:
return self.permissions
return self.get_resource_permissions(resource.resource_type)
def effective_permissions(self,
user, # type: models.User
resource, # type: ServiceOrResourceType
permissions=None, # type: Optional[Collection[Permission]]
allow_match=True, # type: bool
): # type: (...) -> List[PermissionSet]
"""
Obtains the :term:`Effective Resolution` of permissions the user has over the specified resource.
Recursively rewinds the resource tree from the specified resource up to the top-most parent service the resource
resides under (or directly if the resource is the service) and retrieve permissions along the way that should be
applied to children when using scoped-resource inheritance. Rewinding of the tree can terminate earlier when
permissions can be immediately resolved such as when more restrictive conditions enforce denied access.
Both user and group permission inheritance is resolved simultaneously to tree hierarchy with corresponding
allow and deny conditions. User :term:`Direct Permissions <Direct Permission>` have priority over all its groups
:term:`Inherited Permissions <Inherited Permission>`, and denied permissions have priority over allowed access
ones.
All applicable permissions on the resource (as defined by :meth:`allowed_permissions`) will have their
resolution (Allow/Deny) provided as output, unless a specific subset of permissions is requested using
:paramref:`permissions`. Other permissions are ignored in this case to only resolve requested ones.
For example, this parameter can be used to request only ACL resolution from specific permissions applicable
for a given request, as obtained by :meth:`permission_requested`.
Permissions scoped as `match` can be ignored using :paramref:`allow_match`, such as when the targeted resource
does not exist.
.. seealso::
- :meth:`ServiceInterface.resource_requested`
:param user: :term:`User` for which to perform :term:`Effective Resolution`.
:param resource: :term:`Resource` onto which access must be resolved.
:param permissions: List of :term:`Permission` for which to perform the resolution.
:param allow_match: Indicate if the specific :term:`Resource` was matched to allow :data:`Scope.MATCH` handling.
:returns: Resolved set :term:`Effective Permission` for specified parameter combinations.
"""
if not permissions:
permissions = self.allowed_permissions(resource)
requested_perms = set(permissions) # type: Set[Permission]
effective_perms = {} # type: Dict[Permission, PermissionSet]
db_session = get_connected_session(self.request)
user = self._get_connected_object(user) # groups dynamically populated fail if not connected (for admin check)
LOGGER.debug("Resolving effective permission for: [user: %s, resource: %s, permissions: %s, match: %s]",
user, resource, list(permissions), allow_match)
# immediately return all permissions if user is an admin
admin_group = get_constant("MAGPIE_ADMIN_GROUP", self.request)
admin_group = GroupService.by_group_name(admin_group, db_session=db_session)
if admin_group in user.groups: # noqa
LOGGER.debug("Resolved by early detection of admin group membership. Full access granted.")
return [
PermissionSet(perm, access=Access.ALLOW, scope=Scope.MATCH,
typ=PermissionType.EFFECTIVE, reason=PERMISSION_REASON_ADMIN)
for perm in permissions
]
# level at which last permission was found, -1 if not found
# employed to resolve with *closest* scope and for applicable 'reason' combination on same level
effective_level = {} # type: Dict[Permission, Optional[int]]
current_level = 1 # one-based to avoid ``if level:`` check failing with zero
full_break = False
# current and parent resource(s) recursive-scope
while resource is not None and not full_break: # bottom-up until service is reached
LOGGER.debug("Resolving for (sub-)resource: [%s]", resource)
resource = self._get_connected_object(resource)
if resource is None:
LOGGER.warning("Resource 'None' after reconnection attempt. Early stop effective resolution loop.")
break
# include both permissions set in database as well as defined directly on resource
cur_res_perms = ResourceService.perms_for_user(resource, user, db_session=db_session)
cur_res_perms.extend(permission_to_pyramid_acls(resource.__acl__))
for perm_name in requested_perms:
if full_break:
break
for perm_tup in cur_res_perms:
perm_set = PermissionSet(perm_tup)
# if user is owner (directly or via groups), all permissions are set,
# but continue processing this resource until end in case user explicit deny reverts it
if perm_tup.perm_name == ALL_PERMISSIONS:
# FIXME:
# This block needs to be validated if support of ownership rules are added.
# Conditions must be revised according to wanted behaviour...
# General idea for now is that explict user/group deny should be prioritized over resource
# ownership permissions since these can be attributed to *any user* while explicit deny are
# definitely set by an admin-level user.
for perm in requested_perms:
if perm_set.access == Access.DENY:
all_perm = PermissionSet(perm, perm_set.access, perm.scope, PermissionType.OWNED)
effective_perms[perm] = all_perm
else:
all_perm = PermissionSet(perm, perm_set.access, perm.scope, PermissionType.OWNED)
effective_perms.setdefault(perm, all_perm)
full_break = True
break
# skip if the current permission must not be processed (at all or for the moment until next 'name')
if perm_set.name not in requested_perms or perm_set.name != perm_name:
continue
# only first resource can use match (if even enabled with found one), parents are recursive-only
if not allow_match and perm_set.scope == Scope.MATCH:
continue
# pick the first permission if none was found up to this point
prev_perm = effective_perms.get(perm_name)
scope_level = effective_level.get(perm_name)
if not prev_perm:
LOGGER.debug("Found permission [level=%s]: %r (first occurrence)", current_level, perm_set)
effective_perms[perm_name] = perm_set
effective_level[perm_name] = current_level
continue
# user direct permissions have priority over inherited ones from groups
# if inherited permission was found during previous iteration, override it with direct permission
if perm_set.type == PermissionType.DIRECT:
# - reset resolution scope of previous permission attributed to group as it takes precedence
# - since there can't be more than one user permission-name per resource on a given level,
# scope resolution is done after applying this *closest* permission, ignore higher level ones
if prev_perm.type == PermissionType.INHERITED or not scope_level:
LOGGER.debug("Found permission [level=%s]: %r", current_level, perm_set)
effective_perms[perm_name] = perm_set
effective_level[perm_name] = current_level
continue # final decision for this user, skip any group permissions
# resolve prioritized permission according to ALLOW/DENY, scope and group priority
# (see 'PermissionSet.resolve' method for extensive details)
# skip if last permission is not on group to avoid redundant USER > GROUP check processed before
if prev_perm.type == PermissionType.INHERITED:
# - If new permission to process is done against the previous permission from *same* tree-level,
# there is a possibility to combine equal priority groups. In such case, reason is 'MULTIPLE'.
# - If not of equal priority, the appropriate permission is selected and reason is overridden
# accordingly by the new higher priority permission.
# - If no permission was defined at all (first occurrence), also set it using current permission
if scope_level in [None, current_level]:
resolved_perm = PermissionSet.resolve(perm_set, prev_perm, context=PermissionType.EFFECTIVE)
LOGGER.debug("Found permission [level=%s]: %r", current_level, resolved_perm)
effective_perms[perm_name] = resolved_perm
effective_level[perm_name] = current_level
# - If new permission is at *different* tree-level, it applies only if the group has higher
# priority than the previous one, to respect the *closest* scope to the target resource.
# Same priorities are ignored as they were already resolved by *closest* scope above.
# - Reset scope level with new permission such that another permission of same group priority as
# that could be processed in next iteration can be compared against it, to resolve 'access'
# priority between them.
elif perm_set.group_priority > prev_perm.group_priority:
LOGGER.debug("Found permission [level=%s]: %r (higher priority)", current_level, perm_set)
effective_perms[perm_name] = perm_set
effective_level[perm_name] = current_level
# don't bother moving to parent if everything is resolved already
# can only assume nothing left to resolve if all permissions are direct on user (highest priority)
# if any found permission is group inherited, higher level user permission could still override it
if (len(effective_perms) == len(requested_perms) and
all(perm.type == PermissionType.DIRECT for perm in effective_perms.values())):
LOGGER.debug("Found permission has highest possible priority. Stopping search.")
break
# otherwise, move to parent if any available, since we are not done rewinding the resource tree
allow_match = False # reset match not applicable anymore for following parent resources
current_level += 1
if resource.parent_id:
resource = ResourceService.by_resource_id(resource.parent_id, db_session=db_session)
else:
LOGGER.debug("No more children resources to process. Stopping search.")
resource = None
# set deny for all still unresolved permissions from requested ones
resolved_perms = set(effective_perms)
missing_perms = set(permissions) - resolved_perms
final_perms = set(effective_perms.values())
for perm_name in missing_perms:
perm = PermissionSet(perm_name, access=Access.DENY, scope=Scope.MATCH,
typ=PermissionType.EFFECTIVE, reason=PERMISSION_REASON_DEFAULT)
LOGGER.debug("Adding missing permission: %s (requested)", perm)
final_perms.add(perm)
final_perms = list(final_perms)
LOGGER.debug("Resolved applied permissions: %s", final_perms)
# enforce type and scope (use MATCH to make it explicit that it applies specifically for this resource)
for perm in final_perms:
perm.type = PermissionType.EFFECTIVE
perm.scope = Scope.MATCH
LOGGER.debug("Resolved effective permissions: %s", final_perms)
return final_perms
class ServiceOWS(ServiceInterface):
"""
Generic request-to-permission interpretation method of various ``OGC Web Service`` (OWS) implementations.
"""
params_expected = [] # type: List[Str]
"""
Request query parameters that are expected and should be preprocessed by parsing the submitted request.
"""
def __init__(self, service, request):
# type: (models.Service, Request) -> None
self._request = None
self.parser = None
super(ServiceOWS, self).__init__(service, request) # sets request, which in turn parses it with below setter
def _get_request(self):
# type: () -> Request
return self._request
def _set_request(self, request):
# type: (Request) -> None
self._request = request
if request is None:
return # avoid error parsing undefined request
# must reset the parser from scratch if request changes to ensure everything is updated with new inputs
self.parser = ows_parser_factory(request)
self.parser.parse(type(self).params_expected) # run parsing to obtain guaranteed lowercase parameters
request = property(_get_request, _set_request)
@abc.abstractmethod
def resource_requested(self):
# type: () -> MultiResourceRequested
raise NotImplementedError
def permission_requested(self):
# type: () -> PermissionRequested
try:
req = self.parser.params["request"]
perm = Permission.get(str(req).lower())
ax.verify_param(
perm, not_none=True, param_name="request", http_error=HTTPBadRequest,
content={"service": self.service.resource_name, "type": self.service_type, "value": req},
msg_on_fail=(
"Missing or unknown 'Permission' inferred from OWS 'request' parameter: [{!s}]. ".format(req) +
"Unable to resolve the requested access for service: [{!s}].".format(self.service.resource_name)
)
)
if perm not in self.permissions:
return None
return perm
except KeyError as exc:
raise NotImplementedError("Exception: [{!r}] for class '{}'.".format(exc, type(self)))
class ServiceWPS(ServiceOWS):
"""
Service that represents a ``Web Processing Service`` endpoint.
"""
service_type = "wps"
permissions = [
Permission.GET_CAPABILITIES,
# following don't make sense if 'MATCH' directly on Service,
# but can be set with 'RECURSIVE' for all Process children resources
Permission.DESCRIBE_PROCESS,
Permission.EXECUTE,
]
params_expected = [
"service",
"request",
"version",
"identifier",
]
resource_types_permissions = {
models.Process: models.Process.permissions
}
def resource_requested(self):
# type: () -> Optional[Tuple[ServiceOrResourceType, bool]]
wps_request = self.permission_requested()
if wps_request == Permission.GET_CAPABILITIES:
return self.service, True
if wps_request in [Permission.DESCRIBE_PROCESS, Permission.EXECUTE]:
wps_id = self.service.resource_id
proc_id = self.parser.params["identifier"]
if not proc_id:
return self.service, False
session = get_connected_session(self.request)
proc = models.find_children_by_name(proc_id, parent_id=wps_id, db_session=session)
if proc:
return proc, True
return self.service, False
LOGGER.debug("Unknown WPS operation for permission: %s", wps_request)
return None
class ServiceBaseWMS(ServiceOWS):
"""
Service that represents basic capabilities of a ``Web Map Service`` endpoint.
.. seealso::
https://www.ogc.org/standards/wms (OpenGIS WMS 1.3.0 implementation)
"""
@abc.abstractmethod
def resource_requested(self):
raise NotImplementedError
# base implementation only provides the following requests
permissions = [
Permission.GET_CAPABILITIES,
Permission.GET_MAP,
Permission.GET_FEATURE_INFO,
]
params_expected = [
"service",
"request",
"version",
"layers",
"layername",
"dataset"
]
class ServiceNCWMS2(ServiceBaseWMS):
"""
Service that represents a ``Web Map Service`` endpoint with functionalities specific to ``ncWMS2`` .
.. seealso::
https://reading-escience-centre.gitbooks.io/ncwms-user-guide/content/04-usage.html
"""
service_type = "ncwms"
permissions = [
Permission.GET_CAPABILITIES,
Permission.GET_MAP,
Permission.GET_FEATURE_INFO,
# ncWMS specific extensions
Permission.GET_LEGEND_GRAPHIC,
Permission.GET_METADATA,
# FIXME: not implemented
# Permission.GET_TIMESERIES,
# Permission.GET_VERTICAL_PROFILE,
# Permission.GET_VERTICAL_TRANSECT,
]
resource_types_permissions = {
models.File: [
Permission.GET_CAPABILITIES,
Permission.GET_MAP,
Permission.GET_FEATURE_INFO,
Permission.GET_LEGEND_GRAPHIC,
Permission.GET_METADATA,
],
models.Directory: [
Permission.GET_CAPABILITIES,
Permission.GET_MAP,
Permission.GET_FEATURE_INFO,
Permission.GET_LEGEND_GRAPHIC,
Permission.GET_METADATA,
]
}
def resource_requested(self):
# type: () -> Optional[Tuple[ServiceOrResourceType, bool]]
# According to the permission, the resource we want to authorize is not formatted the same way
permission_requested = self.permission_requested()
netcdf_file = None
if permission_requested == Permission.GET_CAPABILITIES:
# https://colibri.crim.ca/twitcher/ows/proxy/ncWMS2/wms?SERVICE=WMS&REQUEST=GetCapabilities&
# VERSION=1.3.0&DATASET=outputs/ouranos/subdaily/aet/pcp/aet_pcp_1961.nc
if "dataset" in self.parser.params:
netcdf_file = self.parser.params["dataset"]
elif permission_requested == Permission.GET_MAP:
# https://colibri.crim.ca/ncWMS2/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&FORMAT=image%2Fpng&
# TRANSPARENT=TRUE&ABOVEMAXCOLOR=extend&STYLES=default-scalar%2Fseq-Blues&
# LAYERS=outputs/ouranos/subdaily/aet/pcp/aet_pcp_1961.nc/PCP&EPSG=4326
netcdf_file = self.parser.params["layers"]
if netcdf_file:
netcdf_file = netcdf_file.rsplit("/", 1)[0]
elif permission_requested == Permission.GET_METADATA:
# https://colibri.crim.ca/ncWMS2/wms?request=GetMetadata&item=layerDetails&
# layerName=outputs/ouranos/subdaily/aet/pcp/aet_pcp_1961.nc/PCP
netcdf_file = self.parser.params["layername"]
if netcdf_file:
netcdf_file = netcdf_file.rsplit("/", 1)[0]
else:
return self.service, False
found_child = self.service
target = False
if netcdf_file:
if "output/" not in netcdf_file:
return self.service, False
# FIXME: this is probably too specific to birdhouse... leave as is for bw-compat, adjust as needed
netcdf_file = netcdf_file.replace("outputs/", "birdhouse/")
db_session = get_connected_session(self.request)
file_parts = netcdf_file.split("/")
while found_child and file_parts:
part = file_parts.pop(0)
res_id = found_child.resource_id
found_child = models.find_children_by_name(part, parent_id=res_id, db_session=db_session)
# target resource reached if no more parts to process, otherwise we have some parent (minimally the service)
target = not len(file_parts)
return found_child, target
class ServiceGeoserverBase(ServiceOWS):
"""
Provides basic configuration parameters and functionalities shared by `Geoserver` implementations.
"""
@property
@abc.abstractmethod
def service_base(self):
# type: () -> Str
"""
Name of the base :term:`OWS` functionality serviced by `Geoserver`.
"""
raise NotImplementedError
@classmethod
@classproperty
@abc.abstractmethod
def resource_scoped(cls):
# type: () -> bool
"""
Indicates if the :term:`Service` is allowed to employ scoped :class:`models.Workspace` naming.
When allowed, the :class:`models.Workspace` can be inferred from the request parameter
defined by :attr:`resource_param` to retrieve scoped name as ``<WORKSPACE>:<RESOURCE>``.
When not allowed, the resource name is left untouched and `Magpie` will not attempt to infer
any :class:`models.Workspace` from it. In that case, :class:`models.Workspace` can only be specified
in the request path for isolated :term:`Resource` references.
.. note::
When this parameter is ``False`` for a given :term:`Service` implementation, children
:term:`Resources <Resource>` can still be named in a similar ``<namespace>:<element>`` fashion.
The only distinction is that the **full** :term:`Resource` should include this complete definition
instead of nesting ``<namespace>`` and ``<element>`` into two distinct :term:`Resources <Resource>`.
"""
raise NotImplementedError
@classproperty
@abc.abstractmethod
def resource_multi(cls): # noqa # pylint: disable=E0213,no-self-argument,W0221,arguments-differ
# type: () -> bool
"""
Indicates if the :term:`Service` supports multiple simultaneous :term:`Resource` references.
When supported, the value retrieved from :attr:`resource_param` can be comma-separated to represent
multiple :term:`Resource` of the same nature, which can all be retrieved with the same request.
Otherwise, single value only is considered by default.
.. note::
Permission modifier :data:`Access.ALLOW` will have to be resolved for all those :term:`Resource`
references for the request to be granted access.
"""
raise NotImplementedError
@classproperty
@abc.abstractmethod
def resource_param(cls): # noqa # pylint: disable=E0213,no-self-argument,W0221,arguments-differ
# type: () -> Union[Str, List[Str]]
"""
Name of the request query parameter(s) to access requested leaf children resource.
If a single string is defined, the parameter must be equal to this value (case insensitive).
When using a list, any specified name combination will be resolved as the same parameter.
.. note::
The resulting parameter(s) are automatically added to :meth:`params_expected` to ensure they are always
retrieved in the :attr:`parser` from the request query parameters.
.. seealso::
:meth:`resource_param_requested` to obtain the resolved parameter considering any applicable combination.
"""
raise NotImplementedError
@classproperty
@abc.abstractmethod
def resource_types_permissions(cls): # noqa # pylint: disable=E0213,no-self-argument,W0221,arguments-differ
# type: () -> ResourceTypePermissions
"""
Explicit permissions provided for resources for a given :term:`OWS` implementation.
"""
raise NotImplementedError
@classproperty
def params_expected(cls): # noqa # pylint: disable=E0213,no-self-argument,W0221,arguments-differ
# type: () -> List[Str]
"""