Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/en_US/server_dialog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ Use the fields in the *Connection* tab to configure a connection:
installed on that database.
* Use the *Username* field to specify the name of a role that will be used when
authenticating with the server.
* In server mode, when Microsoft Entra database authentication is enabled by
the pgAdmin administrator, use *Authentication* to choose *Microsoft Entra
ID*, then click *Sign in to Microsoft*. On a local pgAdmin desktop, the
Microsoft sign-in opens in a browser. On a remote server, pgAdmin can show
a device code and sign-in URL instead. Enter the user's Entra UPN, or an
authorized group name, in *Username*. pgAdmin obtains a PostgreSQL token
for each new connection and renews it when permitted; no database password
is saved. If an organization's sign-in policy requires reauthentication,
pgAdmin prompts for Microsoft sign-in again. This development flow uses
Azure Identity's public client.
The host must be an Azure Database for PostgreSQL host ending in
``.postgres.database.azure.com``. pgAdmin verifies TLS using its bundled
certificate authorities for this method.
* When *Kerberos authentication?* is set to *True*, pgAdmin will try to connect
the PostgreSQL server using Kerberos authentication.
* Use the *Password* field to provide a password that will be supplied when
Expand Down Expand Up @@ -333,4 +346,4 @@ Click on the *+* button to add a new tag. Some of the parameters are:

.. toctree::

clear_saved_passwords
clear_saved_passwords
9 changes: 9 additions & 0 deletions web/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,15 @@
# Add ?no_auto_login to the login page URL to display it regardless.
OAUTH2_AUTO_LOGIN = False

# Enable interactive Microsoft Entra sign-in for Azure PostgreSQL servers.
# Uses the Azure Identity development client and encrypted per-user token
# caches. A dedicated app registration is recommended for production use.
ENTRA_DB_AUTH_ENABLED = False
ENTRA_DB_TENANT_ID = None
# Browser opens on the pgAdmin host; use only when the user and server share
# a desktop. Device code presents a URL and code to remote browser users.
ENTRA_DB_SIGNIN_METHOD = 'device_code'

##########################################################################
# Webserver Configuration
##########################################################################
Expand Down
32 changes: 32 additions & 0 deletions web/migrations/versions/entra_server_auth_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

"""Add the authentication method for registered PostgreSQL servers.

Revision ID: entra_server_auth
Revises: server_passexec_name
"""
from alembic import op
import sqlalchemy as sa

revision = 'entra_server_auth'
down_revision = 'server_passexec_name'
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table('server') as batch_op:
batch_op.add_column(sa.Column('auth_method', sa.String(16),
nullable=False,
server_default='password'))


def downgrade():
pass
132 changes: 125 additions & 7 deletions web/pgadmin/browser/server_groups/servers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
from pgadmin.settings.utils import with_object_filters
from pgadmin.utils.server_access import get_server, \
get_user_server_query, get_server_group
from pgadmin.utils.entra_auth import start_sign_in, \
EntraAuthenticationError


# File-path keys in connection_params that are per-user and must
Expand Down Expand Up @@ -105,6 +107,50 @@ def prepare_passexec_data(data, is_non_owner):
return data, None


def prepare_auth_method_data(data, server=None, is_non_owner=False):
"""Validate database authentication without accepting a hidden password."""
data = dict(data)
method = data.get('auth_method',
server.auth_method if server is not None else 'password')
if method not in ('password', 'entra'):
return data, gettext('Unknown database authentication method.')
if is_non_owner:
data.pop('auth_method', None)
method = server.auth_method
if method == 'entra':
if not config.SERVER_MODE or not getattr(
config, 'ENTRA_DB_AUTH_ENABLED', False):
return data, gettext(
'Microsoft Entra database authentication is not enabled.')
if data.get('password') or data.get('save_password') or \
data.get('passexec_cmd') or \
data.get('passexec_name') not in (
None, '', '__none__', '__inherit__'):
return data, gettext(
'Microsoft Entra authentication cannot use a saved '
'password or password exec command.')
data['password'] = None
data['save_password'] = False
data['passexec_name'] = None
data['passexec_expiration'] = None
return data, None


