Test Failed
Push — master ( 0496d3...626f66 )
by W
01:28
created

st2api/st2api/controllers/exp/inquiries.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
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.execution_views 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
            requester_user=requester_user
75
        )
76
77
        # Since "model" is set to InquiryAPI (for good reasons), _get_all returns a list of
78
        # InquiryAPI instances, already converted to JSON. So in order to convert these to
79
        # InquiryResponseAPI instances, we first have to convert raw_inquiries.body back to
80
        # a list of dicts, and then individually convert these to InquiryResponseAPI instances
81
        inquiries = [InquiryResponseAPI.from_model(raw_inquiry, skip_db=True)
82
                     for raw_inquiry in json.loads(raw_inquiries.body)]
83
84
        # Repackage into Response with correct headers
85
        resp = Response(json=inquiries)
86
        resp.headers['X-Total-Count'] = raw_inquiries.headers['X-Total-Count']
87
        if limit:
88
            resp.headers['X-Limit'] = str(limit)
89
        return resp
90
91
    def get_one(self, inquiry_id, requester_user=None):
92
        """Retrieve a single Inquiry
93
94
            Handles requests:
95
                GET /inquiries/<inquiry id>
96
        """
97
98
        # Retrieve the desired inquiry
99
        #
100
        # (Passing permission_type here leverages _get_one_by_id's built-in
101
        # RBAC assertions)
102
        inquiry = self._get_one_by_id(
103
            id=inquiry_id,
104
            requester_user=requester_user,
105
            permission_type=PermissionType.INQUIRY_VIEW
106
        )
107
108
        sanity_result, msg = self._inquiry_sanity_check(inquiry)
109
        if not sanity_result:
110
            abort(http_client.BAD_REQUEST, msg)
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
139
        if not requester_user:
140
            requester_user = UserDB(cfg.CONF.system_user.user)
141
142
        # Determine permission of this user to respond to this Inquiry
143
        if not self._can_respond(inquiry, requester_user):
144
            abort(
145
                http_client.FORBIDDEN,
146
                'Requesting user does not have permission to respond to inquiry %s.' % inquiry_id
147
            )
148
149
        # Validate the body of the response against the schema parameter for this inquiry
150
        schema = inquiry.schema
151
        LOG.debug("Validating inquiry response: %s against schema: %s" %
152
                  (response_data.response, schema))
153
        try:
154
            util_schema.validate(instance=response_data.response, schema=schema,
155
                                 cls=util_schema.CustomValidator, use_default=True,
156
                                 allow_default_none=True)
157
        except Exception as e:
158
            LOG.debug("Failed to validate response data against provided schema: %s" % e.message)
159
            abort(http_client.BAD_REQUEST, 'Response did not pass schema validation.')
160
161
        # Update inquiry for completion
162
        new_result = copy.deepcopy(inquiry.result)
163
        new_result["response"] = response_data.response
164
        liveaction_db = self._mark_inquiry_complete(
165
            inquiry.liveaction.get('id'),
166
            new_result
167
        )
168
169
        # We only want to request a workflow resume if this has a parent
170
        if liveaction_db.context.get("parent"):
171
172
            # Request that root workflow resumes
173
            root_liveaction = action_service.get_root_liveaction(liveaction_db)
174
            action_service.request_resume(
175
                root_liveaction,
176
                requester_user
177
            )
178
179
        return {
180
            "id": inquiry_id,
181
            "response": response_data.response
182
        }
183
184
    def _inquiry_sanity_check(self, inquiry_candidate):
185
        """Sanity checks for ensuring that a retrieved execution is indeed an Inquiry
186
187
        It must use the "inquirer" runner, and it must currently be in a "pending" status
188
189
        :param inquiry_candidate: The inquiry to check
190
191
        :rtype: bool - True if a valid Inquiry. False if not.
192
        :rtype: str - Error message, if any
193
        """
194
195
        if inquiry_candidate.runner.get('name') != 'inquirer':
196
            return (False, '%s is not an Inquiry.' % inquiry_candidate.id)
197
198
        if inquiry_candidate.status == action_constants.LIVEACTION_STATUS_TIMED_OUT:
199
            return (
200
                False,
201
                'Inquiry %s timed out and can no longer be responded to' % inquiry_candidate.id
202
            )
203
204
        if inquiry_candidate.status != action_constants.LIVEACTION_STATUS_PENDING:
