Changes to querying AD using LDAPHelper in 8.3

After upgrading to 8.3, one functionality that broke for me was my LDAP script that enable users to search for users and share resources with them (trends, messages, etc). This post helped shape my 8.1 code. The new LDAPHelper requirements are documented in the 8.3 javadoc - which now requires adding a context to to the LDAPHelper, the password needs to be a SecreteConfig type, and you need to define getReturnedAttributes function.

I have my 8.1 LDAP script updated to with with 8.3 using a plain text password but ideally this code could be modified to work with the secrets provider. Currently Im getting a type incompatibility between what the secrets library provides; com.inductiveautomation.ignition.common.secrets.PyPlaintext and what LDAPHelper needs: com.inductiveautomation.ignition.gateway.secrets.Plaintext. Any help here would be appreciated :slight_smile:

from com.inductiveautomation.ignition.gateway.authentication.impl import LDAPHelper
from com.inductiveautomation.ignition.common.util import LoggerEx
from com.inductiveautomation.ignition.gateway import IgnitionGateway
from com.inductiveautomation.ignition.gateway.secrets import Plaintext, SecretConfig

class mySearchHandlerClass(LDAPHelper.SearchHandler):
	"""Custom LDAP search handler class used to manage results from LDAP queries in Ignition.
	"""
	def __init__(self):
	    self.ctx = None
	    self.result = None
	    self.results = []
	    return
	def create(self, ctx, result):
	    self.ctx = ctx
	    self.result = result
	    return self.result  
	def getNoun(self):
	    return 'LDAP'	    
	def getReturnedAttributes(self):
		# New required for 8.3
		# Return None or [] to fetch all, or specify subset like:
		return ["sAMAccountName", "cn", "mail", "givenName", "sn"]
	    
class search(object):
	"""
	LDAP interface class that configures a secure connection and provides helper functions
	to query Active Directory for users, emails, and roles.
	
	Initializes connection settings, credentials, and logging for use with Ignition's LDAPHelper class.
	
	ex: 
		LDAP = LDAP.search() # create LDAP search class instance
		results = LDAP.getUserByName('Test name') # return results given search string
	"""
	builder = LoggerEx.newBuilder()
	search_handler = mySearchHandlerClass()
	logger = builder.build('LDAP_INTEGRATION')
	
	PRIMARY_DOMAIN_CONTROLLER  = "SERVERNAME" 
	DC_PORT_PRIMARY = "636" 
	ROLE_ID = "CN"
	ROLE_ATTR = "memberOf"
	USER_ATTR = "sAMAccountName"
	CONTACT_ATTR = ["mail","proxyAddresses","phone"]
	def __init__(self):

		context = IgnitionGateway.get() # New required for 8.3
		self.instance = LDAPHelper(context, self.logger)
		self.instance.setLdapHost(self.PRIMARY_DOMAIN_CONTROLLER)
		self.instance.setLdapPort(self.DC_PORT_PRIMARY)
		self.instance.setUseSSL(True)			
		self.instance.setProfileUsername("username@Domain.com")
		password = "plain text pass"
		systemEncryptionService = IgnitionGateway.getSystemEncryptionService(context)
		plaintext = Plaintext.fromString(password)
		ciphertext = systemEncryptionService.encryptToJson(plaintext)
		secret_config = SecretConfig.Embedded.embedded(ciphertext)
		self.instance.setProfilePassword(secret_config) # new for 8.3
		self.instance.setReadTimeout(60000)
		self.instance.setPageSize(1000)
	def getReadTimeout(self):
		"""
		Returns the configured LDAP read timeout value.
		
		Returns:
			int: Timeout duration in milliseconds.
		"""
		return self.instance.getReadTimeout()
	    
	def getUserByName(self, searchUser = ''):
		"""
		Searches for a user in Active Directory by matching their name or username.
		Supports inputs in the format of 'lastname,firstname', 'firstname lastname', or a simple username.
		Builds a flexible LDAP query accordingly and executes it.
		
		Args:
			searchUser (str): Input string to search (username or name format).
		
		Returns:
			list: LDAP search results matching the given user input.
		"""
		if ',' in searchUser: #account for "lastname,firstname" format
			names = searchUser.replace(' ','').split(',')
			query = "(&(objectClass=user)(!(objectClass=computer))(&(givenName=" + names[1] + "*)(sn=" + names[0] + "*)))"
		elif ' ' in searchUser: #account for "firstname lastname" format
		    		names = searchUser.split(' ')
	        		query = "(&(objectClass=user)(!(objectClass=computer))(&(givenName=" + names[0] + "*)(sn=" + names[1] + "*)))"
		else: 	
			query = "(&(objectClass=user)(!(objectClass=computer))(|(sAMAccountName=" + searchUser + "*)(givenName=" + searchUser + "*)(sn=" + searchUser + "*)))"
		attrs = []
		base = ['DC=DOMAIN,DC=PATH']
		results = self.instance.search(base, query, attrs,self.search_handler)
			#results2 = self.instance.parseBasePatternString(str(results))
		return results

