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

st2client/st2client/base.py (3 issues)

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
from __future__ import absolute_import
17
18
import os
19
import pwd
20
import json
21
import logging
22
import time
23
import calendar
24
import traceback
25
26
import six
27
import requests
28
29
# pylint: disable=import-error
30
from requests.packages.urllib3.exceptions import InsecureRequestWarning
31
32
from st2client import models
33
from st2client.config_parser import CLIConfigParser
34
from st2client.config_parser import ST2_CONFIG_DIRECTORY
35
from st2client.config_parser import ST2_CONFIG_PATH
36
from st2client.client import Client
37
from st2client.config import get_config
38
from st2client.utils.date import parse as parse_isotime
39
from st2client.utils.misc import merge_dicts
40
41
__all__ = [
42
    'BaseCLIApp'
43
]
44
45
# Fix for "os.getlogin()) OSError: [Errno 2] No such file or directory"
46
os.getlogin = lambda: pwd.getpwuid(os.getuid())[0]
47
48
# How many seconds before the token actual expiration date we should consider the token as
49
# expired. This is used to prevent the operation from failing durig the API request because the
50
# token was just about to expire.
51
TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS = 15
52
53
CONFIG_OPTION_TO_CLIENT_KWARGS_MAP = {
54
    'base_url': ['general', 'base_url'],
55
    'auth_url': ['auth', 'url'],
56
    'stream_url': ['stream', 'url'],
57
    'api_url': ['api', 'url'],
58
    'api_version': ['general', 'api_version'],
59
    'api_key': ['credentials', 'api_key'],
60
    'cacert': ['general', 'cacert'],
61
    'debug': ['cli', 'debug']
62
}
63
64
65
class BaseCLIApp(object):
66
    """
67
    Base class for StackStorm CLI apps.
68
    """
69
70
    LOG = logging.getLogger(__name__)  # logger instance to use
71
    client = None  # st2client instance
72
73
    # A list of command classes for which automatic authentication should be skipped.
74
    SKIP_AUTH_CLASSES = []
75
76
    def get_client(self, args, debug=False):
77
        ST2_CLI_SKIP_CONFIG = os.environ.get('ST2_CLI_SKIP_CONFIG', 0)
78
        ST2_CLI_SKIP_CONFIG = int(ST2_CLI_SKIP_CONFIG)
79
80
        skip_config = args.skip_config
81
        skip_config = skip_config or ST2_CLI_SKIP_CONFIG
82
83
        # Note: Options provided as the CLI argument have the highest precedence
84
        # Precedence order: cli arguments > environment variables > rc file variables
85
        cli_options = ['base_url', 'auth_url', 'api_url', 'stream_url', 'api_version', 'cacert']
86
        cli_options = {opt: getattr(args, opt, None) for opt in cli_options}
87
        config_file_options = self._get_config_file_options(args=args)
88
89
        kwargs = {}
90
91
        if not skip_config:
92
            # Config parsing is not skipped
93
            kwargs = merge_dicts(kwargs, config_file_options)
94
95
        kwargs = merge_dicts(kwargs, cli_options)
96
        kwargs['debug'] = debug
97
98
        client = Client(**kwargs)
99
100
        if skip_config:
101
            # Config parsing is skipped
102
            self.LOG.info('Skipping parsing CLI config')
103
            return client
104
105
        # Ok to use config at this point
106
        rc_config = get_config()
107
108
        # Silence SSL warnings
109
        silence_ssl_warnings = rc_config.get('general', {}).get('silence_ssl_warnings', False)
110
        if silence_ssl_warnings:
111
            # pylint: disable=no-member
112
            requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
113
114
        # We skip automatic authentication for some commands such as auth
115
        try:
116
            command_class_name = args.func.__self__.__class__.__name__
117
        except Exception:
118
            command_class_name = None
119
120
        if command_class_name in self.SKIP_AUTH_CLASSES:
