Test Failed
Push — master ( e380d0...f5671d )
by W
02:58
created

st2api/st2api/controllers/resource.py (1 issue)

1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
# pylint: disable=no-member
17
18
import abc
19
import copy
20
21
from oslo_config import cfg
22
from mongoengine import ValidationError, LookUpError
23
import six
24
from six.moves import http_client
25
26
from st2common import log as logging
27
from st2common.models.system.common import ResourceReference
28
from st2common.exceptions.db import StackStormDBObjectNotFoundError
29
from st2common.exceptions.rbac import ResourceAccessDeniedPermissionIsolationError
30
from st2common.rbac import utils as rbac_utils
31
from st2common.exceptions.rbac import AccessDeniedError
32
from st2common.util import schema as util_schema
33
from st2common.router import abort
34
from st2common.router import Response
35
36
LOG = logging.getLogger(__name__)
37
38
RESERVED_QUERY_PARAMS = {
39
    'id': 'id',
40
    'name': 'name',
41
    'sort': 'order_by'
42
}
43
44
45
def split_id_value(value):
46
    if not value or isinstance(value, (list, tuple)):
47
        return value
48
49
    split = value.split(',')
50
51
    if len(split) > 100:
52
        raise ValueError('Maximum of 100 items can be provided for a query parameter value')
53
54
    return split
55
56
57
DEFAULT_FILTER_TRANSFORM_FUNCTIONS = {
58
    # Support for filtering on multiple ids when a commona delimited string is provided
59
    # (e.g. ?id=1,2,3)
60
    'id': split_id_value
61
}
62
63
64
def parameter_validation(validator, properties, instance, schema):
65
    parameter_specific_schema = {
66
        "description": "Input parameters for the action.",
67
        "type": "object",
68
        "patternProperties": {
69
            "^\w+$": util_schema.get_action_parameters_schema()
70
        },
71
        'additionalProperties': False,
72
        "default": {}
73
    }
74
75
    parameter_specific_validator = util_schema.CustomValidator(parameter_specific_schema)
76
77
    for error in parameter_specific_validator.iter_errors(instance=instance):
78
        yield error
79
80
81
@six.add_metaclass(abc.ABCMeta)
82
class ResourceController(object):
83
    model = abc.abstractproperty
84
    access = abc.abstractproperty
85
    supported_filters = abc.abstractproperty
86
87
    # Default kwargs passed to "APIClass.from_model" method
88
    from_model_kwargs = {}
89
90
    # Maximum value of limit which can be specified by user
91
    @property
92
    def max_limit(self):
93
        return cfg.CONF.api.max_page_size
94
95
    # Default number of items returned per page if no limit is explicitly provided
96
    default_limit = 100
97
98
    query_options = {
99
        'sort': []
100
    }
101
102
    # A list of optional transformation functions for user provided filter values
103
    filter_transform_functions = {
104
    }
105
106
    # A list of attributes which can be specified using ?exclude_attributes filter
107
    # If not provided, no validation is performed.
108
    valid_exclude_attributes = []
109
110
    # Method responsible for retrieving an instance of the corresponding model DB object
111
    # Note: This method should throw StackStormDBObjectNotFoundError if the corresponding DB
112
    # object doesn't exist
113
    get_one_db_method = None
114
115
    def __init__(self):
116
        self.supported_filters = copy.deepcopy(self.__class__.supported_filters)
117
        self.supported_filters.update(RESERVED_QUERY_PARAMS)
118
119
        self.filter_transform_functions = copy.deepcopy(self.__class__.filter_transform_functions)
120
        self.filter_transform_functions.update(DEFAULT_FILTER_TRANSFORM_FUNCTIONS)
121
122
        self.get_one_db_method = self._get_by_name_or_id
123
124
    def _get_all(self, exclude_fields=None, include_fields=None, advanced_filters=None,
125
                 sort=None, offset=0, limit=None, query_options=None,
126
                 from_model_kwargs=None, raw_filters=None, requester_user=None):
127
        """
128
        :param exclude_fields: A list of object fields to exclude.
129
        :type exclude_fields: ``list``
130
        """
131
        raw_filters = copy.deepcopy(raw_filters) or {}
132
133
        exclude_fields = exclude_fields or []
134
        include_fields = include_fields or []
135
        query_options = query_options if query_options else self.query_options
136
137
        # TODO: Why do we use comma delimited string, user can just specify
138
        # multiple values using ?sort=foo&sort=bar and we get a list back
139
        sort = sort.split(',') if sort else []
140
141
        db_sort_values = []
142
        for sort_key in sort:
