Restricting Task visibility to Assignment Group Members

JayAdmin_16
Mega Sage

Hello, 

We have a business requirement to restrict the incident table and a custom table “Service Cases” (which functions similarly to incidents) so that if a ticket is assigned to a particular assignment group, only members of that group can view it. Users outside the assignment group should not be able to see tickets assigned to other teams.

Currently, we have ~200 active assignment groups handling Incidents and Service Cases, so I'm looking for guidance on best practices for implementing this at scale using ACLs (I'm open to suggestions)

Any implementation advice, testing solutions or gotchas from those who’ve tackled similar setups would be greatly appreciated. 

2 REPLIES 2

Pavan Srivastav
ServiceNow Employee

Hello Jay,

 

This is a well-known but genuinely complex requirement in ServiceNow. Let me give you a thorough architecture guide covering the right approach, the gotchas, and how to test at scale.


First — Understand What You Are Actually Building

This is a row-level security requirement. You are not restricting fields, you are restricting which records a user can see at all. In ServiceNow, this is handled by table-level ACLs with condition scripts, not field-level ACLs.

The core logic you need is:

User can read a record IF:
  - They are in the assignment group on that record
  - OR they have an elevated role (admin, manager, helpdesk supervisor etc.)
  - OR the record has no assignment group set

Architecture Decision: ACL vs Query Business Rule

Before writing a single ACL, you need to choose your enforcement mechanism. There are two viable approaches at your scale:

Approach How it works Best for
ACL with condition script Evaluated per-record when accessed Strict security, audit compliance
Query Business Rule Appends filter to every GlideRecord query Performance at scale, reporting
Both combined QBR restricts list, ACL restricts direct access Maximum security

Recommendation for 200 groups at scale: use both. The QBR handles list performance (prevents loading thousands of records then filtering), and the ACL handles direct URL access attempts.


Implementation

Part 1 — The ACL (Read restriction on record access)

Navigate to: System Security → Access Controls → New

Create this ACL for the Incident table first, then repeat for your Service Cases table:

Field Value
Type record
Operation read
Name (Table) incident
Roles (leave empty — condition script handles it)
Active true

Condition Script:

// -------------------------------------------------------
// Row-level security: user must be in the assignment group
// -------------------------------------------------------

// Always grant access to admins and privileged roles
// Adjust these roles to match your instance's elevated roles
var privilegedRoles = ['admin', 'itil_admin', 'incident_manager', 'global_viewer'];
for (var i = 0; i < privilegedRoles.length; i++) {
    if (gs.hasRole(privilegedRoles[i])) {
        answer = true;
        return;
    }
}

// If ticket has no assignment group, everyone can see it
// Adjust this default behaviour to match your requirement
var assignmentGroup = current.getValue('assignment_group');
if (!assignmentGroup) {
    answer = true;
    return;
}

// Check if current user is a member of the assignment group
var memberCheck = new GlideRecord('sys_user_grmember');
memberCheck.addQuery('group', assignmentGroup);
memberCheck.addQuery('user', gs.getUserID());
memberCheck.setLimit(1);
memberCheck.query();

answer = memberCheck.hasNext();

⚠️ Critical gotcha: ACL condition scripts execute per record per page load. With 200 groups and potentially thousands of records in a list view, this GlideRecord query inside the ACL will fire for every single row. This is why the Query Business Rule below is essential — it pre-filters the list before the ACL even runs.


Part 2 — Query Business Rule (Performance Layer)

This appends a condition to every query against the incident table so only relevant records are returned in the first place, dramatically reducing ACL evaluation overhead.

Navigate to: System Definition → Business Rules → New

Field Value
Name Restrict Incident Visibility by Group Membership
Table Incident [incident]
When before
Order 100
Query checked
Insert
Update
Delete

Script:

