Hi there,
A) VBS
IADs::GetObject
Set oIADsUser = GetObject(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com)
sDefNC = GetObject("LDAP://rootDSE").Get("defaultNamingContext") ActiveX Data Objects (ADO)
Set adoRS = adoCMD.Execute("<LDAP://DC=contoso,DC=com>;(&(objectClass=user)(!objectClass=inetOrgPerson)(cn=TheCN);ADspath,memberOf;subtree") IADsOpenObject::OpenDSObject
Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", Nothing, Nothing, 513)
B) PoSh
System.DirectoryServices.DirectoryEntry
$IADsUser = New-Object System.DirectoryServices.DirectoryEntry(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com)
System.DirectoryServices.AccountManagement
see .Net
Quest AD-CMDLets
$IADsUser = Get-QADUser "CN=TheCN,OU=TheOU,DC=contoso,DC=com"
Microsoft PowerShell Active Directory Modul CMDLets
$IADsUser = Get-ADUser "UserName"
C) Perl
D) C++
IADsOpenObject::OpenDSObject
hr = ADsOpenObject( LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com, Null, Null, ADS_SECURE_AUTHENTICATION, IID_IADs, (void**)&IADsUser);
E) .Net
System.DirectoryServices.DirectoryEntry
DirectoryEntry deIADsUser = new DirectoryEntry(LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com);
System.DirectoryServices.DirectorySearcher
SearchResult srIADsUser = new DirectorySearcher(new DirectoryEntry("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com"), "(&(objectClass=user)(!objectClass=inetOrgPerson)(cn=TheCN))", new String[] {"ADspath","memberOf"}).FindOne();
System.DirectoryServices.AccountManagement.UserPrincipal
PrincipalContext pContext = new PrincipalContext(ContextType.Domain, Environment.ExpandEnvironmentVariables("%userdnsdomain%")); UserPrincipal User = UserPrincipal.FindByIdentity(Context, IdentityType.SamAccountName, UserName); System.DirectoryServices.AccountManagement.PrincipalSearcher
PrincipalContext pContext = new PrincipalContext(ContextType.Domain, Environment.ExpandEnvironmentVariables("%userdnsdomain%"));
PrincipalSearcher pSearcher = new PrincipalSearcher(); pSearcher.QueryFilter = new UserPrincipal(Context) { Surname = "Smith", GivenName ="John" }; PrincipalSearchResult<Principal> Results = pSearcher.FindAll();
Indeed - the .Net namespace System.DirectoryServices is nothing else than a wrapper library for ADSI and the namepsace System.DirectoryServices.AccountManagement wraps System.DirectoryServices for the ease of use.
Symptom:
Let's have a look at a very simple AD infrastructure design:
- single domain forest
- central site with one Windows Server 2008 R2 DC
- branch site with one Windows Server 2008 R2 RODC
- Windows 7 client in branch site
- Windows 7 client is member of Allowed RODC Password Replication Group
When the client starts up he establishes a secure channel to the RODC (client PWD is cached on the RODC) and caches the RODC in the DCLocator cache.
When executing an ADSI call on the client (like IADs::GetObject(Path) or DirectoryEntry(Path)) against an object of the domain, this is what is going to happen:
- ADSI instructs LSASS to detect a writable (always) Domain Controller of the domain (DsGetDcName call with Flag DS_WRITABLE_FLAG) and passes the RWDC to ADSI.
- ADSI performs a LDAP-Bind to this RWDC.
- ADSI compares the modifyTimeStamp attribute of the Aggregate Schema entry on this RWDC with the locally cached value for this forest.
If the value on the Aggregate Schema is higher than the cached one, the Aggregate Schema is downloaded into the Schema cache on the client and the cached timestamp gets updated.
- LSASS calls again DsGetDcName with Flag DS_WRITABLE_FLAG and passes the returned RWDC to ADSI.
- ADSI performs a LDAP-Bind to this RWDC.
- ADSI does a base query against the object path and asks for the default attributes of this object.
The attribute values that are returned from the AD database are stored in the ADSI Property Cache in the RAM of the client.
- When we try to process one of the cached attributes (all attribute values are coming back as string values), ADSI must use the Schema cache to translate the attribute value into the correct syntax.
-> ADSI verifies the Schema cache again by instructing LSASS to enumerate through the DCs of the domain containing the object. This is done by calls against- DsGetDcOpen
- DsGetDcNext
- DsGetDcClose
In a network trace we see, that there are up to 255 DCLocator calls against DCs - no matter how many DC we do have in the domain.
- After the enormous number of DCLocator calls ADSI decides to use the locally cached Schema Cache - only now ADSI translates the string value of the requested property into it's proper syntax.
Hence we waited approximately 60 seconds between the LDAP-Bind and the attribute retrieval.
Cause:
The RODC in the DCLocator cache is marked with a DCQuality flag, a combination of values based on the following enumeration:
-
DS_DS_FLAG: 1 (Has a Directory Service instance)
-
DS_GOOD_TIMESERV_FLAG: 1 (Uses an external time source)
-
DS_HAS_IP: 2 (Has an IP address)
-
DS_KDC_FLAG: 5 (Has a Key Distribution Center instance)
-
DS_TIMESERV_FLAG: 5 (Is a time server)
-
DS_WS_FLAG: 5 (Has an AD-WebService instance)
-
DS_CLOSEST_FLAG: 10 (Is in the same or the next closest site)
This results in our scenario in a value of 28 for our RODC (DS_DS_FLAG + DS_HAS_IP + DS_KDC_FLAG + DS_TIMESERV_FLAG + DS_WS_FLAG + DS_CLOSEST_FLAG).
Because ADSI always requests a RWDC and never reuses the RWDC used in the last bind, ADSI tries again to get a RWDC for the Schema cache verification - with the assumption that the returned DCQuality flag is higher than the one of the cached RODC.
In our scenario no RWDC can return a DCQuality flag including DS_CLOSEST_FLAG and therefore the DCQuality flag of the RWDC will never be higher than 19. And because there is no DS_WRITABLE_FLAG value for the DCQuality flag - every answer of the DCLocator calls is discarded and LSASS goes on searching for RWDC with a sufficiently high DCQuality flag.
As we can see - LSASS will never find a fitting DC -> LSASS stops enumerating the DCs when reaching the internal limit of 255 calls (fortunately there is a limit - otherwise we would need a Cray computer - it's rumoured the Cray to be the only machine that finishes an endless loop in 4 hours).
This behavior has to be expected on every bind / search call in our code!
AntiDot:
- Get a RWDC into the DCLocator cache
- Call nltest /DSGETDC:DomainName /WRITABLE /FORCE /DS /RET_DNS on the client
- Use DsGetDcname wrapper in .Net namespace System.DirectoryService.ActiveDirectory to get a RWDC into the DCLocator cache.
C# sample: string foundDC = DomainController.FindOne(new DirectoryContext(DirectoryContextType.Domain), ActiveDirectorySite.GetComputerSite().ToString(), LocatorOptions.ForceRediscovery | LocatorOptions.WriteableRequired).Name;
-> we circumvent the DCQuality flag issue because the now cached RWDC has no 'unbeatable' DCQuality flag.
- LDAP-Bind with full qualified domain name
If we use the DNS name of the domain containing our object in our 'bind'* string we avoid the above issue because we need not rely on the DCLocator cache - a new DC discovery is performed via API DsGetDcOpen.
This API uses the DCs found in _tcp.sitename._sites.dc._msdcs.contoso.com respectively tcp.sites.dc._msdcs.contoso.com and no RODC does register himself in these DNS zones -> no DCQuality flag issue -> no endless loop -> no cray computer necessary.
Samples: VBS
Set oIADsUser = GetObject(LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com)
sDefNC = GetObject("LDAP://contoso.com/rootDSE").Get("defaultNamingContext")
PoSh
$IADsUser = [ADSI]LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com
$IADsUser = New-Object System.DirectoryServices.DirectoryEntry("LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com")Perl
my $oIADsUser = Win32::OLE->GetObject("LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com");
C++
hr = ADsOpenObject( "LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com", Null, Null, ADS_SECURE_AUTHENTICATION, IID_IADs, (void**)&IADsUser);
.Net
DirectoryEntry deIADsUser = new DirectoryEntry("LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com");
SearchResult srIADsUser = new DirectorySearcher(new DirectoryEntry("LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com"),"(&(objectClass=user)(!objectClass=inetOrgPerson)(cn=TheCN))", new String[] {"ADspath","memberOf"}).FindOne();
* Note: There is actually no bind against AD objects. ADSI exports a GetObject bind - but this is nothing else than a base query against the obejct path in the underlying LDAP APIs.
- Use IADsOpenDsObject with Flag ADS_READONLY_SERVER
The ADSI interface IADsOpenDsObject::OpenDsobject gives us the possibility to force ADSI to accept RODCs in an LDAP-Bind as well. This is the only ADSI interface exporting this ability.
The implementation is there - you guess it - becauseof backward compatibility to NT4 domains with their Backup Domain Controllers (= 'Read Only Domain Controller')
Samples:VBS:
Const ADS_SECURE_AUTHENTICATION = 1
Const ADS_READONLY_SERVER = 4
Const ADS_SERVER_BIND = 512
Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", vbNullString, vbNullString, ADS_SECURE_AUTHENTICATION Or ADS_READONLY_SERVER Or ADS_SERVER_BIND)
.Net:
DirectoryEntry deIADsUser = new DirectoryEntry(LDAP://contoso.com/CN=TheCN,OU=TheOU,DC=contoso,DC=com, null, null, Authenticationtype.Secure | Authenticationtype.ReadonlyServer | Authenticationtype.ServerBind);
- .NET System.DirectoryServices.Protocols & System.DirectoryServices.ActiveDirectory
In the sample code below you will find an exemplary implementation of System.DirectoryServices.Protocols (S.DS.P) for communicating with AD. S.DS.P is a .Net wrapper library for the LDAP APIs and does not rely on the DCLocator cache or the Schema cache - it's up to the programmer to decide which DC to connect to and to take care of the property value translation into the proper syntax - the Schema cache is out of scope here -> thus we avoid the ADSI trap DCLocator cache and Schema caching.
using System.DirectoryServices.ActiveDirectory;
using System.DirectoryServices.Protocols;
using System.Security.Principal;
namespace SDSP_Play
{
class Program
{
static string foundDC;
static string defNC;
static LdapConnection ldapAsyncCon;
static int AsyncPagesRetreived;
static long AsyncEntriesOverall;
static bool AsyncComplete;
static void Main(string[] args)
{
//find dc
DiscoverDC();
Console.WriteLine("DC: {0}", foundDC);
//get defaultnamingcontext from rootDSE
DefaultNamingContext(foundDC);
Console.WriteLine("DefaultNamingContext: {0}", defNC);
//get contructed attribute by base search
ReadTokenGroups("theboss");
//get all groups count
PagedSearch(foundDC, defNC, "(objectCategory=group)", 1000);
get all users count
PagedSearch(foundDC, defNC, "(objectCategory=user)", 468);
//get all objects count - asynchronous search
AsyncSearch(foundDC, defNC, "(objectCategory=*)");
AsyncPagesRetreived = 0;
AsyncEntriesOverall = 0;
//get all objects count - asynchronous paged search
PagedAsyncSearch(foundDC, defNC, "(objectCategory=*)", 500, null);
Console.ReadLine();
}
static void DiscoverDC()
{
DirectoryContextdirCtx = new DirectoryContext(DirectoryContextType.Domain);
LocatorOptions locOpts = LocatorOptions.ForceRediscovery |
LocatorOptions.WriteableRequired;
string site = ActiveDirectorySite.GetComputerSite().ToString();
static void DefaultNamingContext(string DC)
{
LdapDirectoryIdentifier ldapDirectoryIdentifier;
SearchRequest searchRequest;
{
string DN = string.Empty;
{
searchRequest = new SearchRequest(NC,
searchOptions = new SearchOptionsControl(SearchOption.DomainScope);
searchRequest.Controls.Add(searchOptions);
DN = searchResultEntry.Attributes["distinguishedName"][0].ToString();
static object[] GetConstructedAttribute(string DC, string DN,
{
colValues = resultEntry.Attributes[AttributeName].GetValues(DesiredType);
static void ReadTokenGroups(string sAMAccountName)
{
string ldapFilter = "(&(objectCategory=person)(objectClass=user)" +
Console.WriteLine("ObjectPath: {0}", objectDN);
static void PagedSearch(string DC, string NC, string Query, int PageSize)
{
int PagesReceived = 0;
long EntriesOverall = 0;
{
SearchRequest searchRequest;
searchRequest = new SearchRequest(NC,
searchOptions = new SearchOptionsControl(SearchOption.DomainScope);
searchRequest.Controls.Add(searchOptions);
pageRequest = new PageResultRequestControl(PageSize);
searchRequest.Controls.Add(pageRequest);
{
PagesReceived++;
EntriesOverall = EntriesOverall + searchResponse.Entries.Count;
Console.ReadKey();
static void AsyncSearch(string DC, string NC, string Query)
{
//increase def timeout of 2 min to keep connection open
searchRequest.Controls.Add(searchOptions);
Console.ReadLine();
static void PagedAsyncSearch(string DC, string NC, string Query, int PageSize, byte[] PageCookie)
searchRequest.Controls.Add(searchOptions);
searchRequest.Controls.Add(pageRequest);
Console.WriteLine("Wait Page({0})", AsyncPagesRetreived);
static void AsyncSearchCallBack(IAsyncResult Result)
//#######################################################################
catch (Exception Ex)
}
static void PagedAsyncSearchCallBack(IAsyncResult Result)
AsyncComplete = true;
AsyncEntriesOverall = AsyncEntriesOverall + searchResponse.Entries.Count;
}
Hope you had some fun reading this post.
[Edit] 8/12/2014:
Changed
Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", Nothing, Nothing, ADS_SECURE_AUTHENTICATION Or ADS_READONLY_SERVER Or ADS_SERVER_BIND)
to
Set oIADsUser = GetObject("LDAP:").OpenDSObject("LDAP://CN=TheCN,OU=TheOU,DC=contoso,DC=com", vbNullString, vbNullString, ADS_SECURE_AUTHENTICATION Or ADS_READONLY_SERVER Or ADS_SERVER_BIND)
since OPenDSObject interface expects string values as username and password and will fail when passing Nothing as username or password.
All the best
Michael
PFE | Have keyboard. Will travel.
Thank you very much for this article. Your code samples and explanations helped me resolve issues as I did not understand the authentication methods of PrincipalContext until reading your work.
Todays topic:
The ADSI Schema Cache revealed
Like mentioned in the first article of this blog
Thank you, very interesting article.
Hi Jack,
thx for posting your comment.
Let's have a look on the APIs and how they are implemented in .Net.
Here you will find an illustration about the architecture of the .Net impementations regarding DirectoryServices and how they are related to the underlying APIs:
blogs.technet.com/…/3531.Net_2D00_SDS_2D00_Architecture.jpg
Saying S.DS.AccountManagement is implemented as a S.DS namespace wrapper which is wrapping the ADSI interfaces. ADSI finally wraps the LDAP APIs in wldap32.dll.
Thus the 'closest' namepsace in .Net for the native LDAP APIs is S.DS.Protocols where you have the most granular control about what you are sending to whom in LDAP communications (funny enough – the more 'native' you get the less 'native' the usage will be :-)).
As you can see in the samples above – we always use a DC as target of our LDAPConnection in S.DS.Protocols – saying we will not stumple about the described performance issue when talking to RODCs.
Every other try establishing a LDAP connection without passing a dedicated DC will give us poor performance when our closest DC will be a RODC due to the described behaviour.
If you only want to query AD without the intention of writing to the AD database you are absolutely fine binding to a RODC if this is you closest DC and you won't see big performance impacts in this scenario – as long as you pass the RODC in the bind string.
Hth. Please feel free to come back with any further questions / concerns.
All the best
Michael
PFE | Have keyboard. Will travel.
What about the use of API? RODC is very slow.
System.DirectoryServices.AccountManagement
Dim objPrincipalContext As New PrincipalContext(DirectoryServices.AccountManagement.ContextType.Domain)
Dim objGroupPrincipal As GroupPrincipal = GroupPrincipal.FindByIdentity(objPrincipalContext, strGroupName)
Hi Holger,
thx for being the first one posting a comment, I really appreciate :-)
Somehow you lost me regarding the correlation between your comment and the post above – can you please clarify?
All the best
Michael
PFE | Have keyboard. Will travel.
PowerShell – note there is a bug passing null string to .NET methods:
add-type -AssemblyName System.Management.Automation
New-Object System.DirectoryServices.DirectoryEntry([System.Management.Automation.Language.NullString]::Value System.Management.Automation.Language.NullString]::Value,[System.Management.Automation.Language.NullString]::Value,(1,4,512))