121
            return client
122
123
        # We also skip automatic authentication if token is provided via the environment variable
124
        # or as a command line argument
125
        env_var_token = os.environ.get('ST2_AUTH_TOKEN', None)
126
        cli_argument_token = getattr(args, 'token', None)
127
        env_var_api_key = os.environ.get('ST2_API_KEY', None)
128
        cli_argument_api_key = getattr(args, 'api_key', None)
129
        if env_var_token or cli_argument_token or env_var_api_key or cli_argument_api_key:
130
            return client
131
132
        # If credentials are provided in the CLI config use them and try to authenticate
133
        credentials = rc_config.get('credentials', {})
134
        username = credentials.get('username', None)
135
        password = credentials.get('password', None)
136
        cache_token = rc_config.get('cli', {}).get('cache_token', False)
137
138
        if username:
139
            # Credentials are provided, try to authenticate agaist the API
140
            try:
141
                token = self._get_auth_token(client=client, username=username, password=password,
142
                                             cache_token=cache_token)
143
            except requests.exceptions.ConnectionError as e:
144
                self.LOG.warn('Auth API server is not available, skipping authentication.')
145
                self.LOG.exception(e)
146
                return client
147
            except Exception as e:
148
                print('Failed to authenticate with credentials provided in the config.')
149
                raise e
150
            client.token = token
151
            # TODO: Hack, refactor when splitting out the client
152
            os.environ['ST2_AUTH_TOKEN'] = token
153
154
        return client
155
156
    def _get_config_file_options(self, args, validate_config_permissions=False):
157
        """
158
        Parse the config and return kwargs which can be passed to the Client
159
        constructor.
160
161
        :rtype: ``dict``
162
        """
163
        rc_options = self._parse_config_file(
164
            args=args, validate_config_permissions=validate_config_permissions)
165
        result = {}
166
        for kwarg_name, (section, option) in six.iteritems(CONFIG_OPTION_TO_CLIENT_KWARGS_MAP):
167
            result[kwarg_name] = rc_options.get(section, {}).get(option, None)
168
169
        return result
170
171
    def _parse_config_file(self, args, validate_config_permissions=False):
172
        config_file_path = self._get_config_file_path(args=args)
173
174
        parser = CLIConfigParser(config_file_path=config_file_path,
175
                                 validate_config_exists=False,
176
                                 validate_config_permissions=validate_config_permissions,
177
                                 log=self.LOG)
178
        result = parser.parse()
179
        return result
180
181
    def _get_config_file_path(self, args):
182
        """
183
        Retrieve path to the CLI configuration file.
184
185
        :rtype: ``str``
186
        """
187
        path = os.environ.get('ST2_CONFIG_FILE', ST2_CONFIG_PATH)
188
189
        if args.config_file:
190
            path = args.config_file
191
192
        path = os.path.abspath(os.path.expanduser(path))
193
        if path != ST2_CONFIG_PATH and not os.path.isfile(path):
194
            raise ValueError('Config "%s" not found' % (path))
195
196
        return path
197
198
    def _get_auth_token(self, client, username, password, cache_token):
199
        """
200
        Retrieve a valid auth token.
201
202
        If caching is enabled, we will first try to retrieve cached token from a
203
        file system. If cached token is expired or not available, we will try to
204
        authenticate using the provided credentials and retrieve a new auth
205
        token.
206
207
        :rtype: ``str``
208
        """
209
        if cache_token:
210
            token = self._get_cached_auth_token(client=client, username=username,
211
                                                password=password)
212
        else:
213
            token = None
214
        if not token:
215
            # Token is either expired or not available
216
            token_obj = self._authenticate_and_retrieve_auth_token(client=client,
217
                                                                   username=username,
218
                                                                   password=password)
219
220
            self._cache_auth_token(token_obj=token_obj)
221
            token = token_obj.token
222
223
        return token
224
225
    def _get_cached_auth_token(self, client, username, password):
