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 httplib |
17
|
|
|
import re |
18
|
|
|
import traceback |
19
|
|
|
import uuid |
20
|
|
|
|
21
|
|
|
import webob |
22
|
|
|
from oslo_config import cfg |
23
|
|
|
from pecan.hooks import PecanHook |
24
|
|
|
from six.moves.urllib import parse as urlparse |
25
|
|
|
from webob import exc |
26
|
|
|
|
27
|
|
|
from st2common import log as logging |
28
|
|
|
from st2common.persistence.auth import User |
29
|
|
|
from st2common.exceptions import db as db_exceptions |
30
|
|
|
from st2common.exceptions import auth as auth_exceptions |
31
|
|
|
from st2common.exceptions import rbac as rbac_exceptions |
32
|
|
|
from st2common.exceptions.db import StackStormDBObjectNotFoundError |
33
|
|
|
from st2common.exceptions.apivalidation import ValueValidationException |
34
|
|
|
from st2common.util import auth as auth_utils |
35
|
|
|
from st2common.util.jsonify import json_encode |
36
|
|
|
from st2common.util.debugging import is_enabled as is_debugging_enabled |
37
|
|
|
from st2common.constants.api import REQUEST_ID_HEADER |
38
|
|
|
from st2common.constants.auth import HEADER_ATTRIBUTE_NAME |
39
|
|
|
from st2common.constants.auth import QUERY_PARAM_ATTRIBUTE_NAME |
40
|
|
|
from st2common.constants.auth import HEADER_API_KEY_ATTRIBUTE_NAME |
41
|
|
|
from st2common.constants.auth import QUERY_PARAM_API_KEY_ATTRIBUTE_NAME |
42
|
|
|
|
43
|
|
|
|
44
|
|
|
LOG = logging.getLogger(__name__) |
45
|
|
|
|
46
|
|
|
# A list of method names for which we don't want to log the result / response |
47
|
|
|
RESPONSE_LOGGING_METHOD_NAME_BLACKLIST = [ |
48
|
|
|
'get_all' |
49
|
|
|
] |
50
|
|
|
|
51
|
|
|
# A list of controller classes for which we don't want to log the result / response |
52
|
|
|
RESPONSE_LOGGING_CONTROLLER_NAME_BLACKLIST = [ |
53
|
|
|
'ActionExecutionChildrenController', # action executions can be big |
54
|
|
|
'ActionExecutionAttributeController', # result can be big |
55
|
|
|
'ActionExecutionsController' # action executions can be big, |
56
|
|
|
'FilesController', # files controller returns files content |
57
|
|
|
'FileController' # file controller returns binary file data |
58
|
|
|
] |
59
|
|
|
|
60
|
|
|
# Regex for the st2 auth tokens endpoint (i.e. /tokens or /v1/tokens). |
61
|
|
|
AUTH_TOKENS_URL_REGEX = '^(?:/tokens|/v\d+/tokens)$' |
|
|
|
|
62
|
|
|
|
63
|
|
|
|
64
|
|
|
class CorsHook(PecanHook): |
65
|
|
|
|
66
|
|
|
def after(self, state): |
67
|
|
|
headers = state.response.headers |
68
|
|
|
|
69
|
|
|
origin = state.request.headers.get('Origin') |
70
|
|
|
origins = set(cfg.CONF.api.allow_origin) |
71
|
|
|
|
72
|
|
|
# Build a list of the default allowed origins |
73
|
|
|
public_api_url = cfg.CONF.auth.api_url |
74
|
|
|
|
75
|
|
|
# Default gulp development server WebUI URL |
76
|
|
|
origins.add('http://127.0.0.1:3000') |
77
|
|
|
|
78
|
|
|
# By default WebUI simple http server listens on 8080 |
79
|
|
|
origins.add('http://localhost:8080') |
80
|
|
|
origins.add('http://127.0.0.1:8080') |
81
|
|
|
|
82
|
|
|
if public_api_url: |
83
|
|
|
# Public API URL |
84
|
|
|
origins.add(public_api_url) |
85
|
|
|
|
86
|
|
|
if origin: |
87
|
|
|
if '*' in origins: |
88
|
|
|
origin_allowed = '*' |
89
|
|
|
else: |
90
|
|
|
# See http://www.w3.org/TR/cors/#access-control-allow-origin-response-header |
91
|
|
|
origin_allowed = origin if origin in origins else 'null' |
92
|
|
|
else: |
93
|
|
|
origin_allowed = list(origins)[0] |
94
|
|
|
|
95
|
|
|
methods_allowed = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] |
96
|
|
|
request_headers_allowed = ['Content-Type', 'Authorization', 'X-Auth-Token', |
97
|
|
|
HEADER_API_KEY_ATTRIBUTE_NAME, REQUEST_ID_HEADER] |
98
|
|
|
response_headers_allowed = ['Content-Type', 'X-Limit', 'X-Total-Count', |
99
|
|
|
REQUEST_ID_HEADER] |
100
|
|
|
|
101
|
|
|
headers['Access-Control-Allow-Origin'] = origin_allowed |
102
|
|
|
headers['Access-Control-Allow-Methods'] = ','.join(methods_allowed) |
103
|
|
|
headers['Access-Control-Allow-Headers'] = ','.join(request_headers_allowed) |
104
|
|
|
headers['Access-Control-Expose-Headers'] = ','.join(response_headers_allowed) |
105
|
|
|
if not headers.get('Content-Length') \ |
106
|
|
|
and not headers.get('Content-type', '').startswith('text/event-stream'): |
107
|
|
|
headers['Content-Length'] = str(len(state.response.body)) |
108
|
|
|
|
109
|
|
|
def on_error(self, state, e): |
110
|
|
|
if state.request.method == 'OPTIONS': |
111
|
|
|
return webob.Response() |
112
|
|
|
|
113
|
|
|
|
114
|
|
|
class AuthHook(PecanHook): |
115
|
|
|
|
116
|
|
|
def before(self, state): |
117
|
|
|
# OPTIONS requests doesn't need to be authenticated |
118
|
|
|
if state.request.method == 'OPTIONS': |
119
|
|
|
return |
120
|
|
|
|
121
|
|
|
# Token request is authenticated separately. |
122
|
|
|
if (state.request.method == 'POST' and |
123
|
|
|
re.search(AUTH_TOKENS_URL_REGEX, state.request.path)): |
124
|
|
|
return |
125
|
|
|
|
126
|
|
|
if cfg.CONF.auth.enable: |
127
|
|
|
user_db = self._validate_creds_and_get_user(request=state.request) |
128
|
|
|
|
129
|
|
|
# Store related user object in the context. The token is not passed |
130
|
|
|
# along any longer as that should only be used in the auth domain. |
131
|
|
|
state.request.context['auth'] = { |
132
|
|
|
'user': user_db |
133
|
|
|
} |
134
|
|
|
|
135
|
|
|
if QUERY_PARAM_ATTRIBUTE_NAME in state.arguments.keywords: |
136
|
|
|
del state.arguments.keywords[QUERY_PARAM_ATTRIBUTE_NAME] |
137
|
|
|
|
138
|
|
|
if QUERY_PARAM_API_KEY_ATTRIBUTE_NAME in state.arguments.keywords: |
139
|
|
|
del state.arguments.keywords[QUERY_PARAM_API_KEY_ATTRIBUTE_NAME] |
140
|
|
|
|
141
|
|
|
def on_error(self, state, e): |
142
|
|
|
if isinstance(e, (auth_exceptions.NoAuthSourceProvidedError, |
143
|
|
|
auth_exceptions.MultipleAuthSourcesError)): |
144
|
|
|
LOG.error(str(e)) |
145
|
|
|
return self._abort_unauthorized(str(e)) |
146
|
|
|
if isinstance(e, auth_exceptions.TokenNotProvidedError): |
147
|
|
|
LOG.exception('Token is not provided.') |
148
|
|
|
return self._abort_unauthorized(str(e)) |
149
|
|
|
if isinstance(e, auth_exceptions.TokenNotFoundError): |
150
|
|
|
LOG.exception('Token is not found.') |
151
|
|
|
return self._abort_unauthorized(str(e)) |
152
|
|
|
if isinstance(e, auth_exceptions.TokenExpiredError): |
153
|
|
|
LOG.exception('Token has expired.') |
154
|
|
|
return self._abort_unauthorized(str(e)) |
155
|
|
|
if isinstance(e, auth_exceptions.ApiKeyNotProvidedError): |
156
|
|
|
LOG.exception('API key is not provided.') |
157
|
|
|
return self._abort_unauthorized(str(e)) |
158
|
|
|
if isinstance(e, auth_exceptions.ApiKeyNotFoundError): |
159
|
|
|
LOG.exception('API key is not found.') |
160
|
|
|
return self._abort_unauthorized(str(e)) |
161
|
|
|
if isinstance(e, auth_exceptions.ApiKeyDisabledError): |
162
|
|
|
LOG.exception('API key is disabled.') |
163
|
|
|
return self._abort_unauthorized(str(e)) |
164
|
|
|
|
165
|
|
|
@staticmethod |
166
|
|
|
def _abort_unauthorized(msg): |
167
|
|
|
faultstring = 'Unauthorized - %s' % msg if msg else 'Unauthorized' |
168
|
|
|
body = json_encode({ |
169
|
|
|
'faultstring': faultstring |
170
|
|
|
}) |
171
|
|
|
headers = {} |
172
|
|
|
headers['Content-Type'] = 'application/json' |
173
|
|
|
status = httplib.UNAUTHORIZED |
174
|
|
|
|
175
|
|
|
return webob.Response(body=body, status=status, headers=headers) |
176
|
|
|
|
177
|
|
|
@staticmethod |
178
|
|
|
def _abort_other_errors(): |
179
|
|
|
body = json_encode({ |
180
|
|
|
'faultstring': 'Internal Server Error' |
181
|
|
|
}) |
182
|
|
|
headers = {} |
183
|
|
|
headers['Content-Type'] = 'application/json' |
184
|
|
|
status = httplib.INTERNAL_SERVER_ERROR |
185
|
|
|
|
186
|
|
|
return webob.Response(body=body, status=status, headers=headers) |
187
|
|
|
|
188
|
|
|
@staticmethod |
189
|
|
|
def _validate_creds_and_get_user(request): |
190
|
|
|
""" |
191
|
|
|
Validate one of token or api_key provided either in headers or query parameters. |
192
|
|
|
Will returnt the User |
193
|
|
|
|
194
|
|
|
:rtype: :class:`UserDB` |
195
|
|
|
""" |
196
|
|
|
|
197
|
|
|
headers = request.headers |
198
|
|
|
query_string = request.query_string |
199
|
|
|
query_params = dict(urlparse.parse_qsl(query_string)) |
200
|
|
|
|
201
|
|
|
token_in_headers = headers.get(HEADER_ATTRIBUTE_NAME, None) |
202
|
|
|
token_in_query_params = query_params.get(QUERY_PARAM_ATTRIBUTE_NAME, None) |
203
|
|
|
|
204
|
|
|
api_key_in_headers = headers.get(HEADER_API_KEY_ATTRIBUTE_NAME, None) |
205
|
|
|
api_key_in_query_params = query_params.get(QUERY_PARAM_API_KEY_ATTRIBUTE_NAME, None) |
206
|
|
|
|
207
|
|
|
if ((token_in_headers or token_in_query_params) and |
208
|
|
|
(api_key_in_headers or api_key_in_query_params)): |
209
|
|
|
raise auth_exceptions.MultipleAuthSourcesError( |
210
|
|
|
'Only one of Token or API key expected.') |
211
|
|
|
|
212
|
|
|
user = None |
213
|
|
|
|
214
|
|
|
if token_in_headers or token_in_query_params: |
215
|
|
|
token_db = auth_utils.validate_token_and_source( |
216
|
|
|
token_in_headers=token_in_headers, |
217
|
|
|
token_in_query_params=token_in_query_params) |
218
|
|
|
user = token_db.user |
219
|
|
|
elif api_key_in_headers or api_key_in_query_params: |
220
|
|
|
api_key_db = auth_utils.validate_api_key_and_source( |
221
|
|
|
api_key_in_headers=api_key_in_headers, |
222
|
|
|
api_key_query_params=api_key_in_query_params) |
223
|
|
|
user = api_key_db.user |
224
|
|
|
else: |
225
|
|
|
raise auth_exceptions.NoAuthSourceProvidedError('One of Token or API key required.') |
226
|
|
|
|
227
|
|
|
if not user: |
228
|
|
|
LOG.warn('User not found for supplied token or api-key.') |
229
|
|
|
return None |
230
|
|
|
|
231
|
|
|
try: |
232
|
|
|
return User.get(user) |
233
|
|
|
except StackStormDBObjectNotFoundError: |
234
|
|
|
# User doesn't exist - we should probably also invalidate token/apikey if |
235
|
|
|
# this happens. |
236
|
|
|
LOG.warn('User %s not found.', user) |
237
|
|
|
return None |
238
|
|
|
|
239
|
|
|
|
240
|
|
|
class JSONErrorResponseHook(PecanHook): |
241
|
|
|
""" |
242
|
|
|
Handle all the errors and respond with JSON. |
243
|
|
|
""" |
244
|
|
|
|
245
|
|
|
def on_error(self, state, e): |
246
|
|
|
if hasattr(e, 'body') and isinstance(e.body, dict): |
247
|
|
|
body = e.body |
248
|
|
|
else: |
249
|
|
|
body = {} |
250
|
|
|
|
251
|
|
|
if isinstance(e, exc.HTTPException): |
252
|
|
|
status_code = state.response.status |
253
|
|
|
message = str(e) |
254
|
|
|
elif isinstance(e, db_exceptions.StackStormDBObjectNotFoundError): |
255
|
|
|
status_code = httplib.NOT_FOUND |
256
|
|
|
message = str(e) |
257
|
|
|
elif isinstance(e, db_exceptions.StackStormDBObjectConflictError): |
258
|
|
|
status_code = httplib.CONFLICT |
259
|
|
|
message = str(e) |
260
|
|
|
body['conflict-id'] = e.conflict_id |
261
|
|
|
elif isinstance(e, rbac_exceptions.AccessDeniedError): |
262
|
|
|
status_code = httplib.FORBIDDEN |
263
|
|
|
message = str(e) |
264
|
|
|
elif isinstance(e, (ValueValidationException, ValueError)): |
265
|
|
|
status_code = httplib.BAD_REQUEST |
266
|
|
|
message = getattr(e, 'message', str(e)) |
267
|
|
|
else: |
268
|
|
|
status_code = httplib.INTERNAL_SERVER_ERROR |
269
|
|
|
message = 'Internal Server Error' |
270
|
|
|
|
271
|
|
|
# Log the error |
272
|
|
|
is_internal_server_error = status_code == httplib.INTERNAL_SERVER_ERROR |
273
|
|
|
error_msg = getattr(e, 'comment', str(e)) |
274
|
|
|
extra = { |
275
|
|
|
'exception_class': e.__class__.__name__, |
276
|
|
|
'exception_message': str(e), |
277
|
|
|
'exception_data': e.__dict__ |
278
|
|
|
} |
279
|
|
|
|
280
|
|
|
if is_internal_server_error: |
281
|
|
|
LOG.exception('API call failed: %s', error_msg, extra=extra) |
282
|
|
|
LOG.exception(traceback.format_exc()) |
283
|
|
|
else: |
284
|
|
|
LOG.debug('API call failed: %s', error_msg, extra=extra) |
285
|
|
|
|
286
|
|
|
if is_debugging_enabled(): |
287
|
|
|
LOG.debug(traceback.format_exc()) |
288
|
|
|
|
289
|
|
|
body['faultstring'] = message |
290
|
|
|
|
291
|
|
|
response_body = json_encode(body) |
292
|
|
|
headers = state.response.headers or {} |
293
|
|
|
|
294
|
|
|
headers['Content-Type'] = 'application/json' |
295
|
|
|
headers['Content-Length'] = str(len(response_body)) |
296
|
|
|
|
297
|
|
|
return webob.Response(response_body, status=status_code, headers=headers) |
298
|
|
|
|
299
|
|
|
|
300
|
|
|
class LoggingHook(PecanHook): |
301
|
|
|
""" |
302
|
|
|
Logs all incoming requests and outgoing responses |
303
|
|
|
""" |
304
|
|
|
|
305
|
|
|
def before(self, state): |
306
|
|
|
# Note: We use getattr since in some places (tests) request is mocked |
307
|
|
|
method = getattr(state.request, 'method', None) |
308
|
|
|
path = getattr(state.request, 'path', None) |
309
|
|
|
remote_addr = getattr(state.request, 'remote_addr', None) |
310
|
|
|
|
311
|
|
|
# Log the incoming request |
312
|
|
|
values = {'method': method, 'path': path, 'remote_addr': remote_addr} |
313
|
|
|
values['filters'] = state.arguments.keywords |
314
|
|
|
|
315
|
|
|
request_id = state.request.headers.get(REQUEST_ID_HEADER, None) |
316
|
|
|
values['request_id'] = request_id |
317
|
|
|
|
318
|
|
|
LOG.info('%(request_id)s - %(method)s %(path)s with filters=%(filters)s' % |
|
|
|
|
319
|
|
|
values, extra=values) |
320
|
|
|
|
321
|
|
|
def after(self, state): |
322
|
|
|
# Note: We use getattr since in some places (tests) request is mocked |
323
|
|
|
method = getattr(state.request, 'method', None) |
324
|
|
|
path = getattr(state.request, 'path', None) |
325
|
|
|
remote_addr = getattr(state.request, 'remote_addr', None) |
326
|
|
|
request_id = state.request.headers.get(REQUEST_ID_HEADER, None) |
327
|
|
|
|
328
|
|
|
# Log the outgoing response |
329
|
|
|
values = {'method': method, 'path': path, 'remote_addr': remote_addr} |
330
|
|
|
values['status_code'] = state.response.status |
331
|
|
|
values['request_id'] = request_id |
332
|
|
|
|
333
|
|
|
if hasattr(state.controller, 'im_self'): |
334
|
|
|
function_name = state.controller.im_func.__name__ |
335
|
|
|
controller_name = state.controller.im_class.__name__ |
336
|
|
|
|
337
|
|
|
log_result = True |
338
|
|
|
log_result &= function_name not in RESPONSE_LOGGING_METHOD_NAME_BLACKLIST |
339
|
|
|
log_result &= controller_name not in RESPONSE_LOGGING_CONTROLLER_NAME_BLACKLIST |
340
|
|
|
else: |
341
|
|
|
log_result = False |
342
|
|
|
|
343
|
|
|
if log_result: |
344
|
|
|
values['result'] = state.response.body |
345
|
|
|
log_msg = '%(request_id)s - %(method)s %(path)s result=%(result)s' % values |
346
|
|
|
else: |
347
|
|
|
# Note: We don't want to include a result for some |
348
|
|
|
# methods which have a large result |
349
|
|
|
log_msg = '%(request_id)s - %(method)s %(path)s' % values |
350
|
|
|
|
351
|
|
|
LOG.info(log_msg, extra=values) |
352
|
|
|
|
353
|
|
|
|
354
|
|
|
class RequestIDHook(PecanHook): |
355
|
|
|
""" |
356
|
|
|
If request id header isn't present, this hooks adds one. |
357
|
|
|
""" |
358
|
|
|
|
359
|
|
|
def before(self, state): |
360
|
|
|
headers = getattr(state.request, 'headers', None) |
361
|
|
|
|
362
|
|
|
if headers: |
363
|
|
|
req_id_header = getattr(headers, REQUEST_ID_HEADER, None) |
364
|
|
|
|
365
|
|
|
if not req_id_header: |
366
|
|
|
req_id = str(uuid.uuid4()) |
367
|
|
|
state.request.headers[REQUEST_ID_HEADER] = req_id |
368
|
|
|
|
369
|
|
|
def after(self, state): |
370
|
|
|
req_headers = getattr(state.request, 'headers', None) |
371
|
|
|
resp_headers = getattr(state.response, 'headers', None) |
372
|
|
|
|
373
|
|
|
if req_headers and resp_headers: |
374
|
|
|
req_id_header = req_headers.get(REQUEST_ID_HEADER, None) |
375
|
|
|
if req_id_header: |
376
|
|
|
resp_headers[REQUEST_ID_HEADER] = req_id_header |
377
|
|
|
|
Escape sequences in Python are generally interpreted according to rules similar to standard C. Only if strings are prefixed with
r
orR
are they interpreted as regular expressions.The escape sequence that was used indicates that you might have intended to write a regular expression.
Learn more about the available escape sequences. in the Python documentation.