Skip to content

feat(sources/mssql): support Microsoft Entra ID for the database connection - #4072

Open
taldata wants to merge 3 commits into
googleapis:mainfrom
taldata:feat/mssql-entra-id-auth
Open

taldata wants to merge 3 commits into
googleapis:mainfrom
taldata:feat/mssql-entra-id-auth

Conversation

@taldata

@taldata taldata commented Sep 17, 2026

Copy link
Copy Markdown

Description

Adds an optional azureAuth block to the mssql source so it can connect to an Azure SQL
server with SQL authentication disabled, where there are no SQL logins to hand out. Omitting
the block leaves current behaviour untouched.

kind: source
name: my-azure-sql
type: mssql
host: my-server.database.windows.net
port: "1433"
database: my_db
encrypt: strict
azureAuth:
  mode: workload-identity   # or: default | managed-identity | service-principal
  clientId: <client-id>
  tenantId: <tenant-id>
  disableInstanceDiscovery: true
  additionallyAllowedTenants: [<tenant-id>]

This follows the design proposed in #3571 and the placement @duwenxin99 suggested there
(the switch in internal/sources/mssql/mssql.go, unit tests in mssql_test.go).

Implementation

  • When azureAuth is set the connection opens through the azuread driver with the matching
    fedauth workflow instead of the base driver.
  • user and password become required_without=AzureAuth. This changes one existing test's
    expected message
    from the required tag to required_without; that is the only change to
    existing behaviour.
  • The client id, and optionally its tenant, travel in the DSN as user id in the
    clientID@tenantID form the driver parses. A service principal's secret travels as
    password.
  • disableInstanceDiscovery and additionallyAllowedTenants are passed through for
    network-isolated and sovereign-cloud deployments that cannot reach the public authority
    discovery endpoint.
  • SQL authentication keeps carrying its login in the DSN user info; Entra leaves the user info
    empty and drives the credential from query parameters, so the two cannot disagree about the
    identity.
  • azuread is a package of the already-required github.com/microsoft/go-mssqldb, so there is
    no version change. go mod tidy adds azidentity and MSAL as indirect requirements, which
    MSSQL source: support Microsoft Entra ID (managed identity) for the database connection #3571 anticipated.

Verification

Unit tests cover parsing and validation. The connection path was also exercised against a real
Entra-enabled Azure SQL Database, querying SUSER_SNAME() so the server reports the identity it
actually served:

mode connected_as reported by the server where it ran
default the signed-in user's UPN workstation, via the Azure CLI credential
managed-identity <clientId>@<tenantId> of a user-assigned identity Azure Container Instance with that identity attached

The managed-identity result also confirms clientId reaches the driver, since that identity
was selected by client id.

Not exercised live, and the same one-line fedauth switch in both cases:

  • service-principal — creating an app registration is blocked by policy in the tenant I tested from.
  • workload-identity — needs an AKS pod with federated credentials.

Deliberately out of scope

#3571 also asks for trustServerCertificate. That is an encryption toggle rather than an auth
mode, so it is left out to keep this change to the auth switch; happy to follow up separately, or
to fold it in here if you would rather have both together.

PR Checklist

  • Make sure you reviewed
    CONTRIBUTING.md
  • Make sure to open an issue as a
    bug/issue
    before writing your code! That way we can discuss the change, evaluate
    designs, and agree on the general idea
  • Ensure you have manually reviewed the entire diff before requesting a
    review
  • Ensure the tests and linter pass
  • Code coverage does not decrease (if any source code was changed)
  • Appropriate docs were updated (if necessary)
  • Make sure to add ! if this involve a breaking change

🛠️ Fixes #3571

@taldata
taldata requested a review from a team as a code owner September 17, 2026 12:58
@google-cla

google-cla Bot commented Sep 17, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Microsoft Entra ID authentication to the MSSQL database source, updating the configuration, connection initialization, documentation, and tests. The review feedback highlights two key improvements: adding fail-fast validation to ensure both client ID and client secret are provided when using the service-principal authentication mode, and fixing a bug where the tenant ID is ignored if the client ID is empty.

