Resolving Ignition hasRole NPE with Failover User Sources

Daniel Price12 min read
B&R AutomationHMI / SCADATroubleshooting
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

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/Username carries 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:

  1. The name lookup misses failover-only users.
  2. 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
  1. 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.
  2. Open the Designer's diagnostics console or log view. Filter on the logger com.inductiveautomation.factorypmi.application.sqltags.project.ExpressionTagBinding.
  3. Read the Caused by line. The signature of this fault is Cannot invoke "com.inductiveautomation.ignition.common.user.User.getRoles()" because "user" is null, thrown at ClientFunctionFactory$HasRoleFunctionClient.getRoles. A different cause points to a different fault, such as a bad tag path or a syntax error.
  4. 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.
  5. 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 hasRole reads 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/Username changes. There is no polling cost and no refresh lag.
  • Exact role match. hasRole compares role names, not substrings, so Operator does 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?

  1. In the Designer's Tag Browser, open the Vision Client tags for the project. Select the existing expression tag (named testing in the failing case) or create a new expression client tag.
  2. Set the data type to Boolean so downstream bindings receive a true/false value.
  3. 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.
  4. 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.
  5. 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.
  6. Search any other client tags, bindings or scripts in the project that pass {[System]Client/User/Username} as the second argument to hasRole. 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?

  1. Launch the Designer as an internal-source user who holds the Operator role. Confirm the client tag reads true and the log shows no ExpressionTagBinding errors.
  2. Relaunch as an internal-source user without the role. Confirm the tag reads false.
  3. Relaunch as an AD-only user, authenticated through the failover, who holds the Operator role. Confirm the tag reads true. Confirm the log contains no Error retrieving user from gateway. entries during Designer-Startup. This is the case that failed before.
  4. Repeat with an AD-only user who does not hold the role. Confirm the tag reads false and the log is clean.
  5. 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.
  6. Leave the client open through several switches, then filter the client log on ExpressionTagBinding one final time. Zero NullPointerException entries tied to HasRoleFunctionClient.getRoles is 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.

Back to blog