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

Selva Arun
Mega Sage

 

 

Server-Side GlideRecord Returns Zero Rows for One User and Rows for Admin: the Before-Query Business Rule That Hides em_alert

It isn't ACLs. GlideRecord ignores those, and knowing that will send you the wrong way.

ServiceNow · Event Management + Security Incident Response · July 2026


Summary. A UI action queried em_alert with plain GlideRecord and reported "no linked alert found." It worked for an admin and failed for a network analyst. Same button, same record, and it kept failing after we moved it between scopes.

It wasn't ACLs. Server-side GlideRecord doesn't enforce ACLs, which is true, documented, and cost me three wrong theories.

It was a before-query business rule called "Hide Security Alerts" quietly appending classification!=1 to every query run by anyone without sn_si roles. Query business rules do apply to GlideRecord, they are user-dependent, Debug Security won't show them to you, and they never reproduce for an admin.

Includes the getEncodedQuery() line that finds this in about ten seconds, and an event plus Script Action fix that runs the work as system without granting anybody a role.

See also: CartJS errCode 42202 "Item is unavailable for Guest" and "reqJSON is null" from Inbound Email Actions. Separate write-up of two earlier bugs from the same project. Nothing here depends on it.

 

Why I'm writing this

Because the rule everybody knows is correct, and following it correctly walks you straight past the answer.

Any ServiceNow developer can tell you server-side GlideRecord bypasses ACLs. It's documented. It's the entire reason GlideRecordSecure exists. So when a GlideRecord query hands back different rows to different users, "it can't be permissions" is a sound deduction from a true premise. It's also wrong, because ACLs aren't the only thing on the platform that filters by user.

I spent hours on three theories, each one disproved by the instance, before I found the mechanism that had been sitting there the whole time in a table I never thought to query.

 

The symptom

An automation creates an alert and a catalog task. The network analyst who owns the task clicks a button that finds the linked alert and reopens it for the security team.

He clicked it and got:

No linked em_alert found for RITM RITM0012345.

I clicked the same button on the same record and it worked.

The message was honest and completely misleading. Nothing was missing. The alert was there, and an admin query against that exact RITM returned it. His session, running the same code against the same record, got nothing back.

 

Wrong turn 1: it's ACLs

He had no security-module roles, so this looked obvious enough that I nearly stopped there. Then the rule kicked in: GlideRecord doesn't enforce ACLs. ServiceNow's own developer blog says GlideRecordSecure enforces them and GlideRecord requires you to check canRead(), canWrite(), canDelete() and canCreate() yourself. The GlideRecord API reference even points you at "a class that performs the same functions as GlideRecord and enforces ACLs," which only makes sense if GlideRecord doesn't.

Our UI action used plain GlideRecord and never called canRead(). So ACLs shouldn't have been able to do this.

That reasoning was right, and I still held onto it far too long, because I had a test that seemed to back it up and a user who kept telling me otherwise.

 

Wrong turn 2: it's the scope

The UI action was in a scoped app and em_alert lives in Global. Cross-scope access, Restricted Caller Access, Application Access, all real mechanisms, all plausible, and we'd even seen an RCA warning in this instance earlier. So the theory had evidence behind it, which made it more attractive than it deserved to be.

I moved the UI action to Global and had him click again:

scope=sn_si         | user=analyst | rowCount=0
scope=rhino.global  | user=analyst | rowCount=0

Identical. So much for that.

 

Wrong turn 3: I trusted a test over a person

This is the one I'd take back. To test "as the analyst" I used GlideImpersonate in a background script:

var imp = new GlideImpersonate();
imp.impersonate(analystSysId);
var a = new GlideRecord('em_alert');
a.addQuery('node', ritmNumber);
a.query();
gs.print('rows: ' + a.getRowCount());     // returned 1

That doesn't reproduce a real interactive session. It gave me one row where his actual session got zero, and I built three theories on top of a test that was lying to me. Every time he said "no, it really does depend on the user," I believed my script over the person sitting in front of the actual failure.

When your test disagrees with what the user is experiencing, the test is the thing to doubt first. Particularly if it involves impersonation. Instrument the real code path instead and let the real person click the real button, which is what eventually produced the trace that mattered.

 

What it actually was

