Maik Skoddow
Tera Patron
Tera Patron

MaikSkoddow_0-1701010479595.png

 

In the first article of that series I've introduced a concept to document ACL effectively. And in the second part I demonstrated how to uncover and eliminate ACL differences between all instances.

 

Now, in this last part of my series I want to answer the question "How can we ensure that in the future the entire rights and role concept does not collapse, and unauthorized users gain access to highly sensitive data due to a thoughtless change or extension to the ACLs?"

 

Any change to the code means an increased risk of breaking something that worked before. This is not only true for the functionality of an application, but in ServiceNow it is especially true for ACLs. 

In software development and quality assurance, a special type of test is used to verify that everything still works as expected after a change, called regression testing

 

And in ServiceNow, the Automated Test Framework (ATF) is perfect for the executing such regression tests. Not only can they be run manually or scheduled, but ServiceNow also executes them automatically as part of system upgrades. Therefore, in the following chapters, I will describe the approach I have found to create and execute a large amount of regression tests for ACLs with reasonable effort using ServiceNow's ATF.

 

 

 

Approach

 

Considering the scenarios

 

You may remember what I wrote in my first article: Depending on the involved personas, there are different scenarios that have to be mapped in your ACLs.  For example, an FSM dispatcher has many more rights than a simple FSM agent. And there can also be multiple sub-scenarios for each persona itself. For example, it makes a big difference whether I, as an FSM agent, am accessing my own sys_user record or someone else's sys_user record. All of these different scenarios are taken into account in the ACL documentation table I created and are given an ID for better referencing:

 

MaikSkoddow_4-1699800966677.png

 

 

How to test an ACL technically?

 

This probably the most important question in this article that has to be answered and fortunately, the GlideRecord API gives us everything we need. At the GlideRecord level, there are 4 methods that can verify the 4 different basic ACL operations:

  • canRead()
  • canWrite()
  • canCreate()
  • canDelete()

 

These methods are also available on a per-field basis, making it possible to test ACLs at the field level as well.

 

But how do you do that in a way that is not an explosion of test effort? Considering the gigantic table from the first part of this series, and the 4 test operations that need to be performed per field, you can quickly add up to several hundred to several thousand test steps.

And there is another challenge. The nature of ATF-based testing means that a single failed test step results in the entire test being aborted. Then you have to investigate the reason and fix anything - either in the code or in the test itself. After finishing, the test needs to be run again. 

 

In order to shorten the whole process and to optimize the test development, all the tests for all the record fields under consideration should be run one after the other and the expected result should be compared with the found result without any interruption.

One approach to achieve this is leveraging the Jasmin framework. Some time ago, I wrote an article about that topic, see JUnit-like tests for Script Includes. However, after playing around with it for a while, I abandoned this approach because it became too much effort to describe the tests in the Jasmin manner. It had to be much simpler, and I couldn't expect that many tests would have to be adjusted after minor ACL changes.

 

At some point, I came up with the idea of Excel tables. They are perfect to visualize all the field level rights a user should have for a certain scenario. The following Excel is representing all field-level rights for a certain persona user on a record from table sys_user and based on one of the defined scenarios:

 

MaikSkoddow_0-1702746087269.png

 

Defining the expected state in an Excel file is pretty cool because it allows the user to set values at the requirement or process level without the need for code customization.

 

Now all you need to do is implement a helper method that grabs the Excel file at the specified location, reads it, performs the canRead() and canWrite() operations per field, and compares the expected result with the determined result. In case any of the operations fails, the complete ATF test step has to fail, but only after all field-level tests have been completed.

 

 

 

Implementation

 

Structured organization of ACL-related artifacts

 

The following diagram represents the basic linking of the artifacts required in the ATF context. The previously discussed Excel file is attached to one of the test steps:

 

MaikSkoddow_0-1702796381218.png

 

 

 

 

A single test looks like this:

 

MaikSkoddow_2-1702790793365.png

 

As you can see in the above screenshot:

  1. ATF Test records link the related scenario via its ID defined on the Confluence page that is referenced in the description field.
  2. Each test step also mention the scenario ID to help understand better what's going on there.

 

 

