Passed
Push — master ( ac1734...fff02f )
by
unknown
03:05
created

InquiriesController._can_respond()   B

Complexity

Conditions 6

Size

Total Lines 40

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
c 1
b 0
f 0
dl 0
loc 40
rs 7.5384
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
import json
17
from oslo_config import cfg
18
19
import copy
20
21
from six.moves import http_client
22
from st2common.models.db.auth import UserDB
23
from st2api.controllers.resource import ResourceController
24
from st2api.controllers.v1.executionviews import SUPPORTED_FILTERS
25
from st2common import log as logging
26
from st2common.util import action_db as action_utils
27
from st2common.util import schema as util_schema
28
from st2common.util import system_info
29
from st2common.services import executions
30
from st2common.constants import action as action_constants
31
from st2common.rbac.types import PermissionType
32
from st2common.rbac import utils as rbac_utils
33
from st2common.router import abort
34
from st2common.router import Response
35
from st2common.models.api.inquiry import InquiryAPI, InquiryResponseAPI
36
from st2common.persistence.execution import ActionExecution
37
from st2common.services import action as action_service
38
from st2common.util.action_db import (get_action_by_ref, get_runnertype_by_name)
39
40
from st2actions.container.base import get_runner_container
41
42
__all__ = [
43
    'InquiriesController'
44
]
45
46
LOG = logging.getLogger(__name__)
47
INQUIRY_RUNNER = 'inquirer'
48
49
50
class InquiriesController(ResourceController):
51
    """API controller for Inquiries
52
    """
53
54
    supported_filters = copy.deepcopy(SUPPORTED_FILTERS)
55
56
    # No data model currently exists for Inquiries, so we're "borrowing" ActionExecutions
57
    # for the DB layer
58
    model = InquiryAPI
59
    access = ActionExecution
60
61
    def get_all(self, requester_user=None, limit=None, **raw_filters):
62
        """Retrieve multiple Inquiries
63
64
            Handles requests:
65
                GET /inquiries/
66
        """
67
68
        raw_inquiries = super(InquiriesController, self)._get_all(
69
            limit=limit,
70
            raw_filters={
71
                'status': action_constants.LIVEACTION_STATUS_PENDING,
72
                'runner': INQUIRY_RUNNER
73
            }
74
        )
75
76
        # Since "model" is set to InquiryAPI (for good reasons), _get_all returns a list of
77
        # InquiryAPI instances, already converted to JSON. So in order to convert these to
78
        # InquiryResponseAPI instances, we first have to convert raw_inquiries.body back to
79
        # a list of dicts, and then individually convert these to InquiryResponseAPI instances
80
        inquiries = [InquiryResponseAPI.from_model(raw_inquiry, skip_db=True)
81
                     for raw_inquiry in json.loads(raw_inquiries.body)]
82
83
        # Repackage into Response with correct headers
84
        resp = Response(json=inquiries)
85
        resp.headers['X-Total-Count'] = raw_inquiries.headers['X-Total-Count']
86
        if limit:
87
            resp.headers['X-Limit'] = str(limit)
88
        return resp
89
90
    def get_one(self, inquiry_id, requester_user=None):
91
        """Retrieve a single Inquiry
92
93
            Handles requests:
94
                GET /inquiries/<inquiry id>
95
        """
96
97
        # Retrieve the desired inquiry
98
        #
99
        # (Passing permission_type here leverages _get_one_by_id's built-in
100
        # RBAC assertions)
101
        inquiry = self._get_one_by_id(
102
            id=inquiry_id,
103
            requester_user=requester_user,
104
            permission_type=PermissionType.INQUIRY_VIEW
105
        )
106
107
        sanity_result, msg = self._inquiry_sanity_check(inquiry)
108
        if not sanity_result:
109
            abort(http_client.BAD_REQUEST, msg)
110
            return
111
112
        return InquiryResponseAPI.from_inquiry_api(inquiry)
113
114
    def put(self, inquiry_id, response_data, requester_user):