143
            if sort_key.startswith('-'):
144
                direction = '-'
145
                sort_key = sort_key[1:]
146
            elif sort_key.startswith('+'):
147
                direction = '+'
148
                sort_key = sort_key[1:]
149
            else:
150
                direction = ''
151
152
            if sort_key not in self.supported_filters:
153
                # Skip unsupported sort key
154
                continue
155
156
            sort_value = direction + self.supported_filters[sort_key]
157
            db_sort_values.append(sort_value)
158
159
        default_sort_values = copy.copy(query_options.get('sort'))
160
        raw_filters['sort'] = db_sort_values if db_sort_values else default_sort_values
161
162
        # TODO: To protect us from DoS, we need to make max_limit mandatory
163
        offset = int(offset)
164
        if offset >= 2**31:
165
            raise ValueError('Offset "%s" specified is more than 32-bit int' % (offset))
166
167
        limit = validate_limit_query_param(limit=limit, requester_user=requester_user)
168
        eop = offset + int(limit) if limit else None
169
170
        filters = {}
171
        for k, v in six.iteritems(self.supported_filters):
172
            filter_value = raw_filters.get(k, None)
173
174
            if not filter_value:
175
                continue
176
177
            value_transform_function = self.filter_transform_functions.get(k, None)
178
            value_transform_function = value_transform_function or (lambda value: value)
179
            filter_value = value_transform_function(value=filter_value)
180
181
            if k in ['id', 'name'] and isinstance(filter_value, list):
182
                filters[k + '__in'] = filter_value
183
            else:
184
                field_name_split = v.split('.')
185
186
                # Make sure filter value is a list when using "in" filter
187
                if field_name_split[-1] == 'in' and not isinstance(filter_value, (list, tuple)):
188
                    filter_value = [filter_value]
189
190
                filters['__'.join(field_name_split)] = filter_value
191
192
        if advanced_filters:
193
            for token in advanced_filters.split(' '):
194
                try:
195
                    [k, v] = token.split(':', 1)
196
                except ValueError:
197
                    raise ValueError('invalid format for filter "%s"' % token)
198
                path = k.split('.')
199
                try:
200
                    self.model.model._lookup_field(path)
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _lookup_field was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
201
                    filters['__'.join(path)] = v
202
                except LookUpError as e:
203
                    raise ValueError(str(e))
204
205
        if exclude_fields and include_fields:
206
            msg = ('exclude_fields and include_fields arguments are mutually exclusive. '
207
                   'You need to provide either one or another, but not both.')
208
            raise ValueError(msg)
209
210
        instances = self.access.query(exclude_fields=exclude_fields, only_fields=include_fields,
211
                                      **filters)
212
        if limit == 1:
213
            # Perform the filtering on the DB side
214
            instances = instances.limit(limit)
215
216
        from_model_kwargs = from_model_kwargs or {}
217
        from_model_kwargs.update(self.from_model_kwargs)
218
219
        result = self.resources_model_filter(model=self.model,
220
                                             instances=instances,
221
                                             offset=offset,
222
                                             eop=eop,
223
                                             requester_user=requester_user,
224
                                             **from_model_kwargs)
225
226
        resp = Response(json=result)
227
        resp.headers['X-Total-Count'] = str(instances.count())
228
229
        if limit:
230
            resp.headers['X-Limit'] = str(limit)
231
232
        return resp
233
234
    def resources_model_filter(self, model, instances, requester_user=None, offset=0, eop=0,
235
                              **from_model_kwargs):
236
        """
237
        Method which converts DB objects to API objects and performs any additional filtering.
238
        """
239
240
        result = []
241
        for instance in instances[offset:eop]:
242
            item = model.from_model(instance, **from_model_kwargs)
243
            result.append(item)
244
        return result
245
246
    def resource_model_filter(self, model, instance, requester_user=None, **from_model_kwargs):
247
        """
248
        Method which converts DB object to API object and performs any additional filtering.
249
        """
250
        item = model.from_model(instance, **from_model_kwargs)
251
        return item
252
253
    def _get_one_by_id(self, id, requester_user, permission_type, exclude_fields=None,
254
                       from_model_kwargs=None):
255
        """
256
        :param exclude_fields: A list of object fields to exclude.
257
        :type exclude_fields: ``list``
258
        """
259
260
        instance = self._get_by_id(resource_id=id, exclude_fields=exclude_fields)
261
262
        if permission_type:
263
            rbac_utils.assert_user_has_resource_db_permission(user_db=requester_user,
264
                                                              resource_db=instance,
265
                                                              permission_type=permission_type)