Performing the ACL evaluation

 

The heart of all tests are the steps of the “Run Server Side Script” type, which perform all ACL test operations. A typical example of the step's code looks as follows:

 

 

(function(outputs, steps, params, stepResult, assertEqual) {

	//Sys ID of this step record
	var _strStepSysID = 'a470d2ff2bdeb1902f8384b4ec01a0a0';

	//unfortunately there doesn't seem to be a better way than hardcoding the Sys ID of the 
	//previously executed impersonation step to retrieve the Sys ID of that user
	var _grImpersonatedUser = steps('2870d2ff2bdeb1902f8384b4ec01a093').user.getRefRecord();
	var _grtoBeTestedRecord = new GlideRecord('sys_user');

	//load record of currently impersonated user
	//note: record of currently impersonated user cannot be reused due to its different ACLs!
	_grtoBeTestedRecord.get('user_name', _grImpersonatedUser.getValue('user_name'));
	
	var _jsonTestSet = {
		recordTests: {
			canRead:   true,
			canWrite:  true,
			canCreate: false,
			canDelete: false
		},
		fieldTests: 
			global.AclTestHelper.loadFieldTestDataFromExcelFile(_strStepSysID)
	};

	var _strResult = 
		global.AclTestHelper.testUseCases(_grImpersonatedUser, _grtoBeTestedRecord, _jsonTestSet);

	assertEqual({
		name:     global.AclTestHelper.getStepNotes(_strStepSysID),
		shouldbe: 'passed',
		value:    _strResult === '' ? 'passed' : 'failed, details:\n' + _strResult
	});

})(outputs, steps, params, stepResult, assertEqual);

 

 

 

 

 Explanations:

 

//Sys ID of this step record
var _strStepSysID = 'a470d2ff2bdeb1902f8384b4ec01a0a0';

The Sys ID of the current test step is required later for loading the attached Excel file and for building the failed message output, especially for fetching the content of the step's "Notes" field. Unfortunately, I did not find any way to retrieve that Sys ID via any API method.

//unfortunately there doesn't seem to be a better way
//than hardcoding the Sys ID of the previously executed 
//impersonation step to retrieve the Sys ID of that user
var _grImpersonatedUser = 
     steps('2870d2ff2bdeb1902f8384b4ec01a093')
     .user
     .getRefRecord();

Retrieving the sys_user record of the previously impersonated user is only required later for building the failed message output.

var _grtoBeTestedRecord = new GlideRecord('sys_user');

//load record of currently impersonated user
//note: record of currently impersonated user cannot
//be reused due to its different ACLs!
_grtoBeTestedRecord.get(
    'user_name', 
    _grImpersonatedUser.getValue('user_name')
);

This is the record on which all ACL operations should be tested. This can be any record from any table. In the example scenario, we want to examine what an impersonated FSM Agent is able to do/see on his own sys_user record (his so-called "Profile").

var _jsonTestSet = {
	recordTests: {
		canRead:   true,
		canWrite:  true,
		canCreate: false,
		canDelete: false
	},
	fieldTests: 
		global.AclTestHelper.loadFieldTestDataFromExcelFile(_strStepSysID)
};

The script's most crucial part is defined here, as it contains the test set with the expected results of the ACL tests. It is divided into two parts:

1. In the mandatory branch "recordTests" the expected results for ACL operations on the record level are defined.

2. In the optional branch "fieldTests", the previously shown Excel table, which was attached to the test step record, is loaded and extracted via method
loadFieldTestDataFromExcelFile()
which is listed below.

var _strResult = 
	global.AclTestHelper.testUseCases(
		_grImpersonatedUser, 
		_grtoBeTestedRecord, 
		_jsonTestSet
	);

My helper method testUseCases() (which is listed below) now performs the ACL tests based on the passed test set. 

assertEqual({
  name: global.AclTestHelper.getStepNotes(_strStepSysID),
  shouldbe: 'passed',
  value:
    _strResult === '' ?
       'passed' : 
       'failed, details:\n' + _strResult
});

