Added support for IAM token based authentication for AWS RDS or Azure DB. #3491

This commit is contained in:
aelgn 2022-10-15 11:19:04 +02:00 committed by GitHub
parent 25be215180
commit a62fc2fbff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 176 additions and 5 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -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 <https://www.postgresql.org/docs/current/libpq-pgpass.html>`_.
* 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 <https://www.postgresql.org/docs/current/auth-pam.html>`_ 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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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 ""

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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