Problem
We use a combination of triggers and values set in SESSION_CONTEXT to implement dta change auditing on our SqlServer database.Unfortunately, we have started to notice that the username and Ip addresses are not always correct. It has become clear that some audit records (not many) are recording another users username and ip in the audit table. It always records a username that exists, and the Ip is always an ip that the username does (or has) indeed used. But sometimes it records an ip and username that cannot possibly be correct.Im concerned if setting values in SESSION_CONTEXT is thread-safe, when there are > 100 concurrent connections, all logging in using a connection pool (same domain user credentials). What happens if between SESSION_CONTEXT being set, and the update statements executing, IIS decides to process a different thread that makes a database change? We have never seen this problem on a non-live database.
If this is not thread safe, are there any recommendations for an alternative that can meet the audit requirements detailed below?
Background
I currently support a legacy (2009) system with high number of SOAP API requests (200 per second).It is a SQLServer 2016 Enterprise database in an 3-node AlwaysOn availability group.
As this is a system used by the Government, theres is a requirement to audit all data changes made within SQL Server to all tables and all columns.The requirements include (amongst other things) it must record the UserId making the change, the IP_Address of who is making the change (the remote IP that hits our firewall), the data before it got changed, and the data after it got changed.
A simple table would look like this:
CREATE TABLE [dbo].[tbl_User]( [UserId] [int] IDENTITY(1,1) NOT NULL, [Forename] [nvarchar](50) NOT NULL, [Surname] [nvarchar](50) NOT NULL, [Email] [nvarchar](255) NOT NULL, [Position] [nvarchar](100) NULL, [ActiveDirectoryUsername] [nvarchar](70) NOT NULL, [IsActive] [bit] NOT NULL, [LastLogin] [datetime] NULL )
With a trigger such as this (triggers are on every table):
CREATE TRIGGER [dbo].[trg_User_Audit] ON [dbo].[tbl_User] FOR INSERT, UPDATE, DELETE AS SET NOCOUNT ON; DECLARE @userId INT; DECLARE @userType INT; DECLARE @userIp VARCHAR(256), @auditRecordId VARCHAR(128); EXECUTE [dbo].[sproc_Get_Session_Context_Info] @userId = @userId OUTPUT, @userIp = @userIp OUTPUT, @userType = @userType OUTPUT, @auditRecordId = @auditRecordId OUTPUT; DECLARE @Old xml, @New xml; SET @Old = (SELECT * FROM deleted AS [tbl_User] FOR XML AUTO); SET @New = (SELECT * FROM inserted AS [tbl_User] FOR XML AUTO); INSERT INTO Audit.dbo.tbl_AuditDML(DBName,DBUserName,Actiontime,IPAddress,DMLType,ObjectName,old_XML,new_XML,UserId,UserType,AuditRecordId) SELECT 'db', USER_NAME(), GETDATE(), @userIp, CASE WHEN @Old IS NOT NULL AND @New IS NOT NULL THEN 'U' WHEN @Old IS NULL THEN 'I' ELSE 'D' END,'tbl_User', @Old, @New, @userId, @userType, @auditRecordId;
The [sproc_Get_Session_Context_Info] looks like:
CREATE PROCEDURE [dbo].[sproc_Get_Session_Context_Info] @userId INT OUTPUT, @userIp VARCHAR(256) OUTPUT, @userType INT OUTPUT, @auditRecordId VARCHAR(128) OUTPUT AS SET NOCOUNT ON; DECLARE @sessionUserId SQL_VARIANT, @sessionuserIp SQL_VARIANT, @sessionUserType SQL_VARIANT, @sessionAuditRecordId SQL_VARIANT; SET @sessionUserId = SESSION_CONTEXT(N'userId'); SET @sessionUserIp = SESSION_CONTEXT(N'userIp'); SET @sessionUserType = SESSION_CONTEXT(N'userType'); SET @sessionAuditRecordId = SESSION_CONTEXT(N'auditRecordId'); SET @userId = CASE WHEN @sessionUserId = 'Null' THEN NULL ELSE CAST(@sessionUserId as INT) END; SET @userIp = CASE WHEN @sessionUserIp = 'Null' THEN NULL ELSE CAST(@sessionUserIp as VARCHAR(256)) END; SET @userType = CASE WHEN @sessionUserType = 'Null' THEN NULL ELSE CAST(@sessionUserType as INT) END; SET @auditRecordId = CASE WHEN @sessionAuditRecordId = 'Null' THEN NULL ELSE CAST(@sessionAuditRecordId as VARCHAR(128)) END;
The audit table looks like this:
CREATE TABLE [dbo].[tbl_AuditDML]( [AuditDataID] [int] IDENTITY(1,1) NOT NULL, [AppUserName] [varchar](70) NULL, [DBUserName] [varchar](70) NOT NULL, [IPAddress] [varchar](256) NULL, [ActionTime] [datetime] NOT NULL, [ObjectName] [varchar](128) NOT NULL, [old_XML] [xml] NULL, [new_XML] [xml] NULL, [DBName] [nvarchar](128) NULL, [DMLType] [char](1) NULL, [UserType] [int] NULL, [UserId] [int] NULL, [AuditRecordId] [varchar](128) NULL, )
The database is accessed using a connection string using a domain account (a low privilege service account) (SSPI=true) that has been configured to have minimum permissions to execute the application with minimal permissions on the database. All users therefore get a connection from the connection pool using the same sql login (domain user).
The Application sets the username and IP address of the current user in the SESSION_CONTEXT of sql server as part of the SaveChangesAsync method of a .NET framework application (EF6 / .NET Framework 4.7.2). It sets it like this:c
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken) { await SetUserContextAsync(cancellationToken); try { return await base.SaveChangesAsync(cancellationToken); } catch (Exception ex) { var message = FormatExceptionMessage(ex); throw new AggregateException(message, ex); } }async Task SetUserContextAsync(CancellationToken cancellationToken) { if (Database.Connection.State != ConnectionState.Open) { // Open a connection to the database so the session is set up await Database.Connection.OpenAsync(cancellationToken); } var contextInfo = FetchSessionContextInfo(); // Set the user context // Cannot use ExecuteSqlCommand here as it will close the connection using (var cmd = Database.Connection.CreateCommand()) { SetupSessionContextInfoCommand(contextInfo, cmd); await cmd.ExecuteNonQueryAsync(cancellationToken); } }private void SetupSessionContextInfoCommand(SessionContextInfoModel sessionContextInfo, System.Data.Common.DbCommand cmd) { cmd.Transaction = Database.CurrentTransaction?.UnderlyingTransaction; cmd.CommandType = System.Data.CommandType.Text; var userTypeString = sessionContextInfo.UserType.ToString(); var userIdString = sessionContextInfo.UserId == null ? System.Data.SqlTypes.SqlString.Null : sessionContextInfo.UserId.ToString(); var userIdParm = cmd.CreateParameter(); userIdParm.ParameterName = "@userId"; userIdParm.Value = userIdString; var userTypeParm = cmd.CreateParameter(); userTypeParm.ParameterName = "@userType"; userTypeParm.Value = string.IsNullOrEmpty(userTypeString) ? System.Data.SqlTypes.SqlString.Null : userTypeString; var auditRecordIdParm = cmd.CreateParameter(); auditRecordIdParm.ParameterName = "@auditRecordId"; auditRecordIdParm.Value = string.IsNullOrEmpty(sessionContextInfo.AuditRecordId) ? System.Data.SqlTypes.SqlString.Null : sessionContextInfo.AuditRecordId; var ipParm = cmd.CreateParameter(); ipParm.ParameterName = "@userIp"; ipParm.Value = string.IsNullOrEmpty(sessionContextInfo.UserIp) ? System.Data.SqlTypes.SqlString.Null : sessionContextInfo.UserIp; cmd.CommandText = "sproc_Set_Session_Context_info"; cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add(userIdParm); cmd.Parameters.Add(userTypeParm); cmd.Parameters.Add(auditRecordIdParm); cmd.Parameters.Add(ipParm); }