Comment on lines +223 to +226
workflow, ok := azureFedAuth[azure.Mode]
if !ok {
return nil, fmt.Errorf("unsupported azureAuth mode %q", azure.Mode)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When using the service-principal authentication mode, both the Client ID (clientId) and the Client Secret (passed via the password field) are strictly required by the Azure AD driver to authenticate.

Since the validator makes password optional when azureAuth is present (required_without=AzureAuth), we should add a fail-fast validation check here to prevent runtime connection failures with cryptic errors.

		workflow, ok := azureFedAuth[azure.Mode]
		if !ok {
			return nil, fmt.Errorf("unsupported azureAuth mode %q", azure.Mode)
		}
		if azure.Mode == "service-principal" {
			if azure.ClientID == "" {
				return nil, fmt.Errorf("clientId is required when azureAuth mode is %q", azure.Mode)
			}
			if pass == "" {
				return nil, fmt.Errorf("password (client secret) is required when azureAuth mode is %q", azure.Mode)
			}
		}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in 0b2c6b2. clientId now carries required_if=Mode service-principal, so a service principal without one fails at parse time; the missing secret is caught in Initialize, since password lives on the outer struct where a tag cannot reach it. The same commit stops sending password in the DSN for modes that do not read it.

Comment on lines +231 to +237
if azure.ClientID != "" {
userID := azure.ClientID
if azure.TenantID != "" {
userID = fmt.Sprintf("%s@%s", azure.ClientID, azure.TenantID)
}
query.Add("user id", userID)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If azure.ClientID is empty but azure.TenantID is specified, the current implementation completely ignores the tenant ID because the outer if block only executes when azure.ClientID != "" is true.

Updating the condition to azure.ClientID != "" || azure.TenantID != "" ensures that the tenant ID is correctly passed to the driver (as @tenant_id) even when no client ID is specified.

Suggested change
if azure.ClientID != "" {
userID := azure.ClientID
if azure.TenantID != "" {
userID = fmt.Sprintf("%s@%s", azure.ClientID, azure.TenantID)
}
query.Add("user id", userID)
}
if azure.ClientID != "" || azure.TenantID != "" {
userID := azure.ClientID
if azure.TenantID != "" {
userID = fmt.Sprintf("%s@%s", azure.ClientID, azure.TenantID)
}
query.Add("user id", userID)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The symptom is real — the tenant was being dropped — but the suggested form does not work in the driver. splitTenantAndClientID returns (user, "") when the @ is at index 0 (if at < 1 || … { return user, "" }), so @<tenant> would be taken as the client id and the tenant dropped anyway. The driver has no way to receive a tenant without a client id.

So rather than emit it, 0b2c6b2 refuses the combination at parse time: clientId is now required_with=TenantID, with a test for the message.

@taldata

taldata commented Sep 17, 2026

Copy link
Copy Markdown
Author

@googlebot I signed it!

@taldata taldata closed this Sep 17, 2026
@taldata taldata reopened this Sep 17, 2026
…ection

Adds an optional azureAuth block to the mssql source so it can connect to an Azure
SQL server that has SQL authentication disabled, where there are no SQL logins to
hand out. Omitting the block leaves today's behaviour untouched.

  azureAuth:
    mode: workload-identity   # or default | managed-identity | service-principal
    clientId: <client-id>
    tenantId: <tenant-id>
    disableInstanceDiscovery: true
    additionallyAllowedTenants: [<tenant-id>]

When set, the connection opens through the azuread driver with the matching fedauth
workflow instead of the base driver, and user/password stop being required
(required_without=AzureAuth). The client id, and optionally its tenant, travel in the
DSN as 'user id' in the clientID@tenantID form the driver parses; a service principal's
secret travels as 'password'. disableInstanceDiscovery and additionallyAllowedTenants
are passed through for network-isolated and sovereign-cloud deployments that cannot
reach the public authority discovery endpoint.

azuread is a package of the already-required github.com/microsoft/go-mssqldb, so no
version change; go mod tidy adds azidentity and MSAL as indirect requirements, which
the issue anticipated.

Encryption toggles are deliberately left out to keep this change to the auth switch;
trustServerCertificate can follow separately.

Fixes googleapis#3571
Three cases that used to be accepted and then fail, or silently do the wrong
thing, at connect time:

- tenantId without clientId was dropped. The driver reads the tenant only from
  the clientID@tenantID form of 'user id', and splitTenantAndClientID treats a
  leading '@' as part of the client id, so there is no way to send a tenant on
  its own. clientId is now required_with=TenantID and the mismatch is reported
  at parse time instead of being ignored.
- service-principal without clientId, or without the client secret, reached the
  driver and came back as a login failure. Both are now refused with a message
  that names the missing field.
- user alongside azureAuth was silently ignored. It is now refused, matching how
  the rest of the config treats two identities in one block.

Also stops sending 'password' in the DSN for modes that do not read it.
taldata added a commit to taldata/mcp-toolbox that referenced this pull request Sep 17, 2026
The mssql source connects with one login for every request, so SQL Server sees
the same identity whoever asked. That leaves the executed statement as the only
thing standing between an analyst and data they may not read, and puts the
enforcement in the prompt rather than in the database.

Toolbox already carries the caller's token to the tool, and BigQuery, Spanner
and Looker already use it. This does the same for SQL Server:

  useClientOAuth: "true"
  azureOnBehalfOf:
      clientId: ...
      clientSecret: ...
      tenantId: ...

With useClientOAuth set, each caller gets their own connection pool, cached by a
digest of their token and closed when the entry expires, and the database applies
that person's permissions and row-level security.

The token the client sent is usually issued for Toolbox rather than for the
database, since that is who the client authenticated against. azureOnBehalfOf
exchanges it for a database-scoped one through the on-behalf-of flow. Omitting
the block passes the caller's token on as it arrived, for clients that already
hold one for the database.

Notes on the edges:

- A request with no token is refused, never downgraded to a shared identity.
  'user' and 'password' must be omitted with useClientOAuth, so there is no
  fallback identity to reach by accident.
- Pool creation is serialised. Without it a caller's first two concurrent
  requests each open a pool, and caching the second closes the first while it is
  in use. Neither opening a pool nor building the credential does any I/O, so
  the lock is not on the request path in any real sense.
- Cache keys are a SHA-256 of the token rather than the token itself.
- With no identity of its own, this source cannot verify the connection at
  startup; the first request carrying a token does that instead.

Tests cover the configuration surface, the refusal of a missing or malformed
Authorization header, pool reuse for one caller and pool separation between two.
They run without a server, since the per-caller path opens nothing until a query
is executed.

Touches the same validate tag as googleapis#4072, which is the only overlap between them;
happy to rebase whichever lands second.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MSSQL source: support Microsoft Entra ID (managed identity) for the database connection

2 participants