[C#.NET] GroupPrincipal で別ドメインの端末からユーザーをグループに追加できない

こんにちは、kenzauros です。

.NET アプリで Active Directory を管理する際、 .NET 3.5 以降であれば System.DirectoryServices.AccountManagement 名前空間の PrincipalContextGroupPrincipalUserPrincipal を使うのが、安心・安全だと思いますが、ラッピングされている分、なかなか融通が利かないこともあります。

今回は Active Directory でユーザーをセキュリティグループに参加させるときにつまづいたので、原因と解決法を紹介します。

前提条件

  • ドメイン名: ad.msen.jp (ドメインコントローラーも同名)
  • ユーザー: Users/ほげほげ
  • グループ: Users/グループA, Temp/グループB

※すべて架空の情報です。

UserPrincipal と GroupPrincipal を使ったグループへの参加

ユーザーとグループが同じコンテナであれば、同じコンテキストでいけるので Users/グループA には下記のコードで参加させることができます。

using (var context = new PrincipalContext(
    ContextType.Domain,
    "ad.msen.jp",
    "CN=Users,DC=ad,DC=msen,DC=jp"))
using (var user = UserPrincipal.FindByIdentity(context, "ほげほげ"))
using (var group = GroupPrincipal.FindByIdentity(context, "グループA"))
{
    if (!group.Members.Contains(user)) {
        group.Members.Add(user);
    }
}

※説明を簡単にするため、エラー処理等は省いて Disposeusing に任せています。

実にシンプルですね。

OU が異なる場合 (Temp/グループB) でもグループ用に別のコンテキストを開くことで実現できます。

using (var context = new PrincipalContext(
    ContextType.Domain,
    "ad.msen.jp",
    "CN=Users,DC=ad,DC=msen,DC=jp"))
using (var groupContext = new PrincipalContext(
    ContextType.Domain,
    "ad.msen.jp",
    "OU=Temp,DC=ad,DC=msen,DC=jp"))
using (var user = UserPrincipal.FindByIdentity(context, "ほげほげ"))
using (var group = GroupPrincipal.FindByIdentity(groupContext, "グループB"))
{
    if (!group.Members.Contains(user)) {
        group.Members.Add(user);
    }
}

こちらもわかりやすいですね。

ドメイン外の端末から実行できない

が、しかし、上記のプログラムはドメイン外の端末から実行できませんでした。

group.Members.Contains(user); の部分で下記の例外 PrincipalOperationException 「ドメインについての情報を取得できませんでした (1355)」 が発生します。

System.DirectoryServices.AccountManagement.PrincipalOperationException
  HResult=0x80131501
  Message=ドメインについての情報を取得できませんでした (1355)。
  Source=System.DirectoryServices.AccountManagement
  スタック トレース:
   場所 System.DirectoryServices.AccountManagement.Utils.GetDcName(String computerName, String domainName, String siteName, Int32 flags)
   場所 System.DirectoryServices.AccountManagement.ADStoreCtx.LoadDomainInfo()
   場所 System.DirectoryServices.AccountManagement.ADStoreCtx.get_UserSuppliedServerName()
   場所 System.DirectoryServices.AccountManagement.ADStoreCtx.IsMemberOfInStore(GroupPrincipal g, Principal p)
   場所 System.DirectoryServices.AccountManagement.PrincipalCollection.ContainsNativeTest(Principal principal)
   場所 System.DirectoryServices.AccountManagement.PrincipalCollection.Contains(Principal principal)
   場所 System.DirectoryServices.AccountManagement.PrincipalCollection.Add(Principal principal)
   場所 System.DirectoryServices.AccountManagement.PrincipalCollection.Add(UserPrincipal user)
   場所 HOGEHOGE.DomainUtilTest.test() (C:\HOGEHOGE\ActiveDirectory\DomainUtilTest.cs):行 362

スタックトレースの一番最後を見てみると System.DirectoryServices.AccountManagement.Utils.GetDcName となっているのでソースを見てみました。

System.DirectoryServices.AccountManagement.Utils.GetDcName - GitHub dotnet/corefx

590 行目を見てみると UnsafeNativeMethods.DsGetDcName が呼ばれて、結果がエラーなら PrincipalOperationException がスローされることがわかります。

DsGetDcName は Windows API の一つで「最も早く応答を返したドメインコントローラーの情報を取得する」 ものです。

DsGetDcNameA function | Microsoft Docs

ということで PrincipalContext ですでに DC のコンテキストにいるのにもかかわらず、メンバーがいるかどうかの確認の際に再度 DC の情報を取りに行く (同ファイル 540 行目) という謎仕様です。

DsGetDcName の仕様によれば、 DNS サーバーに DC の SRV レコードと A レコードが必要なようですが、ドメイン外の端末だと利用している DNS サーバーに DC の情報が完全に登録されているわけではありませんので、情報が取得できないようです。

で、結果、例外が発生する、と。

おそらく内部的にはいろいろ都合があって、こうなっているのだと思いますが、なんのためにコンテキストを開いているのかわかりません(T_T)

回避策 (DirectoryEntry を使う)

さて、いろいろ試行錯誤はしましたが、どうも GroupPrincipal.Members を使う限り解決できそうにないので、少々泥臭い方法に切り替えて、別ドメインのマシンからもグループ参加に成功しました。

using (var context = new PrincipalContext(
    ContextType.Domain,
    "ad.msen.jp",
    "CN=Users,DC=ad,DC=msen,DC=jp"))
using (var groupContext = new PrincipalContext(
    ContextType.Domain,
    "ad.msen.jp",
    "OU=Temp,DC=ad,DC=msen,DC=jp"))
using (var user = UserPrincipal.FindByIdentity(context, "ほげほげ"))
using (var group = GroupPrincipal.FindByIdentity(groupContext, "グループB"))
{
    var entry = group.GetUnderlyingObject() as System.DirectoryServices.DirectoryEntry;
    if (entry.Properties["member"].IndexOf(user.DistinguishedName) < 0)
    {
        entry.Properties["member"].Add(user.DistinguishedName);
        entry.CommitChanges();
    }
}

見たままですが、 GroupPrincipal でやるのはあきらめて、 DirectoryEntry を使っています。 GroupPrincipalUserPrincipal は旧来の API である DirectoryEntry を内部的に使っているラッパークラスなので、 GetUnderlyingObject() を呼ぶことで、内部の DirectoryEntry を取得できます。

「凝ったことは DirectoryEntry でやってください♡」という MS 開発サイドの愛情 (?) が窺えます。

DirectoryEntry.Properties["member"] でメンバー管理ができますが、こちらは DN (Distinguished Name, CN=ほげほげ,DC=ad,DC=msen,DC=jp のような完全名) で指定する必要がありますが、 UserPrincipal.DistinguishedName で取得できるのでそのまま渡せます。

どなたかのお役に立てれば幸いです。

参考

kenzauros