diff --git a/.drone.yml b/.drone.yml index 7084efa..7a57909 100644 --- a/.drone.yml +++ b/.drone.yml @@ -10,7 +10,7 @@ steps: # way to share system packages between steps commands: # install dependencies - - apt update && apt install -y libkrb5-dev python3-dev + - apt update && apt install -y libkrb5-dev libpq-dev python3-dev - python3 -m venv venv - . venv/bin/activate - pip install -r dev-requirements.txt diff --git a/ceo_common/interfaces/IDatabaseService.py b/ceo_common/interfaces/IDatabaseService.py index 1062df2..973d21d 100644 --- a/ceo_common/interfaces/IDatabaseService.py +++ b/ceo_common/interfaces/IDatabaseService.py @@ -9,7 +9,10 @@ class IDatabaseService(Interface): auth_password = Attribute('password to a privileged user on the database host') def create_db(username: str) -> str: - """try to create a database and user and return its password""" + """create a user and database and return the password""" + + def reset_passwd(username: str) -> str: + """reset user password and return it""" def delete_db(username: str): """remove user and delete their database""" diff --git a/ceod/api/database.py b/ceod/api/database.py index 3001ee6..1a2d169 100644 --- a/ceod/api/database.py +++ b/ceod/api/database.py @@ -1,50 +1,58 @@ from flask import Blueprint, request from zope import component +from functools import wraps + from ceod.api.utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ user_is_in_group, requires_authentication_no_realm, \ create_streaming_response, development_only -from ceo_common.errors import UserNotFoundError, DatabaseConnectionError, DatabasePermissionError, InvalidUsernameError +from ceo_common.errors import UserNotFoundError, DatabaseConnectionError, DatabasePermissionError, \ + InvalidUsernameError, UserAlreadyExistsError from ceo_common.interfaces import ILDAPService, IDatabaseService bp = Blueprint('db', __name__) +def db_exception_handler(func): + @wraps(func) + def function(db_type: str, username: str): + try: + if not username.isalnum(): # username should not contain symbols + raise InvalidUsernameError() + ldap_srv = component.getUtility(ILDAPService) + ldap_srv.get_user(username) # make sure user exists + return func(db_type, username) + except UserNotFoundError: + return {'error': 'user not found'}, 404 + except UserAlreadyExistsError: + return {'error': 'database user is already created'}, 409 + except InvalidUsernameError: + return {'error': 'username contains invalid characters'}, 400 + except DatabaseConnectionError: + return {'error': 'unable to connect or authenticate to sql server'}, 500 + except DatabasePermissionError: + return {'error': 'unable to perform action due to permissions'}, 500 + return function + + +@db_exception_handler def create_db_from_type(db_type: str, username: str): - try: - if not username.isalnum(): # username should not contain symbols - raise InvalidUsernameError() - ldap_srv = component.getUtility(ILDAPService) - ldap_srv.get_user(username) # make sure user exists - db_srv = component.getUtility(IDatabaseService, db_type) - password = db_srv.create_db(username) - return {'password': password} - except UserNotFoundError: - return {'error': 'user not found'}, 404 - except InvalidUsernameError: - return {'error': 'username contains invalid characters'}, 400 - except DatabaseConnectionError: - return {'error': 'unable to connect or authenticate to sql server'}, 500 - except DatabasePermissionError: - return {'error': 'unable to perform action due to permissions'}, 500 + db_srv = component.getUtility(IDatabaseService, db_type) + password = db_srv.create_db(username) + return {'password': password} +@db_exception_handler +def reset_db_passwd_from_type(db_type: str, username: str): + db_srv = component.getUtility(IDatabaseService, db_type) + password = db_srv.reset_passwd(username) + return {'password': password} + + +@db_exception_handler def delete_db_from_type(db_type: str, username: str): - try: - if not username.isalnum(): # username should not contain symbols - raise InvalidUsernameError() - ldap_srv = component.getUtility(ILDAPService) - ldap_srv.get_user(username) # make sure user exists - db_srv = component.getUtility(IDatabaseService, db_type) - db_srv.delete_db(username) - except UserNotFoundError: - return {'error': 'user not found'}, 404 - except InvalidUsernameError: - return {'error': 'username contains invalid characters'}, 400 - except DatabaseConnectionError: - return {'error': 'unable to connect or authenticate to sql server'}, 500 - except DatabasePermissionError: - return {'error': 'unable to perform action due to permissions'}, 500 + db_srv = component.getUtility(IDatabaseService, db_type) + db_srv.delete_db(username) @bp.route('/mysql/', methods=['POST']) @@ -63,6 +71,22 @@ def create_postgresql_db(auth_user: str, username: str): return create_db_from_type('postgresql', username) +@bp.route('/mysql//pwreset', methods=['POST']) +@requires_authentication_no_realm +def reset_mysql_db_passwd(auth_user: str, username: str): + if not (auth_user == username or user_is_in_group(auth_user, 'syscom')): + return {'error': "not authorized to request password reset for others"}, 403 + return reset_db_passwd_from_type('mysql', username) + + +@bp.route('/postgresql//pwreset', methods=['POST']) +@requires_authentication_no_realm +def reset_postgresql_db_passwd(auth_user: str, username: str): + if not (auth_user == username or user_is_in_group(auth_user, 'syscom')): + return {'error': "not authorized to request password reset for others"}, 403 + return reset_db_passwd_from_type('postgresql', username) + + @bp.route('/mysql/', methods=['DELETE']) @authz_restrict_to_syscom @development_only diff --git a/ceod/db/MySQLService.py b/ceod/db/MySQLService.py index bd41a9f..8e7c8cb 100644 --- a/ceod/db/MySQLService.py +++ b/ceod/db/MySQLService.py @@ -1,9 +1,13 @@ from zope.interface import implementer from zope import component +from contextlib import contextmanager + from ceo_common.interfaces import IDatabaseService, IConfig -from ceo_common.errors import DatabaseConnectionError, DatabasePermissionError +from ceo_common.errors import DatabaseConnectionError, DatabasePermissionError, UserAlreadyExistsError, \ + UserNotFoundError from ceod.utils import gen_password from ceod.db.utils import response_is_empty + from mysql.connector import connect from mysql.connector.errors import InterfaceError, ProgrammingError @@ -18,48 +22,58 @@ class MySQLService: self.auth_username = config.get('mysql_username') self.auth_password = config.get('mysql_password') - def create_db(self, username: str) -> str: - password = gen_password() - search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'" - search_for_db = f"SHOW DATABASES LIKE '{username}'" - create_user = f"CREATE USER '{username}'@'localhost' IDENTIFIED BY %(password)s" - reset_password = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY %(password)s" - create_database = f""" - CREATE DATABASE {username}; - GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'localhost'; - """ - try: - with connect( - host='localhost', - user=self.auth_username, - password=self.auth_password, - ) as con: - with con.cursor() as cursor: - if response_is_empty(search_for_user, con): - cursor.execute(create_user, {'password': password}) - else: - cursor.execute(reset_password, {'password': password}) - if response_is_empty(search_for_db, con): - cursor.execute(create_database) - return password - except InterfaceError: - raise DatabaseConnectionError() - except ProgrammingError: - raise DatabasePermissionError() - - def delete_db(self, username: str): - drop_user = f"DROP USER IF EXISTS '{username}'@'localhost'" - drop_db = f"DROP DATABASE IF EXISTS {username}" + @contextmanager + def mysql_connection(self): try: with connect( host='localhost', user=self.auth_username, password=self.auth_password, ) as con: - with con.cursor() as cursor: - cursor.execute(drop_db) - cursor.execute(drop_user) + yield con except InterfaceError: raise DatabaseConnectionError() except ProgrammingError: raise DatabasePermissionError() + + def create_db(self, username: str) -> str: + password = gen_password() + search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'" + search_for_db = f"SHOW DATABASES LIKE '{username}'" + create_user = f"CREATE USER '{username}'@'localhost' IDENTIFIED BY %(password)s" + create_database = f""" + CREATE DATABASE {username}; + GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'localhost'; + """ + + with self.mysql_connection() as con: + with con.cursor() as cursor: + if response_is_empty(search_for_user, con): + cursor.execute(create_user, {'password': password}) + if response_is_empty(search_for_db, con): + cursor.execute(create_database) + else: + raise UserAlreadyExistsError() + return password + + def reset_db_passwd(self, username: str) -> str: + password = gen_password() + search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'" + reset_password = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY %(password)s" + + with self.mysql_connection() as con: + with con.cursor() as cursor: + if not response_is_empty(search_for_user, con): + cursor.execute(reset_password, {'password': password}) + else: + raise UserNotFoundError(username) + return password + + def delete_db(self, username: str): + drop_user = f"DROP USER IF EXISTS '{username}'@'localhost'" + drop_db = f"DROP DATABASE IF EXISTS {username}" + + with self.mysql_connection() as con: + with con.cursor() as cursor: + cursor.execute(drop_db) + cursor.execute(drop_user) diff --git a/ceod/db/PostgreSQLService.py b/ceod/db/PostgreSQLService.py index 009542d..1ddef9e 100644 --- a/ceod/db/PostgreSQLService.py +++ b/ceod/db/PostgreSQLService.py @@ -1,9 +1,13 @@ from zope.interface import implementer from zope import component +from contextlib import contextmanager + from ceo_common.interfaces import IDatabaseService, IConfig -from ceo_common.errors import DatabaseConnectionError, DatabasePermissionError +from ceo_common.errors import DatabaseConnectionError, DatabasePermissionError, UserAlreadyExistsError, \ + UserNotFoundError from ceod.utils import gen_password from ceod.db.utils import response_is_empty + from psycopg2 import connect, OperationalError, ProgrammingError from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT @@ -18,16 +22,8 @@ class PostgreSQLService: self.auth_username = config.get('postgresql_username') self.auth_password = config.get('postgresql_password') - def create_db(self, username: str) -> str: - password = gen_password() - search_for_user = f"SELECT FROM pg_roles WHERE rolname='{username}'" - search_for_db = f"SELECT FROM pg_database WHERE datname='{username}'" - create_user = f"CREATE USER {username} WITH PASSWORD %(password)s" - reset_password = f"ALTER USER {username} WITH PASSWORD %(password)s" - create_database = f""" - CREATE DATABASE {username} OWNER {username}; - REVOKE ALL ON DATABASE {username} FROM PUBLIC; - """ + @contextmanager + def psql_connection(self): try: with connect( host='localhost', @@ -35,35 +31,50 @@ class PostgreSQLService: password=self.auth_password, ) as con: con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - with con.cursor() as cursor: - if response_is_empty(search_for_user, con): - cursor.execute(create_user, {'password': password}) - else: - cursor.execute(reset_password, {'password': password}) - if response_is_empty(search_for_db, con): - cursor.execute(create_database) - return password + yield con except OperationalError: raise DatabaseConnectionError() except ProgrammingError: raise DatabasePermissionError() + def create_db(self, username: str) -> str: + password = gen_password() + search_for_user = f"SELECT FROM pg_roles WHERE rolname='{username}'" + search_for_db = f"SELECT FROM pg_database WHERE datname='{username}'" + create_user = f"CREATE USER {username} WITH PASSWORD %(password)s" + create_database = f""" + CREATE DATABASE {username} OWNER {username}; + REVOKE ALL ON DATABASE {username} FROM PUBLIC; + """ + + with self.psql_connection() as con: + with con.cursor() as cursor: + if response_is_empty(search_for_user, con): + cursor.execute(create_user, {'password': password}) + if response_is_empty(search_for_db, con): + cursor.execute(create_database) + else: + raise UserAlreadyExistsError() + return password + + def reset_db_passwd(self, username: str) -> str: + password = gen_password() + search_for_user = f"SELECT FROM pg_roles WHERE rolname='{username}'" + reset_password = f"ALTER USER {username} WITH PASSWORD %(password)s" + + with self.psql_connection() as con: + with con.cursor() as cursor: + if not response_is_empty(search_for_user, con): + cursor.execute(reset_password, {'password': password}) + else: + raise UserNotFoundError(username) + return password + def delete_db(self, username: str): drop_user = f"DROP USER IF EXISTS {username}" drop_db = f"DROP DATABASE IF EXISTS {username}" - try: - with connect( - host='localhost', - user=self.auth_username, - password=self.auth_password, - ) as con: - con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - with con.cursor() as cursor: - cursor.execute(drop_db) - cursor.execute(drop_user) - except OperationalError: - raise DatabaseConnectionError() - except ProgrammingError: - raise DatabasePermissionError() - + with self.psql_connection() as con: + with con.cursor() as cursor: + cursor.execute(drop_db) + cursor.execute(drop_user) diff --git a/tests/ceod/db/test_mysql.py b/tests/ceod/db/test_mysql.py index 4c7da26..2d14544 100644 --- a/tests/ceod/db/test_mysql.py +++ b/tests/ceod/db/test_mysql.py @@ -30,6 +30,23 @@ def test_mysql_db_create(cfg): password=password, ) + +def test_mysql_passwd_reset(): + pass + + +# test with curl +# test with invalid perms for curl + +# test perms + +# test with dup user + +# test with invalid perms for db + +# test with invalid host for db + + # except InterfaceError: # raise DatabaseConnectionError() # except ProgrammingError: @@ -41,6 +58,3 @@ def test_mysql_db_create(cfg): # each test should not require anything before or change anything # this means you should delete user and databases created after done -# attempt to create database -# password reset -# recreate database (user dropped database)