Calling assertEqual() is fundamental to making an ATF test what it is. My helper method getStepNotes() just fetches the content of the "Notes" field from the currently executed test step. And in case one of the ACL tests has failed the collected messages are returned so it can be displayed in the output of the execution result (see example below).

 

 

Helper methods for evaluating the ACLs

 

AclTestHelper.loadFieldTestDataFromExcelFile()

 

Processing Excel files is extremely easy thanks to the GlideExcelParser API offered by ServiceNow. Therefore, method AclTestHelper.loadFieldTestDataFromExcelFile() can load the Excel file attached to the test step record and extract all rows in order to add their contained values to the test set. The _objExcelParser.getRow() method kindly generates the JSON-based data structure that is required in the test set.

 

 

AclTestHelper.loadFieldTestDataFromExcelFile = function(_strTestStepSysID) {
  var _grAttachments = new GlideRecord('sys_attachment');
  var _arrTestData = [];

  _grAttachments.addQuery('table_sys_id', _strTestStepSysID);
  _grAttachments.query();

  while (_grAttachments.next()) {
    var _arrFileNameParts = String(_grAttachments.getValue('file_name')).split('.');

    if (_arrFileNameParts.length > 1 && 
        _arrFileNameParts[_arrFileNameParts.length - 1].startsWith('xls')) 
    {
      var _gaExcelFile    = new GlideSysAttachment();
      var _objExcelParser = new sn_impex.GlideExcelParser();

      _objExcelParser.parse(
         _gaExcelFile.getContentStream(_grAttachments.getUniqueValue())
      );

      while (_objExcelParser.next()) {
        _arrTestData.push(_objExcelParser.getRow());
      }
    }
  }

  return _arrTestData;
};

 

 

 

AclTestHelper.testUseCases()

 

That helper method performs all ACL tests as the currently impersonated user based on the test set passed in jsonTestSet on the record grToBeTestedRecord. In parallel, some log messages are generated which are returned in case any test is failing.

 

 

