Connect to Azure SQL database in SQL Alchemy using Entra ID tokens

kummerer94

Alexander Kammerer

Posted on October 28, 2024

Connect to Azure SQL database in SQL Alchemy using Entra ID tokens

We have a web application at work that needs to connect to our Azure SQL database for the duration of the request. To make querying the database easier, we use SQL Alchemy and pyodbc.

We have a few goals we want to achieve:

  • Every request gets its own session. We want to open a new session when the request starts and close it once it is done.
  • We want to connect to the database using the managed identity of the web service (Azure Function App or Azure Web App).
  • We want to delegate the handling of the session and connection as much as we can.

Session lifetime management

First, to make sure we open a new session for every request, we can wrap the request functions into decorators that make sure a new session is created and subsequently destroyed.

To make sure our session is easily accessible from all over the application without having to hand it over in every function call, we use a singleton pattern. However, since we run multiple threads to handle multiple requests at the same time, we have to make sure that there are no race conditions with respect to the session objects.

SQL Alchemy has a great utility to make this easier: the scoped session.

To use this, we wrap our connection factory into a scoped_session() call:

from urllib.parse import quote_plus
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session

connection_string = "..."
engine = create_engine("mssql+pyodbc:///?odbc_connect={}".format(quote_plus(connection_string))
session_factory = sessionmaker(bind=engine)
Session = scoped_session(session_factory)
Enter fullscreen mode Exit fullscreen mode

Now, whenever we want to use the session, we just call Session() and SQL Alchemy makes sure we reuse the existing session object. If we are done, we can call Session.remove() to close the session. There is no way for SQL Alchemy to tell if the thread is done so we have to do this ourselves.

Connect via Entra ID tokens using managed identity

There are some posts that describe how to setup the connection to Azure SQL databases using access tokens, but the best resource for this is the SQL Alchemy docs themselves.

Let's go over the details together. First, we need a connection string. Since we want to rely on managed identity (or the Azure CLI for local development), we do not put any credentials into the connection string:

Driver={ODBC Driver 18 for SQL Server};Database=YOUR_DB;Server=tcp:you.database.windows.net,1433;Encrypt=yes;TrustServerCertificate=no;Connection Timeout=30

Our general plan is:

  • Add an event handler that fires whenever we connect to the database.
  • Retrieve an access token whenever we connect to the database.
  • Adjust the connection arguments so that we put the (fresh!) access token into the connection string.

Now, let's take a look at the code:

import struct
from urllib.parse import quote_plus

from azure.core.exceptions import ClientAuthenticationError
from azure.identity import DefaultAzureCredential
from sqlalchemy import event


@event.listens_for(engine, "do_connect")
def provide_token(dialect, conn_rec, cargs, cparams):
    # Uid may not be provided for the token authentication to work
    if "Uid=" in cargs[0]:
        return

    # Connection option for access tokens as defined in msodbcsql.h
    SQL_COPT_SS_ACCESS_TOKEN = 1256

    # remove the "Trusted_Connection" parameter that SQLAlchemy adds
    cargs[0] = cargs[0].replace(";Trusted_Connection=Yes", "")
    cargs[0] = cargs[0].replace(";Authentication=TokenIdentifiedPrincipal", "")

    # create token credential => try two ways to make local dev work
    scope = "https://database.windows.net/.default"
    try:
        cred = DefaultAzureCredential(exclude_managed_identity_credential=True).get_token(scope)
    except ClientAuthenticationError:
        cred = DefaultAzureCredential(
            exclude_managed_identity_credential=True,
            exclude_shared_token_cache_credential=True,
        ).get_token(scope)

    raw_token = cred.token.encode("utf-16-le")
    token_struct = struct.pack(f"<I{len(raw_token)}s", len(raw_token), raw_token)
    cparams["attrs_before"] = {SQL_COPT_SS_ACCESS_TOKEN: token_struct}

Enter fullscreen mode Exit fullscreen mode

Summary

With this, we achieved our goals. With the scoped_session() we do not need to open a new session every time a request comes in (this will be handled for us) but we should close the session at the end so that we do not have too many dangling sessions.

We also connect to the SQL database using our own identity (for local dev) or the managed identity of the web service. We modify the connection string each time a new connection is created.

Acknowledgements

A big thank you to David for helping me figure out the concept of scoped sessions.

💖 💪 🙅 🚩
kummerer94
Alexander Kammerer

Posted on October 28, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related