115
        """Provide response data to an Inquiry
116
117
            In general, provided the response data validates against the provided
118
            schema, and the user has the appropriate permissions to respond,
119
            this will set the Inquiry execution to a successful status, and resume
120
            the parent workflow.
121
122
            Handles requests:
123
                PUT /inquiries/<inquiry id>
124
        """
125
126
        LOG.debug("Inquiry %s received response payload: %s" % (inquiry_id, response_data.response))
127
128
        # Retrieve details of the inquiry via ID (i.e. params like schema)
129
        inquiry = self._get_one_by_id(
130
            id=inquiry_id,
131
            requester_user=requester_user,
132
            permission_type=PermissionType.INQUIRY_RESPOND
133
        )
134
135
        sanity_result, msg = self._inquiry_sanity_check(inquiry)
136
        if not sanity_result:
137
            abort(http_client.BAD_REQUEST, msg)
138
            return
139
140
        if not requester_user:
141
            requester_user = UserDB(cfg.CONF.system_user.user)
142
143
        # Determine permission of this user to respond to this Inquiry
144
        if not self._can_respond(inquiry, requester_user):
145
            abort(
146
                http_client.FORBIDDEN,
147
                'Requesting user does not have permission to respond to inquiry %s.' % inquiry_id
148
            )
149
            return
150
151
        # Validate the body of the response against the schema parameter for this inquiry
152
        schema = inquiry.schema
153
        LOG.debug("Validating inquiry response: %s against schema: %s" %
154
                  (response_data.response, schema))
155
        try:
156
            util_schema.validate(instance=response_data.response, schema=schema,
157
                                 cls=util_schema.CustomValidator, use_default=True,
158
                                 allow_default_none=True)
159
        except Exception as e:
160
            LOG.debug("Failed to validate response data against provided schema: %s" % e.message)
161
            abort(http_client.BAD_REQUEST, 'Response did not pass schema validation.')
162
            return
163
164
        # Update inquiry for completion
165
        new_result = copy.deepcopy(inquiry.result)
166
        new_result["response"] = response_data.response
167
        liveaction_db = self._mark_inquiry_complete(
168
            inquiry.liveaction.get('id'),
169
            new_result
170
        )
171
172
        # We only want to request a workflow resume if this has a parent
173
        if liveaction_db.context.get("parent"):
174
175
            # Request that root workflow resumes
176
            root_liveaction = action_service.get_root_liveaction(liveaction_db)
177
            action_service.request_resume(
178
                root_liveaction,
179
                requester_user
180
            )
181
182
        return {
183
            "id": inquiry_id,
184
            "response": response_data.response
185
        }
186
187
    def _inquiry_sanity_check(self, inquiry_candidate):
188
        """Sanity checks for ensuring that a retrieved execution is indeed an Inquiry
189
190
        It must use the "inquirer" runner, and it must currently be in a "pending" status
191
192
        :param inquiry_candidate: The inquiry to check
193
194
        :rtype: bool - True if a valid Inquiry. False if not.
195
        :rtype: str - Error message, if any
196
        """
197
198
        if inquiry_candidate.runner.get('runner_module') != "inquirer":
199
            return (False, '%s is not an Inquiry.' % inquiry_candidate.id)
200
201
        if inquiry_candidate.status == action_constants.LIVEACTION_STATUS_TIMED_OUT:
202
            return (
203
                False,
204
                'Inquiry %s timed out and can no longer be responded to' % inquiry_candidate.id
205
            )
206
207
        if inquiry_candidate.status != action_constants.LIVEACTION_STATUS_PENDING:
208
            return (False, 'Inquiry %s has already been responded to' % inquiry_candidate.id)
209
210
        return (True, "")
211
212
    def _mark_inquiry_complete(self, inquiry_id, result):
213
        """Mark Inquiry as completed
214
215
        This function updates the local LiveAction and Execution with a successful
216
        status as well as call the "post_run" function for the Inquirer runner so that
217
        the appropriate callback function is executed
218
219
        :param inquiry: The Inquiry for which the response is given
220
        :param requester_user: The user providing the response
221
222
        :rtype: bool - True if requester_user is able to respond. False if not.
223
        """