205
            return (False, 'Inquiry %s has already been responded to' % inquiry_candidate.id)
206
207
        return (True, "")
208
209
    def _mark_inquiry_complete(self, inquiry_id, result):
210
        """Mark Inquiry as completed
211
212
        This function updates the local LiveAction and Execution with a successful
213
        status as well as call the "post_run" function for the Inquirer runner so that
214
        the appropriate callback function is executed
215
216
        :param inquiry: The Inquiry for which the response is given
217
        :param requester_user: The user providing the response
218
219
        :rtype: bool - True if requester_user is able to respond. False if not.
220
        """
221
222
        # Update inquiry's execution result with a successful status and the validated response
223
        liveaction_db = action_utils.update_liveaction_status(
224
            status=action_constants.LIVEACTION_STATUS_SUCCEEDED,
225
            runner_info=system_info.get_process_info(),
226
            result=result,
227
            liveaction_id=inquiry_id)
228
        executions.update_execution(liveaction_db)
229
230
        # Call Inquiry runner's post_run to trigger callback to workflow
231
        runner_container = get_runner_container()
232
        action_db = get_action_by_ref(liveaction_db.action)
233
        runnertype_db = get_runnertype_by_name(action_db.runner_type['name'])
234
        runner = runner_container._get_runner(runnertype_db, action_db, liveaction_db)
235
        runner.post_run(status=action_constants.LIVEACTION_STATUS_SUCCEEDED, result=result)
236
237
        return liveaction_db
238
239
    def _can_respond(self, inquiry, requester_user):
240
        """Determine if requester_user is permitted to respond based on parameters
241
242
        This determines if the requesting user has permission to respond to THIS inquiry.
243
        Note this is NOT RBAC, and you should still protect the API endpoint with RBAC
244
        where appropriate.
245
246
        :param inquiry: The Inquiry for which the response is given
247
        :param requester_user: The user providing the response
248
249
        :rtype: bool - True if requester_user is able to respond. False if not.
250
        """
251
252
        # Deny by default
253
        roles_passed = False
254
        users_passed = False
255
256
        # Determine role-level permissions
257
        roles = getattr(inquiry, 'roles', [])
258
259
        if not roles:
260
            # No roles definition so we treat it as a pass
261
            roles_passed = True
262
263
        for role in roles:
264
            LOG.debug("Checking user %s is in role %s - %s" % (
265
                requester_user, role, rbac_utils.user_has_role(requester_user, role))
266
            )
267
268
            if rbac_utils.user_has_role(requester_user, role):
269
                roles_passed = True
270
                break
271
272
        # Determine user-level permissions
273
        users = getattr(inquiry, 'users', [])
274
        if not users or requester_user.name in users:
275
            users_passed = True
276
277
        # Both must pass
278
        return roles_passed and users_passed
279
280
    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...
281
                       from_model_kwargs=None):
282
        """Override ResourceController._get_one_by_id to contain scope of Inquiries UID hack
283
284
        :param exclude_fields: A list of object fields to exclude.
285
        :type exclude_fields: ``list``
286
        """
287
288
        instance = self._get_by_id(resource_id=id, exclude_fields=exclude_fields)
289
290
        # _get_by_id pulls the resource by ID directly off of the database. Since
291
        # Inquiries don't have their own DB model yet, this comes in the format
292
        # "execution:<id>". So, to allow RBAC to get a handle on inquiries specifically,
293
        # we're overriding the "get_uid" function to return one specific to Inquiries.
294
        #
295
        # TODO (mierdin): All of this should be removed once Inquiries get their own DB model
296
        if getattr(instance, 'runner', None) and instance.runner.get('runner_module') == 'inquirer':
297
            def get_uid():
298
                return "inquiry"
299
            instance.get_uid = get_uid
300
301
        if permission_type:
302
            rbac_utils.assert_user_has_resource_db_permission(user_db=requester_user,
303
                                                              resource_db=instance,
304
                                                              permission_type=permission_type)
305
306
        if not instance:
307
            msg = 'Unable to identify resource with id "%s".' % id
308
            abort(http_client.NOT_FOUND, msg)
309
310
        from_model_kwargs = from_model_kwargs or {}
311
        from_model_kwargs.update(self.from_model_kwargs)
312
        result = self.model.from_model(instance, **from_model_kwargs)
313
314
        return result
315
316
317
inquiries_controller = InquiriesController()
318