Skip to content

Comments

Add On-Behalf-Of (OBO) user-delegated authentication for SQL Se…#3151

Open
anushakolan wants to merge 5 commits intomainfrom
dev/anushakolan/obo-delegated-identity
Open

Add On-Behalf-Of (OBO) user-delegated authentication for SQL Se…#3151
anushakolan wants to merge 5 commits intomainfrom
dev/anushakolan/obo-delegated-identity

Conversation

@anushakolan
Copy link
Contributor

@anushakolan anushakolan commented Feb 21, 2026

Why make this change?

Closes #3122

Data API Builder currently supports managed identity authentication to SQL Server/Azure SQL, where the DAB service itself authenticates using its own identity. However, this approach doesn't support user-level authorization scenarios where:

Row-Level Security (RLS) policies need to filter data based on the actual end user's identity
Database audit logs should reflect the actual user who performed the operation
Fine-grained permissions need to be enforced per-user at the database level
This PR introduces On-Behalf-Of (OBO) user-delegated authentication for SQL Server, enabling DAB to exchange the incoming user's access token for a downstream SQL token using the OAuth 2.0 OBO flow. This allows the actual user's identity to flow through to the database, enabling true user-level authorization.

What is this change?

Overview
Implements the OAuth 2.0 On-Behalf-Of (OBO) flow for SQL Server authentication, allowing DAB to:

  1. Accept a user's access token from the incoming request
  2. Exchange it for a SQL Server-scoped token using MSAL
  3. Use that token to authenticate to SQL Server as the actual user

Configuration Schema
Added new user-delegated-auth configuration section under data-source:

{
  "data-source": {
    "database-type": "mssql",
    "connection-string": "@env('SQL_CONNECTION_STRING')",
    "user-delegated-auth": {
      "enabled": true,
      "provider": "EntraId",
      "database-audience": "https://database.windows.net"
    }
  }
}

New Components

  • IOboTokenProvider - Interface for OBO token exchange
  • OboSqlTokenProvider - Implementation that performs the OBO token exchange using MSAL
  • IMsalClientWrapper / MsalClientWrapper - Wrapper around MSAL's ConfidentialClientApplication for testability
  • Schema updates - Added user-delegated-auth to dab.draft.schema.json

Key Implementation Details

  1. Token Caching: A custom in-memory ConcurrentDictionary cache is used to avoid repeated OBO token exchanges for the same user. Cache keys are derived from user identity claims (oid/sub + tenant ID).
  2. Scope: Requests token for https://database.windows.net/.default scope
  3. Connection String: When OBO is configured, the AccessToken property is set on SqlConnection with the exchanged token
  4. Validation:
    • OBO is only supported for mssql database type
    • All three fields (client-id, client-secret, tenant-id) are required when OBO is configured

How was this tested?

Unit Tests (14 tests)

Added comprehensive unit tests in OboSqlTokenProviderUnitTests.cs:

  1. GetAccessTokenAsync_ReturnsToken_WhenOboSucceeds - Happy path - successful token exchange
  2. GetAccessTokenAsync_ThrowsException_WhenOboFails - Error handling when MSAL fails
  3. GetAccessTokenAsync_UsesCorrectScope - Verifies SQL scope is requested
  4. GetAccessTokenAsync_PassesUserAssertionCorrectly - Verifies user token is passed correctly
  5. GetAccessTokenAsync_HandlesNullResult - Null result handling
  6. GetAccessTokenAsync_HandlesEmptyAccessToken - Empty token handling

Added validation tests in ConfigValidationUnitTests.cs:

  1. ValidateUserDelegatedAuthConfig_ValidConfig_NoErrors - Valid OBO config passes validation
  2. ValidateUserDelegatedAuthConfig_MissingDatabaseAudience_ReturnsError - Missing database-audience fails when enabled
  3. ValidateUserDelegatedAuthConfig_NonMsSql_ReturnsError - OBO only supported for mssql
  4. ValidateUserDelegatedAuthConfig_NullOptions_NoErrors - No OBO config is valid
  5. ValidateUserDelegatedAuthConfig_DisabledWithMissingFields_NoErrors - Disabled OBO doesn't require all fields

Manual End-to-End Testing (Azure)

Test Environment Setup

Deployed DAB to Azure Container Apps with OBO authentication enabled, connected to an Azure SQL Database configured with Row-Level Security (RLS). The RLS policy filters data based on the authenticated user's email address.

