diff --git a/docs/en_US/images/server_advanced.png b/docs/en_US/images/server_advanced.png index a5bf10e2d..ba715ae18 100644 Binary files a/docs/en_US/images/server_advanced.png and b/docs/en_US/images/server_advanced.png differ diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index ecb14159d..cf1a94546 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -189,6 +189,16 @@ Use the fields in the *Advanced* tab to configure a connection: (.pgpass). A .pgpass file allows a user to login without providing a password when they connect. For more information, see `Section 33.15 of the Postgres documentation `_. +* Use the *Password exec command* field to specify a shell command to be executed + to retrieve a password to be used for SQL authentication. The ``stdout`` of the + command will be used as the SQL password. This may be useful when the password + should be generated as a transient authorization token instead of providing a + password when connecting in `PAM authentication `_ scenarios. +* Use the *Password exec expiration* field to specify a maximum age, in seconds, + of the password generated with a *Password exec command*. If not specified, + the password will not expire until your pgAdmin session does. + Zero means the command will be executed for each new connection or reconnection that is made. + If the generated password is not valid indefinitely, set this value to slightly before it will expire. * Use the *Connection timeout* field to specify the maximum wait for connection, in seconds. Zero or not specified means wait indefinitely. It is not recommended to use a timeout of less than 2 seconds. By default it is set to @@ -197,6 +207,8 @@ Use the fields in the *Advanced* tab to configure a connection: .. note:: The password file option is only supported when pgAdmin is using libpq v10.0 or later to connect to the server. +.. note:: The Password exec option is only supported when pgAdmin is run in desktop mode. + * Click the *Save* button to save your work. * Click the *Close* button to exit without saving your work. * Click the *Reset* button to return the values specified on the Server dialog diff --git a/web/migrations/versions/f79844e926ae_.py b/web/migrations/versions/f79844e926ae_.py new file mode 100644 index 000000000..5cbbbe991 --- /dev/null +++ b/web/migrations/versions/f79844e926ae_.py @@ -0,0 +1,40 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2022, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Update DB to version 32 + +Added passexec_cmd and passexec_expiration columns to server configuration. + +Revision ID: f79844e926ae +Revises: 1586db67b98e +Create Date: 2022-10-11 11:25:00.000000 + +""" +from pgadmin.model import db + +# revision identifiers, used by Alembic. +revision = 'f79844e926ae' +down_revision = '1586db67b98e' +branch_labels = None +depends_on = None + + +def upgrade(): + db.engine.execute(""" + ALTER TABLE server ADD COLUMN passexec_cmd TEXT(256) null + """) + db.engine.execute(""" + ALTER TABLE server ADD COLUMN passexec_expiration INT null + """) + # ### end Alembic commands ### + + +def downgrade(): + # pgAdmin only upgrades, downgrade not implemented. + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 62afc31c5..e34b3d9b8 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -710,6 +710,8 @@ class ServerNode(PGChildNodeView): 'role': 'role', 'db_res': 'db_res', 'passfile': 'passfile', + 'passexec_cmd': 'passexec_cmd', + 'passexec_expiration': 'passexec_expiration', 'sslcert': 'sslcert', 'sslkey': 'sslkey', 'sslrootcert': 'sslrootcert', @@ -978,6 +980,11 @@ class ServerNode(PGChildNodeView): 'fgcolor': server.fgcolor, 'db_res': server.db_res.split(',') if server.db_res else None, 'passfile': server.passfile if server.passfile else None, + 'passexec_cmd': + server.passexec_cmd if server.passexec_cmd else None, + 'passexec_expiration': + server.passexec_expiration if server.passexec_expiration + else None, 'sslcert': sslcert, 'sslkey': sslkey, 'sslrootcert': sslrootcert, @@ -1092,6 +1099,8 @@ class ServerNode(PGChildNodeView): tunnel_identity_file=data.get('tunnel_identity_file', None), shared=data.get('shared', None), passfile=data.get('passfile', None), + passexec_cmd=data.get('passexec_cmd', None), + passexec_expiration=data.get('passexec_expiration', None), kerberos_conn=1 if data.get('kerberos_conn', False) else 0, ) db.session.add(server) @@ -1378,7 +1387,9 @@ class ServerNode(PGChildNodeView): server.kerberos_conn is None): conn_passwd = getattr(conn, 'password', None) if conn_passwd is None and not server.save_password and \ - server.passfile is None and server.service is None: + server.passfile is None and \ + server.passexec_cmd is None and \ + server.service is None: prompt_password = True elif server.passfile and server.passfile != '': passfile = server.passfile diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index c395222d1..1e7fd6212 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -39,6 +39,8 @@ export default class ServerSchema extends BaseUISchema { save_password: false, db_res: [], passfile: undefined, + passexec: undefined, + passexec_expiration: undefined, sslcompression: false, sslcert: undefined, sslkey: undefined, @@ -424,7 +426,21 @@ export default class ServerSchema extends BaseUISchema { let passfile = state.passfile; return !_.isUndefined(passfile) && !_.isNull(passfile); }, - },{ + }, + { + id: 'passexec_cmd', label: gettext('Password exec command'), type: 'text', + group: gettext('Advanced'), + mode: ['properties', 'edit', 'create'], + }, + { + id: 'passexec_expiration', label: gettext('Password exec expiration (seconds)'), type: 'int', + group: gettext('Advanced'), + mode: ['properties', 'edit', 'create'], + visible: function(state) { + return !_.isEmpty(state.passexec_cmd); + }, + }, + { id: 'connect_timeout', label: gettext('Connection timeout (seconds)'), type: 'int', group: gettext('Advanced'), mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, diff --git a/web/pgadmin/messages.pot b/web/pgadmin/messages.pot index ad630325a..3954af8f2 100644 --- a/web/pgadmin/messages.pot +++ b/web/pgadmin/messages.pot @@ -8316,6 +8316,14 @@ msgstr "" msgid "Password file" msgstr "" +#: pgadmin/browser/server_groups/servers/static/js/server.ui.js:431 +msgid "Password exec command" +msgstr "" + +#: pgadmin/browser/server_groups/servers/static/js/server.ui.js:436 +msgid "Password exec expiration (seconds)" +msgstr "" + #: pgadmin/browser/server_groups/servers/static/js/server.ui.js:438 msgid "Connection timeout (seconds)" msgstr "" diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index e4ce3ec30..ad8dc03d5 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -30,7 +30,7 @@ import uuid # ########################################################################## -SCHEMA_VERSION = 33 +SCHEMA_VERSION = 34 ########################################################################## # @@ -158,6 +158,8 @@ class Server(db.Model): ) db_res = db.Column(db.Text(), nullable=True) passfile = db.Column(db.Text(), nullable=True) + passexec_cmd = db.Column(db.Text(), nullable=True) + passexec_expiration = db.Column(db.Integer(), nullable=True) sslcert = db.Column(db.Text(), nullable=True) sslkey = db.Column(db.Text(), nullable=True) sslrootcert = db.Column(db.Text(), nullable=True) @@ -215,6 +217,8 @@ class Server(db.Model): "discovery_id": self.discovery_id, "db_res": self.db_res, "passfile": self.passfile, + "passexec_cmd": self.passexec_cmd, + "passexec_expiration": self.passexec_expiration, "sslcert": self.sslcert, "sslkey": self.sslkey, "sslrootcert": self.sslrootcert, diff --git a/web/pgadmin/utils/driver/psycopg2/connection.py b/web/pgadmin/utils/driver/psycopg2/connection.py index 65bbe4a3a..0c1f507ce 100644 --- a/web/pgadmin/utils/driver/psycopg2/connection.py +++ b/web/pgadmin/utils/driver/psycopg2/connection.py @@ -300,6 +300,8 @@ class Connection(BaseConnection): # if it's present then we will use it if not password and not encpass and not passfile: passfile = manager.passfile if manager.passfile else None + if manager.passexec: + password = manager.passexec.get() try: database = self.db diff --git a/web/pgadmin/utils/driver/psycopg2/server_manager.py b/web/pgadmin/utils/driver/psycopg2/server_manager.py index d6431e29d..abb3eae9c 100644 --- a/web/pgadmin/utils/driver/psycopg2/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg2/server_manager.py @@ -13,6 +13,7 @@ Implementation of ServerManager import os import datetime import config +import logging from flask import current_app, session from flask_security import current_user from flask_babel import gettext @@ -27,6 +28,7 @@ from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\ CryptKeyMissing from pgadmin.utils.master_password import get_crypt_key from pgadmin.utils.exception import ObjectGone +from pgadmin.utils.passexec import PasswordExec if config.SUPPORT_SSH_TUNNEL: from sshtunnel import SSHTunnelForwarder, BaseSSHTunnelForwarderError @@ -77,6 +79,9 @@ class ServerManager(object): self.server_types = None self.db_res = server.db_res self.passfile = server.passfile + self.passexec = \ + PasswordExec(server.passexec_cmd, server.passexec_expiration) \ + if server.passexec_cmd else None self.sslcert = server.sslcert self.sslkey = server.sslkey self.sslrootcert = server.sslrootcert @@ -567,20 +572,28 @@ WHERE db.oid = {0}""".format(did)) try: # If authentication method is 1 then it uses identity file # and password + ssh_logger = None + if current_app.debug: + ssh_logger = logging.getLogger('sshtunnel') + ssh_logger.setLevel(logging.DEBUG) + for h in current_app.logger.handlers: + ssh_logger.addHandler(h) if self.tunnel_authentication == 1: self.tunnel_object = SSHTunnelForwarder( (self.tunnel_host, int(self.tunnel_port)), ssh_username=self.tunnel_username, ssh_pkey=get_complete_file_path(self.tunnel_identity_file), ssh_private_key_password=tunnel_password, - remote_bind_address=(self.host, self.port) + remote_bind_address=(self.host, self.port), + logger=ssh_logger ) else: self.tunnel_object = SSHTunnelForwarder( (self.tunnel_host, int(self.tunnel_port)), ssh_username=self.tunnel_username, ssh_password=tunnel_password, - remote_bind_address=(self.host, self.port) + remote_bind_address=(self.host, self.port), + logger=ssh_logger ) # flag tunnel threads in daemon mode to fix hang issue. self.tunnel_object.daemon_forward_servers = True diff --git a/web/pgadmin/utils/passexec.py b/web/pgadmin/utils/passexec.py new file mode 100644 index 000000000..a9b25b97f --- /dev/null +++ b/web/pgadmin/utils/passexec.py @@ -0,0 +1,65 @@ + + +import logging +import subprocess +from datetime import datetime, timedelta +from threading import Lock + +from flask import current_app + +import config + + +class PasswordExec: + + lock = Lock() + + def __init__(self, cmd, expiration_seconds=None, timeout=60): + self.cmd = str(cmd) + self.expiration_seconds = int(expiration_seconds) \ + if expiration_seconds is not None else None + self.timeout = int(timeout) + self.password = None + self.last_result = None + + def get(self): + if config.SERVER_MODE: + # Arbitrary shell execution on server is a security risk + raise NotImplementedError('Passexec not available in server mode') + with self.lock: + if not self.password or self.is_expired(): + if not self.cmd: + return None + current_app.logger.info(f'Calling passexec') + now = datetime.utcnow() + try: + p = subprocess.run( + self.cmd, + shell=True, + timeout=self.timeout, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as e: + if (e.stderr): + self.create_logger().error(e.stderr) + raise + + current_app.logger.info(f'Passexec completed successfully') + self.last_result = now + self.password = p.stdout.strip() + return self.password + + def is_expired(self): + if self.expiration_seconds is None: + return False + return self.last_result is not None and\ + datetime.utcnow() - self.last_result \ + >= timedelta(seconds=self.expiration_seconds) + + def create_logger(self): + logger = logging.getLogger('passexec') + for h in current_app.logger.handlers: + logger.addHandler(h) + return logger