From 3b7ff79f055468b04d030f0b4fa4dc66426df6ce Mon Sep 17 00:00:00 2001 From: turtleDev Date: Thu, 9 Jul 2026 18:04:31 +0300 Subject: [PATCH] bruin: support Entra ID auth for Fabric connections Fabric connections were routed straight to the MSSQL factory, which indexes raw["username"] and raw["password"] unconditionally. Bruin's Fabric connection also accepts a service principal (client_id, client_secret, tenant_id) or the DefaultAzureCredential chain, and those payloads carry no username, so building the client raised KeyError: 'username'. pymssql cannot carry a Microsoft Entra ID access token, so give Fabric its own factory: the two Entra ID modes acquire a token via azure-identity and hand it to pyodbc through the msodbcsql SQL_COPT_SS_ACCESS_TOKEN pre-connect attribute, while SQL authentication keeps using the shared pymssql path. The ODBC driver defaults to the newest installed "ODBC Driver NN for SQL Server" and can be pinned with a driver field on the connection. A payload with none of the three credential sets now raises ConnectionParseError naming the fields it expects. --- README.md | 17 ++++++ pyproject.toml | 2 +- src/bruin/_connection.py | 106 +++++++++++++++++++++++++++++++- tests/conftest.py | 24 ++++++++ tests/test_connection.py | 126 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 97a8a68..cb06fff 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ bruin-sdk[snowflake] # Snowflake bruin-sdk[postgres] # PostgreSQL / Redshift bruin-sdk[redshift] # Redshift (alias for postgres extra) bruin-sdk[mssql] # Microsoft SQL Server +bruin-sdk[fabric] # Microsoft Fabric Warehouse bruin-sdk[mysql] # MySQL bruin-sdk[duckdb] # DuckDB bruin-sdk[sheets] # Google Sheets (for GCP connections) @@ -209,12 +210,28 @@ conn.client # Lazy-initialized database client | `postgres` | `psycopg2.connection` | `bruin-sdk[postgres]` | | `redshift` | `psycopg2.connection` | `bruin-sdk[redshift]` | | `mssql` | `pymssql.Connection` | `bruin-sdk[mssql]` | +| `fabric` | `pyodbc.Connection` or `pymssql.Connection` | `bruin-sdk[fabric]` | | `mysql` | `mysql.connector.Connection` | `bruin-sdk[mysql]` | | `duckdb` | `duckdb.DuckDBPyConnection` | `bruin-sdk[duckdb]` | | `generic` | N/A (raises error) | — | Client creation is **lazy** — the actual database connection is only established when `.client` is first accessed. +#### Fabric connections + +Fabric supports three authentication modes, selected by the fields present on the connection: + +| Fields | Mode | Driver | +|--------|------|--------| +| `use_azure_default_credential: true` | `DefaultAzureCredential` (e.g. `az login`, managed identity) | `pyodbc` | +| `client_id` + `client_secret` + `tenant_id` | Microsoft Entra ID service principal | `pyodbc` | +| `username` + `password` | SQL authentication | `pymssql` | + +The Microsoft Entra ID modes acquire an access token and hand it to the driver, which requires an +[ODBC Driver for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server) +(msodbcsql18 or newer) on the machine running the asset. The newest installed driver is used unless +the connection sets `driver` explicitly. + #### GCP connections GCP connections have extra methods since one connection can access multiple Google services: diff --git a/pyproject.toml b/pyproject.toml index de5c354..8f322c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ athena = ["pyathena"] trino = ["trino"] motherduck = ["duckdb"] synapse = ["pymssql"] -fabric = ["pymssql"] +fabric = ["pymssql", "pyodbc", "azure-identity"] oracle = ["oracledb"] db2 = ["ibm-db"] hana = ["hdbcli"] diff --git a/src/bruin/_connection.py b/src/bruin/_connection.py index 3cfc022..b07e7fd 100644 --- a/src/bruin/_connection.py +++ b/src/bruin/_connection.py @@ -1,6 +1,7 @@ import json import logging import os +import struct from bruin.exceptions import ( ConnectionNotFoundError, @@ -11,6 +12,13 @@ logger = logging.getLogger("bruin") +def _as_bool(value) -> bool: + """Interpret a connection field as a boolean, tolerating string payloads.""" + if isinstance(value, str): + return value.lower() == "true" + return bool(value) + + class Connection: """A Bruin-managed database connection with lazy client initialization.""" @@ -73,7 +81,7 @@ def __init__(self, name: str, raw: dict): def _uses_adc(self) -> bool: """Return True when the connection is configured for Application Default Credentials.""" - return self.raw.get("use_application_default_credentials") in ("true", "True", True) + return _as_bool(self.raw.get("use_application_default_credentials")) def _parse_sa_info(self): """Parse the service account JSON from the connection payload.""" @@ -194,7 +202,7 @@ def _create_client(conn_type: str, raw): "redshift": _create_redshift, "mssql": _create_mssql, "synapse": _create_mssql, - "fabric": _create_mssql, + "fabric": _create_fabric, "mysql": _create_mysql, "duckdb": _create_duckdb, "databricks": _create_databricks, @@ -308,6 +316,100 @@ def _create_mssql(raw: dict): ) +_FABRIC_TOKEN_SCOPE = "https://database.windows.net/.default" + +# SQL_COPT_SS_ACCESS_TOKEN — the msodbcsql pre-connect attribute that carries an Entra ID token. +_SQL_COPT_SS_ACCESS_TOKEN = 1256 + + +def _fabric_driver(raw: dict) -> str: + """Pick the ODBC driver to talk to Fabric with.""" + import pyodbc + + installed = pyodbc.drivers() + configured = raw.get("driver") + if configured: + if configured not in installed: + raise ConnectionTypeError( + f"ODBC driver '{configured}' is not installed. Available drivers: " + f"{', '.join(installed) or '(none)'}." + ) + return configured + + candidates = sorted( + (d for d in installed if d.startswith("ODBC Driver ") and d.endswith(" for SQL Server")), + reverse=True, + ) + if not candidates: + raise ConnectionTypeError( + "No 'ODBC Driver NN for SQL Server' is installed, which is required for Microsoft " + "Entra ID authentication against Fabric. Install msodbcsql18: " + "https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server" + ) + return candidates[0] + + +def _fabric_token_struct(raw: dict) -> bytes: + """Fetch an Entra ID access token and pack it the way msodbcsql expects.""" + try: + from azure.identity import ClientSecretCredential, DefaultAzureCredential + except ImportError: + raise ImportError( + "Install bruin-sdk[fabric] to use Microsoft Entra ID authentication: " + "pip install 'bruin-sdk[fabric]'" + ) + + if _as_bool(raw.get("use_azure_default_credential")): + logger.debug("Using DefaultAzureCredential for Fabric connection") + credential = DefaultAzureCredential() + else: + logger.debug("Using service principal %s for Fabric connection", raw["client_id"]) + credential = ClientSecretCredential( + tenant_id=raw["tenant_id"], + client_id=raw["client_id"], + client_secret=raw["client_secret"], + ) + + token = credential.get_token(_FABRIC_TOKEN_SCOPE).token.encode("utf-16-le") + return struct.pack(f"