-
Notifications
You must be signed in to change notification settings - Fork 317
Improved OBO logging and added OBO observability to SESSION_CONTEXT #3192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8dc80df
31dba47
aceafbd
80c1e43
530f644
e243ca3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
|
|
||
| using System.Data; | ||
| using System.Data.Common; | ||
| using System.Diagnostics; | ||
| using System.Net; | ||
| using System.Security.Claims; | ||
| using System.Text; | ||
|
|
@@ -520,6 +521,49 @@ public override string GetSessionParamsQuery(HttpContext? httpContext, IDictiona | |
| sessionMapQuery = sessionMapQuery.Append(statementToSetReadOnlyParam); | ||
| } | ||
|
|
||
| // Add OpenTelemetry correlation values for observability. | ||
| // These allow correlating database queries with distributed traces. | ||
| Activity? currentActivity = Activity.Current; | ||
| if (currentActivity is not null) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isnt OBO independent of whether the set-session-context property is set to true or not in the config? If yes, seems like the traces will get added only when that property is set to true as well - since thats when the We need to insert this session context even if the claims in the for loop above are not added as long as OBO is enabled. |
||
| { | ||
| string traceIdParamName = $"{SESSION_PARAM_NAME}{counter.Next()}"; | ||
| parameters.Add(traceIdParamName, new(currentActivity.TraceId.ToString())); | ||
| sessionMapQuery.Append($"EXEC sp_set_session_context 'dab.trace_id', {traceIdParamName}, @read_only = 0;"); | ||
|
|
||
| string spanIdParamName = $"{SESSION_PARAM_NAME}{counter.Next()}"; | ||
| parameters.Add(spanIdParamName, new(currentActivity.SpanId.ToString())); | ||
| sessionMapQuery.Append($"EXEC sp_set_session_context 'dab.span_id', {spanIdParamName}, @read_only = 0;"); | ||
| } | ||
|
|
||
| // Add OBO-specific observability values when user-delegated auth is enabled. | ||
| // These values are for observability/auditing only and MUST NOT be used for authorization decisions. | ||
| // For row-level security, use database policies with the user's actual claims. | ||
| if (_dataSourceUserDelegatedAuth.ContainsKey(dataSourceName)) | ||
| { | ||
| // Set auth type indicator for OBO requests | ||
| string authTypeParamName = $"{SESSION_PARAM_NAME}{counter.Next()}"; | ||
| parameters.Add(authTypeParamName, new("obo")); | ||
| sessionMapQuery.Append($"EXEC sp_set_session_context 'dab.auth_type', {authTypeParamName}, @read_only = 0;"); | ||
|
|
||
| // Set user identifier (oid preferred, fallback to sub) for auditing/observability | ||
| string? userId = httpContext.User.FindFirst("oid")?.Value ?? httpContext.User.FindFirst("sub")?.Value; | ||
| if (!string.IsNullOrWhiteSpace(userId)) | ||
| { | ||
| string userIdParamName = $"{SESSION_PARAM_NAME}{counter.Next()}"; | ||
| parameters.Add(userIdParamName, new(userId)); | ||
| sessionMapQuery.Append($"EXEC sp_set_session_context 'dab.user_id', {userIdParamName}, @read_only = 0;"); | ||
| } | ||
|
|
||
| // Set tenant identifier for auditing/observability | ||
| string? tenantId = httpContext.User.FindFirst("tid")?.Value; | ||
| if (!string.IsNullOrWhiteSpace(tenantId)) | ||
| { | ||
| string tenantIdParamName = $"{SESSION_PARAM_NAME}{counter.Next()}"; | ||
| parameters.Add(tenantIdParamName, new(tenantId)); | ||
| sessionMapQuery.Append($"EXEC sp_set_session_context 'dab.tenant_id', {tenantIdParamName}, @read_only = 0;"); | ||
| } | ||
| } | ||
|
|
||
| return sessionMapQuery.ToString(); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1013,6 +1013,206 @@ private static Mock<IHttpContextAccessor> CreateHttpContextAccessorWithAuthentic | |
|
|
||
| #endregion | ||
|
|
||
| /// <summary> | ||
| /// Validates that GetSessionParamsQuery includes all observability values: | ||
| /// - OpenTelemetry correlation values (dab.trace_id, dab.span_id) when an Activity is present | ||
| /// - OBO observability values (dab.auth_type, dab.user_id, dab.tenant_id) when user-delegated auth is enabled | ||
| /// </summary> | ||
| [TestMethod, TestCategory(TestCategory.MSSQL)] | ||
| public void GetSessionParamsQuery_IncludesAllObservabilityValues_WhenActivityAndOboEnabled() | ||
| { | ||
| // Arrange | ||
| TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL); | ||
|
|
||
| // Create runtime config with user-delegated-auth enabled and set-session-context | ||
| RuntimeConfig runtimeConfig = new( | ||
| Schema: "UnitTestSchema", | ||
| DataSource: new DataSource( | ||
| DatabaseType: DatabaseType.MSSQL, | ||
| ConnectionString: "Server=localhost;Database=TestDb;", | ||
| Options: new Dictionary<string, object> { { "set-session-context", true } }) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should test this when set-session-context is false and still expect OBO activity |
||
| { | ||
| UserDelegatedAuth = new UserDelegatedAuthOptions( | ||
| Enabled: true, | ||
| Provider: "EntraId", | ||
| DatabaseAudience: "https://database.windows.net/") | ||
| }, | ||
| Runtime: new( | ||
| Rest: new(), | ||
| GraphQL: new(), | ||
| Mcp: new(), | ||
| Host: new(Cors: null, Authentication: null)), | ||
| Entities: new(new Dictionary<string, Entity>())); | ||
|
|
||
| MockFileSystem fileSystem = new(); | ||
| fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(runtimeConfig.ToJson())); | ||
| FileSystemRuntimeConfigLoader loader = new(fileSystem); | ||
| RuntimeConfigProvider runtimeConfigProvider = new(loader); | ||
|
|
||
| Mock<ILogger<QueryExecutor<SqlConnection>>> queryExecutorLogger = new(); | ||
| Mock<IHttpContextAccessor> httpContextAccessor = new(); | ||
| DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(runtimeConfigProvider); | ||
|
|
||
| MsSqlQueryExecutor msSqlQueryExecutor = new( | ||
| runtimeConfigProvider, | ||
| dbExceptionParser, | ||
| queryExecutorLogger.Object, | ||
| httpContextAccessor.Object); | ||
|
|
||
| // Create a mock HttpContext with OBO-specific claims (oid, tid, sub) | ||
| Mock<HttpContext> mockContext = new(); | ||
| Mock<HttpRequest> mockRequest = new(); | ||
| Mock<IHeaderDictionary> mockHeaders = new(); | ||
|
|
||
| mockHeaders.Setup(h => h["Authorization"]).Returns("Bearer test-token"); | ||
| mockRequest.Setup(r => r.Headers).Returns(mockHeaders.Object); | ||
| mockContext.Setup(c => c.Request).Returns(mockRequest.Object); | ||
|
|
||
| var identity = new System.Security.Claims.ClaimsIdentity( | ||
| new[] | ||
| { | ||
| new System.Security.Claims.Claim("oid", "00000000-0000-0000-0000-000000000001"), | ||
| new System.Security.Claims.Claim("tid", "11111111-1111-1111-1111-111111111111"), | ||
| new System.Security.Claims.Claim("sub", "test-subject") | ||
| }, | ||
| "TestAuth"); | ||
| var principal = new System.Security.Claims.ClaimsPrincipal(identity); | ||
| mockContext.Setup(c => c.User).Returns(principal); | ||
|
|
||
| Dictionary<string, DbConnectionParam> parameters = new(); | ||
|
|
||
| // Act - Create an Activity to simulate OpenTelemetry tracing | ||
| using ActivitySource activitySource = new("TestActivitySource"); | ||
| using ActivityListener listener = new() | ||
| { | ||
| ShouldListenTo = _ => true, | ||
| Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData | ||
| }; | ||
| ActivitySource.AddActivityListener(listener); | ||
|
|
||
| using Activity testActivity = activitySource.StartActivity("TestOperation")!; | ||
| Assert.IsNotNull(testActivity, "Activity should be created for test"); | ||
|
|
||
| string sessionParamsQuery = msSqlQueryExecutor.GetSessionParamsQuery( | ||
| mockContext.Object, | ||
| parameters, | ||
| runtimeConfigProvider.GetConfig().DefaultDataSourceName); | ||
|
|
||
| // Assert | ||
| Assert.IsFalse(string.IsNullOrEmpty(sessionParamsQuery), "Session params query should not be empty"); | ||
|
|
||
| // Verify OpenTelemetry correlation values are included | ||
| Assert.IsTrue( | ||
| sessionParamsQuery.Contains("'dab.trace_id'"), | ||
| "Session params query should include dab.trace_id"); | ||
| Assert.IsTrue( | ||
| sessionParamsQuery.Contains("'dab.span_id'"), | ||
| "Session params query should include dab.span_id"); | ||
|
|
||
| // Verify the correlation values are in the parameters | ||
| Assert.IsTrue( | ||
| parameters.Values.Any(p => p.Value?.ToString() == testActivity.TraceId.ToString()), | ||
| $"Parameters should contain trace_id value: {testActivity.TraceId}"); | ||
| Assert.IsTrue( | ||
| parameters.Values.Any(p => p.Value?.ToString() == testActivity.SpanId.ToString()), | ||
| $"Parameters should contain span_id value: {testActivity.SpanId}"); | ||
|
|
||
| // Verify OBO-specific observability values are included | ||
| Assert.IsTrue( | ||
| sessionParamsQuery.Contains("'dab.auth_type'"), | ||
| "Session params query should include dab.auth_type for OBO"); | ||
| Assert.IsTrue( | ||
| sessionParamsQuery.Contains("'dab.user_id'"), | ||
| "Session params query should include dab.user_id for OBO"); | ||
| Assert.IsTrue( | ||
| sessionParamsQuery.Contains("'dab.tenant_id'"), | ||
| "Session params query should include dab.tenant_id for OBO"); | ||
|
|
||
| // Verify the OBO parameter values are correct | ||
| Assert.IsTrue( | ||
| parameters.Values.Any(p => p.Value?.ToString() == "obo"), | ||
| "Parameters should contain auth_type value: obo"); | ||
| Assert.IsTrue( | ||
| parameters.Values.Any(p => p.Value?.ToString() == "00000000-0000-0000-0000-000000000001"), | ||
| "Parameters should contain user_id value (oid)"); | ||
| Assert.IsTrue( | ||
| parameters.Values.Any(p => p.Value?.ToString() == "11111111-1111-1111-1111-111111111111"), | ||
| "Parameters should contain tenant_id value"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Validates that GetSessionParamsQuery does NOT include correlation values | ||
| /// when no Activity is present. | ||
| /// </summary> | ||
| [TestMethod, TestCategory(TestCategory.MSSQL)] | ||
| public void GetSessionParamsQuery_ExcludesCorrelationIds_WhenNoActivity() | ||
| { | ||
| // Arrange | ||
| TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL); | ||
| RuntimeConfig runtimeConfig = new( | ||
| Schema: "UnitTestSchema", | ||
| DataSource: new DataSource( | ||
| DatabaseType: DatabaseType.MSSQL, | ||
| ConnectionString: "Server=localhost;Database=TestDb;", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OBO doesnt seem to have been enabled here in this test. |
||
| Options: new Dictionary<string, object> { { "set-session-context", true } }), | ||
| Runtime: new( | ||
| Rest: new(), | ||
| GraphQL: new(), | ||
| Mcp: new(), | ||
| Host: new(Cors: null, Authentication: null)), | ||
| Entities: new(new Dictionary<string, Entity>())); | ||
|
|
||
| MockFileSystem fileSystem = new(); | ||
| fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(runtimeConfig.ToJson())); | ||
| FileSystemRuntimeConfigLoader loader = new(fileSystem); | ||
| RuntimeConfigProvider runtimeConfigProvider = new(loader); | ||
|
|
||
| Mock<ILogger<QueryExecutor<SqlConnection>>> queryExecutorLogger = new(); | ||
| Mock<IHttpContextAccessor> httpContextAccessor = new(); | ||
| DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(runtimeConfigProvider); | ||
|
|
||
| MsSqlQueryExecutor msSqlQueryExecutor = new( | ||
| runtimeConfigProvider, | ||
| dbExceptionParser, | ||
| queryExecutorLogger.Object, | ||
| httpContextAccessor.Object); | ||
|
|
||
| // Create a mock HttpContext with a simple authenticated user | ||
| Mock<HttpContext> mockContext = new(); | ||
| Mock<HttpRequest> mockRequest = new(); | ||
| Mock<IHeaderDictionary> mockHeaders = new(); | ||
|
|
||
| mockHeaders.Setup(h => h["Authorization"]).Returns(string.Empty); | ||
| mockRequest.Setup(r => r.Headers).Returns(mockHeaders.Object); | ||
| mockContext.Setup(c => c.Request).Returns(mockRequest.Object); | ||
|
|
||
| var identity = new System.Security.Claims.ClaimsIdentity( | ||
| new[] { new System.Security.Claims.Claim("sub", "test-user") }, | ||
| "TestAuth"); | ||
| var principal = new System.Security.Claims.ClaimsPrincipal(identity); | ||
| mockContext.Setup(c => c.User).Returns(principal); | ||
|
|
||
| Dictionary<string, DbConnectionParam> parameters = new(); | ||
|
|
||
| // Act - Ensure no Activity is present (Activity.Current should be null) | ||
| // We don't start any activity here | ||
| string sessionParamsQuery = msSqlQueryExecutor.GetSessionParamsQuery( | ||
| mockContext.Object, | ||
| parameters, | ||
| runtimeConfigProvider.GetConfig().DefaultDataSourceName); | ||
|
|
||
| // Assert | ||
| Assert.IsFalse(string.IsNullOrEmpty(sessionParamsQuery), "Session params query should not be empty (has user claims)"); | ||
|
|
||
| // Verify trace_id and span_id are NOT included when no Activity | ||
| Assert.IsFalse( | ||
| sessionParamsQuery.Contains("'dab.trace_id'"), | ||
| "Session params query should NOT include dab.trace_id when no Activity present"); | ||
| Assert.IsFalse( | ||
| sessionParamsQuery.Contains("'dab.span_id'"), | ||
| "Session params query should NOT include dab.span_id when no Activity present"); | ||
| } | ||
|
|
||
| [TestCleanup] | ||
| public void CleanupAfterEachTest() | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Who instantiates the Current activity? and when?