Test Scenarios Validated

  1. User Identity Propagation: Verified that the actual user's identity flows through to the database. When User A calls the API, they only see their own data (1 row out of 6 total rows in the table).

  2. Multi-User Isolation: Tested with multiple users - each user only sees rows where their email matches the UserEmail column, confirming RLS works correctly with OBO tokens.

  3. Authentication Enforcement:

    • Requests without a token receive 403 Forbidden
    • Requests with invalid/expired tokens receive 401 Unauthorized
  4. Authorization Enforcement: Users with valid tokens but no SQL database permissions receive a 500 error with login failed message (SQL Error 18456).

  5. Token Caching: Confirmed MSAL token caching is working - initial requests take ~500ms for token exchange, while subsequent requests for the same user complete in 75-150ms.

  6. Database Audit Trail: Verified that SQL Server audit logs show the actual user's identity (e.g., user@example.com rather than the managed identity, enabling proper audit compliance.

Comparison with Managed Identity
Deployed a separate instance using managed identity (without OBO) to compare behavior:

  • With Managed Identity + RLS: Returns 0 rows (MI identity doesn't match any user in the table)
  • With Managed Identity + RLS disabled: Returns all 6 rows
  • With OBO + RLS: Returns only the authenticated user's row(s)

This validates that OBO correctly enables per-user data isolation at the database level.

@anushakolan
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds On-Behalf-Of (OBO) user-delegated authentication support for MSSQL data sources so DAB can exchange an incoming user JWT for a SQL-scoped token and execute SQL as the end user (enabling RLS/auditing scenarios).

Changes:

  • Introduces OBO token acquisition + in-memory caching via IOboTokenProvider/OboSqlTokenProvider and an MSAL wrapper for testability.
  • Wires OBO into MSSQL query execution (sets SqlConnection.AccessToken, disables pooling for OBO-enabled data sources).
  • Adds config/schema support and validation + unit tests for OBO configuration and token-provider behavior.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/Service/Startup.cs Registers OBO token provider in DI and builds MSAL confidential client.
src/Core/Resolvers/MsSqlQueryExecutor.cs Uses OBO tokens for request-time SQL connections and disables pooling when enabled.
src/Core/Resolvers/Factories/QueryManagerFactory.cs Plumbs optional OBO provider into MSSQL/DWSQL query executors.
src/Core/Resolvers/IOboTokenProvider.cs Defines OBO token provider abstraction used by query execution.
src/Core/Resolvers/OboSqlTokenProvider.cs Implements OBO token acquisition, cache keying, caching, and refresh logic.
src/Core/Resolvers/IMsalClientWrapper.cs Adds MSAL wrapper interface for mocking MSAL in unit tests.
src/Core/Resolvers/MsalClientWrapper.cs Implements wrapper over MSAL IConfidentialClientApplication.
src/Core/Configurations/RuntimeConfigValidator.cs Adds semantic validation rules for user-delegated-auth configuration.
src/Config/ObjectModel/DataSource.cs Extends data source model with user-delegated-auth options and defaults/constants.
src/Config/Converters/DataSourceConverterFactory.cs Deserializes user-delegated-auth block into the data source model.
src/Config/DataApiBuilderException.cs Adds OBO-specific error messages and a new substatus code.
schemas/dab.draft.schema.json Extends JSON schema to allow data-source.user-delegated-auth configuration.
src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs Adds unit tests for OBO-related config validation scenarios.
src/Service.Tests/Authentication/OboSqlTokenProviderUnitTests.cs Adds unit tests for OBO token provider behavior (claims, scopes, caching, refresh).
src/Service.Tests/Authentication/Helpers/RuntimeConfigAuthHelper.cs Adds helper for building runtime configs that include user-delegated auth in tests.

@souvikghosh04 souvikghosh04 self-assigned this Feb 23, 2026
Copy link
Contributor

@souvikghosh04 souvikghosh04 left a comment

Choose a reason for hiding this comment

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

Posting comments so far. Yet to review some remaining part.

@JerryNixon JerryNixon added the 2.0 label Feb 23, 2026
@anushakolan
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

Copilot AI added a commit that referenced this pull request Feb 23, 2026
…ptions and add provider field

Co-authored-by: anushakolan <45540936+anushakolan@users.noreply.github.com>
@anushakolan
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

@anushakolan anushakolan changed the title [MCP] Add On-Behalf-Of (OBO) user-delegated authentication for SQL Se… Add On-Behalf-Of (OBO) user-delegated authentication for SQL Se… Feb 24, 2026
Copy link
Contributor

@souvikghosh04 souvikghosh04 left a comment

Choose a reason for hiding this comment

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

added additional review comments which needs some changes and discussion. also, the PR description can be shortened. we don't have to mention all tests and functions, instead just categorize or summarize them.

Copy link
Collaborator

@Aniruddh25 Aniruddh25 left a comment

Choose a reason for hiding this comment

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

Leaving comments so far

@anushakolan
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 6 pipeline(s).

Copy link
Collaborator

@Aniruddh25 Aniruddh25 left a comment

Choose a reason for hiding this comment

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

Consider if we can use Fusion InMemory Cache.

_dataSourceAccessTokenUsage[dataSourceName] = ShouldManagedIdentityAccessBeAttempted(builder);

// Track user-delegated authentication settings
if (dataSource.IsUserDelegatedAuthEnabled && dataSource.UserDelegatedAuth is not null)
Copy link
Collaborator

Choose a reason for hiding this comment

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

why is the second check needed? dataSource.UserDelegatedAuth is not null - this is already covered in the first condition, no?

if (isInRequestContext)
{
// Validate database-audience is configured before attempting OBO
if (string.IsNullOrWhiteSpace(userDelegatedAuth.DatabaseAudience))
Copy link
Collaborator

Choose a reason for hiding this comment

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

this validation should be done at startup, no need to check again. this is the core execution logic, need to keep it simple as possible - so that its quick.

subStatusCode: DataApiBuilderException.SubStatusCodes.OboAuthenticationFailure);
}