Query business rules run before the query executes and append conditions to it. Unlike ACLs they do affect GlideRecord, because by the time your query runs the restriction is already inside it. There's nothing to bypass. The rows never come back to be filtered out.

One query found it:

var q = new GlideRecord('sys_script');
q.addQuery('action_query', true);
q.addActiveQuery();
q.addQuery('collection', 'IN', 'em_alert,em_event,task');
q.query();
while (q.next())
    gs.print(q.getValue('name') + ' | isInteractive guard? ' +
             (q.getValue('script').indexOf('isInteractive') > -1 ? 'yes' : 'NO'));

Which turned up a rule called "Hide Security Alerts" on em_alert:

if (!gs.hasRole("sn_si.admin") && !gs.hasRole("sn_si.manager") &&
    !gs.hasRole("sn_si.analyst") && !gs.hasRole("sn_si.basic") &&
    !gs.hasRole("sn_si.ciso") && !gs.hasRole("sn_si.read") &&
    !gs.hasRole("sn_si.special_access") && !gs.hasRole("sn_si.read_alerts")) {
    current.addEncodedQuery('classification!=1');
}

Our automation creates alerts with classification = 1. He holds none of those roles. So every query he ran against em_alert got classification!=1 stapled onto it, including the server-side GlideRecord inside a UI action, and came back empty. In any scope, regardless of ACLs, working exactly as designed and reaching a good deal further than anyone intended.

Here's the detail that makes this so hard to spot. In the same instance, the equivalent rule on em_event has a gs.isInteractive() guard. The em_alert one doesn't. That guard is what normally keeps a query business rule confined to interactive sessions, so it filters list views and forms and leaves your server scripts alone. Without it the rule wanders into your automation. Check your own instance rather than assuming it looks like ours.

 

Why this is so easy to miss

  • An admin will never reproduce it. Every test you run passes. It only shows up for a real user with no roles, which usually means after go-live, which usually means someone else finds it.
  • Debug Security won't help. It traces ACLs. A query business rule is something else entirely and doesn't appear. Silence from Debug Security while rows are still missing is itself a clue, though I didn't read it that way at the time.
  • "GlideRecord bypasses ACLs" is true, and it points you away from the answer.
  • The two mechanisms produce opposite symptoms. ACL-blocked means you get the data anyway, which is a security problem. Query-BR-blocked means you don't get the data, which is a functional problem. Both depend on the user. Both vanish for admin. From the outside they look identical.

 

The one line that finds it

This is the whole diagnostic, and nobody reaches for it:

gr.query();
gs.info('ENCODED: ' + gr.getEncodedQuery());   // shows what query BRs appended

If more comes back than you put in, something rewrote your query, and the extra condition tells you what to go and look for. This should be the first thing you try when rows go missing per-user, not the thing you get to after three theories and half a night.

 

Fixing it

Two ways, and which one you can use isn't really a technical decision.

Grant the role

Add the narrowest role from that condition list to the group that needs to see the data. Simple, keeps everything synchronous, done in five minutes.

We couldn't. Non-security staff aren't permitted visibility of the security alert queue, which is a reasonable position and may well be yours too. Ask before you assume it's on the table, because the answer shapes everything else.

Or run the work as system

The button fires an event and a Script Action does the alert work. Script actions run as system, which sails past the role check, and the analyst never touches the alert at all.

// UI Action (or business rule): needs no em_alert access
gs.eventQueue('myapp.escalate', current, ritmNumber, gs.getUserID());
current.work_notes = 'Escalating to the security queue...';
current.update();
gs.addInfoMessage('Escalation submitted.');
// Script Action: runs as system
(function runAction(current, event) {
    var ritmNumber = event.parm1 + '';
    var actorSysId = event.parm2 + '';          // keep the real user for the audit trail

    var a = new GlideRecord('em_alert');
    a.addQuery('node', ritmNumber);
    a.addQuery('state', 'Rogue Access');
    a.query();
    gs.info('rowCount=' + a.getRowCount());     // leave this in
    if (!a.next()) return;

    a.state = 'Open';
    a.work_notes = 'Escalated by: ' + /* resolve actorSysId */ '';
    a.update();
})(current, event);