226
        """
227
        Retrieve cached auth token from the file in the config directory.
228
229
        :rtype: ``str``
230
        """
231
        if not os.path.isdir(ST2_CONFIG_DIRECTORY):
232
            os.makedirs(ST2_CONFIG_DIRECTORY, mode=0o2770)
233
            # os.makedirs straight up ignores the setgid bit, so we have to set
234
            # it manually
235
            os.chmod(ST2_CONFIG_DIRECTORY, 0o2770)
236
237
        cached_token_path = self._get_cached_token_path_for_user(username=username)
238
239
        if not os.access(ST2_CONFIG_DIRECTORY, os.R_OK):
240
            # We don't have read access to the file with a cached token
241
            message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read '
242
                       'access to the parent directory). Subsequent requests won\'t use a '
243
                       'cached token meaning they may be slower.' % (cached_token_path,
244
                                                                     os.getlogin()))
245
            self.LOG.warn(message)
246
            return None
247
248
        if not os.path.isfile(cached_token_path):
249
            return None
250
251
        if not os.access(cached_token_path, os.R_OK):
252
            # We don't have read access to the file with a cached token
253
            message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read '
254
                       'access to this file). Subsequent requests won\'t use a cached token '
255
                       'meaning they may be slower.' % (cached_token_path, os.getlogin()))
256
            self.LOG.warn(message)
257
            return None
258
259
        # Safety check for too permissive permissions
260
        file_st_mode = oct(os.stat(cached_token_path).st_mode & 0o777)
261
        others_st_mode = int(file_st_mode[-1])
262
263
        if others_st_mode >= 2:
264
            # Every user has access to this file which is dangerous
265
            message = ('Permissions (%s) for cached token file "%s" are too permissive. Please '
266
                       'restrict the permissions and make sure only your own user can read '
267
                       'from or write to the file.' % (file_st_mode, cached_token_path))
268
            self.LOG.warn(message)
269
270
        with open(cached_token_path) as fp:
271
            data = fp.read()
272
273
        try:
274
            data = json.loads(data)
275
276
            token = data['token']
277
            expire_timestamp = data['expire_timestamp']
278
        except Exception as e:
279
            msg = ('File "%s" with cached token is corrupted or invalid (%s). Please delete '
280
                   ' this file' % (cached_token_path, str(e)))
281
            raise ValueError(msg)
282
283
        now = int(time.time())
284
        if (expire_timestamp - TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS) < now:
285
            self.LOG.debug('Cached token from file "%s" has expired' % (cached_token_path))
0 ignored issues
show
Coding Style Best Practice introduced by
Specify string format arguments as logging function parameters
Loading history...
286
            # Token has expired
287
            return None
288
289
        self.LOG.debug('Using cached token from file "%s"' % (cached_token_path))
0 ignored issues
show
Coding Style Best Practice introduced by
Specify string format arguments as logging function parameters
Loading history...
290
        return token
291
292
    def _cache_auth_token(self, token_obj):
293
        """
294
        Cache auth token in the config directory.
295
296
        :param token_obj: Token object.
297
        :type token_obj: ``object``
298
        """
299
        if not os.path.isdir(ST2_CONFIG_DIRECTORY):
300
            os.makedirs(ST2_CONFIG_DIRECTORY, mode=0o2770)
301
            # os.makedirs straight up ignores the setgid bit, so we have to set
302
            # it manually
303
            os.chmod(ST2_CONFIG_DIRECTORY, 0o2770)
304
305
        username = token_obj.user
306
        cached_token_path = self._get_cached_token_path_for_user(username=username)
307
308
        if not os.access(ST2_CONFIG_DIRECTORY, os.W_OK):
309
            # We don't have write access to the file with a cached token
310
            message = ('Unable to write token to "%s" (user %s doesn\'t have write '
311
                       'access to the parent directory). Subsequent requests won\'t use a '
312
                       'cached token meaning they may be slower.' % (cached_token_path,
313
                                                                     os.getlogin()))