(function executeRule(current, previous) {

    // Skip restriction for privileged roles
    var privilegedRoles = ['admin', 'itil_admin', 'incident_manager', 'global_viewer'];
    for (var i = 0; i < privilegedRoles.length; i++) {
        if (gs.hasRole(privilegedRoles[i])) {
            return; // no filter applied — see everything
        }
    }

    // Get all groups the current user belongs to
    var userGroups = [];
    var groupMember = new GlideRecord('sys_user_grmember');
    groupMember.addQuery('user', gs.getUserID());
    groupMember.query();
    while (groupMember.next()) {
        userGroups.push(groupMember.getValue('group'));
    }

    // Build the query condition:
    // Show records where:
    //   a) assignment group is one the user belongs to
    //   b) OR assignment group is empty (unassigned tickets)
    var qc = current.addQuery('assignment_group', 'IN', userGroups.join(','));
    qc.addOrCondition('assignment_group', 'ISEMPTY', '');

})(current, previous);

Duplicate this Business Rule for your Service Cases table, changing only the table name.


Part 3 — Caching User Groups (Critical for Performance at Scale)

The QBR above queries sys_user_grmember on every single query. With heavy usage this adds up. Cache the user's group list in their session:

(function executeRule(current, previous) {

    var privilegedRoles = ['admin', 'itil_admin', 'incident_manager', 'global_viewer'];
    for (var i = 0; i < privilegedRoles.length; i++) {
        if (gs.hasRole(privilegedRoles[i])) {
            return;
        }
    }

    // Check session cache first
    var session       = gs.getSession();
    var cachedGroups  = session.getClientData('user_assignment_groups');
    var userGroups    = [];

    if (cachedGroups) {
        // Use cached value — avoids DB hit on every query
        userGroups = cachedGroups.split(',');
    } else {
        // Build fresh and cache for this session
        var groupMember = new GlideRecord('sys_user_grmember');
        groupMember.addQuery('user', gs.getUserID());
        groupMember.query();
        while (groupMember.next()) {
            userGroups.push(groupMember.getValue('group'));
        }
        // Cache as comma-separated string
        session.putClientData('user_assignment_groups', userGroups.join(','));
    }

    if (userGroups.length === 0) {
        // User is in no groups — show only unassigned tickets
        current.addQuery('assignment_group', 'ISEMPTY', '');
        return;
    }

    var qc = current.addQuery('assignment_group', 'IN', userGroups.join(','));
    qc.addOrCondition('assignment_group', 'ISEMPTY', '');

})(current, previous);

⚠️ Session cache gotcha: If a user's group membership changes mid-session, the cache won't reflect it until they log out and back in. For most environments this is acceptable. If it is not acceptable for yours, skip the caching and accept the extra DB queries.


Gotchas at Scale (200 Groups)

Gotcha 1 — Reporting Breaks

Your reporting users (managers, analysts) will suddenly see empty reports if they are not members of the groups. You need a reporting role that bypasses the restriction:

// Add to both the ACL and QBR privileged roles list
var privilegedRoles = ['admin', 'itil_admin', 'incident_manager', 
                       'global_viewer', 'report_viewer']; // ← add this

Gotcha 2 — Assignment Group Field on New Records

When an agent creates a new incident and selects an assignment group, they are setting the very field that controls their own visibility. If they save it to a group they are not in, they will immediately lose sight of the record. Consider:

// Business Rule — warn if user is assigning to a group they don't belong to
// On Incident, Before Insert/Update
var assignedGroup = current.getValue('assignment_group');
if (assignedGroup) {
    var memberCheck = new GlideRecord('sys_user_grmember');
    memberCheck.addQuery('group', assignedGroup);
    memberCheck.addQuery('user', gs.getUserID());
    memberCheck.setLimit(1);
    memberCheck.query();
    if (!memberCheck.hasNext() && !gs.hasRole('admin')) {
        gs.addInfoMessage('Note: You are assigning this ticket to a group ' +
                         'you are not a member of. You will lose visibility ' +
                         'of this record after saving.');
    }
}

Gotcha 3 — Integrations and Background Scripts

Any integration user or automated process querying incidents will also be restricted by the QBR. Ensure your integration service accounts either:

  • Have a privileged role that bypasses the restriction
  • Are explicitly excluded by user sys_id in the QBR
// Exclude known integration accounts
var excludedUsers = ['integration_user_sysid_1', 'integration_user_sysid_2'];
if (excludedUsers.indexOf(gs.getUserID()) !== -1) {
    return;
}

Gotcha 4 — Related Lists and Dot-Walking