What we saw when this ran: gs.getUserName() returns system, every sn_si.* role comes back false, admin comes back true, and the em_alert query returns rows. Analysts need no alert access whatsoever and the work note on the alert still names whoever actually escalated it.

Caveat, and I'd rather say this than pretend. System holds none of the roles that query business rule checks for, so reading the script literally it should have been filtered too. It wasn't, and admin=true looks like the difference. Whether admin bypasses before-query business rules or system context skips them, I don't know, and I'm not going to invent a mechanism to round the story off. It works, and the rowCount line proves it every time it runs. If you know why, I'd like to hear it.

Things that will trip you up on the event route

  • A scoped business rule can't fire an event registered in Global. You get "Access to event '...' from scope '...' has been refused. The event is not defined," which is a slightly unhelpful way of saying the caller is in the wrong scope. Either set caller_access on the sysevent_register record to whatever your instance uses for cross-scope events, or move the caller. We moved ours to Global, since both tables were Global anyway and the scoped rule was the odd one out.
  • It's asynchronous, so the user gets "submitted" rather than "done." Given they can't see the alert either way that's honest enough, but write the message so it doesn't promise more than it delivers.
  • Pass the real user through as an event parameter or your audit trail says "system" for every escalation, which is exactly the accountability you were trying to keep.
  • Leave the rowCount logging in permanently. It's the line that'll tell you whether this still works after the next upgrade.
  • Say out loud in review that analysts can now trigger a state change on a record they can't read. It's one-way, it's restricted to fixed states, and it's fully attributed, so it's defensible. It's much better coming from you than from whoever spots it later.

 

What I took away from it

  1. An admin can't reproduce a permissions bug. If your feature has a role dimension, testing it as admin isn't testing it.
  2. When the test and the user disagree, doubt the test. I had a script insisting he could see the data while he sat there unable to see it, and I believed the script three times.
  3. The person holding the failure knows something you don't. Their observation is evidence, not an opinion that needs correcting.
  4. Instrument the real path. Four theories, none of them right. Ten minutes of logging inside the actual UI action with the actual user clicking, and there it was.
  5. "GlideRecord bypasses ACLs" is true and it's a trap. Correct, documented, and it points the wrong way.
  6. Debug Security only traces ACLs. Nothing in the tool plus rows still missing means you're looking at the wrong mechanism.
  7. An error message can be honest and still lie. "No linked alert found" was true from inside his session. Write messages that say what was checked and who as.
  8. Say what you've verified and what you're guessing. I still can't explain why system passes a role-checking query business rule, and "I don't know" is more use to the next person than a confident guess that sends them somewhere wrong.

Quick reference

Symptom Cause Fix
Server-side GlideRecord returns zero rows for one user, rows for admin, in every scope A before-query business rule appending conditions based on roles. Not ACLs, which GlideRecord ignores. getEncodedQuery() to confirm, then grant the role, or run the work as system via event plus Script Action.
Debug Security shows nothing, rows still missing Wrong mechanism. Debug Security only traces ACLs. Query sys_script where action_query = true on that table.
Your impersonation test disagrees with the real user GlideImpersonate in a background script isn't a real interactive session. Instrument the real UI action or business rule and let the real user trigger it.
"Access to event '...' from scope '...' has been refused. The event is not defined." A scoped caller firing an event registered in another scope. Set caller_access on the sysevent_register record, or move the caller into the event's scope.

References

  • ServiceNow Developer Blog, GlideRecord vs GlideRecordSecure: GlideRecordSecure enforces ACLs, GlideRecord makes you check yourself
  • ServiceNow community: query business rules restrict GlideRecord results, and can be guarded with gs.getSession().isInteractive() so they only apply to interactive sessions
  • ServiceNow docs: sysevent_register and script actions

Related article: CartJS errCode 42202 "Item is unavailable for Guest" and "reqJSON is null" from Inbound Email Actions. Two bugs in one code path, and why the DEV test proved nothing.

 

https://www.servicenow.com/community/developer-articles/cartjs-errcode-42202-quot-item-is-unavailabl...

Every error string, script excerpt and test result here came out of a live instance. Where I couldn't confirm a mechanism I've said so rather than guessed. Versions and configuration differ, so check yours.


Thank you,
Selva(Meena) Arun

 

 

 

Version history
Last update:
52m ago
Updated by:
Contributors