266
267
        if not instance:
268
            msg = 'Unable to identify resource with id "%s".' % id
269
            abort(http_client.NOT_FOUND, msg)
270
271
        from_model_kwargs = from_model_kwargs or {}
272
        from_model_kwargs.update(self.from_model_kwargs)
273
274
        result = self.resource_model_filter(model=self.model, instance=instance,
275
                                            requester_user=requester_user,
276
                                            **from_model_kwargs)
277
278
        if not result:
279
            LOG.debug('Not returning the result because RBAC resource isolation is enabled and '
280
                      'current user doesn\'t match the resource user')
281
            raise ResourceAccessDeniedPermissionIsolationError(user_db=requester_user,
282
                                                               resource_api_or_db=instance,
283
                                                               permission_type=permission_type)
284
285
        return result
286
287
    def _get_one_by_name_or_id(self, name_or_id, requester_user, permission_type,
288
                               exclude_fields=None, from_model_kwargs=None):
289
        """
290
        :param exclude_fields: A list of object fields to exclude.
291
        :type exclude_fields: ``list``
292
        """
293
294
        instance = self._get_by_name_or_id(name_or_id=name_or_id, exclude_fields=exclude_fields)
295
296
        if permission_type:
297
            rbac_utils.assert_user_has_resource_db_permission(user_db=requester_user,
298
                                                              resource_db=instance,
299
                                                              permission_type=permission_type)
300
301
        if not instance:
302
            msg = 'Unable to identify resource with name_or_id "%s".' % (name_or_id)
303
            abort(http_client.NOT_FOUND, msg)
304
305
        from_model_kwargs = from_model_kwargs or {}
306
        from_model_kwargs.update(self.from_model_kwargs)
307
        result = self.model.from_model(instance, **from_model_kwargs)
308
309
        return result
310
311
    def _get_one_by_pack_ref(self, pack_ref, exclude_fields=None, include_fields=None,
312
                             from_model_kwargs=None):
313
        instance = self._get_by_pack_ref(pack_ref=pack_ref, exclude_fields=exclude_fields,
314
                                         include_fields=include_fields)
315
316
        if not instance:
317
            msg = 'Unable to identify resource with pack_ref "%s".' % (pack_ref)
318
            abort(http_client.NOT_FOUND, msg)
319
320
        from_model_kwargs = from_model_kwargs or {}
321
        from_model_kwargs.update(self.from_model_kwargs)
322
        result = self.model.from_model(instance, **from_model_kwargs)
323
324
        return result
325
326
    def _get_by_id(self, resource_id, exclude_fields=None, include_fields=None):
327
        try:
328
            resource_db = self.access.get(id=resource_id, exclude_fields=exclude_fields,
329
                                          only_fields=include_fields)
330
        except ValidationError:
331
            resource_db = None
332
333
        return resource_db
334
335
    def _get_by_name(self, resource_name, exclude_fields=None, include_fields=None):
336
        try:
337
            resource_db = self.access.get(name=resource_name, exclude_fields=exclude_fields,
338
                                          only_fields=include_fields)
339
        except Exception:
340
            resource_db = None
341
342
        return resource_db
343
344
    def _get_by_pack_ref(self, pack_ref, exclude_fields=None, include_fields=None):
345
        try:
346
            resource_db = self.access.get(pack=pack_ref, exclude_fields=exclude_fields,
347
                                          only_fields=include_fields)
348
        except Exception:
349
            resource_db = None
350
351
        return resource_db
352
353
    def _get_by_name_or_id(self, name_or_id, exclude_fields=None, include_fields=None):
354
        """
355
        Retrieve resource object by an id of a name.
356
        """
357
        resource_db = self._get_by_id(resource_id=name_or_id, exclude_fields=exclude_fields,
358
                                      include_fields=include_fields)
359
360
        if not resource_db:
361
            # Try name
362
            resource_db = self._get_by_name(resource_name=name_or_id,
363
                                            exclude_fields=exclude_fields)
364
365
        if not resource_db:
366
            msg = 'Resource with a name or id "%s" not found' % (name_or_id)
367
            raise StackStormDBObjectNotFoundError(msg)
368
369
        return resource_db
370
371
    def _get_one_by_scope_and_name(self, scope, name, from_model_kwargs=None):
372
        """
373
        Retrieve an item given scope and name. Only KeyValuePair now has concept of 'scope'.
374
375
        :param scope: Scope the key belongs to.
376
        :type scope: ``str``
377
378
        :param name: Name of the key.
379
        :type name: ``str``
380
        """