// At startup/metadata phase (no HTTP context) - fall through to use Managed Identity
Copy link
Collaborator

Choose a reason for hiding this comment

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

do this comment is incorrect, we don't necessarily always fall back to Managed identity. We fallback to use the connection string from the config - which could be Managed Identity or whatever DAB is configured with - could be SQL user/password too.
We do recommend managed identity but thats not what this code will fall through to always.


// Check cache for valid token
// Refresh if: token older than cache duration OR token expires within early refresh buffer
if (_tokenCache.TryGetValue(cacheKey, out CachedToken? cached) && !ShouldRefresh(cached))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Although Redis Cache is not needed, we could use the Fusion InMemory Cache - we already use that for storing results of query. It handles the refresh and expiry logic in a better way.

_tokenCache[cacheKey] = newCachedToken;

// Periodically clean up expired tokens to prevent unbounded memory growth
CleanupExpiredTokensIfNeeded();
Copy link
Collaborator

Choose a reason for hiding this comment

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

If we use Fusion Cache, this periodic cleanup is automatically taken care of for us, because when we add the cachedToken in the in-memory cache, we set the expiration time.

EventHandler handler = null;
Mock<MsSqlQueryExecutor> queryExecutor
= new(provider, dbExceptionParser, queryExecutorLogger.Object, httpContextAccessor.Object, handler);
= new(provider, dbExceptionParser, queryExecutorLogger.Object, httpContextAccessor.Object, handler, null);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: name the argument which is passed here as null, at all constructors in this file.

[DataTestMethod]
[DataRow("", DisplayName = "Empty string audience should fail")]
[DataRow(" ", DisplayName = "Whitespace audience should fail")]
public void ValidateUserDelegatedAuth_EmptyDatabaseAudience_ThrowsError(string audience)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Arent empty and missing database audience similar tests?

/// Test to validate that user-delegated-auth with caching enabled throws an error.
/// </summary>
[TestMethod]
public void ValidateUserDelegatedAuth_CachingEnabled_ThrowsError()
Copy link
Collaborator

Choose a reason for hiding this comment

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

There was already a test above to ensure Caching is Disabled.

[DataRow(null, "test-tenant", "test-secret", DisplayName = "Missing DAB_OBO_CLIENT_ID")]
[DataRow("test-client", null, "test-secret", DisplayName = "Missing DAB_OBO_TENANT_ID")]
[DataRow("test-client", "test-tenant", null, DisplayName = "Missing DAB_OBO_CLIENT_SECRET")]
[DataRow("", "test-tenant", "test-secret", DisplayName = "Empty DAB_OBO_CLIENT_ID")]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please inform @JerryNixon about the need for these env variables and get an ACK. This affects how the users will be using this feature. So, we need to ensure we are on same page.

[TestMethod]
public void ValidateUserDelegatedAuth_AzureEnvironment_PassesValidation()
{
// Arrange - Simulate Azure environment (IDENTITY_ENDPOINT is set in Azure Container Apps/App Service)
Copy link
Collaborator

Choose a reason for hiding this comment

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

How is this test different than above apart from setting the IDENTITY_ENDPOINT? It is testing the same codepath, how is setting IDENTITY_ENDPOINT affecting validation?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OBO] JWT Validation, Identity Extraction, Cache‑Key Construction, SQL Token Acquisition & Delegated SQL Execution (No Pooling)

4 participants