Where does the two-argument hasRole() request stop?
Follow the packet. The failing configuration looks like this:
- A Vision Client tag uses the expression
hasRole('Operator',{[System]Client/User/Username}). - The project's user source is an Ignition internal source.
- An Active Directory source is configured as its failover.
Users from the internal source evaluate correctly. Users who authenticate through the AD failover produce an ExpressionException on every evaluation. The stack trace shows each hop in order.
| Hop | Frame in the trace | What happens |
|---|---|---|
| 1. Client tag provider starts |
ProjectTagManager.startup → ExpressionTagBinding.startBinding
|
The client tag expression is instantiated during project load. In the trace this runs on the Designer-Startup thread. |
| 2. Tag reference subscribed |
TagListener.startup → SystemTagManager.subscribeAsync
|
{[System]Client/User/Username} is subscribed as a dependency of the expression. |
| 3. Username value arrives |
TagListener.tagChanged → ExpressionTagBinding.childInteractionUpdated
|
The expression executes with the current username. |
| 4. Function dispatch |
ClientDynamicDispatchFunction.execute → HasRoleFunctionClient.execute (line 272) |
The client-side hasRole implementation runs its two-argument branch. |
| 5. Gateway user retrieval | HasRoleFunctionClient.getRoles |
The client asks the gateway for a User object that matches the supplied username. |
| 6. Null dereference |
getRoles line 298 |
The gateway returns no user. The client calls User.getRoles() on null, which raises NullPointerException. |
| 7. Wrap and log |
getRoles line 302 |
The NPE is rethrown as ExpressionException: Error retrieving user from gateway. and logged by ExpressionTagBinding against the tag (here named testing). |
The request stops at hop 5. The client-to-gateway channel works: the same call succeeds for internal-source users. What fails is the gateway's resolution of a username into a user record. The client code then turns that empty result into an NPE instead of returning false.
The one-argument form, hasRole('Operator'), never reaches hop 5. It checks the roles of the session already logged into the client and makes no extra gateway call. That difference drives every workaround below.
Why does a user who logged in successfully come back null?
Two separate operations touch the user source, and they do not follow the same route.
-
Authentication (login). The gateway validates credentials against the project's user source. When the internal source cannot satisfy the login, the gateway falls through to the AD failover. The AD user gets in, and
[System]Client/User/Usernamecarries that user's name. -
Retrieval by name (two-argument
hasRole). The client asks the gateway to fetch a user record by username. In this setup that lookup comes back empty for AD-only users, so it does not walk the failover chain the way login does. Name-based retrieval resolves against the primary source, where the AD user does not exist.
Swapping the configuration confirms the mechanism. When AD becomes the project's default user source, the same AD user logs in and the same two-argument expression evaluates without error. The expression syntax, the tag reference and the user are unchanged. Only the source that the name lookup hits has changed.
This is two defects stacked on each other:
- The name lookup misses failover-only users.
- The client dereferences the null result instead of handling it.
You cannot fix either one from project configuration. The practical fix is to keep role checks for the logged-in user off the name-lookup path entirely. Report the NPE through Inductive Automation's official support channel, including the full stack trace.
How do you prove the lookup is the failure point?
Isolate one variable at a time. The expected results for this configuration are below.
| Project default user source | User logs in from | Expression | Result |
|---|---|---|---|
| Internal (AD as failover) | Internal | hasRole('Operator',{[System]Client/User/Username}) |
Correct value, no error |
| Internal (AD as failover) | AD (failover) | hasRole('Operator',{[System]Client/User/Username}) |
NPE, Error retrieving user from gateway.
|
| AD | AD | hasRole('Operator',{[System]Client/User/Username}) |
Correct value, no error |
| Internal (AD as failover) | Either | hasRole('Operator') |
Correct at evaluation; does not re-evaluate on user change |
- Open the project in the Designer as a user who exists only in the AD failover source. The error fires at project load because client tags start during
Designer-Startup. You do not need to open a window to reproduce it. - Open the Designer's diagnostics console or log view. Filter on the logger
com.inductiveautomation.factorypmi.application.sqltags.project.ExpressionTagBinding. - Read the
Caused byline. The signature of this fault isCannot invoke "com.inductiveautomation.ignition.common.user.User.getRoles()" because "user" is null, thrown atClientFunctionFactory$HasRoleFunctionClient.getRoles. A different cause points to a different fault, such as a bad tag path or a syntax error. - In the gateway's user source configuration, confirm the test user exists only in the AD source and not in the internal source. If the user exists in both, the lookup resolves from the primary and the fault will not reproduce.
- Repeat the login as an internal-source user. A clean log here confirms the client-gateway path is healthy and the failure is specific to failover-resolved users.
Which expressions avoid the gateway lookup?
Every viable option does two things:
- It calls the one-argument
hasRole('Operator'), which reads the current session's roles locally. - It gives the expression a reason to re-evaluate when the user changes.
Vision always has a logged-in user, so the username argument adds nothing to the role check itself. The username tag was only there as a refresh trigger. A bare hasRole('Operator') has no tag dependency, so after a user switch the client tag keeps the previous user's result.
Option A: username tag as a boolean gate
len({[System]Client/User/Username}) > 0 && hasRole('Operator')
The tag reference subscribes the expression to username changes, as in hop 2 of the trace. The len() > 0 test is always true for a logged-in Vision user, so the result equals hasRole('Operator'). A shorter form, {[System]Client/User/Username} && hasRole('Operator'), also works. It relies on coercing a string to a boolean, which is less explicit.
Option B: throwaway list element (third-party module)
asList({[System]Client/User/Username}, hasRole('Operator'))[1]
This needs the third-party Integration Toolkit module, which supplies asList(). Index [1] returns the hasRole result. The username is evaluated only to create the dependency.
Option C: if() with duplicate branches
if(len({[System]Client/User/Username}) > 0, hasRole('Operator'), hasRole('Operator'))
This uses native functions only. Both branches return the same value, so the condition only serves to pull in the tag dependency.
Option D: timed re-evaluation
now() && hasRole('Operator')
now() with no argument forces the expression to re-execute on a polling cycle, every 1 second. The one-argument hasRole makes no extra gateway call, so each cycle returns almost instantly. You still pay a periodic evaluation on every open client, and a user switch shows up with up to one poll period of lag.
Option E: parse the roles system tag
indexOf('Operator',coalesce({[System]Client/User/RolesString},'')) > -1
This subscribes to [System]Client/User/RolesString and searches it as text. It avoids hasRole entirely. It has matching problems, covered in the section on RolesString below.
Option F: make AD the project's default user source
This leaves the two-argument expression unchanged and moves the lookup onto the source that actually holds the AD users. It works for AD users. It also changes which source authenticates first for every user of the project. Internal-source users now depend on the failover path, and the null lookup now hits them instead.
How do the options compare?
| Option | Gateway name lookup per evaluation | Refreshes on user change | Exact role-name match | Extra module | Evaluation load | Safe for failover users |
|---|---|---|---|---|---|---|
Original two-arg hasRole
|
Yes | Yes | Yes | No | Event-driven + round trip | No (NPE) |
Bare hasRole('Operator')
|
No | No | Yes | No | Once | Yes |
A: len(...) > 0 && hasRole(...)
|
No | Yes | Yes | No | Event-driven | Yes |
B: asList(...)[1]
|
No | Yes | Yes | Integration Toolkit | Event-driven | Yes |
C: if() duplicate branches |
No | Yes | Yes | No | Event-driven | Yes |
D: now() && hasRole(...)
|
No | Yes, up to 1 s lag | Yes | No | Every 1 s per client | Yes |
E: RolesString text search |
No | Yes | No (substring) | No | Event-driven | Yes |
| F: AD as default source | Yes | Yes | Yes | No | Event-driven + round trip | Moves the failure to internal users |
Which expression should you deploy?
Deploy Option A:
len({[System]Client/User/Username}) > 0 && hasRole('Operator')
It wins on every criterion that matters here:
-
No gateway name lookup. The one-argument
hasRolereads the session's own roles. The failover lookup defect is never exercised, whichever source authenticated the user. -
Event-driven refresh. The expression re-evaluates only when
[System]Client/User/Usernamechanges. There is no polling cost and no refresh lag. -
Exact role match.
hasRolecompares role names, not substrings, soOperatordoes not match a role that merely contains that text. - Native functions only. Nothing depends on a module being installed on every gateway the project is deployed to.
-
Explicit boolean on both sides of
&&. The result does not depend on how a string coerces to a boolean.
Option C is equivalent and fine if your team prefers the if() form. Use Option D only where no tag dependency is available. Leave Option F alone: it relocates the null lookup to the other population of users.
The short-circuit on && does not weaken the refresh. The trace shows tag references are subscribed when the expression starts up (BoundTagExpression.startup → TagListener.startup), before any evaluation. The subscription to the username tag therefore exists whether or not the left operand short-circuits.
How do you build the client tag?
- In the Designer's Tag Browser, open the Vision Client tags for the project. Select the existing expression tag (named
testingin the failing case) or create a new expression client tag. - Set the data type to Boolean so downstream bindings receive a true/false value.
- Replace the expression with
len({[System]Client/User/Username}) > 0 && hasRole('Operator'). Use the tag browser button in the expression editor to insert the system tag path, so the path matches exactly. - Keep the role name in one place. If a later change needs a different role, edit only the string literal in this tag. Every window bound to the client tag picks it up, which was the point of centralizing the check.
- Save the project. Close and reopen the Designer so the client tag provider restarts and runs the startup evaluation path that originally threw the error.
- Search any other client tags, bindings or scripts in the project that pass
{[System]Client/User/Username}as the second argument tohasRole. Convert them the same way. Every one of them fails for failover-source users.
What does the RolesString approach get wrong?
Option E looks attractive because it needs no function-behavior tricks, but it has three problems.
Argument order. The Ignition expression function reference defines the signature as indexOf(string, substring): it searches for the second argument inside the first. The expression indexOf('Operator',coalesce({[System]Client/User/RolesString},'')) therefore searches for the whole roles string inside the literal 'Operator', which is backwards. Two consequences follow:
- It returns a match only when the roles string is itself a piece of the word "Operator".
- An empty roles string is found at position 0. The
coalesce(..., '')guard supplies exactly that empty string when the tag is null at client startup, so the check passes for a user with no roles.
Check the argument order against the indexOf entry in your version's expression function reference before relying on it.
Substring collisions. Even with the arguments in the right order, a plain substring search on Operator matches any role whose name contains that text, not just the Operator role. To make the match exact, wrap both the haystack and the needle in the list delimiter. Assuming RolesString is comma-separated with no spaces:
indexOf(',' + coalesce({[System]Client/User/RolesString}, '') + ',', ',Operator,') > -1
Read the live value of [System]Client/User/RolesString in the Tag Browser while logged in as a multi-role user. Confirm the delimiter and whether spaces follow it before you commit to that pattern.
Null at startup. System client tags can be null for a moment while the client tag provider starts. The coalesce() wrapper is required. Choose its default so that a null roles string evaluates to false, which the corrected expression above does.
Given these traps, use RolesString for display or logging, not as the primary authorization bit.
How do you verify the fix across both user sources?
- Launch the Designer as an internal-source user who holds the
Operatorrole. Confirm the client tag readstrueand the log shows noExpressionTagBindingerrors. - Relaunch as an internal-source user without the role. Confirm the tag reads
false. - Relaunch as an AD-only user, authenticated through the failover, who holds the
Operatorrole. Confirm the tag readstrue. Confirm the log contains noError retrieving user from gateway.entries duringDesigner-Startup. This is the case that failed before. - Repeat with an AD-only user who does not hold the role. Confirm the tag reads
falseand the log is clean. - Launch a Vision client and switch users in the running client, from a user without the role to one with it and back. Watch the client tag in the diagnostics tag view. It must change state immediately on each switch. That confirms the username-tag dependency drives re-evaluation.
- Leave the client open through several switches, then filter the client log on
ExpressionTagBindingone final time. ZeroNullPointerExceptionentries tied toHasRoleFunctionClient.getRolesis the pass condition.
FAQ
What happens if hasRole() gets a username that only exists in the failover user source?
In a Vision client the two-argument call asks the gateway to retrieve that user by name, and the lookup returns null. The client then throws NullPointerException at HasRoleFunctionClient.getRoles, wrapped as ExpressionException: Error retrieving user from gateway., and the client tag never gets a valid value.
What happens if I use hasRole('Operator') alone in a Vision client tag?
It evaluates correctly against the logged-in session's roles with no extra gateway call. It has no tag dependency, though, so after a user switch it keeps the previous user's result. Add len({[System]Client/User/Username}) > 0 && in front of it to force re-evaluation.
What happens if I add now() to force hasRole() to refresh?
now() && hasRole('Operator') re-executes on a 1 second poll on every open client. The result is correct, but it costs a periodic evaluation per client and lags a user switch by up to one poll period. A username-tag dependency refreshes only on change.
What happens if [System]Client/User/RolesString is null when the client starts?
Without coalesce() the expression errors. With coalesce(..., '') and the arguments reversed, indexOf('Operator', '') returns 0 and the check passes for a user with no roles. Put the roles string first and the delimited role name second, and confirm a null value evaluates to false.
Why does making AD the default user source stop the hasRole error?
The name-based user retrieval then resolves against the source that actually holds the AD users, so the lookup returns a user instead of null. It also moves internal-source users onto the failover path, where the same null lookup can hit them. The one-argument hasRole with a username-tag dependency is the safer fix.