We've shown you how the schema works in Chapter 4, and how to design extensions in Chapter 12. Now let's take a look at how to query and manipulate the schema using ADSI.
In addition to being able to query and update schema objects as you can any other type of object with the IADs interface, there are two main schema-specific interfaces available: IADsClass and IADsProperty. Each of these interfaces has a variety of useful methods and property methods to allow you to set mandatory properties for classes, optional properties for classes, maximum values for attributes, and so on. If you look at these interfaces, you will see that they are very simple to understand.
First, let's compare accessing and modifying the schema by using the attributes we are interested directly in versus using the IADsClass and IADsProperty methods. This first code section uses attributes directly:
objAttribute.Put "isSingleValued", False objAttribute.Put "attributeId", "184.108.40.206.4.1.9999220.127.116.11" arrMustContain = objSchemaClass.Get("mustContain") arrMayContain = objSchemaClass.Get("mayContain")
Now we will use the ADSI schema interfaces to do the same thing:
objAttribute.MultiValued = True objAttribute.OID = "18.104.22.168.4.1.999922.214.171.124" arrMustContain = objSchemaClass.MandatoryProperties arrMayContain = objSchemaClass.OptionalProperties
This makes use of IADsProperty::MultiValued, IADsProperty::OID, IADsClass::MandatoryProperties, and IADsClass::OptionalProperties. As you can see, it's not hard to convert the code. However, we feel that including code that directly modifies the properties themselves gives you some idea of what you are actually changing and helps you to refer back to the definitions presented in Chapter 4.
More details on these three interfaces can be found in the MSDN Library (http://msdn.microsoft.com/library/) under Networking and Directory Services Active Directory, ADSI and Directory Services SDK Documentation Directory Services Active Directory Service Interfaces ADSI Reference ADSI Interfaces Schema Interfaces.
We will create an example attribute called Mycorp-LanguagesSpoken. It is to be a multivalued, indexed attribute that can hold an array of case-sensitive strings of between 1 and 50 characters. The name is prefixed with Mycorp so it is obvious that Mycorp created the attribute.
Mycorp's Schema Manager has decided that the OID for this attribute is to be 126.96.36.199.4.1.9999188.8.131.52. This is worked out as follows:
Mycorp's root OID namespace is 184.108.40.206.4.1.999999.
Mycorp's new attributes use 220.127.116.11.4.1.999999.1.1.xxxx (where xxxx increments from 1).
Mycorp's new classes use 18.104.22.168.4.1.999999.1.2.xxxx (where xxxx increments from 1).
The attribute is to be the 28th new attribute created by Mycorp.
The code to create such an attribute is included in Example 24-1.
Dim objAttribute Dim objSchemaContainer Set objSchemaContainer = _ GetObject("LDAP://cn=Schema,cn=Configuration,dc=mycorp,dc=com") Set objAttribute = objSchemaContainer.Create("attributeSchema", _ "cn=Mycorp-LanguagesSpoken") 'Write out mandatory attributes objAttribute.Put "attributeId", "22.214.171.124.4.1.9999126.96.36.199" objAttribute.Put "oMSyntax", 20 objAttribute.Put "attributeSyntax", "188.8.131.52" objAttribute.Put "isSingleValued", False objAttribute.Put "lDAPDisplayName", "myCorp-LanguagesSpoken" 'Create the attribute objAttribute.SetInfo 'Write out optional attributes objAttribute.GetInfo objAttribute.Description = "Indicates the languages that " & _ "a user speaks" objAttribute.Put "rangeLower", 1 objAttribute.Put "rangeUpper", 50 objAttribute.Put "searchFlags", True objAttribute.SetInfo
That was fairly straightforward. Remember to change the attributeID to correspond to your own OID namespace if you use the code. Figure 24-1 shows the newly created attribute using the Schema Manager snap-in.
We will now create a new class called Mycorp-FinanceUser. It is to be a structural class so that others can create instances of it within containers. It will have the new Mycorp-LanguagesSpoken as an attribute, as well as inheriting from the User class. The OID for the class will be 184.108.40.206.4.1.9999220.127.116.11, representing the fourth class we've created under our base OID. Example 24-2 contains the code to create the class.
Const ADS_PROPERTY_APPEND = 3 Dim objAttribute Dim objSchemaContainer Set objSchemaContainer = _ GetObject("LDAP://cn=Schema,cn=Configuration,dc=mycorp,dc=com") Set objClass = objSchemaContainer.Create("classSchema", _ "cn=Mycorp-FinanceUser") 'Write out mandatory attributes objClass.Put "cn", "Mycorp-FinanceUser" objClass.Put "governsId", "18.104.22.168.4.1.999922.214.171.124" objClass.Put "objectClassCategory", 1 'Structural Class objClass.Put "subClassOf", "user" objClass.Put "lDAPDisplayName", "mycorp-FinanceUser" 'Create the class objClass.SetInfo 'Write out optional attributes objClass.GetInfo objClass.Description = "Indicates a Financial User" objClass.Put "mustContain", "126.96.36.199.4.1.9999188.8.131.52" objClass.SetInfo
Figure 24-2 is the Schema Manager view of the newly created Mycorp-FinanceUser class.
Finally, we want to create a new Mycorp-FinanceUser object. First, we have to get a reference to the Schema Container and create the object with all the mandatory attributes. Example 24-3 shows what this would look like.
Dim objContainer Dim objMycorpFinanceUser Set objContainer = _ GetObject("LDAP://ou=Finance Users,dc=Mycorp,dc=com") 'Create the new Mycorp-FinanceUser object Set objMycorpFinanceUser = objContainer.Create("Mycorp-FinanceUser", _ "cn=SimonWilliams") 'Set the mandatory properties objMycorpFinanceUser.Put "sAMAccountName", "SimonWilliams" objMycorpFinanceUser.Put "userPrincipalName", "SimonWilliams@mycorp.com" objMycorpFinanceUser.Put "Mycorp-LanguagesSpoken", _ Array("English", "French", "German") 'Write the object to the AD objMycorpFinanceUser.SetInfo
Note that the mandatory properties include Mycorp-LanguagesSpoken from the Mycorp-FinanceUser class and sAMAccountName from the User class, which the Mycorp-FinanceUser class inherits from. UserPrincipalName is also included for completeness.
In your scripts or applications, it is good practice to locate the Schema Container and Schema FSMO dynamically instead of hardcoding those values. By finding those values programmatically, your scripts become much more forest-independent, which makes it much easier to transport to other forests in the future.
The solution to find the Schema Container is an easy one. The DN of the Schema Container for a forest can be found by querying the schemaNamingContext value of the RootDSE on any domain controller in the forest. The following code shows how to do that:
Dim objRootDSE Dim objSchemaContainer Dim strSchemaPath 'Get the Root DSE from a random DC Set objRootDSE = GetObject("LDAP://RootDSE") 'Get the Schema NC path for the domain strSchemaPath = objRootDSE.Get("schemaNamingContext") 'Connect to the schema container on a random DC Set objSchemaContainer = GetObject("LDAP://" & strSchemaPath)
The first GetObject call retrieves the RootDSE. Next we simply get the schemaNamingContext attribute and pass that to another GetObject call (or the IADsOpenDSObject::OpenDSObject method if you prefer to authenticate), which will return a reference to the Schema Container on a random domain controller. If you want to make changes without forcing the FSMO role to your currently connected server, you need to change the last line to connect to the server currently holding the Schema FSMO. This can be done in three additional steps:
Set objSchemaContainer = GetObject("LDAP://" & strSchemaPath) strFSMORoleOwner = objSchemaContainer.Get("fSMORoleOwner") Dim objNTDS, objServer Set objNTDS = GetObject("LDAP://" & objSchemaContainer.Get("fSMORoleOwner") ) Set objServer = GetObject( objNTDS.Parent ) strFSMORoleOwner = objServer.Get("dNSHostName") 'Connect to the schema container on the server holding the FSMO Schema 'Master role Set objSchemaContainer = _ GetObject("LDAP://" & strServerIPName & "/" & strSchemaPath)
The fSMORoleOwner attribute of the Schema Container actually contains the NTDS Settings DN (e.g., cn=NTDS Settings,cn=MOOSE,cn=Servers,cn=Main-Headquarters-Site,cn=Sites,cn=Configuration,dc=mycorp,dc=com) of the domain controller holding the Schema FSMO. From this you can retrieve the ADsPath of the parent container which holds an attribute called dNSHostName that contains the DNS host name of the domain controller that object represents.
If you want to transfer the Schema FSMO role to a specific server, just set the becomeSchemaMaster attribute to 1 on the RootDSE for that server. The script will need to either run under the credentials of someone in the Schema Admins group to perform this transfer or use IADsOpenDSObject::OpenDSObject and authenticate as someone in Schema Admins. The moment we write out the property cache, the proposed master contacts the current master and requests the role and any updates to the Schema NC that it has yet to see. Here is the code to do the transfer:
Const DC_TO_TRANSFER_FSMO_TO = "niles.mycorp.com" Dim objRootDSE Dim objSchemaContainer Dim strSchemaPath 'Get the Root DSE from a random DC Set objRootDSE = GetObject("LDAP://" & DC_TO_TRANSFER_FSMO_TO & _ "/RootDSE") 'Request a Schema Master transfer objRootDSE.Put "becomeSchemaMaster", 1 objRootDSE.SetInfo
At this point, the transfer has been requested. We now need to connect to the Schema NC and wait until the fSMORoleOwner attribute points to our new server:
'Get the Schema NC path for the domain strSchemaPath = objRootDSE.Get("schemaNamingContext") 'Connect to the schema container on my DC Set objSchemaContainer = GetObject("LDAP://" & DC_TO_TRANSFER_TO _ & "/" & strSchemaPath) 'Initialize the while loop by indicating that the server is not the one 'I am looking for strServerName = "" 'While the Server Name is not the one we are looking for, keep searching While Not strServerName = DC_TO_TRANSFER_FSMO_TO 'Get the FSMO Role Owner attribute strFSMORoleOwner = objSchemaContainer.Get("fSMORoleOwner") Set objNTDS = GetObject("LDAP://" & strFSMORoleOwner ) Set objServer = GetObject( objNTDS.Parent ) strServerName = objServer.Get("dNSHostName") objNTDS = Nothing objServer = Nothing Wend 'At this point in the code, the role has been transferred, so I can continue
You shouldn't use the code exactly as written here because no error checking is being done. Without error checking, there is no guarantee that the original writing of the becomeSchemaMaster attribute actually worked. There is also no guarantee that the attachment to the DC_TO_TRANSFER_FSMO_TO server actually worked, either. So if either of these or anything else went wrong, we may never exit the while loop. Even if both of these conditions worked, we may set the value, and the DC may attempt to contact the current Schema FSMO to find that it is unavailable. Again, we go into an infinite loop and the code never terminates. You certainly should include a timeout value as a second condition to the while loop to trap an occurrence of this problem.
If you need to reload the schema cache, Microsoft recommends that you do so once you've finished all your writes. While the cache is being reloaded, any new queries are served from the old cache and will continue to be served by the old cache until the new one comes online. Microsoft specifically states that working threads that are referencing the old cache once a reload is finished will continue to reference the old cache. Only new threads will reference the new cache. As a worst-case (and really daft) scenario, if you were to create 100 new attributes, start a process that queried the schema cache, and then reload the schema cache before continuing on to the next attribute, you potentially have 101 sets of schema caches (the original plus 100 new ones) being maintained by the DC acting as the Schema FSMO. This would cause the DC to have 101 times the amount of normal schema cache memory in use for caching. This is likely to cause a drain on your DC. In this scenario, things will only improve as the working threads cease referencing the old caches on the DC, allowing it to free up the memory.
Reloading the cache using ADSI is very simple. All you have to do is set the schemaUpdateNow attribute to 1 on the RootDSE of a DC. The following code shows how to do this:
Dim objRootDSE Dim strDC strDC = "dc01" 'Get the Root DSE Set objRootDSE = GetObject("LDAP://" & strDC & "RootDSE") 'Reload the cache on that DC objRootDSE.Put "schemaUpdateNow", 1
Note that just because you have requested a change doesn't mean it's going to happen instantaneously, although your code will continue executing. You should check the schema to see if your new objects are there, and if they are not, wait until they are before proceeding.
In Chapter 4, we described a desire to programmatically query all attributes directly defined on an object class in the schema to find out which ones are in the GC. It now should be possible to see how simple it is to write this code. First, you know that you can find out which attributes a classSchema object can have by looking in the mayContain, systemMayContain, mustContain, and systemMustContain attributes. Once you have the entire list of attributes, you can use the lDAPDisplayName that you will have retrieved to reference the attributeSchema class in the Schema Container. Lastly, you need to check to see whether each attributeSchema object has an attribute called isMemberOfPartialAttributeSet. Example 24-4 contains the code.
'Check the User class, via the administrator user Const OBJECT_TO_CHECK = _ "LDAP://cn=administrator, cn=Users, dc=Mycorp, dc=com" Dim objObject, objSchemaClass, arrMustContain, arrMayContain Dim strListOfAttributesinGC, objAttribute, strAttribute 'Connect to object and get IADs::Schema on an object instance Set objObject = GetObject(OBJECT_TO_CHECK) Set objSchemaClass = GetObject(objUser.Schema) 'Get May-Contain and Must-Contain attributes directly on the class arrMustContain = objSchemaClass.Get("mustContain") arrMayContain = objSchemaClass.Get("mayContain") 'Initialize the output string strListOfAttributesinGC = "The list of attributes for the class: " & vbCrLf _ & vbCrLf & vbTab & objUser.Schema & vbCrLf & vbCrLf & "is:"_ & vbCrLf & vbCrLf 'Use the array of LDAP names to connect to each attribute in turn 'and read whether it is in the GC or not. If it is, then add it to the string For Each strAttribute In arrMustContain Set objAttribute = GetObject("LDAP://" & strAttribute & _ ",cn=Schema,cn=Configuration,dc=Mycorp,dc=com") If objAttribute.Get("isMemberOfPartialAttributeSet") Then strListOfAttributesinGC = strListOfAttributesinGC & _ strAttribute & vbCrLf End If 'Stop referencing the current attribute Set objAttribute = Nothing Next For Each strAttribute In arrMayContain Set objAttribute = GetObject("LDAP://" & strAttribute & _ ",cn=Schema,cn=Configuration,dc=Mycorp,dc=com") If objAttribute.Get("isMemberOfPartialAttributeSet") Then strListOfAttributesinGC = strListOfAttributesinGC & _ strAttribute & vbCrLf End If 'Stop referencing the current attribute Set objAttribute = Nothing Next Wscript.Echo strListOfAttributesinGC
This will print out a list of all attributes that are held in the GC and defined directly on the object. Of course, to be very thorough and find every attribute that the classSchema object could have in the GC, you also would need to look inside auxiliaryClass, systemAuxiliaryClass, and subClassOf to retrieve any class names and then iterate back up the tree to find the mayContain, systemMayContain, mustContain, and systemMustContain from any inherited classes.
If you identify an attribute that you would like to be part of the GC, but it is not, it is straightforward to add it. Typically, an attribute that is part of the GC should be "globally interesting," meaning more than one application would find use for it. The data for the attribute also should not be very volatile or very large since it will be replicated to every GC server in the forest. To add an attribute, the isMemberOfPartialAttributeSet attribute for the attributeSchema object must be set to true. Example 24-5 shows how to enable the myCorp-SpokenLanguages attribute in the GC.
Const ATTR_TO_GC = "myCorp-LanguagesSpoken" Dim objRootDSE, objSchemaContainer Dim strFSMORoleOwner, strSchemaPath 'Get the Root DSE from a random DC Set objRootDSE = GetObject("LDAP://RootDSE") 'Get the Schema NC path for the domain strSchemaPath = objRootDSE.Get("schemaNamingContext") 'Connect to the schema container on a random DC Set objSchemaContainer = GetObject("LDAP://" & strSchemaPath) strFSMORoleOwner = objSchemaContainer.Get("fSMORoleOwner") 'Get the Schema FSMO DNS name Dim objNTDS, objServer Set objNTDS = GetObject("LDAP://" & objSchemaContainer.Get("fSMORoleOwner") ) Set objServer = GetObject( objNTDS.Parent ) strFSMORoleOwner = objServer.Get("dNSHostName") 'Get the attribute Set objSchemaContainer = _ GetObject("LDAP://" & strServerIPName & "/" & strSchemaPath) Dim objAttr Set objAttr = GetObject("LDAP://" & strFSMORoleOwner & "," & _ "cn=" & ATTR_TO_GC & "," & strSchemaPath 'Set the property to true objAttr.Put "isMemberOfPartialAttributeSet", True objAttr.SetInfo