224
225
        # Update inquiry's execution result with a successful status and the validated response
226
        liveaction_db = action_utils.update_liveaction_status(
227
            status=action_constants.LIVEACTION_STATUS_SUCCEEDED,
228
            runner_info=system_info.get_process_info(),
229
            result=result,
230
            liveaction_id=inquiry_id)
231
        executions.update_execution(liveaction_db)
232
233
        # Call Inquiry runner's post_run to trigger callback to workflow
234
        runner_container = get_runner_container()
235
        action_db = get_action_by_ref(liveaction_db.action)
236
        runnertype_db = get_runnertype_by_name(action_db.runner_type['name'])
237
        runner = runner_container._get_runner(runnertype_db, action_db, liveaction_db)
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _get_runner 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...
238
        runner.post_run(status=action_constants.LIVEACTION_STATUS_SUCCEEDED, result=result)
239
240
        return liveaction_db
241
242
    def _can_respond(self, inquiry, requester_user):
243
        """Determine if requester_user is permitted to respond based on parameters
244
245
        This determines if the requesting user has permission to respond to THIS inquiry.
246
        Note this is NOT RBAC, and you should still protect the API endpoint with RBAC
247
        where appropriate.
248
249
        :param inquiry: The Inquiry for which the response is given
250
        :param requester_user: The user providing the response
251
252
        :rtype: bool - True if requester_user is able to respond. False if not.
253
        """
254
255
        # Deny by default
256
        roles_passed = False
257
        users_passed = False
258
259
        # Determine role-level permissions
260
        roles = getattr(inquiry, 'roles', [])
261
262
        if not roles:
263
            # No roles definition so we treat it as a pass
264
            roles_passed = True
265
266
        for role in roles:
267
            LOG.debug("Checking user %s is in role %s - %s" % (
268
                requester_user, role, rbac_utils.user_has_role(requester_user, role))
269
            )
270
271
            if rbac_utils.user_has_role(requester_user, role):
272
                roles_passed = True
273
                break
274
275
        # Determine user-level permissions
276
        users = getattr(inquiry, 'users', [])
277
        if not users or requester_user.name in users:
278
            users_passed = True
279
280
        # Both must pass
281
        return roles_passed and users_passed
282
283
    def _get_one_by_id(self, id, requester_user, permission_type, exclude_fields=None,
0 ignored issues
show
Bug Best Practice introduced by
This seems to re-define the built-in id.

It is generally discouraged to redefine built-ins as this makes code very hard to read.

Loading history...
284
                       from_model_kwargs=None):
285
        """Override ResourceController._get_one_by_id to contain scope of Inquiries UID hack
286
287
        :param exclude_fields: A list of object fields to exclude.
288
        :type exclude_fields: ``list``
289
        """
290
291
        instance = self._get_by_id(resource_id=id, exclude_fields=exclude_fields)
292
293
        # _get_by_id pulls the resource by ID directly off of the database. Since
294
        # Inquiries don't have their own DB model yet, this comes in the format
295
        # "execution:<id>". So, to allow RBAC to get a handle on inquiries specifically,
296
        # we're overriding the "get_uid" function to return one specific to Inquiries.
297
        #
298
        # TODO (mierdin): All of this should be removed once Inquiries get their own DB model
299
        if getattr(instance, 'runner', None) and instance.runner.get('runner_module') == 'inquirer':
300
            def get_uid():
301
                return "inquiry"
302
            instance.get_uid = get_uid
303
304
        if permission_type:
305
            rbac_utils.assert_user_has_resource_db_permission(user_db=requester_user,
306
                                                              resource_db=instance,
307
                                                              permission_type=permission_type)
308
309
        if not instance:
310
            msg = 'Unable to identify resource with id "%s".' % id
311
            abort(http_client.NOT_FOUND, msg)
312
313
        from_model_kwargs = from_model_kwargs or {}
314
        from_model_kwargs.update(self.from_model_kwargs)
315
        result = self.model.from_model(instance, **from_model_kwargs)
316
317
        return result
318
319
320
inquiries_controller = InquiriesController()
321