381
        instance = self.access.get_by_scope_and_name(scope=scope, name=name)
382
        if not instance:
383
            msg = 'KeyValuePair with name: %s and scope: %s not found in db.' % (name, scope)
384
            raise StackStormDBObjectNotFoundError(msg)
385
        from_model_kwargs = from_model_kwargs or {}
386
        result = self.model.from_model(instance, **from_model_kwargs)
387
        LOG.debug('GET with scope=%s and name=%s, client_result=%s', scope, name, result)
388
389
        return result
390
391
    def _validate_exclude_fields(self, exclude_fields):
392
        """
393
        Validate that provided exclude fields are valid.
394
        """
395
        if not exclude_fields:
396
            return exclude_fields
397
398
        if not self.valid_exclude_attributes:
399
            return exclude_fields
400
401
        for field in exclude_fields:
402
            if field not in self.valid_exclude_attributes:
403
                msg = 'Invalid or unsupported exclude attribute specified: %s' % (field)
404
                raise ValueError(msg)
405
406
        return exclude_fields
407
408
409
class BaseResourceIsolationControllerMixin(object):
410
    """
411
    Base API controller which isolates resources for users. Users can only see their own resources.
412
413
    Exceptions include admin and system user which can view all the resources (also for other
414
    users).
415
    """
416
417
    def resources_model_filter(self, model, instances, requester_user=None, offset=0, eop=0,
418
                              **from_model_kwargs):
419
        # RBAC or permission isolation is disabled, bail out
420
        if not (cfg.CONF.rbac.enable and cfg.CONF.rbac.permission_isolation):
421
            result = super(BaseResourceIsolationControllerMixin, self).resources_model_filter(
422
                model=model, instances=instances, requester_user=requester_user,
423
                offset=offset, eop=eop, **from_model_kwargs)
424
425
            return result
426
427
        result = []
428
        for instance in instances[offset:eop]:
429
            item = self.resource_model_filter(model=model, instance=instance,
430
                                              requester_user=requester_user, **from_model_kwargs)
431
432
            if not item:
433
                continue
434
435
            result.append(item)
436
437
        return result
438
439
    def resource_model_filter(self, model, instance, requester_user=None, **from_model_kwargs):
440
        # RBAC or permission isolation is disabled, bail out
441
        if not (cfg.CONF.rbac.enable and cfg.CONF.rbac.permission_isolation):
442
            result = super(BaseResourceIsolationControllerMixin, self).resource_model_filter(
443
                model=model, instance=instance, requester_user=requester_user,
444
                **from_model_kwargs)
445
446
            return result
447
448
        user_is_admin = rbac_utils.user_is_admin(user_db=requester_user)
449
        user_is_system_user = (requester_user.name == cfg.CONF.system_user.user)
450
451
        item = model.from_model(instance, **from_model_kwargs)
452
453
        # Admin users and system users can view all the resoruces
454
        if user_is_admin or user_is_system_user:
455
            return item
456
457
        user = item.context.get('user', None)
458
        if user and (user == requester_user.name):
459
            return item
460
461
        return None
462
463
464
class ContentPackResourceController(ResourceController):
465
    include_reference = False
466
467
    def __init__(self):
468
        super(ContentPackResourceController, self).__init__()
469
        self.get_one_db_method = self._get_by_ref_or_id
470
471
    def _get_one(self, ref_or_id, requester_user, permission_type, exclude_fields=None,
472
                 include_fields=None, from_model_kwargs=None):
473
        try:
474
            instance = self._get_by_ref_or_id(ref_or_id=ref_or_id, exclude_fields=exclude_fields,
475
                                              include_fields=include_fields)
476
        except Exception as e:
477
            LOG.exception(str(e))
478
            abort(http_client.NOT_FOUND, str(e))
479
            return
480
481
        if permission_type:
482
            rbac_utils.assert_user_has_resource_db_permission(user_db=requester_user,
483
                                                              resource_db=instance,
484
                                                              permission_type=permission_type)
485
486
        # Perform resource isolation check (if supported)
487
        from_model_kwargs = from_model_kwargs or {}
488
        from_model_kwargs.update(self.from_model_kwargs)
489
490
        result = self.resource_model_filter(model=self.model, instance=instance,
491
                                            requester_user=requester_user,
492
                                            **from_model_kwargs)
493
494
        if not result:
495
            LOG.debug('Not returning the result because RBAC resource isolation is enabled and '
496
                      'current user doesn\'t match the resource user')
497
            raise ResourceAccessDeniedPermissionIsolationError(user_db=requester_user,
498
                                                               resource_api_or_db=instance,
499
                                                               permission_type=permission_type)
