By: John Miner | Updated: 2018-01-26 | Comments (2) | Related: > Auditing and Compliance
Problem
The process of classifying a company into an industry segment has been around since the 1950’s. Wikipedia has listed several popular taxonomies that are in current use. Some industries are more regulated and have stricter compliance regulations than others. The business requirement to provide an audit trail to a compliance officer when a security issue arises is “table stakes” in most organizations.
SQL Server has had the ability to define an audit specification at the server or database level since 2008. However, the actual changed data is not readily available for reversing the unwanted action. In today’s complex team environments, certain key tables need to be protected against inserts, updates and deletes. As a database administrator, how can we audit and prevent unwanted table changes?
Solution
A design pattern using an audit schema, a central audit table and table triggers can be used to log all user actions against a group of tables. For larger databases, the central audit table can be transitioned to one audit table to one data table design. Spreading the audit data across multiple tables will allow the system to scale. Because this design pattern uses database objects that are core to every modern version of SQL Server, this design will work with both on premise and in-cloud databases.
Business Problem
The medical industry has to comply with the Health Insurance Portability and Accountability Act of 1996 (HIPAA). If you are interested, detailed information can be found on the Department of Health and Human Services website.
I am going to re-use the simple database schema for a fictitious urgent care center called “The Last Stop”. Please see my prior article for details on how to setup this test database. Our boss has asked us to showcase these new design patterns.
Use existing Azure SQL database
I am going to use SQL Server Management Studio to manage my existing Azure SQL database. Choose the database engine as the server type and enter the fully qualified name of the Azure SQL Server. Pick SQL Server authentication as the security option and supply the login/password of the server administrator. The image below shows a typical connect to server login window.
Right now, there is one custom user defined database. Use the object explorer to drill into the hippa database and expand the tables list. There are three tables in the active schema.
The patient_info table contains patients that visited the clinic and the doctor_info table contains doctors that saw the patients. The visit_info table has the overall medical readings taken during the visit as well as a medical diagnosis.
If we take a closer look at the user defined objects, we can see that each of the three tables have a surrogate primary key. The visit_info table is used to join all tables together with foreign key relationships.
Please make sure that your test database is all ready for the rest of the article. Since the build script can be deployed to either locally using on premise resources or remotely using in cloud resources, it is up to you on where you want to do your testing.
Securing Audit Data
I think user defined database schemas are under-utilized in the real world. One great feature behind this security object is the ability to group like objects and assign permissions to the group.
/* 1 - Create audit schema */ -- Delete existing schema DROP SCHEMA IF EXISTS [audit] GO -- Add schema for audit purposes CREATE SCHEMA [audit] AUTHORIZATION [dbo] GO -- Show database schemas SELECT * FROM sys.schemas WHERE principal_id < 16384 GO
The above code creates a new schema named audit. We can query the system catalog view named sys.schemas to see our three user defined schemas: active, stage and audit. See the screen shot below for details.
Containing a database user
The contained database user feature was introduced in SQL Server 2012. All Azure SQL database users should be defined this way since they will not be dependent upon the master database. This is especially important when active geo-replication is used for high availability.
/* 2 - Create contained user */ -- Add new user. CREATE USER [hippa_user] WITH PASSWORD=N'SzfX6ThnLeDPwpelMHYdV2MW', DEFAULT_SCHEMA=[active] GO -- Show the user SELECT * FROM sys.database_principals WHERE type_desc = 'SQL_USER' GO
The T-SQL code above creates a contained database user. We can query the system catalog view named sys.database_principles to view our new user named hippa_user.
The image above shows the default schema is set to active. This is important since we will only give this user access to that schema via a database role.
Granting access via database roles
Another rarely used feature in database design is a user defined database role. Roles are great when you are using either standard or mixed security. They serve the same purpose as an active directory group. The code below creates a role named hippa_role.
/* 3 - Create database role */ -- Delete existing role. DROP ROLE IF EXISTS [hippa_role] GO -- Create database role CREATE ROLE [hippa_role] AUTHORIZATION [dbo] GO
The next step in assigning security is to grant permissions to the role. I am going to give out CRUD (create, read, update and delete) rights to the user. Of course, we can’t forget to add the user to the role.
/* 4 – Add user 2 role & apply permissions 2 schema */ -- Apply permissions to schema GRANT INSERT ON SCHEMA::[active] TO [hippa_role] GRANT UPDATE ON SCHEMA::[active] TO [hippa_role] GRANT DELETE ON SCHEMA::[active] TO [hippa_role] GRANT SELECT ON SCHEMA::[active] TO [hippa_role] GO -- Add user to role EXEC sp_addrolemember N'hippa_role', N'hippa_user' GO
The sys.database_role_members catalog view is a little more difficult to query than most. It is a paired list of role and user ids.
/* 5 – Show role membership */ -- Show role membership SELECT DP1.name AS Roles_Name, isnull(DP2.name, 'No members') AS Users_Name FROM sys.database_role_members AS RM RIGHT OUTER JOIN sys.database_principals AS DP1 ON RM.role_principal_id = DP1.principal_id LEFT OUTER JOIN sys.database_principals AS DP2 ON RM.member_principal_id = DP2.principal_id WHERE DP1.name = 'hippa_role'
The query above will return the role name and user name in the current database. See the screen shot below for details.
Make a central audit table
It is time now to leverage the audit schema that we created previously. The name of our audit table is log_table_changes. Basic information such as change date, change type, change by, schema name and object name will be recorded.
What makes this table interesting are the default constraints. We are capturing the user name, application name, and host name via calls to system functions. Last but not least, the changed record(s) are stored as an XML document.
/* 6 - Audit data changes (table for DML trigger) */ -- Delete existing table DROP TABLE IF EXISTS [audit].[log_table_changes] GO -- Add the table CREATE TABLE [audit].[log_table_changes] ( [chg_id] [numeric](18, 0) IDENTITY(1,1) NOT NULL, [chg_date] [datetime] NOT NULL, [chg_type] [varchar](20) NOT NULL, [chg_by] [nvarchar](256) NOT NULL, [app_name] [nvarchar](128) NOT NULL, [host_name] [nvarchar](128) NOT NULL, [schema_name] [sysname] NOT NULL, [object_name] [sysname] NOT NULL, [xml_recset] [xml] NULL, CONSTRAINT [pk_ltc_chg_id] PRIMARY KEY CLUSTERED ([chg_id] ASC) ); GO -- Add defaults for key information ALTER TABLE [audit].[log_table_changes] ADD CONSTRAINT [DF_LTC_CHG_DATE] DEFAULT (getdate()) FOR [chg_date]; ALTER TABLE [audit].[log_table_changes] ADD CONSTRAINT [DF_LTC_CHG_TYPE] DEFAULT ('') FOR [chg_type]; ALTER TABLE [audit].[log_table_changes] ADD CONSTRAINT [DF_LTC_CHG_BY] DEFAULT (coalesce(suser_sname(),'?')) FOR [chg_by]; ALTER TABLE [audit].[log_table_changes] ADD CONSTRAINT [DF_LTC_APP_NAME] DEFAULT (coalesce(app_name(),'?')) FOR [app_name]; ALTER TABLE [audit].[log_table_changes] ADD CONSTRAINT [DF_LTC_HOST_NAME] DEFAULT (coalesce(host_name(),'?')) FOR [host_name]; GO
The screen shot below shows the new table named log_table_changes.
This current database only has three tables. Therefore, creating a table trigger by hand is not a problem. What happens when the database has hundreds of tables. In the next section, we will create a stored procedure named manage_table_triggers which will automate the process.
Managing table triggers
The actual code behind the stored procedure is quite large. Therefore, I am more concerned about explaining the complete algorithm versus you understanding each line of code. The manage_table_triggers stored procedure takes three parameters as input.
The @target_schema_name parameter defaults to the active schema. However, you can supply any schema name with one exception. Creating a trigger on the log_table_changes table will cause a never ending cycle when the trigger fires.
The @command_action parameter has two correct values. The drop action removes all audit triggers from all tables in a given schema. The create action clobbers the current audit trigger with a new one.
The @verbose_flag is used to optionally print the transact SQL statements that are being executed by the stored procedure.
Using a cursor to compose and execute dynamic transact SQL inside a maintenance procedure is an excepted practice. This custom stored procedure assumes the number of tables to create an trigger on is limited. The resulting DML after triggers use the inserted and deleted tables to capture and store the changed data as a XML document in the audit table.
The enclosed script creates the custom stored procedure and is provided as reference. However, you do not need to understand the code to start auditing your tables.
The code snippet below creates triggers on the active schema. If we query the sys.triggers, sys.tables and sys.schemas system catalog views, we can list the newly created triggers.
/* 7 - Dynamically make DML after triggers to capture changes */ -- Add triggers to tables EXEC [audit].[manage_table_triggers] @target_schema = 'active', @command_action = 'create', @verbose_flag = 1 GO -- Show the triggers SELECT s.name, tr.name, tr.type_desc, tr.is_instead_of_trigger FROM sys.triggers tr join sys.tables t on tr.parent_id = t.object_id join sys.schemas s ON t.schema_id = s.schema_id WHERE s.name = 'active' GO
As expected, there are three triggers for the three tables in the active schema. See screen shot below for details.
Testing audit security
It is important that the audit logs and/or triggers created in this process can’t be tampered with by the average user. Otherwise, why create our auditing design pattern?
Let’s connect to the SQL Server engine using the hippa_user account. Since there are not login entries for this contained database user in the master database, it is important to specify the default database using the connection properties tab.
Unlike the jminer account that can see the centralized logging table and custom stored procedure, the hippa_user account does not have any rights to the audit schema.
The typical user can see the audit trigger on the tables. However, they do not have rights to view the definition of the trigger. This action executed thru SSMS was denied below.
The worst case scenario would be the ability for the user to drop the auditing trigger. Again, the typical user does not have such rights. See the denial of this action via SSMS below.
In a nutshell, we now have auditing in place on the three user defined tables in the active schema. Add new users to the hippa_role to keep everything secure. The next task in this proof of concept is to test auditing of DML statements.
Testing audit logging
The software development life cycle dictates that we test each object that we create. Some testing scenarios have both positive and negative inputs.
For instance, if we bulk insert into an Azure SQL database, does the stored procedure work if the file exists. That is our positive test case. In contrast, does the bulk insert correctly fail and alert us when the file does not exist? That is our negative test case. Let’s start testing.
The Campbell family has visited the urgent care center today. The hypothetical application software returns all patients with the correct last name.
-- Show the Campbell family select * from active.patient_info where last_name = 'CAMPBELL' go
Ashely is complaining of influenza like symptoms. A record of her vitals is logged for the visit.
-- Add a new visit insert into active.visit_info values (getdate(), 125, 60, 98.6, 120, 60, 487, 'Influenza', 11, 1); go
However, the data entry was incorrect since she has a low grade temperature. An update to the record is made by the nurse.
-- Update the visit update active.visit_info set diagnosis_desc = upper(diagnosis_desc), patient_temp = 98.4 where visit_id = 21 go
The current software keeps the most recent visit in the active schema. Thus, the older record is deleted from the system.
-- Delete first visit delete from active.visit_info where visit_id = 11; go
The above actions might be a test script you write for your system. Did the actions get correctly recorded to the log table? Of course the answer is yes. We have an insert, update and delete action stored as positive test cases. We can consider the select statement a negative test case since nothing is logged for that action.
See the image below for details.
If you double click on the xml document, SQL Server Management Studio will open the information in a new window. The screen shot below shows the record that was deleted from the visit_info table.
This design pattern is better than SQL Server Auditing. Not only does it record changes, the information to readily available to reverse the unwanted action.
Reversing Unwanted Actions
As a database administrator, there will be times in which the business line will want to reverse an unwanted action. This section goes over the code necessary to perform these tasks.
The delete statement can be reversed with an insert statement. I am not using the chg_id in the where clause since there is only one action. In real life, you would supply this value. The code below inserts the captured xml record.
/* 8 – Reverse delete Action */ -- Find deleted record DECLARE @xml1 XML SELECT @xml1 = xml_recset FROM [audit].[log_table_changes] WHERE chg_type = 'DELETE'; -- Insert lost record WITH cte_Captured_Record as ( SELECT Tbl.Col.value('visit_id[1]', 'int') as visit_id, Tbl.Col.value('visit_date[1]', 'datetime') as visit_date, Tbl.Col.value('patient_weight[1]', 'real') as patient_weight, Tbl.Col.value('patient_height[1]', 'real') as patient_height, Tbl.Col.value('patient_temp[1]', 'real') as patient_temp, Tbl.Col.value('patient_systolic[1]', 'int') as patient_systolic, Tbl.Col.value('patient_diastolic[1]', 'int') as patient_diastolic, Tbl.Col.value('diagnosis_icd9[1]', 'int') as diagnosis_icd9, Tbl.Col.value('diagnosis_desc[1]', 'varchar(128)') as diagnosis_desc, Tbl.Col.value('patient_id[1]', 'int') as patient_id, Tbl.Col.value('doctor_id[1]', 'int') as doctor_id FROM @xml1.nodes('//Record') Tbl(Col) ) INSERT INTO [active].[visit_info] SELECT * FROM cte_Captured_Record; GO
The update statement can be reversed with another update statement. Again, I am not using the chg_id in the where clause since there is only one action. The code below updates the current record with the captured xml record.
/* 9 - Update Action */ -- Find updated record DECLARE @xml2 XML SELECT @xml2 = xml_recset FROM [audit].[log_table_changes] WHERE chg_type = 'UPDATE'; -- Reverse record change WITH cte_Captured_Record as ( SELECT Tbl.Col.value('visit_id[1]', 'int') as visit_id, Tbl.Col.value('visit_date[1]', 'datetime') as visit_date, Tbl.Col.value('patient_weight[1]', 'real') as patient_weight, Tbl.Col.value('patient_height[1]', 'real') as patient_height, Tbl.Col.value('patient_temp[1]', 'real') as patient_temp, Tbl.Col.value('patient_systolic[1]', 'int') as patient_systolic, Tbl.Col.value('patient_diastolic[1]', 'int') as patient_diastolic, Tbl.Col.value('diagnosis_icd9[1]', 'int') as diagnosis_icd9, Tbl.Col.value('diagnosis_desc[1]', 'varchar(128)') as diagnosis_desc, Tbl.Col.value('patient_id[1]', 'int') as patient_id, Tbl.Col.value('doctor_id[1]', 'int') as doctor_id FROM @xml2.nodes('//Record') Tbl(Col) ) UPDATE cur SET cur.patient_temp = prv.patient_temp FROM [active].[visit_info] as cur JOIN cte_Captured_Record as prv ON cur.visit_id = prv.visit_id GO
The insert statement can be reversed with a delete statement. Please supply the chg_id when you use this code in production. The code below deletes the current record.
/* 10 - Insert Action */ -- Find inserted record DECLARE @xml3 XML SELECT @xml3 = xml_recset FROM [audit].[log_table_changes] WHERE chg_type = 'INSERT'; -- Remove identified record WITH cte_Captured_Record as ( SELECT Tbl.Col.value('visit_id[1]', 'int') as visit_id FROM @xml3.nodes('//Record') Tbl(Col) ) DELETE FROM [active].[visit_info] WHERE visit_id in (SELECT visit_id FROM cte_Captured_Record) GO
Unfortunately, each of the three statements is tightly bound to the table structure of the audited table. I leave the task to dynamically create the statement, given the schema and table name, as an exercise for you to try.
Preventing unwanted actions
So far, the article has been centered on auditing unwanted actions. How can we prevent inserts, updates and/or deletes on the doctor_info table? Table triggers can be used to ROLLBACK the unwanted actions.
The code below prevents changes from happening to our static table.
/* 11 - Preventing unwanted data changes (static data) */ -- Remove trigger if it exists IF OBJECT_ID('[active].[trg_sd_doctor_info]') IS NOT NULL DROP TRIGGER [active].[trg_sd_doctor_info] GO -- Add trigger to prevent data changes CREATE TRIGGER [active].[trg_sd_doctor_info] ON [active].[doctor_info] FOR INSERT, UPDATE, DELETE AS BEGIN -- Detect inserts IF EXISTS (select * from inserted) AND NOT EXISTS (select * from deleted) BEGIN ROLLBACK TRANSACTION; RAISERROR ('inserts are not allowed on table [active].[doctor_info]!', 15, 1); RETURN; END -- Detect deletes IF EXISTS (select * from deleted) AND NOT EXISTS (select * from inserted) BEGIN ROLLBACK TRANSACTION; RAISERROR ('deletes are not allowed on table [active].[doctor_info]!', 15, 1); RETURN; END -- Detect updates IF EXISTS (select * from inserted) AND EXISTS (select * from deleted) BEGIN ROLLBACK TRANSACTION; RAISERROR ('updates are not allowed on table [active].[doctor_info]!', 15, 1); RETURN; END END; GO
Testing the preventative trigger
Right now, we have two triggers defined on one table. I do not want to log audit actions that will be rolled back. Therefore, I will use the code below to disable the audit trigger.
-- Disable audit trigger DISABLE TRIGGER [active].[trg_ltc_doctor_info] ON [active].[doctor_info]; GO Let’s try adding Jack Kevorkian’s wife to our list of doctors. -- Inserts will fail INSERT INTO active.doctor_info ( first_name, last_name, infamous_desc ) VALUES ('Jackie', 'Kevorkian', 'Euthanasia'); GO
This action was blocked by our user defined trigger.
Many people have a nick name of Jack but a real first name of John. Let’s try changing Mr. Kevorkian’s first name.
-- Updates will fail UPDATE active.doctor_info SET first_name = 'John' WHERE doctor_id = 1; GO
Again, this action was blocked by our user defined trigger.
For some reason, many of the older patients under Jack Kevorkian’s care have died. The director of the urgent care center has decided to let him go. Let’s try deleting his record from the system.
-- Deletes will fail DELETE FROM active.doctor_info WHERE doctor_id = 1; GO
Okay. Something unexpected has happened. There is a foreign key constraint on the visit_info table. Therefore, we can’t remove Mr. Kevorkian’s record without producing orphan records.
I do not care about bad data since I am testing out my new trigger. We can disable the foreign check constraint with the following command.
-- Disable foreign key constraint ALTER TABLE [active].[visit_info] NOCHECK CONSTRAINT [fk_visit_2_doctor]; GO
After trying to remove his record a second time, we get the expected result.
To recap this section, table triggers can be used to prevent unwanted user actions. Any table constraints violated by an action will fire first before our user defined trigger.
Summary
Today, we implemented an audit design pattern using an audit schema, a central audit table and audit table triggers to log all user actions against a group of tables. This design is an improvement over standard SQL Server auditing since we have captured the actual changed data as an XML document.
Any unwanted insert, update or delete actions can be reversed using the XML record data. Sample code for each DML statement was created and tested.
Unlike SQL Server auditing, table triggers can be used to prevent actions by rolling back the offending transaction. Again, the three standard DML statements were tested. It was surprising to find out that table constraints that are violated are triggered before any custom after triggers.
In short, the new design pattern satisfies stricter business requirements for auditing. However, this design pattern does not capture the records that an employee is looking at via a SELECT statement. In a future talk, I will go over custom stored procedures to track what data is viewed or manipulated by a user.
Next Steps
- Exploring the available audit action groups.
- Using database triggers to audit or prevent database changes.
- Using custom stored procedures to track actual data that was viewed and/or manipulated by a user.
About the author
This author pledges the content of this article is based on professional experience and not AI generated.
View all my tips
Article Last Updated: 2018-01-26