Introduction
Triggers are great to enforce different kind of business logic. For example, if the business rules define that a column value cannot be changed if another column contains certain data, triggers can prevent these kind of modifications.
However, sometimes it may be necessary to allow such modifications. Typically in this situation the trigger is temporarily disabled, changes are carried out and the trigger is enabled again. The tricky part is that while the trigger is disabled it affects all users. If the modification is done by an admin or equivalent it would be handy to disable the checks only for that person.
This article describes one way to create a user based allowance to bypass checks.
The scenario
First we need a table. The test table is simple and contains only few columns:
KeyColumn
, this is the primary keyModifiableColumn
, this column can be modified normallyNonModifiableColumn
, the value of this column can be set in INSERT
, but later the value cannot be modified via UPDATE
CREATE TABLE TableWithRestrictions (
KeyColumn INT IDENTITY(1,1) NOT NULL,
ModifiableColumn VARCHAR(100) NOT NULL,
NonModifiableColumn INT NOT NULL
);
GO
Now to ensure that the last column cannot be modified let's create a simple trigger
CREATE TRIGGER trgTableWithRestrictions
ON TableWithRestrictions
AFTER UPDATE
AS
DECLARE @IllegalModifications INT;
BEGIN
SET @IllegalModifications = (SELECT COUNT(*)
FROM deleted d INNER JOIN inserted i on d.KeyColumn = i.KeyColumn
AND d.NonModifiableColumn <> i.NonModifiableColumn);
IF (@IllegalModifications > 0) BEGIN
RAISERROR('NonModifiableColumn cannot be changed', 16, 1) WITH SETERROR;
ROLLBACK;
END;
END;
GO
The trigger fires upon update and just checks if any of the updated rows contain a different value than the original row in NonModifiableColumn
. If there are differences, an error is generated and the transaction is rolled back.
Next, add a few rows
INSERT INTO TableWithRestrictions (ModifiableColumn, NonModifiableColumn) VALUES ('First', 1);
INSERT INTO TableWithRestrictions (ModifiableColumn, NonModifiableColumn) VALUES ('Second', 2);
and fetch the data
SELECT * FROM TableWithRestrictions;
The results should look like
KeyColumn ModifiableColumn NonModifiableColumn
--------- ---------------- -------------------
1 First 1
2 Second 2
So let's check that the trigger doesn't allow to update the NonModifiableColumn
.
UPDATE TableWithRestrictions SET NonModifiableColumn = NonModifiableColumn + 1;
What should be seen is an error message like:
Msg 50000, Level 16, State 1, Procedure trgTableWithRestrictions, Line 14
NonModifiableColumn cannot be changed
Msg 3609, Level 16, State 1, Line 2
The transaction ended in the trigger. The batch has been aborted.
Create allowance configuration
In order to allow bypassing a check, lets create a small configuration table. The table will contain the following information:
TargetSchema
, name of the schema of the tableTargetTable
, the table nameUserName
, name of the user allowed to make modifications
So the table looks like this
CREATE TABLE AllowedModicication (
TargetSchema VARCHAR(128) NOT NULL,
TargetTable VARCHAR(128) NOT NULL,
UserName VARCHAR(128) NOT NULL
);
The logic is going to rely on the fact that both the table and the user exist in the database. Because of this a small trigger is created to check that both the table and the user exist in the database. The trigger looks like the following
CREATE TRIGGER trgAllowedModicication
ON AllowedModicication
AFTER INSERT, UPDATE
AS
DECLARE @invalidRows INT;
BEGIN
SET @invalidRows = (SELECT COUNT(*)
FROM inserted i
WHERE NOT EXISTS (SELECT 1
FROM INFORMATION_SCHEMA.TABLES t
WHERE t.TABLE_SCHEMA = i.TargetSchema
AND t.TABLE_NAME = i.TargetTable));
IF (@invalidRows > 0) BEGIN
RAISERROR('Table not found in catalog', 16, 1) WITH SETERROR;
ROLLBACK;
END;
SET @invalidRows = (SELECT COUNT(*)
FROM inserted i
WHERE NOT EXISTS (SELECT 1
FROM sysusers u
WHERE u.name = i.UserName));
IF (@invalidRows > 0) BEGIN
RAISERROR('User not found in database', 16, 1) WITH SETERROR;
ROLLBACK;
END;
END;
GO
So the trigger checks from INFORMATION_SCHEMA.TABLES
that the table is present and from sysusers
that the user inserted to the table is a real database user.
Next step is to alter the original trigger on TableWithRestrictions
to check if the user is allowed to make modifications. The new trigger version looks like this
ALTER TRIGGER trgTableWithRestrictions
ON TableWithRestrictions
AFTER UPDATE
AS
DECLARE @IllegalModifications INT;
DECLARE @ByPassAllowed INT;
BEGIN
SET @ByPassAllowed = (SELECT COUNT(*)
FROM AllowedModicication am
WHERE am.UserName = USER
AND am.TargetSchema = 'dbo'
AND am.TargetTable = 'TableWithRestrictions');
IF (@ByPassAllowed = 0) BEGIN
SET @IllegalModifications = (SELECT COUNT(*)
FROM deleted d INNER JOIN inserted i on d.KeyColumn = i.KeyColumn
AND d.NonModifiableColumn <> i.NonModifiableColumn);
IF (@IllegalModifications > 0) BEGIN
RAISERROR('NonModifiableColumn cannot be changed', 16, 1) WITH SETERROR;
ROLLBACK;
END;
END;
END;
GO
The check for NonModifiableColumn
is still the same but before it is checked, the trigger checks if the user and this table is listed in AllowedModification
table. If the user is found, the business rule check is bypassed.
Test with another user
In order to test this, let's create another user. For this we need a login, a database user definition and privileges to modify the TableWithRestrictions
.
EXEC sp_addlogin @loginame = 'mika', @passwd = 'whoknows';
EXEC sp_adduser @loginame = 'mika';
GRANT SELECT, INSERT, UPDATE, DELETE ON TableWithRestrictions TO mika;
GO
Now that the user is ready, let's give him privileges to bypass the business rule check
INSERT INTO AllowedModicication (TargetSchema, TargetTable, UserName) VALUES ('dbo', 'TableWithRestrictions', 'mika');
GO
Now to test the behavior we need to open a new session to the database using the just created credentials. When the session is opened, try running the following statements.
SELECT * FROM TableWithRestrictions;
GO
UPDATE TableWithRestrictions SET NonModifiableColumn = NonModifiableColumn + 1;
GO
SELECT * FROM TableWithRestrictions;
GO
Now the UPDATE was carried out successfully and the data selected from TableWithRestrictions
looks like
KeyColumn ModifiableColumn NonModifiableColumn
--------- ---------------- -------------------
1 First 2
2 Second 3
And if the same statements are executed with some other credentials, an error still generates.
Taking this a bit further
While we're at it, lets not stop here. We can now control who can bypass the checks and for the rest the checks apply. But in the beginning I said that occasionally there may be need to bypass the checks. In order for the solution so far to work nicely, the user should be removed from AllowedModicication
so that he cannot bypass the checks all the time. That's not handy.
So lets expand the configuration table a little bit. First remove all the rows
TRUNCATE TABLE AllowedModicication;
And add few additional columns
ValidityStart
, defines the starting moment the user can bypass checksValidityEnd
, after this moment the user cannot bypass the checks anymoreIsValid
, this is a calculated column; Is the bypass permission currently validComment
, free description, for example why the permission was granted
ALTER TABLE AllowedModicication ADD ValidityStart DATETIME DEFAULT GETDATE() NOT NULL;
ALTER TABLE AllowedModicication ADD ValidityEnd DATETIME DEFAULT DATEADD(hour, 1, GETDATE()) NOT NULL;
ALTER TABLE AllowedModicication ADD IsValid AS IIF(GETDATE() BETWEEN ValidityStart AND ValidityEnd, 1, 0);
So if no value is defined to ValidityStart
, the permission is granted immediately and it lasts for one hour by default.
Another enhancement is to create a small function to actually do the check. The function looks like
CREATE FUNCTION BypassAllowed(
@UserName VARCHAR(128),
@TargetSchema VARCHAR(128),
@TargetTable VARCHAR(128)) RETURNS INT
AS
BEGIN
DECLARE @BypassAllowed INT;
SET @BypassAllowed = (SELECT COUNT(*)
FROM AllowedModicication am
WHERE am.UserName = @UserName
AND am.TargetSchema = @TargetSchema
AND am.TargetTable = @TargetTable
AND am.IsValid = 1);
RETURN @BypassAllowed;
END;
GO
Now the original trigger on TableWithRestrictions
can be simplified as
ALTER TRIGGER trgTableWithRestrictions
ON TableWithRestrictions
AFTER UPDATE
AS
DECLARE @IllegalModifications INT;
BEGIN
IF (0 = dbo.BypassAllowed( USER, 'dbo', 'TableWithRestrictions')) BEGIN
SET @IllegalModifications = (SELECT COUNT(*)
FROM deleted d INNER JOIN inserted i on d.KeyColumn = i.KeyColumn
AND d.NonModifiableColumn <> i.NonModifiableColumn);
IF (@IllegalModifications > 0) BEGIN
RAISERROR('NonModifiableColumn cannot be changed', 16, 1) WITH SETERROR;
ROLLBACK;
END;
END;
END;
GO
So let's make a small test and insert a new permission
INSERT INTO AllowedModicication (TargetSchema, TargetTable, UserName, Comment)
VALUES ('dbo', 'TableWithRestrictions', 'mika', 'Writing a CodeProject article');
And to see the permission details
SELECT TargetTable, UserName, ValidityStart, ValidityEnd, IsValid, Comment FROM AllowedModicication;
The results looks something like
TargetTable UserName ValidityStart ValidityEnd IsValid Comment
--------------------- -------- ----------------------- ----------------------- ------- -------
TableWithRestrictions mika 2012-05-20 23:55:13.930 2012-05-21 00:55:13.930 1 Writing a
CodeProject
article
So now I have one hour to make modifications and that permission will expire automatically without any further operations.
Final words
So, hopefully this article explained a few ideas how logic in triggers can be controlled based on the database user. References you may find useful:
History
- 20th May, 2012: Initial version