def require_entra_ssl(params):
"""A database access token must not be sent on a plaintext connection."""
params = dict(params or {})
params.pop('passfile', None)
params['sslmode'] = 'verify-full'
return params


def should_connect_server_on_create(data):
"""Defer an Entra connection until this pgAdmin user signs in."""
return bool(data.get('connect_now')) and not (
data.get('auth_method') == 'entra' and
not session.get('entra_db_auth'))


def passexec_api_fields(server, shared_server, is_non_owner):
"""The password exec fields for the properties response. In server
mode the free-text command is never returned, and a non-owner sees
Expand Down Expand Up @@ -509,7 +555,11 @@ def register_preferences(self):
ServerType.register_preferences()

def get_exposed_url_endpoints(self):
return ['NODE-server.connect_id']
return [
'NODE-server.connect_id',
'NODE-server.entra_signin',
'NODE-server.entra_signin_code',
]

@staticmethod
def create_shared_server(data, gid):
Expand Down Expand Up @@ -624,6 +674,29 @@ def __init__(self, **kwargs):
blueprint = ServerModule(__name__)


@blueprint.route('/entra_signin/', methods=['POST'],
endpoint='entra_signin')
@pga_login_required
def entra_signin():
"""Authenticate this pgAdmin user to Azure PostgreSQL."""
try:
username = start_sign_in()
except EntraAuthenticationError as error:
return make_json_response(success=0, status=401, errormsg=str(error))
return make_json_response(success=1, data={'username': username})


@blueprint.route('/entra_signin_code/', methods=['GET'],
endpoint='entra_signin_code')
@pga_login_required
def entra_signin_code():
"""Return the current device challenge without exposing a token."""
if not getattr(config, 'ENTRA_DB_AUTH_ENABLED', False):
return make_json_response(success=0, status=404)
code = session.get('entra_db_code')
return make_json_response(success=1 if code else 0, data=code)


class ServerNode(PGChildNodeView):
node_type = ServerModule._NODE_TYPE
node_label = "Server"
Expand Down Expand Up @@ -957,6 +1030,7 @@ def update(self, gid, sid):
'port': 'port',
'db': 'maintenance_db',
'username': 'username',
'auth_method': 'auth_method',
'password': 'password',
'save_password': 'save_password',
'gid': 'servergroup_id',
Expand Down Expand Up @@ -992,6 +1066,7 @@ def update(self, gid, sid):
'port': gettext('Port'),
'db': gettext('Maintenance database'),
'username': gettext('Username'),
'auth_method': gettext('Authentication'),
'comment': gettext('Comments'),
'role': gettext('Role'),
'service': gettext('Service')
Expand All @@ -1007,11 +1082,21 @@ def update(self, gid, sid):
return make_json_response(
success=0, status=400, errormsg=passexec_err)

data, auth_err = prepare_auth_method_data(
data, server, _is_non_owner(server))
if auth_err:
return make_json_response(
success=0, status=400, errormsg=auth_err)

if 'db_res' in data and isinstance(data['db_res'], list):
data['db_res'] = ','.join(data['db_res'])

# Update connection parameter if any.
self.update_connection_parameter(data, server, sharedserver)
if data.get('auth_method', server.auth_method) == 'entra':
data['connection_params'] = require_entra_ssl(
data.get('connection_params',
(sharedserver or server).connection_params))
self.update_tags(data, sharedserver or server,
server.tags if sharedserver else None)

Expand All @@ -1032,6 +1117,11 @@ def update(self, gid, sid):
conn = manager.connection()
connected = conn.connected()

if connected and 'auth_method' in data and \
data['auth_method'] != server.auth_method:
return forbidden(errmsg=gettext(
'Disconnect the server before changing authentication.'))

disallowed = self._server_modify_disallowed_when_connected(
connected, data, disp_lbl)
if disallowed:
Expand Down Expand Up @@ -1116,7 +1206,7 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server,
# SharedServer — they enable command/SQL execution
# or are owner-level concepts not on SharedServer.
_owner_only_fields = frozenset({
'passexec_cmd',
'passexec_cmd', 'auth_method',
'db_res', 'db_res_type',
})

Expand All @@ -1128,7 +1218,8 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server,
continue
value = data[arg]
if arg == 'password':
value = encrypt(data[arg], crypt_key)
value = encrypt(data[arg], crypt_key) \
if data[arg] is not None else None
# sqlite3 do not have boolean type so we need to
# convert it manually to integer.
# Only the owner may unshare — this deletes ALL
Expand Down Expand Up @@ -1277,6 +1368,7 @@ def properties(self, gid, sid):
'shared_username': server.shared_username
if config.SERVER_MODE else None,
'username': server.username,
'auth_method': server.auth_method,
'gid': str(server.servergroup_id),
'group-name': sg.name if (sg and sg.name) else gettext('Servers'),
'comment': server.comment,
Expand Down Expand Up @@ -1357,6 +1449,11 @@ def create(self, gid):
return make_json_response(
success=0, status=400, errormsg=passexec_err)

data, auth_err = prepare_auth_method_data(data)
if auth_err:
return make_json_response(
success=0, status=400, errormsg=auth_err)

# Get enc key
crypt_key_present, crypt_key = get_crypt_key()
if not crypt_key_present:
Expand Down Expand Up @@ -1402,6 +1499,8 @@ def create(self, gid):

# To check ssl configuration
_, connection_params = check_ssl_fields(connection_params)
if data.get('auth_method') == 'entra':
connection_params = require_entra_ssl(connection_params)
# set the connection params again in the data
if 'connection_params' in data:
data['connection_params'] = connection_params
Expand All @@ -1423,6 +1522,7 @@ def create(self, gid):
port=data.get('port'),
maintenance_db=data.get('db', None),
username=data.get('username'),
auth_method=data.get('auth_method', 'password'),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
save_password=1 if data.get('save_password', False) and
config.ALLOW_SAVE_PASSWORD else 0,
comment=data.get('comment', None),
Expand Down Expand Up @@ -1461,7 +1561,7 @@ def create(self, gid):
replication_type = None
tunnel_password_saved = False

if 'connect_now' in data and data['connect_now']:
if should_connect_server_on_create(data):
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(
server.id)
manager.update(server)
Expand All @@ -1478,9 +1578,9 @@ def create(self, gid):
have_password = True
password = data['password']
password = encrypt(password, crypt_key)
elif 'passfile' in data['connection_params'] and \
data['connection_params']['passfile'] != '':
passfile = data['connection_params']['passfile']
elif 'passfile' in connection_params and \
connection_params['passfile'] != '':
passfile = connection_params['passfile']

if 'tunnel_password' in data and data["tunnel_password"] != '':
have_tunnel_password = True
Expand Down Expand Up @@ -1824,11 +1924,17 @@ def connect(self, gid, sid, is_qt=False, server=None):
server_types=ServerType.types()
)
except Exception as e:
if server.auth_method == 'entra':
return self.get_response_for_password(
server, 428, errmsg=str(e))
return self.get_response_for_password(
server, 401, not server.save_password, prompt_tunnel_password,
getattr(e, 'message', str(e)))

if not status:
if server.auth_method == 'entra':
return self.get_response_for_password(
server, 428, errmsg=errmsg)
current_app.logger.error(
"Could not connect to server(#{0}) - '{1}'.\nError: {2}"
.format(server.id, server.name, errmsg)
Expand Down Expand Up @@ -2274,6 +2380,18 @@ def check_pgpass(self, gid, sid):
def get_response_for_password(self, server, status, prompt_password=False,
prompt_tunnel_password=False, errmsg=None):

if server.auth_method == 'entra' and not prompt_tunnel_password:
return make_json_response(
success=0, status=status,
result={
'sid': server.id,
'server_label': server.name,
'username': server.username,
'errmsg': errmsg,
'prompt_entra': True,
'entra_signin_method': config.ENTRA_DB_SIGNIN_METHOD,
})

if server.use_ssh_tunnel:
data = {
"server_label": server.name,
Expand Down
Loading