314
            self.LOG.warn(message)
315
            return None
316
317
        if os.path.isfile(cached_token_path) and not os.access(cached_token_path, os.W_OK):
318
            # We don't have write access to the file with a cached token
319
            message = ('Unable to write token to "%s" (user %s doesn\'t have write '
320
                       'access to this file). Subsequent requests won\'t use a '
321
                       'cached token meaning they may be slower.' % (cached_token_path,
322
                                                                     os.getlogin()))
323
            self.LOG.warn(message)
324
            return None
325
326
        token = token_obj.token
327
        expire_timestamp = parse_isotime(token_obj.expiry)
328
        expire_timestamp = calendar.timegm(expire_timestamp.timetuple())
329
330
        data = {}
331
        data['token'] = token
332
        data['expire_timestamp'] = expire_timestamp
333
        data = json.dumps(data)
334
335
        # Note: We explictly use fdopen instead of open + chmod to avoid a security issue.
336
        # open + chmod are two operations which means that during a short time frame (between
337
        # open and chmod) when file can potentially be read by other users if the default
338
        # permissions used during create allow that.
339
        fd = os.open(cached_token_path, os.O_WRONLY | os.O_CREAT, 0o660)
340
        with os.fdopen(fd, 'w') as fp:
341
            fp.write(data)
342
        os.chmod(cached_token_path, 0o660)
343
344
        self.LOG.debug('Token has been cached in "%s"' % (cached_token_path))
0 ignored issues
show
Coding Style Best Practice introduced by
Specify string format arguments as logging function parameters
Loading history...
345
        return True
346
347
    def _authenticate_and_retrieve_auth_token(self, client, username, password):
348
        manager = models.ResourceManager(models.Token, client.endpoints['auth'],
349
                                         cacert=client.cacert, debug=client.debug)
350
        instance = models.Token()
351
        instance = manager.create(instance, auth=(username, password))
352
        return instance
353
354
    def _get_cached_token_path_for_user(self, username):
355
        """
356
        Retrieve cached token path for the provided username.
357
        """
358
        file_name = 'token-%s' % (username)
359
        result = os.path.abspath(os.path.join(ST2_CONFIG_DIRECTORY, file_name))
360
        return result
361
362
    def _print_config(self, args):
363
        config = self._parse_config_file(args=args, validate_config_permissions=False)
364
365
        for section, options in six.iteritems(config):
366
            print('[%s]' % (section))
367
368
            for name, value in six.iteritems(options):
369
                print('%s = %s' % (name, value))
370
371
    def _print_debug_info(self, args):
372
        # Print client settings
373
        self._print_client_settings(args=args)
374
375
        # Print exception traceback
376
        traceback.print_exc()
377
378
    def _print_client_settings(self, args):
379
        client = self.client
380
381
        if not client:
382
            return
383
384
        config_file_path = self._get_config_file_path(args=args)
385
386
        print('CLI settings:')
387
        print('----------------')
388
        print('Config file path: %s' % (config_file_path))
389
        print('Client settings:')
390
        print('----------------')
391
        print('ST2_BASE_URL: %s' % (client.endpoints['base']))
392
        print('ST2_AUTH_URL: %s' % (client.endpoints['auth']))
393
        print('ST2_API_URL: %s' % (client.endpoints['api']))
394
        print('ST2_STREAM_URL: %s' % (client.endpoints['stream']))
395
        print('ST2_AUTH_TOKEN: %s' % (os.environ.get('ST2_AUTH_TOKEN')))
396
        print('')
397
        print('Proxy settings:')
398
        print('---------------')
399
        print('HTTP_PROXY: %s' % (os.environ.get('HTTP_PROXY', '')))
400
        print('HTTPS_PROXY: %s' % (os.environ.get('HTTPS_PROXY', '')))
401
        print('')
402