From 381c2a4fa82db80543fd67da6669de022d7576c4 Mon Sep 17 00:00:00 2001 From: Bhouse99 Date: Wed, 30 Mar 2022 13:37:22 -0700 Subject: [PATCH] add plugin support for CQLSH patch by Brian Houser; reviewed by Stefan Miklosovic and Brandon Williams for CASSANDRA-16456 --- .gitignore | 1 + CHANGES.txt | 1 + bin/cqlsh.py | 68 ++++--- build.xml | 5 +- conf/cqlshrc.sample | 8 + conf/credentials.sample | 2 +- lib/puresasl-internal-only-0.6.2.zip | Bin 0 -> 9085 bytes pylib/cqlshlib/authproviderhandling.py | 176 ++++++++++++++++ .../test/test_authproviderhandling.py | 190 ++++++++++++++++++ .../complex_auth_provider | 10 + .../complex_auth_provider_creds | 3 + .../complex_auth_provider_with_pass | 11 + .../empty_example | 2 + .../full_plain_text_example | 10 + .../illegal_example | 5 + .../no_classname_example | 5 + .../partial_example | 8 + .../plain_text_full_creds | 3 + .../plain_text_partial_creds | 2 + .../plain_text_partial_example | 8 + pylib/cqlshlib/util.py | 19 +- 21 files changed, 501 insertions(+), 36 deletions(-) create mode 100644 lib/puresasl-internal-only-0.6.2.zip create mode 100644 pylib/cqlshlib/authproviderhandling.py create mode 100644 pylib/cqlshlib/test/test_authproviderhandling.py create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_with_pass create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/empty_example create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/partial_example create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_full_creds create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds create mode 100644 pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example diff --git a/.gitignore b/.gitignore index a99fd14310..c8e79b312a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ lib/ pylib/src/ **/cqlshlib.xml !lib/cassandra-driver-internal-only-*.zip +!lib/puresasl-*.zip # C* debs build-stamp diff --git a/CHANGES.txt b/CHANGES.txt index 4ae12a7c97..7850ca8e5b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 4.1 + * Add plugin support for CQLSH (CASSANDRA-16456) * Add guardrail to disallow querying with ALLOW FILTERING (CASSANDRA-17370) * Enhance SnakeYAML properties to be reusable outside of YAML parsing, support camel case conversion to snake case, and add support to ignore properties (CASSANDRA-17166) * nodetool compact should support using a key string to find the range to avoid operators having to manually do this (CASSANDRA-17537) diff --git a/bin/cqlsh.py b/bin/cqlsh.py index c412d20ddf..637c95e70a 100755 --- a/bin/cqlsh.py +++ b/bin/cqlsh.py @@ -113,7 +113,7 @@ if cql_zip: sys.path.insert(0, os.path.join(cql_zip, 'cassandra-driver-' + ver)) # the driver needs dependencies -third_parties = ('six-') +third_parties = ('six-', 'puresasl-') for lib in third_parties: lib_zip = find_zip(lib) @@ -145,7 +145,7 @@ cqlshlibdir = os.path.join(CASSANDRA_PATH, 'pylib') if os.path.isdir(cqlshlibdir): sys.path.insert(0, cqlshlibdir) -from cqlshlib import cql3handling, pylexotron, sslhandling, cqlshhandling +from cqlshlib import cql3handling, pylexotron, sslhandling, cqlshhandling, authproviderhandling from cqlshlib.copyutil import ExportTask, ImportTask from cqlshlib.displaying import (ANSI_RESET, BLUE, COLUMN_NAME_COLORS, CYAN, RED, WHITE, FormattedValue, colorme) @@ -154,6 +154,8 @@ from cqlshlib.formatting import (DEFAULT_DATE_FORMAT, DEFAULT_NANOTIME_FORMAT, format_by_type) from cqlshlib.tracing import print_trace, print_trace_session from cqlshlib.util import get_file_encoding_bomsize +from cqlshlib.util import is_file_secure + DEFAULT_HOST = '127.0.0.1' DEFAULT_PORT = 9042 @@ -426,7 +428,7 @@ class Shell(cmd.Cmd): default_page_size = 100 def __init__(self, hostname, port, color=False, - username=None, password=None, encoding=None, stdin=None, tty=True, + username=None, encoding=None, stdin=None, tty=True, completekey=DEFAULT_COMPLETEKEY, browser=None, use_conn=None, cqlver=None, keyspace=None, tracing_enabled=False, expand_enabled=False, @@ -442,16 +444,21 @@ class Shell(cmd.Cmd): request_timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS, protocol_version=None, connect_timeout=DEFAULT_CONNECT_TIMEOUT_SECONDS, - is_subshell=False): + is_subshell=False, + auth_provider=None): cmd.Cmd.__init__(self, completekey=completekey) self.hostname = hostname self.port = port - self.auth_provider = None - if username: - if not password: - password = getpass.getpass() - self.auth_provider = PlainTextAuthProvider(username=username, password=password) + self.auth_provider = auth_provider self.username = username + + if isinstance(auth_provider, PlainTextAuthProvider): + self.username = auth_provider.username + if not auth_provider.password: + # if no password is provided, we need to query the user to get one. + password = getpass.getpass() + self.auth_provider = PlainTextAuthProvider(username=auth_provider.username, password=password) + self.keyspace = keyspace self.ssl = ssl self.tracing_enabled = tracing_enabled @@ -1613,10 +1620,8 @@ class Shell(cmd.Cmd): except IOError as e: self.printerr('Could not open %r: %s' % (fname, e)) return - username = self.auth_provider.username if self.auth_provider else None - password = self.auth_provider.password if self.auth_provider else None subshell = Shell(self.hostname, self.port, color=self.color, - username=username, password=password, + username=self.username, encoding=self.encoding, stdin=f, tty=False, use_conn=self.conn, cqlver=self.cql_version, keyspace=self.current_keyspace, tracing_enabled=self.tracing_enabled, @@ -1629,7 +1634,8 @@ class Shell(cmd.Cmd): max_trace_wait=self.max_trace_wait, ssl=self.ssl, request_timeout=self.session.default_timeout, connect_timeout=self.conn.connect_timeout, - is_subshell=True) + is_subshell=True, + auth_provider=self.auth_provider) # duplicate coverage related settings in subshell if self.coverage: subshell.coverage = True @@ -2077,21 +2083,6 @@ def should_use_color(): return True -def is_file_secure(filename): - try: - st = os.stat(filename) - except OSError as e: - if e.errno != errno.ENOENT: - raise - return True # the file doesn't exists, the security of it is irrelevant - - uid = os.getuid() - - # Skip enforcing the file owner and UID matching for the root user (uid == 0). - # This is to allow "sudo cqlsh" to work with user owned credentials file. - return (uid == 0 or st.st_uid == uid) and stat.S_IMODE(st.st_mode) & (stat.S_IRGRP | stat.S_IROTH) == 0 - - def read_options(cmdlineargs, environment): configs = configparser.ConfigParser() configs.read(CONFIG_FILE) @@ -2102,13 +2093,14 @@ def read_options(cmdlineargs, environment): username_from_cqlshrc = option_with_default(configs.get, 'authentication', 'username') password_from_cqlshrc = option_with_default(rawconfigs.get, 'authentication', 'password') if username_from_cqlshrc or password_from_cqlshrc: - if password_from_cqlshrc and not is_file_secure(CONFIG_FILE): + if password_from_cqlshrc and not is_file_secure(os.path.expanduser(CONFIG_FILE)): print("\nWarning: Password is found in an insecure cqlshrc file. The file is owned or readable by other users on the system.", end='', file=sys.stderr) print("\nNotice: Credentials in the cqlshrc file is deprecated and will be ignored in the future." "\nPlease use a credentials file to specify the username and password.\n", file=sys.stderr) optvalues = optparse.Values() + optvalues.username = None optvalues.password = None optvalues.credentials = os.path.expanduser(option_with_default(configs.get, 'authentication', 'credentials', @@ -2153,6 +2145,13 @@ def read_options(cmdlineargs, environment): (options, arguments) = parser.parse_args(cmdlineargs, values=optvalues) + # Credentials from cqlshrc will be expanded, + # credentials from the command line are also expanded if there is a space... + # we need the following so that these two scenarios will work + # cqlsh --credentials=~/.cassandra/creds + # cqlsh --credentials ~/.cassandra/creds + options.credentials = os.path.expanduser(options.credentials) + if not is_file_secure(options.credentials): print("\nWarning: Credentials file '{0}' exists but is not used, because:" "\n a. the file owner is not the current user; or" @@ -2169,7 +2168,7 @@ def read_options(cmdlineargs, environment): credentials.read(options.credentials) # use the username from credentials file but fallback to cqlshrc if username is absent from the command line parameters - options.username = option_with_default(credentials.get, 'plain_text_auth', 'username', username_from_cqlshrc) + options.username = username_from_cqlshrc if not options.password: rawcredentials = configparser.RawConfigParser() @@ -2177,6 +2176,7 @@ def read_options(cmdlineargs, environment): # handling password in the same way as username, priority cli > credentials > cqlshrc options.password = option_with_default(rawcredentials.get, 'plain_text_auth', 'password', password_from_cqlshrc) + options.password = password_from_cqlshrc elif not options.insecure_password_without_warning: print("\nWarning: Using a password on the command line interface can be insecure." "\nRecommendation: use the credentials file to securely provide the password.\n", file=sys.stderr) @@ -2330,7 +2330,6 @@ def main(options, hostname, port): port, color=options.color, username=options.username, - password=options.password, stdin=stdin, tty=options.tty, completekey=options.completekey, @@ -2349,7 +2348,12 @@ def main(options, hostname, port): single_statement=options.execute, request_timeout=options.request_timeout, connect_timeout=options.connect_timeout, - encoding=options.encoding) + encoding=options.encoding, + auth_provider=authproviderhandling.load_auth_provider( + config_file=CONFIG_FILE, + cred_file=options.credentials, + username=options.username, + password=options.password)) except KeyboardInterrupt: sys.exit('Connection aborted.') except CQL_ERRORS as e: diff --git a/build.xml b/build.xml index 5bb53855f3..8b1bf4c9f9 100644 --- a/build.xml +++ b/build.xml @@ -392,7 +392,7 @@ - + @@ -1333,9 +1333,10 @@ - + + diff --git a/conf/cqlshrc.sample b/conf/cqlshrc.sample index 0da2b6dba5..4878b589bc 100644 --- a/conf/cqlshrc.sample +++ b/conf/cqlshrc.sample @@ -24,6 +24,14 @@ ; keyspace = ks1 +[auth_provider] +;; you can specify any auth provider found in your python environment +;; module and class will be used to dynamically load the class +;; all other properties found here and in the credentials file under the class name +;; will be passed to the constructor +; module = cassandra.auth +; classname = PlainTextAuthProvider +; username = user1 [ui] ;; Whether or not to display query results with colors diff --git a/conf/credentials.sample b/conf/credentials.sample index 9b5c644e9d..23d0beb71b 100644 --- a/conf/credentials.sample +++ b/conf/credentials.sample @@ -19,7 +19,7 @@ ; ; Please ensure this file is owned by the user and is not readable by group and other users -[plain_text_auth] +[PlainTextAuthProvider] ; username = fred ; password = !!bang!!$ diff --git a/lib/puresasl-internal-only-0.6.2.zip b/lib/puresasl-internal-only-0.6.2.zip new file mode 100644 index 0000000000000000000000000000000000000000..8314a045f68eed93f0dabe994c160dd84a2f3fb9 GIT binary patch literal 9085 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 J`qclr{TJ6hy$JvS literal 0 HcmV?d00001 diff --git a/pylib/cqlshlib/authproviderhandling.py b/pylib/cqlshlib/authproviderhandling.py new file mode 100644 index 0000000000..68031e5101 --- /dev/null +++ b/pylib/cqlshlib/authproviderhandling.py @@ -0,0 +1,176 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Handles loading of AuthProvider for CQLSH authentication. +""" + +import configparser +import sys +from importlib import import_module +from cqlshlib.util import is_file_secure + + +def _warn_for_plain_text_security(config_file, provider_settings): + """ + Call when using PlainTextAuthProvider + check to see if password appears in the basic provider settings + as this is a security risk + + Will write errors to stderr + """ + if 'password' in provider_settings: + if not is_file_secure(config_file): + print("""\nWarning: Password is found in an insecure cqlshrc file. + The file is owned or readable by other users on the system.""", + end='', + file=sys.stderr) + print("""\nNotice: Credentials in the cqlshrc file is deprecated and + will be ignored in the future.\n + Please use a credentials file to + specify the username and password.\n""", + file=sys.stderr) + + +def load_auth_provider(config_file=None, cred_file=None, username=None, password=None): + """ + Function which loads an auth provider from available config. + + Params: + * config_file ..: path to cqlsh config file (usually ~/.cassandra/cqlshrc). + * cred_file ....: path to cqlsh credentials file (default is ~/.cassandra/credentials). + * username .....: override used to return PlainTextAuthProvider according to legacy case + * password .....: override used to return PlainTextAuthProvider according to legacy case + + Will attempt to load an auth provider from available config file, using what's found in + credentials file as an override. + + Config file is expected to list module name /class in the *auth_provider* + section for dynamic loading (which is to be of type auth_provider) + + Additional params passed to the constructor of class should be specified + in the *auth_provider* section and can be freely named to match + auth provider's expectation. + + If passed username and password these will be overridden and passed to auth provider + + None is returned if no possible auth provider is found, and no username/password can be + returned. If a username is found, system will assume that PlainTextAuthProvider was + specified + + EXAMPLE CQLSHRC: + # .. inside cqlshrc file + + [auth_provider] + module = cassandra.auth + classname = PlainTextAuthProvider + username = user1 + password = password1 + + if credentials file is specified put relevant properties under the class name + EXAMPLE + # ... inside credentials file for above example + [PlainTextAuthProvider] + password = password2 + + Credential attributes will override found in the cqlshrc. + in the above example, PlainTextAuthProvider would be used with a password of 'password2', + and username of 'user1' + """ + + def get_settings_from_config(section_name, + conf_file, + interpolation=configparser.BasicInterpolation()): + """ + Returns dict from section_name, and ini based conf_file + + * section_name ..: Section to read map of properties from (ex: [auth_provider]) + * conf_file .....: Ini based config file to read. Will return empty dict if None. + * interpolation .: Interpolation to use. + + If section is not found, or conf_file is None, function will return an empty dictionary. + """ + conf = configparser.ConfigParser(interpolation=interpolation) + if conf_file is None: + return {} + + conf.read(conf_file) + if section_name in conf.sections(): + return dict(conf.items(section_name)) + return {} + + def get_cred_file_settings(classname, creds_file): + # Since this is the credentials file we may be encountering raw strings + # as these are what passwords, or security tokens may inadvertently fall into + # we don't want interpolation to mess with them. + return get_settings_from_config( + section_name=classname, + conf_file=creds_file, + interpolation=None) + + def get_auth_provider_settings(conf_file): + return get_settings_from_config( + section_name='auth_provider', + conf_file=conf_file) + + def get_legacy_settings(legacy_username, legacy_password): + result = {} + if legacy_username is not None: + result['username'] = legacy_username + if legacy_password is not None: + result['password'] = legacy_password + return result + + provider_settings = get_auth_provider_settings(config_file) + + module_name = provider_settings.pop('module', None) + class_name = provider_settings.pop('classname', None) + + if module_name is None and class_name is None: + # not specified, default to plaintext auth provider + module_name = 'cassandra.auth' + class_name = 'PlainTextAuthProvider' + elif module_name is None or class_name is None: + # then this was PARTIALLY specified. + return None + + credential_settings = get_cred_file_settings(class_name, cred_file) + + if module_name == 'cassandra.auth' and class_name == 'PlainTextAuthProvider': + # merge credential settings as overrides on top of provider settings. + + # we need to ensure that password property gets "set" in all cases. + # this is to support the ability to give the user a prompt in other parts + # of the code. + _warn_for_plain_text_security(config_file, provider_settings) + ctor_args = {'password': None, + **provider_settings, + **credential_settings, + **get_legacy_settings(username, password)} + # if no username, we can't create PlainTextAuthProvider + if 'username' not in ctor_args: + return None + else: + # merge credential settings as overrides on top of provider settings. + ctor_args = {**provider_settings, + **credential_settings, + **get_legacy_settings(username, password)} + + # Load class definitions + module = import_module(module_name) + auth_provider_klass = getattr(module, class_name) + + # instantiate the class + return auth_provider_klass(**ctor_args) diff --git a/pylib/cqlshlib/test/test_authproviderhandling.py b/pylib/cqlshlib/test/test_authproviderhandling.py new file mode 100644 index 0000000000..19a61334fd --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling.py @@ -0,0 +1,190 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import io +import os +import sys +import pytest + +from cassandra.auth import PlainTextAuthProvider +from cqlshlib.authproviderhandling import load_auth_provider + + +def construct_config_path(config_file_name): + return os.path.join(os.path.dirname(__file__), + 'test_authproviderhandling_config', + config_file_name) + + +# Simple class to help verify AuthProviders that don't need arguments. +class NoUserNamePlainTextAuthProvider(PlainTextAuthProvider): + def __init__(self): + super(NoUserNamePlainTextAuthProvider, self).__init__('', '') + + +class ComplexTextAuthProvider(PlainTextAuthProvider): + def __init__(self, username, password='default_pass', extra_flag=None): + super(ComplexTextAuthProvider, self).__init__(username, password) + self.extra_flag = extra_flag + + +def _assert_auth_provider_matches(actual, klass, expected_props): + """ + Assert that the provider matches class and properties + * actual ..........: Thing to compare with it + * klass ...........: Class to ensure this matches to (ie PlainTextAuthProvider) + * expected_props ..: Dict of var properties to match + """ + assert isinstance(actual, klass) + assert expected_props == vars(actual) + +class CustomAuthProviderTest(unittest.TestCase): + + def setUp(self): + self._captured_std_err = io.StringIO() + sys.stderr = self._captured_std_err + + def tearDown(self): + self._captured_std_err.close() + sys.stdout = sys.__stderr__ + + def test_no_warning_insecure_if_no_pass(self): + load_auth_provider(construct_config_path('plain_text_partial_example')) + err_msg = self._captured_std_err.getvalue() + assert err_msg == '' + + def test_insecure_creds(self): + load_auth_provider(construct_config_path('full_plain_text_example')) + err_msg = self._captured_std_err.getvalue() + assert "Notice:" in err_msg + assert "Warning:" in err_msg + + def test_creds_not_checked_for_non_plaintext(self): + load_auth_provider(construct_config_path('complex_auth_provider_with_pass')) + err_msg = self._captured_std_err.getvalue() + assert err_msg == '' + + def test_partial_property_example(self): + actual = load_auth_provider(construct_config_path('partial_example')) + _assert_auth_provider_matches( + actual, + NoUserNamePlainTextAuthProvider, + {"username": '', + "password": ''}) + + def test_full_property_example(self): + actual = load_auth_provider(construct_config_path('full_plain_text_example')) + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user1', + "password": 'pass1'}) + + def test_empty_example(self): + actual = load_auth_provider(construct_config_path('empty_example')) + assert actual is None + + def test_plaintextauth_when_not_defined(self): + creds_file = construct_config_path('plain_text_full_creds') + actual = load_auth_provider(cred_file=creds_file) + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user2', + "password": 'pass2'}) + + def test_no_cqlshrc_file(self): + actual = load_auth_provider() + assert actual is None + + def test_no_classname_example(self): + actual = load_auth_provider(construct_config_path('no_classname_example')) + assert actual is None + + def test_improper_config_example(self): + with pytest.raises(ModuleNotFoundError) as error: + load_auth_provider(construct_config_path('illegal_example')) + assert error is not None + + def test_username_password_passed_from_commandline(self): + creds_file = construct_config_path('complex_auth_provider_creds') + cqlshrc = construct_config_path('complex_auth_provider') + + actual = load_auth_provider(cqlshrc, creds_file, 'user-from-legacy', 'pass-from-legacy') + _assert_auth_provider_matches( + actual, + ComplexTextAuthProvider, + {"username": 'user-from-legacy', + "password": 'pass-from-legacy', + "extra_flag": 'flag2'}) + + def test_creds_example(self): + creds_file = construct_config_path('complex_auth_provider_creds') + cqlshrc = construct_config_path('complex_auth_provider') + + actual = load_auth_provider(cqlshrc, creds_file) + _assert_auth_provider_matches( + actual, + ComplexTextAuthProvider, + {"username": 'user1', + "password": 'pass2', + "extra_flag": 'flag2'}) + + def test_legacy_example_use_passed_username(self): + creds_file = construct_config_path('plain_text_partial_creds') + cqlshrc = construct_config_path('plain_text_partial_example') + + actual = load_auth_provider(cqlshrc, creds_file, 'user3') + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user3', + "password": 'pass2'}) + + def test_legacy_example_no_auth_provider_given(self): + cqlshrc = construct_config_path('empty_example') + creds_file = construct_config_path('complex_auth_provider_creds') + + actual = load_auth_provider(cqlshrc, creds_file, 'user3', 'pass3') + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user3', + "password": 'pass3'}) + + def test_shouldnt_pass_no_password_when_alt_auth_provider(self): + cqlshrc = construct_config_path('complex_auth_provider') + creds_file = None + + actual = load_auth_provider(cqlshrc, creds_file, 'user3') + _assert_auth_provider_matches( + actual, + ComplexTextAuthProvider, + {"username": 'user3', + "password": 'default_pass', + "extra_flag": 'flag1'}) + + def test_legacy_example_no_password(self): + cqlshrc = construct_config_path('plain_text_partial_example') + creds_file = None + + actual = load_auth_provider(cqlshrc, creds_file, 'user3') + _assert_auth_provider_matches( + actual, + PlainTextAuthProvider, + {"username": 'user3', + "password": None}) diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider new file mode 100644 index 0000000000..879b7a66d6 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider @@ -0,0 +1,10 @@ +; Config for a custom auth provider that uses the auth_provider field +; ComplexTextAuthProvider is a PlainTextAuthProvider in the driver which +; takes an extra field (extra_flag). +; used by unit testing + +[auth_provider] +module = cqlshlib.test.test_authproviderhandling +classname = ComplexTextAuthProvider +username = user1 +extra_flag = flag1 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds new file mode 100644 index 0000000000..bb102bc1ac --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_creds @@ -0,0 +1,3 @@ +[ComplexTextAuthProvider] +extra_flag = flag2 +password = pass2 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_with_pass b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_with_pass new file mode 100644 index 0000000000..c322008750 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/complex_auth_provider_with_pass @@ -0,0 +1,11 @@ +; Config for a custom auth provider that uses the auth_provider field +; ComplexTextAuthProvider is a PlainTextAuthProvider in the driver which +; takes an extra field (extra_flag). +; used by unit testing + +[auth_provider] +module = cqlshlib.test.test_authproviderhandling +classname = ComplexTextAuthProvider +username = user1 +password = pass1 +extra_flag = flag1 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/empty_example b/pylib/cqlshlib/test/test_authproviderhandling_config/empty_example new file mode 100644 index 0000000000..3dfda04654 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/empty_example @@ -0,0 +1,2 @@ +; Config for a custom auth provider that uses only the auth_provider field + diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example b/pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example new file mode 100644 index 0000000000..b962e63d53 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/full_plain_text_example @@ -0,0 +1,10 @@ +; Config for a custom auth provider that uses all possible fields +; This example loads the PlainTextAuthProvider and passes username and password to constructor +; dynamically. +; used by unit testing + +[auth_provider] +module = cassandra.auth +classname = PlainTextAuthProvider +username = user1 +password = pass1 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example b/pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example new file mode 100644 index 0000000000..615fe9f184 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/illegal_example @@ -0,0 +1,5 @@ +; Example that shouldn't work + +[auth_provider] +module = nowhere.illegal.wrong +classname = badclass diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example b/pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example new file mode 100644 index 0000000000..cf27bfd435 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/no_classname_example @@ -0,0 +1,5 @@ +; Config for a custom auth provider that uses only the auth_provider field +; this version doesn't have a classname, but has a module name. + +[auth_provider] +module = cqlshlib.test diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/partial_example b/pylib/cqlshlib/test/test_authproviderhandling_config/partial_example new file mode 100644 index 0000000000..23be26e3ee --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/partial_example @@ -0,0 +1,8 @@ +; Config for a custom auth provider that uses only the auth_provider field +; NoUserNamePlainTextAuthProvider is a PlainTextAuthProvider in the driver which +; doesn't take a username or password. +; used by unit testing + +[auth_provider] +module = cqlshlib.test.test_authproviderhandling +classname = NoUserNamePlainTextAuthProvider diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_full_creds b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_full_creds new file mode 100644 index 0000000000..3cd44708c3 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_full_creds @@ -0,0 +1,3 @@ +[PlainTextAuthProvider] +password = pass2 +username = user2 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds new file mode 100644 index 0000000000..1faf24dbb1 --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_creds @@ -0,0 +1,2 @@ +[PlainTextAuthProvider] +password = pass2 diff --git a/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example new file mode 100644 index 0000000000..37baebdd2e --- /dev/null +++ b/pylib/cqlshlib/test/test_authproviderhandling_config/plain_text_partial_example @@ -0,0 +1,8 @@ +; Config for a custom auth provider that uses some possible fields +; validate that the partial breakdown works successfully +; used by unit testing + +[auth_provider] +module = cassandra.auth +classname = PlainTextAuthProvider +username = user1 diff --git a/pylib/cqlshlib/util.py b/pylib/cqlshlib/util.py index f29141d80f..144586aae0 100644 --- a/pylib/cqlshlib/util.py +++ b/pylib/cqlshlib/util.py @@ -18,7 +18,9 @@ import cProfile import codecs import pstats - +import os +import errno +import stat from datetime import timedelta, tzinfo from io import StringIO @@ -112,6 +114,21 @@ def trim_if_present(s, prefix): return s +def is_file_secure(filename): + try: + st = os.stat(filename) + except OSError as e: + if e.errno != errno.ENOENT: + raise + # the file doesn't exist, the security of it is irrelevant + return True + uid = os.getuid() + + # Skip enforcing the file owner and UID matching for the root user (uid == 0). + # This is to allow "sudo cqlsh" to work with user owned credentials file. + return (uid == 0 or st.st_uid == uid) and stat.S_IMODE(st.st_mode) & (stat.S_IRGRP | stat.S_IROTH) == 0 + + def get_file_encoding_bomsize(filename): """ Checks the beginning of a file for a Unicode BOM. Based on this check,