Skip to content

Commit dabb8ef

Browse files
authored
feat(postgresql): add Azure Entra CLI authentication (#317)
Co-authored-by: Peter Adams <18162810+Maxteabag@users.noreply.github.com>
1 parent 976d234 commit dabb8ef

17 files changed

Lines changed: 745 additions & 40 deletions

‎sqlit/cli.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,11 @@ def main() -> int:
727727
choices=[t.value for t in AuthType],
728728
help="Authentication type (SQL Server only)",
729729
)
730+
edit_parser.add_argument(
731+
"--postgres-auth-method",
732+
choices=["password", "azure_entra_cli"],
733+
help="PostgreSQL authentication method",
734+
)
730735
edit_parser.add_argument("--file-path", help="Database file path (SQLite only)")
731736
edit_parser.add_argument("--password-command", dest="password_command", help="Shell command to retrieve the database password")
732737
edit_parser.add_argument("--ssh-password-command", dest="ssh_password_command", help="Shell command to retrieve the SSH password")

‎sqlit/core/connection_manager.py‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import copy
56
from dataclasses import dataclass
67
from typing import Any
78

@@ -32,19 +33,38 @@ def populate_credentials(self, config: ConnectionConfig) -> ConnectionConfig:
3233
return config
3334

3435
service = self._services.credentials_service
35-
if endpoint and endpoint.password is None and uses_db_password(config):
36+
if endpoint and endpoint.password is None and not endpoint.password_command and uses_db_password(config):
3637
password = service.get_password(config.name)
3738
if password is not None:
3839
endpoint.password = password
39-
if config.tunnel and config.tunnel.password is None:
40+
if config.tunnel and config.tunnel.password is None and not config.tunnel.password_command:
4041
ssh_password = service.get_ssh_password(config.name)
4142
if ssh_password is not None:
4243
config.tunnel.password = ssh_password
4344
return config
4445

46+
def _resolve_dynamic_credentials(self, config: ConnectionConfig) -> ConnectionConfig:
47+
"""Resolve commands on a copy so short-lived secrets are not persisted."""
48+
from sqlit.domains.connections.domain.password_command import (
49+
run_password_command,
50+
)
51+
from sqlit.domains.connections.providers.config_service import (
52+
normalize_connection_config,
53+
)
54+
55+
resolved = normalize_connection_config(copy.deepcopy(config))
56+
endpoint = resolved.tcp_endpoint
57+
if endpoint and endpoint.password is None and endpoint.password_command and resolved.db_type != "postgresql":
58+
# PostgreSQL resolves this at the adapter boundary so reconnects and
59+
# database switches always fetch a fresh short-lived token.
60+
endpoint.password = run_password_command(endpoint.password_command)
61+
if resolved.tunnel and resolved.tunnel.auth_type == "password" and resolved.tunnel.password is None and resolved.tunnel.password_command:
62+
resolved.tunnel.password = run_password_command(resolved.tunnel.password_command)
63+
return resolved
64+
4565
def connect(self, config: ConnectionConfig) -> Any:
4666
"""Create a session for the given config."""
47-
return self._services.session_factory(config)
67+
return self._services.session_factory(self._resolve_dynamic_credentials(config))
4868

4969
def test_connection(self, config: ConnectionConfig) -> ConnectionTestResult:
5070
"""Test a connection without mutating UI state."""
@@ -55,13 +75,14 @@ def test_connection(self, config: ConnectionConfig) -> ConnectionTestResult:
5575
error: Exception | None = None
5676

5777
try:
58-
tunnel, host, port = self._services.tunnel_factory(config)
78+
resolved = self._resolve_dynamic_credentials(config)
79+
tunnel, host, port = self._services.tunnel_factory(resolved)
5980
if tunnel:
60-
connect_config = config.with_endpoint(host=host, port=str(port))
81+
connect_config = resolved.with_endpoint(host=host, port=str(port))
6182
else:
62-
connect_config = config
83+
connect_config = resolved
6384