AclTestHelper.testUseCases = function(grImpersonatedUser, grtoBeTestedRecord, jsonTestSet) {
  
  var _arrOut                  = [];
  var _hasCompletePassed       = true;
  var _strImpersonatedUserName = grImpersonatedUser.getValue('user_name');
  var _strTestedTableName      = grtoBeTestedRecord.getTableName();

  function _getRecordTestResult(strOperation, expectedResult, testedResult) {
    var _strTestStatus = expectedResult === testedResult ? 'passed' : 'failed';

    _arrOut.push(gs.getMessage(
      '- record operation "{0}" should be "{1}" and was "{2}" -> {3}',
      [strOperation, expectedResult, testedResult, _strTestStatus]
    ));

    return expectedResult === testedResult;
  };

  function _getFieldTestResult(strOperation, strFieldName, expectedResult, testedResult) {
    var _strTestStatus = expectedResult === testedResult ? 'passed' : 'failed';

    _arrOut.push(gs.getMessage(
      '- operation "{0}" on field "{1}" should be "{2}" and was "{3}" -> {4}',
      [strOperation, strFieldName, expectedResult, testedResult, _strTestStatus]
    ));

    return expectedResult === testedResult;
  }

  _arrOut.push(gs.getMessage(
    'Start ACL tests as impersonated user "{0}" accessing "{1}" record "{2}":', 
    [_strImpersonatedUserName, _strTestedTableName, grtoBeTestedRecord.getDisplayValue()]
  ));

  if (typeof jsonTestSet.recordTests === 'object') {
    if ('canRead' in jsonTestSet.recordTests) {
      var _hasPassedRecordTestForCanRead = 
        _getRecordTestResult(
          'canRead', 
          jsonTestSet.recordTests.canRead === true || jsonTestSet.recordTests.canRead === 'true',
          grtoBeTestedRecord.canRead()
        );

      _hasCompletePassed = _hasCompletePassed && _hasPassedRecordTestForCanRead;
    }

    if ('canWrite' in jsonTestSet.recordTests) {
      var _hasPassedRecordTestForCanWrite = 
        _getRecordTestResult(
          'canWrite', 
          jsonTestSet.recordTests.canWrite === true || jsonTestSet.recordTests.canWrite === 'true',
          grtoBeTestedRecord.canWrite()
        );

      _hasCompletePassed = _hasCompletePassed && _hasPassedRecordTestForCanWrite;
    }

    if ('canCreate' in jsonTestSet.recordTests) {
      var _hasPassedRecordTestForCanCreate = 
        _getRecordTestResult(
          'canCreate', 
          jsonTestSet.recordTests.canCreate === true || jsonTestSet.recordTests.canCreate === 'true',
          grtoBeTestedRecord.canCreate()
        );

      _hasCompletePassed = _hasCompletePassed && _hasPassedRecordTestForCanCreate;
    }

    if ('canDelete' in jsonTestSet.recordTests) {
      var _hasPassedRecordTestForCanDelete = 
        _getRecordTestResult(
          'canDelete', 
          jsonTestSet.recordTests.canDelete === true || jsonTestSet.recordTests.canDelete === 'true',
          grtoBeTestedRecord.canDelete()
        );

      _hasCompletePassed = _hasCompletePassed && _hasPassedRecordTestForCanDelete;
    }
  }


  if (Array.isArray(jsonTestSet.fieldTests) && _hasCompletePassed) {
    jsonTestSet.fieldTests.forEach(function(_objFieldTest) {
      var _strFieldName = String(_objFieldTest.fieldName || '');
        
      if ("canRead" in _objFieldTest) {
        var _hasPassedFieldTestForCanRead = 
          _getFieldTestResult(
            'canRead', 
            _strFieldName,
            _objFieldTest.canRead === true || _objFieldTest.canRead === 'true',
            grtoBeTestedRecord[_strFieldName].canRead()
          );

          _hasCompletePassed = _hasCompletePassed && _hasPassedFieldTestForCanRead;
      }

      if ("canWrite" in _objFieldTest) {
        var _hasPassedFieldTestForCanWrite = 
          _getFieldTestResult(
            'canWrite', 
            _strFieldName,        
            _objFieldTest.canWrite === true || _objFieldTest.canWrite === 'true',
            grtoBeTestedRecord[_strFieldName].canWrite()
          );

         _hasCompletePassed = _hasCompletePassed && _hasPassedFieldTestForCanWrite;
      }      
    });
  }

  return _hasCompletePassed ? '' : _arrOut.join('\n');
};

 

 

 

 

 

 

The result

 

And this is what the output looks like if an ACL test fails:

 

MaikSkoddow_0-1702819301867.png

 

You can quickly and easily determine which of the ACL test operations failed on the entire record or on specific fields, and then use this information to analyze the root cause.

 

 

 

Test the Tests

 

Of course, the tests are only as good as the person who created them. And that's why you should always try to break the ACL tests after they have been run successfully the first time.

 

For example, by

  • adding or removing roles to existing ACLs.
  • adding new ACLs
  • deactivating existing ACLs
  • changing conditions or scripts in existing ACLs.

 

If after these changes your existing ACL tests still are "green" you have to examine what was missing and rework your tests.

This approach automatically produces a valuable side-effect: it is in the nature of things that the huge ACL documentation tables introduced in the first article will not be error-free. However, the constant comparison of TARGET and ACTUAL will inevitably lead to a point where something does not match. For example, a persona may have more/less rights in a certain scenario than documented. This leads to a questioning of the documented TARGET status, and thus to valuable discussions on a business level.

 

 

 

Conclusion

 

By the end of this journey across 3 articles, you'll probably be saying, "Man, that's a lot of work just to make sure the ACLs work the way they're supposed to!" And you know what? You're absolutely right! But what is the alternative? How will you be able to prove that the users of your ServiceNow instance are only allowed to see and do what they are allowed to see and do? 

 

I am not claiming that the approaches I have presented are perfect or optimal and I know that there is still a lot of potential for optimization. However, if you know of an approach that achieves the goal with less effort, I look forward to exchanging ideas with you.

Version history
Last update:
‎12-17-2023 07:34 PM
Updated by:
Contributors