500
501
        if result and self.include_reference:
502
            pack = getattr(result, 'pack', None)
503
            name = getattr(result, 'name', None)
504
            result.ref = ResourceReference(pack=pack, name=name).ref
505
506
        return Response(json=result)
507
508
    def _get_all(self, exclude_fields=None, include_fields=None,
509
                 sort=None, offset=0, limit=None, query_options=None,
510
                 from_model_kwargs=None, raw_filters=None, requester_user=None):
511
        resp = super(ContentPackResourceController,
512
                     self)._get_all(exclude_fields=exclude_fields,
513
                                    include_fields=include_fields,
514
                                    sort=sort,
515
                                    offset=offset,
516
                                    limit=limit,
517
                                    query_options=query_options,
518
                                    from_model_kwargs=from_model_kwargs,
519
                                    raw_filters=raw_filters,
520
                                    requester_user=requester_user)
521
522
        if self.include_reference:
523
            result = resp.json
524
            for item in result:
525
                pack = item.get('pack', None)
526
                name = item.get('name', None)
527
                item['ref'] = ResourceReference(pack=pack, name=name).ref
528
            resp.json = result
529
530
        return resp
531
532
    def _get_by_ref_or_id(self, ref_or_id, exclude_fields=None, include_fields=None):
533
        """
534
        Retrieve resource object by an id of a reference.
535
536
        Note: This method throws StackStormDBObjectNotFoundError exception if the object is not
537
        found in the database.
538
        """
539
540
        if exclude_fields and include_fields:
541
            msg = ('exclude_fields and include_fields arguments are mutually exclusive. '
542
                   'You need to provide either one or another, but not both.')
543
            raise ValueError(msg)
544
545
        if ResourceReference.is_resource_reference(ref_or_id):
546
            # references always contain a dot and id's can't contain it
547
            is_reference = True
548
        else:
549
            is_reference = False
550
551
        if is_reference:
552
            resource_db = self._get_by_ref(resource_ref=ref_or_id, exclude_fields=exclude_fields,
553
                                          include_fields=include_fields)
554
        else:
555
            resource_db = self._get_by_id(resource_id=ref_or_id, exclude_fields=exclude_fields,
556
                                          include_fields=include_fields)
557
558
        if not resource_db:
559
            msg = 'Resource with a reference or id "%s" not found' % (ref_or_id)
560
            raise StackStormDBObjectNotFoundError(msg)
561
562
        return resource_db
563
564
    def _get_by_ref(self, resource_ref, exclude_fields=None, include_fields=None):
565
        if exclude_fields and include_fields:
566
            msg = ('exclude_fields and include_fields arguments are mutually exclusive. '
567
                   'You need to provide either one or another, but not both.')
568
            raise ValueError(msg)
569
570
        try:
571
            ref = ResourceReference.from_string_reference(ref=resource_ref)
572
        except Exception:
573
            return None
574
575
        resource_db = self.access.query(name=ref.name, pack=ref.pack,
576
                                        exclude_fields=exclude_fields,
577
                                        only_fields=include_fields).first()
578
        return resource_db
579
580
581
def validate_limit_query_param(limit, requester_user=None):
582
    """
583
    Validate that the provided value for "limit" query parameter is valid.
584
585
    Note: We only perform max_page_size check for non-admin users. Admin users
586
    can provide arbitrary limit value.
587
    """
588
    user_is_admin = rbac_utils.user_is_admin(user_db=requester_user)
589
590
    if limit:
591
        # Display all the results
592
        if int(limit) == -1:
593
            if not user_is_admin:
594
                # Only admins can specify limit -1
595
                message = ('Administrator access required to be able to specify limit=-1 and '
596
                           'retrieve all the records')
597
                raise AccessDeniedError(message=message,
598
                                        user_db=requester_user)
599
600
            return 0
601
        elif int(limit) <= -2:
602
            msg = 'Limit, "%s" specified, must be a positive number.' % (limit)
603
            raise ValueError(msg)
604
        elif int(limit) > cfg.CONF.api.max_page_size and not user_is_admin:
605
            msg = ('Limit "%s" specified, maximum value is "%s"' % (limit,
606
                                                                    cfg.CONF.api.max_page_size))
607
608
            raise AccessDeniedError(message=msg,
609
                                    user_db=requester_user)
610
    # Disable n = 0
611
    elif limit == 0:
612
        msg = ('Limit, "%s" specified, must be a positive number or -1 for full result set.' %
613
               (limit))
614
        raise ValueError(msg)
615
616
    return limit
617