64-
provider = self._services.provider_factory(config.db_type)
85+
provider = self._services.provider_factory(resolved.db_type)
6586
conn = provider.connection_factory.connect(connect_config)
6687
conn.close()
6788
except Exception as exc:

‎sqlit/domains/connections/app/persist_utils.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ def build_persist_connections(
2222
persist_connections = copy.deepcopy(connections)
2323
for conn in persist_connections:
2424
endpoint = conn.tcp_endpoint
25-
if endpoint and endpoint.password is None:
25+
if endpoint and endpoint.password is None and not endpoint.password_command:
2626
stored = credentials_service.get_password(conn.name)
2727
if stored is not None:
2828
endpoint.password = stored
2929

30-
if conn.tunnel and conn.tunnel.password is None:
30+
if (
31+
conn.tunnel
32+
and conn.tunnel.password is None
33+
and not conn.tunnel.password_command
34+
):
3135
stored_ssh = credentials_service.get_ssh_password(conn.name)
3236
if stored_ssh is not None:
3337
conn.tunnel.password = stored_ssh

‎sqlit/domains/connections/cli/commands.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,12 @@ def cmd_connection_edit(args: Any, *, services: AppServices | None = None) -> in
277277
valid_types = ", ".join(t.value for t in AuthType)
278278
print(f"Error: Invalid auth type '{args.auth_type}'. Valid types: {valid_types}")
279279
return 1
280+
postgres_auth_method = getattr(args, "postgres_auth_method", None)
281+
if postgres_auth_method is not None:
282+
if conn.db_type != "postgresql":
283+
print("Error: --postgres-auth-method is only valid for PostgreSQL.")
284+
return 1
285+
conn.set_option("postgres_auth_method", postgres_auth_method)
280286
if endpoint:
281287
if args.username is not None:
282288
endpoint.username = args.username
@@ -286,6 +292,24 @@ def cmd_connection_edit(args: Any, *, services: AppServices | None = None) -> in
286292
password_command = getattr(args, "password_command", None)
287293
if password_command is not None and endpoint:
288294
endpoint.password_command = password_command or None
295+
if conn.db_type == "postgresql":
296+
from sqlit.domains.connections.providers.postgresql.auth import (
297+
AZURE_ENTRA_PASSWORD_COMMAND,
298+
POSTGRES_AUTH_AZURE_ENTRA_CLI,
299+
)
300+
301+
if password_command == AZURE_ENTRA_PASSWORD_COMMAND:
302+
conn.set_option("postgres_auth_method", POSTGRES_AUTH_AZURE_ENTRA_CLI)
303+
postgres_auth_method = POSTGRES_AUTH_AZURE_ENTRA_CLI
304+
305+
if conn.db_type == "postgresql":
306+
from sqlit.domains.connections.providers.config_service import (
307+
normalize_connection_config,
308+
)
309+
310+
conn = normalize_connection_config(conn)
311+
connections[conn_idx] = conn
312+
endpoint = conn.tcp_endpoint
289313

290314
ssh_password_command = getattr(args, "ssh_password_command", None)
291315
if ssh_password_command is not None and conn.tunnel:

‎sqlit/domains/connections/cli/helpers.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,7 @@
1515
def _get_connection_arg_names() -> set[str]:
1616
from sqlit.domains.connections.providers.catalog import get_all_schemas
1717

18-
names = {
19-
field.name
20-
for schema in get_all_schemas().values()
21-
for field in schema.fields
22-
}
18+
names = {field.name for schema in get_all_schemas().values() for field in schema.fields}
2319
names.add("host")
2420
return names
2521

@@ -119,6 +115,14 @@ def build_connection_config_from_args(
119115
password_command = getattr(args, "password_command", None)
120116
if password_command:
121117
config_values["password_command"] = password_command
118+
if schema.db_type == "postgresql":
119+
from sqlit.domains.connections.providers.postgresql.auth import (
120+
AZURE_ENTRA_PASSWORD_COMMAND,
121+
POSTGRES_AUTH_AZURE_ENTRA_CLI,
122+
)
123+
124+
if password_command == AZURE_ENTRA_PASSWORD_COMMAND:
125+
config_values["postgres_auth_method"] = POSTGRES_AUTH_AZURE_ENTRA_CLI
122126
ssh_password_command = getattr(args, "ssh_password_command", None)
123127
if ssh_password_command:
124128
config_values["ssh_password_command"] = ssh_password_command

‎sqlit/domains/connections/domain/passwords.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ def uses_db_password(config: ConnectionConfig) -> bool:
1818
if auth_type in ("ad_default", "ad_integrated", "windows"):
1919
return False
2020

21+
if config.db_type == "postgresql":
22+
from sqlit.domains.connections.providers.postgresql.auth import (
23+
POSTGRES_AUTH_AZURE_ENTRA_CLI,
24+
get_postgres_auth_method,
25+
)
26+
27+
if get_postgres_auth_method(config) == POSTGRES_AUTH_AZURE_ENTRA_CLI:
28+
return False
29+
2130
if config.db_type == "trino":
2231
auth_method = str(config.options.get("trino_auth_method", config.extra_options.get("trino_auth_method", "basic"))).lower()
2332
if auth_method in {"none", "kerberos", "gssapi"}:

‎sqlit/domains/connections/providers/postgresql/adapter.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,16 @@ def install_package(self) -> str:
7272
def driver_import_names(self) -> tuple[str, ...]:
7373
return ("psycopg2",)
7474

75+
def normalize_config(self, config: ConnectionConfig) -> ConnectionConfig:
76+
from sqlit.domains.connections.providers.postgresql.auth import (
77+
normalize_postgres_auth,
78+
)
79+
80+
return normalize_postgres_auth(config)
81+
7582
def connect(self, config: ConnectionConfig) -> Any:
7683
"""Connect to PostgreSQL database."""
84+
config = self.normalize_config(config)
7785
psycopg2 = self._import_driver_module(
7886
"psycopg2",
7987
driver_name=self.name,
@@ -100,8 +108,15 @@ def connect(self, config: ConnectionConfig) -> Any:
100108
connect_args["port"] = int(endpoint.port or get_default_port("postgresql"))
101109
if endpoint.username:
102110
connect_args["user"] = endpoint.username
103-
if endpoint.password is not None:
104-
connect_args["password"] = endpoint.password
111+
password = endpoint.password
112+
if password is None and endpoint.password_command:
113+
from sqlit.domains.connections.domain.password_command import (
114+
run_password_command,
115+
)
116+
117+
password = run_password_command(endpoint.password_command)
118+
if password is not None:
119+
connect_args["password"] = password
105120

106121
tls_mode = get_tls_mode(config)
107122
tls_ca, tls_cert, tls_key, tls_key_password = get_tls_files(config)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Authentication helpers for PostgreSQL connections."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from sqlit.domains.connections.domain.config import ConnectionConfig
9+
10+
POSTGRES_AUTH_PASSWORD = "password"
11+
POSTGRES_AUTH_AZURE_ENTRA_CLI = "azure_entra_cli"
12+
AZURE_ENTRA_PASSWORD_COMMAND = "az account get-access-token --resource-type oss-rdbms --query accessToken --output tsv"
13+
14+
15+
def get_postgres_auth_method(config: ConnectionConfig) -> str:
16+
return str(config.get_option("postgres_auth_method", POSTGRES_AUTH_PASSWORD)).lower()
17+
18+
19+
def normalize_postgres_auth(config: ConnectionConfig) -> ConnectionConfig:
20+
endpoint = config.tcp_endpoint
21+
if endpoint is None:
22+
return config
23+
if "postgres_auth_method" not in config.options and endpoint.password_command == AZURE_ENTRA_PASSWORD_COMMAND:
24+
# Preserve and adopt connections that used the generic password-command
25+
# support before Azure Entra became a first-class authentication mode.
26+
config.set_option("postgres_auth_method", POSTGRES_AUTH_AZURE_ENTRA_CLI)
27+
if get_postgres_auth_method(config) == POSTGRES_AUTH_AZURE_ENTRA_CLI:
28+
endpoint.password = None
29+
endpoint.password_command = AZURE_ENTRA_PASSWORD_COMMAND
30+
elif endpoint.password_command == AZURE_ENTRA_PASSWORD_COMMAND:
31+
endpoint.password_command = None
32+
return config

‎sqlit/domains/connections/providers/postgresql/schema.py‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,56 @@
11
"""Connection schema for PostgreSQL."""
22

3+
from sqlit.domains.connections.providers.postgresql.auth import (
4+
POSTGRES_AUTH_AZURE_ENTRA_CLI,
5+
POSTGRES_AUTH_PASSWORD,
6+
)
37
from sqlit.domains.connections.providers.schema_helpers import (
48
SSH_FIELDS,
59
TLS_FIELDS,
610
ConnectionSchema,
11+
FieldType,
12+
SchemaField,
13+
SelectOption,
714
_database_field,
8-
_password_field,
915
_port_field,
1016
_server_field,
1117
_username_field,
1218
)
1319

20+
21+
def _auth_is_password(values: dict) -> bool:
22+
return values.get("postgres_auth_method", POSTGRES_AUTH_PASSWORD) == POSTGRES_AUTH_PASSWORD
23+
24+
1425
SCHEMA = ConnectionSchema(
1526
db_type="postgresql",
1627
display_name="PostgreSQL",
1728
fields=(
1829
_server_field(required=False),
1930
_port_field("5432"),
2031
_database_field(),
32+
SchemaField(
33+
name="postgres_auth_method",
34+
label="Authentication",
35+
field_type=FieldType.DROPDOWN,
36+
options=(
37+
SelectOption(POSTGRES_AUTH_PASSWORD, "Password"),
38+
SelectOption(
39+
POSTGRES_AUTH_AZURE_ENTRA_CLI,
40+
"Azure Entra ID (Azure CLI)",
41+
),
42+
),
43+
default=POSTGRES_AUTH_PASSWORD,
44+
),
2145
_username_field(required=False),
22-
_password_field(),
46+
SchemaField(
47+
name="password",
48+
label="Password",
49+
field_type=FieldType.PASSWORD,
50+
placeholder="(empty = ask every connect)",
51+
group="credentials",
52+
visible_when=_auth_is_password,
53+
),
2354
)
2455
+ SSH_FIELDS
2556
+ TLS_FIELDS,

‎sqlit/domains/connections/store/connections.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,16 @@ def _load_credentials(self, config: ConnectionConfig) -> None:
130130
config: ConnectionConfig to populate with credentials.
131131
"""
132132
endpoint = config.tcp_endpoint
133-
if endpoint and endpoint.password is None:
133+
if endpoint and endpoint.password is None and not endpoint.password_command:
134134
password = self.credentials_service.get_password(config.name)
135135
if password is not None:
136136
endpoint.password = password
137137

138-
if config.tunnel and config.tunnel.password is None:
138+
if (
139+
config.tunnel
140+
and config.tunnel.password is None
141+
and not config.tunnel.password_command
142+
):
139143
ssh_password = self.credentials_service.get_ssh_password(config.name)
140144
if ssh_password is not None:
141145
config.tunnel.password = ssh_password
@@ -295,9 +299,17 @@ def save_one(
295299
target = copy.deepcopy(connection)
296300
try:
297301
endpoint = target.tcp_endpoint
298-
if endpoint and endpoint.password is None:
302+
if (
303+
endpoint
304+
and endpoint.password is None
305+
and not endpoint.password_command
306+
):
299307
endpoint.password = self.credentials_service.get_password_for_migration(previous_name) # type: ignore[arg-type]
300-
if target.tunnel and target.tunnel.password is None:
308+
if (
309+
target.tunnel
310+
and target.tunnel.password is None
311+
and not target.tunnel.password_command
312+
):
301313
target.tunnel.password = self.credentials_service.get_ssh_password_for_migration(previous_name) # type: ignore[arg-type]
302314
destination_db_password = self.credentials_service.get_password_for_migration(connection.name)
303315
destination_ssh_password = self.credentials_service.get_ssh_password_for_migration(

0 commit comments

Comments
 (0)