Well, you're already well outside the bounds of supported behavior, so what can it hurt...

You can use FieldUtils (as done here) to read the inner com.inductiveautomation.ignition.gateway.secrets.Plaintext field from a PyPlainText. The field name is "wrapped".

As in, something like this:

        pySecret = system.security.readSecretValue("abc", "xyz")
        javaSecret = FieldUtils.readField(pySecret, "wrapped", True)
        self.instance.setProfilePassword(javaSecret) # new for 8.3

I used your code but changed the pySecret line to usesystem.secrets.readSecretValue()but still have the same error of:
TypeError: setProfilePassword(): 1st arg can't be coerced to com.inductiveautomation.ignition.gateway.secrets.SecretConfig

Ah, okay, then I think it's something like this:

        pySecret = system.secrets.encrypt(password)
        jsonSecret = TypeUtilities.pyToGson(pySecret)
        javaSecret = SecretConfig.embedded(jsonSecret)
        self.instance.setProfilePassword(javaSecret)

(plus from com.inductiveautomation.ignition.common import TypeUtilities at the top of the file)

Not quite. I see why you chose the TypeUtilities but a different but similar error appears about type issue.

Well, what's the exact error?

Failed making field 'java.lang.ref.Reference#referent' accessible; either increase its visibility or write a custom TypeAdapter for its declaring type.

It's working for me:

def runAction(self, event):
	from com.inductiveautomation.ignition.gateway.secrets import Plaintext, SecretConfig
	from com.inductiveautomation.ignition.common import TypeUtilities
	
	pySecret = system.secrets.encrypt("abc")
	jsonSecret = TypeUtilities.pyToGson(pySecret)
	javaSecret = SecretConfig.embedded(jsonSecret)
	system.perspective.print(javaSecret)
1 Like

Ahh yes, you are passing in a standard string as the password. My original script already does this :slight_smile: . Per the description of the TypeUtilities it suggest it might be able to convert py-types but it was not successful in converting the return from the gateway.secrets com.inductiveautomation.ignition.common.secrets.PyPlaintext type to what the LDAPHelper needs: com.inductiveautomation.ignition.gateway.secrets.Plaintext.

Thats the dream for me.

I'm confused or you are.
LDAPHelper accepts a SecretConfig, which it internally reads as a Plaintext. You don't need to pass one in to LDAPHelper.

EDIT:
Okay, I was confused, but now I understand.

You don't need system.secrets at all, because ultimately you want to create a SecretConfig that's a reference to an existing secret manager, instead of an embedded one. system.secrets is all about consuming secrets directly within scripting, it's not designed for "passing" secrets in to other (Java) APIs.

But the good news is what you want is actually extremely simple:

javaSecret = SecretConfig.referenced(provider, secretName)
self.instance.setProfilePassword(javaSecret)

Paul’s response is the answer. Use the internal secret provider to store your LDAP bind password. This will help reduce password sprawl from your scripts. For instance, if your secret provider name is MySecretProvider, and the LDAP password is stored as the named secret LDAP_PASS the following could be used:

self.instance.setProfilePassword(SecretConfig.referenced("MySecretProvider", "LDAP_PASS"))

I changed the setProfilePassword method in 8.3 to take a SecretConfig instance to keep the password from being passed around unprotected as long as possible.

Thats it! Thank you guys for the help. No more plain text password :smiley: