We're reclaiming inactive PDIs to keep them available for active builders. Learn what's changing, who's affected, and how to protect your work. Read More

Why does gs.hasRole() behave differently in Background Script vs Scripted REST ACL?

rohan_agarwal
Tera Contributor

Hello ServiceNow Community 👋,

I've come across an interesting platform behavior while securing a Scripted REST API, and I'm curious to know whether this is expected by design or if there's something I'm overlooking.

I've spent some time debugging this and have been able to reproduce the behavior consistently. Before opening a support case, I wanted to check with the community to see if anyone has experienced something similar or can explain what's happening behind the scenes.

The Scenario

I created a custom role with the following details:

  • Role Name: Disaster Intake Agent

  • Role Label: Disaster Intake Agent

This role is assigned directly to the integration user that authenticates my inbound Scripted REST API.


Experiment #1 - Background Script

To verify the role assignment, I executed the following Background Script:

var gr = new GlideRecord('sys_user_role');
gr.addQuery('name', 'Disaster Intake Agent');
gr.query();

while (gr.next()) {

    gs.info("Role Name  : " + gr.name);
    gs.info("Role Label : " + gr.getDisplayValue());

    gs.info("gs.hasRole('Disaster Intake Agent') : " +
        gs.hasRole("Disaster Intake Agent"));

    gs.info("gs.hasRole('disaster_intake_agent') : " +
        gs.hasRole("disaster_intake_agent"));
}

Result

Both statements return true.

gs.hasRole("Disaster Intake Agent")  -> true
gs.hasRole("disaster_intake_agent")  -> true

At this point everything looked perfectly normal.


Experiment #2 - Scripted REST External Default ACL

Next, I used exactly the same role checks inside the Scripted REST External Default ACL.

Test A

answer = !gs.hasRole("Disaster Intake Agent");

This evaluates to true, meaning:

gs.hasRole("Disaster Intake Agent")

returns false in the ACL.


Test B

answer = !gs.hasRole("disaster_intake_agent");

This evaluates to false, meaning:

gs.hasRole("disaster_intake_agent")

returns true.


Here's What Confused Me 🤔

For the same authenticated integration user, I get two different behaviors depending on where the code executes.

Execution ContextDisaster Intake Agentdisaster_intake_agent
Background ScriptTrueTrue
Scripted REST External Default ACLFalseTrue

The user, the assigned role, and the API request are all the same.

The only thing that changes is the execution context.


What I've Already Verified

The integration user is authenticated successfully.

The custom role is assigned directly to the user.

The behavior is reproducible every time.

Using the underscore version (disaster_intake_agent) works correctly inside the ACL.


Questions for the Experts

I'm trying to understand the platform internals rather than just finding a workaround.

  1. Is this expected behavior for the Scripted REST External Default ACL?

  2. Does the ACL engine resolve roles differently from other server-side execution contexts?

  3. Why does gs.hasRole() successfully recognize both formats in a Background Script but only the internal role identifier inside the ACL?

  4. Is the ACL evaluation engine bypassing any role alias or display-name resolution?

  5. Is this behavior documented anywhere?

  6. Has anyone else observed this while working with Scripted REST APIs or external integrations?

  7. Should we always use the internal role name (underscore format) when calling gs.hasRole() inside ACLs, even if other server-side scripts accept both formats?


My Goal

I'm not looking for an alternative implementation because using the underscore version solves the problem.

What I'm really interested in is understanding why the same ServiceNow API (gs.hasRole()) behaves differently depending on where it's executed.

Is this:

  • Expected platform behavior?

  • A security optimization within the ACL engine?

  • A known limitation?

  • Or possibly something worth reporting?

I'd really appreciate insights from anyone who has explored the internals of ACL evaluation or role resolution.

Looking forward to hearing your thoughts. Thanks in advance! 🙏

#ServiceNow#ScriptedRESTAPI#AccessControl#ACL#gsHasRole#RESTAPI#Security#Authentication#Scripting#GlideSystem#Integration#Developer#ServerSideScripting

2 REPLIES 2

Vikram Reddy
Tera Guru

Hi @rohan_agarwal ,

 

I don't think you've found an inconsistency in the ACL engine's role resolution. I think you've accidentally tested two different identities, and the second one (the ACL) is the only one that was actually checking anything.

Here's the mechanism.

 

gs.hasRole() has a documented "admin override": it returns true for literally any string you pass it, valid role or not, as long as the currently executing user has the admin role. This isn't specific to your custom role, it happens for made-up role names too. It's a known, widely discussed platform behavior, not a bug: KB3067343, "ACL Allows Admin Access When Using gs.hasRole(<custom_role>) in Script", and community threads like "gs.getUser().hasRole('anytext') always returns true" confirm it.

 

Now look at where each experiment actually ran:

  • Experiment #1, Background Script: the "Scripts - Background" module is admin-only by default (it requires the admin role, or security_admin if elevation is enforced on your instance). So when you ran that script, it executed as you, the developer logged into the platform, not as the integration user. Because you were an admin in that session, gs.hasRole() gave you true for both strings for free. It wasn't validating that "Disaster Intake Agent" is a legitimate role identifier, it was demonstrating the admin bypass. You'd have gotten true even if you'd typed gs.hasRole("banana").
  • Experiment #2, Scripted REST External Default ACL: this runs in the context of the actual authenticated request, meaning the real integration user record, which only carries your custom role and not admin. With no admin override in play, gs.hasRole() falls back to what it actually does under the hood: an exact, case-sensitive string comparison against the role's stored name value in sys_user_role. There is no label/display-value resolution and no alias lookup at any point, in any context. Whatever string is literally sitting in that role's Name field is the only string that will ever match, and in your case that's disaster_intake_agent.

So to answer your list directly:

  1. Yes, this is expected, once you know which identity each context is running as.
  2. No, the ACL engine doesn't resolve roles differently. It's the same gs.hasRole() implementation everywhere, doing the same exact-match check against sys_user_role.name.
  3. It "recognized" both formats in the background script only because you were admin in that session, not because the platform matched a display name to an internal name.
  4. There's no alias or display-name resolution being bypassed, because there was never any alias resolution to begin with. gs.hasRole() never matches on label/display value, in any execution context.
  5. Yes, the admin-override behavior is documented (see the KB above), even if it's easy to miss until it produces a confusing false-positive like this one.
  6. This is a very common gotcha, mainly because Background Scripts are almost always run by an admin, which quietly hides the fact that hasRole() isn't doing real validation in that session.
  7. Yes. Always hardcode the exact value of the role's name field (what you'd see querying sys_user_role directly, not the display label) in any gs.hasRole() check, in ACLs, Business Rules, Script Includes, everywhere. Treat the label as cosmetic and irrelevant to the API.

One practical takeaway for testing this kind of thing going forward: don't trust a Background Script run under your own session to validate role logic for another account. Either impersonate the integration user first (Session > Impersonate User, or gs.getSession().impersonate()) before running the check, or inspect gs.getSession().getRoles() for that impersonated session to see the exact role names it's actually carrying. There's no server-side hasRoleExactly() equivalent to strip out the admin bypass automatically (that only exists client-side as g_user.hasRoleExactly()), so impersonation or a manual role-array check against getRoles() is the closest you'll get to a clean test

 

Thank you,
Vikram Karety
Octigo Solutions INC

Hi @Vikram Reddy ,

Thank you for the detailed explanation. I really appreciate you taking the time to break down the execution contexts and the admin override behavior of gs.hasRole().

You're absolutely right that Background Scripts can be misleading because they typically execute under an admin session, and the documented admin override explains why both role strings returned true there. That was an important detail I hadn't fully considered.

After reading your response, I impersonated the integration user and repeated the test. The behavior was consistent with what you described:

  • gs.hasRole("disaster_intake_agent") → true

  • gs.hasRole("Disaster Intake Agent") → false

This confirms that gs.hasRole() is matching against sys_user_role.name rather than the role label/display value, and that there isn't a special role resolution mechanism in the Scripted REST External Default ACL.

My original assumption was that gs.hasRole() might internally resolve the display value as well, especially since the Background Script appeared to accept both formats. Your explanation makes it clear that this was simply the admin override masking the actual behavior.

The suggestion to validate role checks by impersonating the target user instead of relying on an admin Background Script is particularly useful. That's a good takeaway for future testing.

Thanks again for the thorough explanation and for pointing me in the right direction.