Child records that reference incidents (tasks, approvals, attachments) do not inherit this restriction automatically. If a user can access a child record and dot-walk to the parent incident, they may see data they should not. Evaluate whether related tables need the same treatment.

Gotcha 5 — Impersonation Testing Gives False Confidence

Always test with a real user login in an incognito window, not just impersonation. Some security evaluations behave differently under impersonation.


Testing Strategy at Scale

Step 1 — Script-Based Validation

Run this in Scripts - Background to verify the restriction logic before deploying:

// Test as a specific user — replace with a test user sys_id
var testUserId = 'YOUR_TEST_USER_SYS_ID';

// Get their groups
var groups = [];
var gm = new GlideRecord('sys_user_grmember');
gm.addQuery('user', testUserId);
gm.query();
while (gm.next()) {
    groups.push(gm.getValue('group'));
}
gs.info('User belongs to ' + groups.length + ' groups: ' + groups.join(', '));

// Count incidents they SHOULD see
var inc = new GlideRecord('incident');
inc.addQuery('assignment_group', 'IN', groups.join(','));
inc.query();
gs.info('Incidents they should see: ' + inc.getRowCount());

// Count total open incidents for comparison
var total = new GlideRecord('incident');
total.addActiveQuery();
total.query();
gs.info('Total incidents in system: ' + total.getRowCount());

Step 2 — ATF (Automated Test Framework) Test

Create an ATF test that:

  1. Impersonates a user in Group A
  2. Attempts to retrieve an incident assigned to Group B via GlideAjax
  3. Asserts the record is not returned
  4. Attempts to retrieve an incident assigned to Group A
  5. Asserts the record IS returned

Step 3 — Staged Rollout

Do not enable this for all 200 groups at once. Use a Group Attribute to flag which groups are enrolled in the restriction:

// On the sys_user_group table add a boolean field: u_restricted_visibility

// In your QBR — only apply restriction if the group has the flag set
// This lets you roll out group by group

// Modified QBR condition — only filter groups with restriction enabled
var restrictedGroups = [];
var allGroups        = [];

var groupMember = new GlideRecord('sys_user_grmember');
groupMember.addQuery('user', gs.getUserID());
groupMember.query();
while (groupMember.next()) {
    allGroups.push(groupMember.getValue('group'));
}

// Get which of those groups have restriction enabled
var groupGr = new GlideRecord('sys_user_group');
groupGr.addQuery('sys_id', 'IN', allGroups.join(','));
groupGr.addQuery('u_restricted_visibility', true);
groupGr.query();
while (groupGr.next()) {
    restrictedGroups.push(groupGr.getUniqueValue());
}

// Only apply the filter if at least one restricted group exists
if (restrictedGroups.length > 0) {
    var qc = current.addQuery('assignment_group', 'IN', restrictedGroups.join(','));
    qc.addOrCondition('assignment_group', 'ISEMPTY', '');
    // Also always show tickets in unrestricted groups the user belongs to
    for (var k = 0; k < allGroups.length; k++) {
        if (restrictedGroups.indexOf(allGroups[k]) === -1) {
            qc.addOrCondition('assignment_group', allGroups[k]);
        }
    }
}

Summary

Layer Component Purpose
Performance Query Business Rule Pre-filters lists before ACL fires
Security ACL condition script Blocks direct record access
Performance Session-cached group list Avoids repeated DB hits
Safety Privileged role bypass Admins, managers, reporting users
Rollout Group-level flag Staged enablement across 200 groups
Testing ATF + Scripts-Background Validates before production

The QBR does the heavy lifting at scale, the ACL provides the hard security boundary, and the staged rollout flag lets you enable group by group with zero risk of a big-bang failure across all 200 groups simultaneously.

MAHAMKALI
Giga Guru

Hi @JayAdmin_16 
Use Query Business Rule on both Incident and custom table. 
In the Query BR - use the following script:

var user = gs.getUser();
var groups = user.getMyGroups(); // this will fetch logged in user group sys ids
current.addQuery('assignment_group','IN',groups);


Note: Handling Query Business Rule is quite problematic, it impacts to everyone who has the access to that table. Give proper conditions or role conditions to make it suitable for your requirement.

 If my response helped, please mark it as correct.

Regards,
Kali