Microsoft Entra ID のユーザー情報を cybozu.com 環境へ定期的に同期しよう

目次

はじめに

Microsoft Entra ID(以降、Entra ID)を使って kintone や Garoon にシングルサインオンしている場合、Entra ID と cybozu.com のユーザー情報の同期が必要です。
この記事では、Azure Functions を使って、定期的に Entra ID から cybozu.com 環境へ自動でユーザー情報の同期を行う方法を紹介します。

必要なもの

  • cybozu.com 環境
  • Microsoft Azure アカウント
  • 構築済みの Entra ID

システム構成

Azure Functions を使って、Entra ID のユーザー情報を cybozu.com に同期します。

  1. cybozu.com の情報(ユーザー・組織・利用サービス)を User API を使って取得します。
  2. Entra ID のユーザー情報を Microsoft Graph API を使って取得します。
    1. で取得した cybozu.com の情報と突合し、データが新規登録 or 更新を判定しています。
  3. さいごに、User API を使って、cybozu.com にユーザー情報を登録・更新します。

同期内容

登録・更新される cybozu.com のユーザー情報の項目は次のとおりです。

Entra ID の項目名 cybozu.com の項目名 備考
ユーザー名 ログイン名
使用状態 利用状況
名前 表示名
会社電話 電話番号
携帯電話 モバイルフォン
メール Email アドレス
部署 部署コード 複数の部署を設定する場合は、半角カンマ区切りで指定します。
cybozu.com に存在する部署コードのみ、ユーザーの所属部署として反映されます。
役職 役職コード 複数の役職を設定する場合は、半角カンマ区切りで指定します。
cybozu.com に存在する役職コードのみ、ユーザーの役職として反映されます。
利用するサービスのサービスコード 後述の環境変数で設定した値 を利用します。この記事の例では kintone と Garoon が設定されます。
初期パスワード 後述の環境変数で設定した値 を利用します。この記事の例では「password」が設定されます。

cybozu.com のユーザー情報は、Entra ID のユーザーの利用状況に応じて次のように登録・更新されます。

  • Entra ID の利用状況が「利用中」のユーザー情報
    • 「利用中」のユーザーになる(cybozu.com に存在しない場合は新規登録)
  • Entra ID の利用状況が「停止中」のユーザー情報
    • 「停止中」のユーザーになる(cybozu.com に存在しない場合は登録されない)

設定手順

次の流れで、Azure Functions を設定します。

手順1:関数アプリの作成

  1. Microsoft Azure アカウントで、 Azure Portal (External link) にログインします。
  2. Azure Portal のホーム画面で、[リソースの作成]をクリックします。
  3. 「関数アプリ」を検索します。
  4. 「関数アプリ」の[作成]をクリックします。
  5. 「基本」タブで、次の内容を入力します。
    カテゴリ 項目
    プロジェクトの詳細 サブスクリプション 任意のサブスクリプション
    リソースグループ 任意のリソースグループ
    新規作成もできます。
    インスタンスの詳細 関数アプリ名 任意のアプリ名
    この記事では「cybozu-users-sync」とします。
    公開 「コード」
    ランタイム 「Node.js」
    バージョン 「18 LTS」
    地域 任意の地域
    オペレーティング システム オペレーティングシステム 「Windows」
    プラン プランの種類 任意のプラン
  6. [次: ホスティング]をクリックします。
  7. 「ホスティング」タブで、次の内容を入力します。
    カテゴリ 項目
    ストレージアカウント ストレージアカウント 必要に応じて変更します。
    新規作成もできます。
  8. [次: ネットワーク]をクリックします。
  9. 必要に応じて設定を変更し、[次: 監視]をクリックします。
  10. 「監視」タブで、次の内容を入力します。
    カテゴリ 項目
    Application Insights Application Insights を有効にする はい
    Application Insights 必要に応じて変更します
  11. [次: デプロイ]をクリックします。
  12. 必要に応じて設定を変更し、[次: タグ]をクリックします。
  13. 必要に応じて設定を変更し、[次: 確認および作成]をクリックします。
  14. [作成]をクリックします。
    関数アプリのデプロイが始まります。デプロイが完了すると「デプロイが完了しました」が表示されます。
  15. [リソースに移動]をクリックします。

手順2:関数の追加

  1. サイドメニューから[関数]をクリックします。

  2. [+作成]をクリックします。
  3. 次の内容を入力します。
    カテゴリ 項目
    開発環境の選択 開発環境 ポータルでの開発
    テンプレートの選択 テンプレート Timer trigger
    テンプレートの詳細 新しい関数 任意の値
    この記事では「CybozuUserSyncTimer」とします。
    Schedule 関数を実行するスケジュール
    「{second} {minute} {hour} {day} {month} {day of week}」の cron 式で指定します。
    この記事では毎日0時に実行するとして、「0 0 0 * * *」を指定しています。
    指定例は、 Microsoft ドキュメント|NCRONTAB 式 (External link) を参照してください。
  4. [作成]をクリックします。
    関数が作成されると、作成した関数の詳細画面が表示されます。
  5. [無効化]をクリックします。
    無効化することで、関数の定期実行を停止できます。
  6. 画面右上の[X]をクリックして、関数の画面を閉じます。

手順3:拡張機能のインストール

  1. サイドメニューから[概要]をクリックします。

  2. [■ 停止]をクリックします。

  3. 「<アプリ名>を停止しますか?」メッセージで、[はい]をクリックします。

  4. サイドメニューの[高度なツール]をクリックします。

  5. [移動]をクリックします。
    ブラウザーの新規タブが開きます。

  6. ヘッダーメニューの[Debug console]をクリックし、[CMD]を選択します。

  7. 画面上部のエクスプローラーで、「site」>「wwwroot」の順にクリックします。

  8. 「bin」ディレクトリーがある場合は、ファイル一覧の[-]をクリックして削除します。

  9. 上部の「.../wwwroot」横にある[+]をクリックし、[New file]を選択します。

  10. 「extensions.csproj」と入力し、エンターキーを押します。

  11. 「extensions.csproj」横の鉛筆の形をしたアイコンをクリックします。

  12. エディターに、次の内容を入力します。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
          <WarningsAsErrors />
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Microsoft.Azure.WebJobs.Script.ExtensionsMetadataGenerator" Version="4.0.1" />
        <PackageReference Include="Microsoft.Azure.WebJobs.Extensions.MicrosoftGraph" Version="1.0.0-beta6" />
        <PackageReference Include="Microsoft.Azure.WebJobs.Extensions.AuthTokens" Version="1.0.0-beta6" />
      </ItemGroup>
    </Project>
  13. [Save]をクリックします。

  14. 画面下部のコンソール画面で次のコマンドを実行し、拡張機能をインストールします。

    1
    
    dotnet build extensions.csproj -o bin --no-incremental --packages D:\home\.nuget

    しばらくして「Build succeeded.」と表示されれば完了です。

タブは開いたままで手順 4 に進みます。

手順4:Node.js パッケージのインストール

  1. 追加した関数名(この記事では「CybozuUserSyncTimer」)のディレクトリーを開きます。

  2. 画面下部のコンソールで次のコマンドを 1 行ずつ実行します。

    1
    2
    3
    
    npm init -y
    npm install csv@6 form-data@4 axios@1
    npm install @azure/identity@3 @microsoft/microsoft-graph-client@3 isomorphic-fetch@3
  3. ブラウザーのタブを閉じます。

手順5:Azure Functions の環境変数の設定

  1. サイドメニューから[構成]をクリックします。

  2. [高度な編集]をクリックします。

  3. 最終行とその前の行を次のように書き換えます。
    修正前:

    1
    2
    
      }
    ]

    修正後:

      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    
      },
      {
        "name": "WEBSITE_TIME_ZONE",
        "value": "Tokyo Standard Time",
        "slotSetting": false
      },
      {
        "name": "AZURE_CLIENT_ID",
        "value": "",
        "slotSetting": false
      },
      {
        "name": "AZURE_CLIENT_SECRET",
        "value": "",
        "slotSetting": false
      }, {
        "name": "AZURE_TENANT_ID",
        "value": "",
        "slotSetting": false
      },
      {
        "name": "Cybozu Sub Domain Name",
        "value": "example.cybozu.com",
        "slotSetting": false
      },
      {
        "name": "Cybozu Login Id",
        "value": "admin",
        "slotSetting": false
      },
      {
        "name": "Cybozu Password",
        "value": "password",
        "slotSetting": false
      },
      {
        "name": "Password",
        "value": "password",
        "slotSetting": false
      },
      {
        "name": "Cybozu Services",
        "value": "ki,gr",
        "slotSetting": false
      },
      {
        "name": "Login Name",
        "value": "userPrincipalName",
        "slotSetting": false
      },
      {
        "name": "New Login Name",
        "value": "userPrincipalName",
        "slotSetting": false
      },
      {
        "name": "Display Name",
        "value": "displayName",
        "slotSetting": false
      },
      {
        "name": "Status",
        "value": "accountEnabled",
        "slotSetting": false
      },
      {
        "name": "Surname",
        "value": "surname",
        "slotSetting": false
      },
      {
        "name": "Given Name",
        "value": "givenName",
        "slotSetting": false
      },
      {
        "name": "E-mail Address",
        "value": "mail",
        "slotSetting": false
      },
      {
        "name": "Phone",
        "value": "businessPhones",
        "slotSetting": false
      },
      {
        "name": "Mobile Phone",
        "value": "mobilePhone",
        "slotSetting": false
      },
      {
        "name": "Department Code",
        "value": "department",
        "slotSetting": false
      },
      {
        "name": "Job Title Code",
        "value": "jobTitle",
        "slotSetting": false
      }
    ]
  4. [OK]をクリックします。

  5. 次の項目の名前をクリックし、利用する環境に合わせて編集します。編集したら [OK]をクリックします。

    • WEBSITE_TIME_ZONE:タイムゾーン
      タイムゾーンの詳細は タイムゾーン (External link) を参照してください。
    • AZURE_TENANT_ID:Azure のテナント ID
      テナント ID は「Microsoft Entra ID」から確認できます。詳細は テナント ID を見つける方法 (External link) を参照してください。
    • Cybozu Sub Domain Name:cybozu.com のドメイン
    • Cybozu Login Id:cybozu.com 共通管理者のアカウントのログイン名
    • Cybozu Password:cybozu.com 共通管理者のアカウントのパスワード
    • Password:登録するユーザーの初期パスワード
    • Cybozu Services:登録するユーザーが利用するサービスのサービスコード
      複数ある場合は、半角カンマ区切りで指定します。
      サービスコードの詳細は サービスコード一覧 を参照してください。

  6. [保存]をクリックします。

  7. 「変更の保存」メッセージで、[続行]をクリックします。

手順6:ID プロバイダーの追加

  1. サイドメニューから[認証]をクリックします。

  2. [ID プロバイダーを追加]をクリックします。

  3. 「ID プロバイダー」で「Microsoft」を選択します。

  4. 次の内容を入力します。

    項目
    アプリの登録の種類 「アプリの登録を新規に作成する」
    名前 任意の名前
    この記事では「cybozu-users-sync」です。
    サポートされているアカウントの種類 任意のアカウントの種類を選択
    アクセスを制限する 「認証されていないアクセスを許可する」
    トークンストア 選択
  5. [次へ: アクセス許可]をクリックします。
    アクセス許可は、手順 7 で設定するため、ここでは行いません。

  6. [追加]をクリックします。

手順7:クライアントIDのメモとアクセス許可の追加

  1. 追加した ID プロバイダー名で、鉛筆の形をしたアイコンをクリックします。

  2. [基本]タブで次の値をメモします。後述の環境変数の設定で利用します。
    • アプリケーション(クライアント)ID
      この値が、クライアント ID です。
  3. [アクセス許可]タブをクリックします。
  4. [API アクセス許可にアクセスするには、こちらをクリックします。]をクリックします。
  5. [アクセス許可の追加]をクリックします。
  6. [Microsoft Graph]をクリックします。
  7. 次の項目のチェックボックスを選択します。
    • 委任されたアクセス許可
      • Directory.AccessAsUser.All
      • Directory.Read.All
      • User.Read
    • アプリケーションの許可
      • Directory.Read.All
  8. [アクセス許可の追加]をクリックします。
  9. [既定のディレクトリー に管理者の同意を与えます]をクリックします。
    「既定のディレクトリー」部分に表示される文言は、ユーザーによって異なります。
  10. 「管理者の同意の確認を与えます」メッセージで、[はい]をクリックします。
  11. 各アクセス許可の状態が、「~に付与されました」となっていることを確認します。

  12. 「API のアクセス許可」横の [X]をクリックして、「API のアクセス許可」画面を閉じます。
  13. [ホーム]をクリックし、作成した関数アプリを開き直します。

手順8:クライアント ID とシークレットの設定

  1. サイドメニューから[構成]をクリックします。

  2. 「MICROSOFT_PROVIDER_AUTHENTICATION_SECRET」をクリックし、値をメモします。
    この値が、クライアントシークレットです。
  3. 「AZURE_CLIENT_SECRET」をクリックし、「値」に 2. でメモしたクライアントシークレットを貼り付けます。
  4. [OK]をクリックします。
  5. 「AZURE_CLIENT_ID」をクリックし、「値」に 手順7:クライアントIDのメモとアクセス許可の追加 でメモしたクライアント ID を貼り付けます。
  6. [OK]をクリックします。
  7. [保存]をクリックします。
  8. 「変更の保存」メッセージで、[続行]をクリックします。

手順9:Azure Functions プログラムの設定

  1. サイドメニューの[概要]をクリックします。

  2. [▷ 開始]をクリックします。アプリが起動します。
  3. サイドメニューから[関数]をクリックします。

  4. 手順2:関数の追加 で作成した関数を選択します。
  5. サイドメニューの[コードとテスト]をクリックします。
  6. コード部分に サンプルコード の内容を貼り付けます。
  7. [保存]をクリックします。

手順10:動作確認

warning
注意

動作確認をする前に cybozu.com のユーザー情報をバックアップしてください。
参考: ファイルにデータを書き出す | kintone ヘルプ (External link) . 関数実行すると、cybozu.com のユーザー情報に、Entra ID のユーザー情報が追加されます。

  1. サイドメニューの[関数]をクリックします。

  2. 作成した関数をクリックします。
  3. サイドメニューの[コードとテスト]をクリックします。
  4. 画面下部のログの種類を「App Insight ログ」に切り替えます。

  5. [テストと実行]をクリックします。
  6. [入力]タブの「キー」で「master(Host key)」を選択し、[実行]をクリックします。
  7. しばらくして、ログに「ユーザ連携処理終了」と表示されていれば、成功です。

手順11:関数の有効化(定期実行の有効化)

  1. サイドメニューの[概要]をクリックします。

  2. [有効化]をクリックします。

手順 3 で設定したスケジュールで、関数が自動で実行されるようになります。

サンプルコード

手順9:Azure Functions プログラムの設定 で貼り付けるコードです。

   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
/* global Buffer */

/*
 * Sync Entra ID users to cybozu.com
 * Copyright (c) 2020 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

'use strict';

const {stringify} = require('csv-stringify/sync');
const {parse} = require('csv-parse/sync');
const FormData = require('form-data');
const axios = require('axios');
const {DefaultAzureCredential} = require('@azure/identity');
require('isomorphic-fetch');
const graph = require('@microsoft/microsoft-graph-client');

/**
 * 環境変数の設定
 */
class EnvSetting {
  /**
   * @param {Object} env 環境変数
   */
  constructor(env) {
    this.cybozuSubDomainName = env['Cybozu Sub Domain Name'];
    this.cybozuLoginId = env['Cybozu Login Id'];
    this.cybozuPassword = env['Cybozu Password'];
    this.newLoginName = env['New Login Name'];
    this.cybozuServices = env['Cybozu Services'];
    this.loginName = env['Login Name'];
    this.displayName = env['Display Name'];
    this.surname = env.Surname;
    this.givenName = env['Given Name'];
    this.emailAddress = env['E-mail Address'];
    this.phone = env.Phone;
    this.mobilePhone = env['Mobile Phone'];
    this.status = env.Status;
    this.password = env.Password;
    this.departmentCode = env['Department Code'];
    this.jobTitleCode = env['Job Title Code'];

    this.registerNewOrganizations = this.getOnOrOffValue(
      env['Register New Organizations']
    );
    this.userNameOnly = this.getOnOrOffValue(env['User Name Only']);
    this.toBeDeleted = this.getOnOrOffValue(env['To Be Deleted']);
  }

  /**
   * 環境変数で設定された値が 1 なら 1, それ以外なら 0 を返す
   * @param {string} 環境変数での設定値
   * @return {string} 1 or 0
   */
  getOnOrOffValue(value) {
    return value === '1' ? '1' : '0';
  }
}

/**
 * cybozu.com の API をコールする既定クラス
 * cybozu.com の各 API はこのクラスを継承したクラスからコールされる
 */
class CybozuCom {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {string} csvFilename CSV ファイル
   * @param {string} endpoint エンドポイント名
   * @param {Object} envSetting 環境変数
   */
  constructor(context, csvFilename, endpoint, envSetting) {
    this.context = context;
    this.auth = this.encodeBase64Auth(
      envSetting.cybozuLoginId,
      envSetting.cybozuPassword
    );
    this.subDomainName = envSetting.cybozuSubDomainName;
    this.csvFilename = csvFilename;
    this.endpoint = endpoint;
    this.fileKey = '';
    this.csvId = 0;
    this.sleepTime = 2000;
    this.charset = 'utf-8';
  }

  /**
   * エラーオブジェクトからエラーメッセージを生成する
   * @param {Object} error エラー情報
   * @return {string} エラーメッセージ
   */
  buildErrorMessage(error) {
    const url = error.config.url;
    const response = error.response;
    let message = `接続先: ${url}`;
    if (response) {
      const errorCode = response.data.code || '--';
      const errorMessage = response.data.message || '--';
      if (response.headers['content-type'].indexOf('json')) {
        message += `, ステータス: ${response.status}, コード: ${errorCode}, メッセージ: ${errorMessage}`;
      }
    }
    return `Cybozu.com へ接続に失敗しました。${message}`;
  }

  /**
   * ファイルアップロード API で csv 情報をアップロードする
   * @param {Object} csv csv 形式の情報
   */
  async uploadCsv(csv) {
    const formData = new FormData();
    const buf = Buffer.from(csv, this.charset);
    formData.append('file', buf, {
      filename: this.csvFilename,
      contentType: 'text/csv',
      knownLength: buf.length,
    });
    const headers = formData.getHeaders();
    headers['X-Cybozu-Authorization'] = this.auth;
    const config = {
      method: 'POST',
      url: `https://${this.subDomainName}/v1/file.json`,
      data: formData.getBuffer(),
      headers: headers,
    };
    await axios(config)
      .then((response) => {
        this.fileKey = response.data.fileKey;
      })
      .catch((err) => {
        throw this.buildErrorMessage(err);
      });
  }

  /**
   * アップロードした csv 情報を使って各インポート API を実行する
   */
  async applyCsv() {
    const config = {
      method: 'POST',
      url: `https://${this.subDomainName}${this.endpoint}`,
      headers: {
        'X-Cybozu-Authorization': this.auth,
        'Content-Type': 'application/json',
      },
      data: {
        fileKey: this.fileKey,
        variableCustomItemLength: true,
      },
    };
    await axios(config)
      .then((response) => {
        this.csvId = response.data.id;
      })
      .catch((err) => {
        throw this.buildErrorMessage(err);
      });
  }

  /**
   * CSV 取り込み処理の結果を確認する
   */
  async checkResult() {
    const config = {
      method: 'GET',
      url: `https://${this.subDomainName}/v1/csv/result.json?id=${this.csvId}`,
      headers: {
        'X-Cybozu-Authorization': this.auth,
      },
    };
    for (let reqCount = 0; reqCount < 180; reqCount++) {
      const result = await axios(config).catch((err) => {
        throw new Error(err);
      });
      const data = result.data;
      if (data.done && data.success) {
        return;
      } else if (data.errorCode !== null) {
        throw new Error(data.errorCode);
      }
      await new Promise((resolve) => setTimeout(resolve, this.sleepTime));
    }
    throw new Error('Cybozu.comのCSV更新に時間がかかっているため停止します');
  }

  /**
   * 認証情報をBase64エンコードする
   * @param {string} id cybozu.com ログイン名
   * @param {string} pw cybozu.com パスワード
   * @return {string} Base64 エンコードした認証情報
   */
  encodeBase64Auth(id, pw) {
    const buffer = Buffer.from(id + ':' + pw);
    return buffer.toString('base64');
  }
}

/**
 * cybozu.com ユーザー情報の処理クラス
 */
class CybozuUserInfo extends CybozuCom {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {Object} envSetting 環境変数
   */
  constructor(context, envSetting) {
    super(context, 'user.csv', '/v1/csv/user.json', envSetting);
    this.users = {};
    this.userIdTargets = {
      new: [],
      update: [],
      delete: [],
    };
    this.envSetting = envSetting;
  }

  /**
   * ユーザー情報を返す
   * @return {object} ユーザー情報
   */
  getUsers() {
    return this.users;
  }

  /**
   * 新規登録するユーザーのID一覧を取得する
   * @return {Array<string>} 登録対象のユーザーのID一覧
   */
  getNewUserIds() {
    return this.userIdTargets.new;
  }

  /**
   * 更新対象のユーザーのID一覧を取得する
   * @return {Array<string>} 更新対象のユーザーのID一覧
   */
  getUpdateUserIds() {
    return this.userIdTargets.update;
  }

  /**
   * 削除対象のユーザーのID一覧を取得する
   * @return {Array<string>} 削除対象のユーザーのID一覧
   */
  getDeleteUserIds() {
    return this.userIdTargets.delete;
  }

  /**
   * cybozu.com からユーザー情報を取得する
   */
  async fetchUserInfo() {
    const config = {
      url: `https://${this.subDomainName}/v1/csv/user.csv`,
      headers: {
        'X-Cybozu-Authorization': this.auth,
      },
    };
    await axios(config)
      .then((response) => {
        this.users = this.convertUserInfoList(response.data);
        this.context.log.info(
          `cybozu.com から取得したユーザの件数: ${
            Object.keys(this.users).length
          } 件`
        );
      })
      .catch((err) => {
        throw this.buildErrorMessage(err);
      });
  }

  /**
   * 取得したユーザー情報の一覧をログイン名をキーに持つオブジェクトに変換する
   * @param {Array<Object>} data cybozu.com から取得したユーザー情報
   * @return {Object} ログイン名をキーに持つユーザー情報オブジェクト
   */
  convertUserInfoList(data) {
    const csvUsers = parse(data);
    const users = {};
    csvUsers.forEach((user) => {
      users[user[0]] = {
        login_user: user[0],
        display_name: user[1],
        surname: user[4],
        given_name: user[5],
        email_address: user[10],
        status: {
          cybozu: user[11],
          azureAd: null,
        },
        phone: user[14],
        mobile_phone: user[15],
        organizationTitles: [],
      };
    });
    return users;
  }

  /**
   * Entra ID のユーザー情報を設定する
   * cybozu.com にないユーザー情報があれば登録対象に設定する
   * @param {Array<Object>} azureAdUsers Entra ID のユーザー情報
   */
  setUserInfoFromAzureAd(azureAdUsers) {
    azureAdUsers.forEach((user) => {
      const loginId = this.getLoginId(
        this.getAzureUserInfoValue(user, this.envSetting.loginName)
      );
      const newLoginId = this.getLoginId(
        this.getAzureUserInfoValue(user, this.envSetting.newLoginName)
      );
      const azureAdUserInfo = {
        display_name: this.getAzureUserInfoValue(
          user,
          this.envSetting.displayName
        ),
        new_login_name: newLoginId,
        surname: this.getAzureUserInfoValue(user, this.envSetting.surname),
        given_name: this.getAzureUserInfoValue(user, this.envSetting.givenName),
        email_address: this.getAzureUserInfoValue(
          user,
          this.envSetting.emailAddress
        ),
        phone: this.getAzureUserInfoValue(user, this.envSetting.phone),
        mobile_phone: this.getAzureUserInfoValue(
          user,
          this.envSetting.mobilePhone
        ),
        status: this.getAzureUserStatus(user, this.envSetting.status),
        organizationTitles: user.organizationTitles,
      };
      const status =
        azureAdUserInfo.status || azureAdUserInfo.status === null ? '1' : '0';
      const phone =
        azureAdUserInfo.phone.length > 0 ? azureAdUserInfo.phone[0] : '';
      if (loginId in this.users) {
        // azureAd cybozu.com 両方にある場合
        this.users[loginId].display_name = azureAdUserInfo.display_name;
        this.users[loginId].new_login_name = azureAdUserInfo.new_login_name;
        this.users[loginId].surname = azureAdUserInfo.surname;
        this.users[loginId].given_name = azureAdUserInfo.given_name;
        this.users[loginId].email_address = azureAdUserInfo.email_address;
        this.users[loginId].status.azureAd = status;
        this.users[loginId].phone = phone;
        this.users[loginId].mobile_phone = azureAdUserInfo.mobile_phone;
        this.users[loginId].organizationTitles =
          azureAdUserInfo.organizationTitles;
      } else {
        // azureAd のみある場合
        this.users[loginId] = {
          login_user: loginId,
          display_name: azureAdUserInfo.display_name,
          new_login_name: azureAdUserInfo.new_login_name,
          surname: azureAdUserInfo.surname,
          given_name: azureAdUserInfo.given_name,
          email_address: azureAdUserInfo.email_address,
          status: {
            cybozu: null,
            azureAd: status,
          },
          phone: phone,
          mobile_phone: azureAdUserInfo.mobile_phone,
          organizationTitles: azureAdUserInfo.organizationTitles,
        };
        if (status === '1') {
          const tmpNewLoginId = newLoginId || loginId;
          this.userIdTargets.new.push(
            loginId === tmpNewLoginId ? loginId : tmpNewLoginId
          );
        }
      }
    });
  }

  /**
   * cybozu.com から取得したユーザーの所属する組織情報を、ユーザー情報に追加する
   * @param {Object} userOrganizations cybozu.com から取得したユーザーの所属する組織情報
   */
  addUserOrganizationTitles(userOrganizations) {
    for (const key in userOrganizations) {
      if (!Object.prototype.hasOwnProperty.call(userOrganizations, key)) {
        continue;
      }
      userOrganizations[key].organizationTitles.forEach((organizationTitle) => {
        this.users[key].organizationTitles.push({
          organization: organizationTitle.organizationName,
          title: organizationTitle.organizationTitle,
        });
      });
    }
  }

  /**
   * ユーザーインポートAPI(CSV)を利用するための csv 情報を生成する
   * @return {Object} ユーザー情報の csv
   */
  makeUserCsv() {
    const cybozuCsv = [];
    for (const key in this.users) {
      if (!Object.prototype.hasOwnProperty.call(this.users, key)) {
        continue;
      }
      const user = this.users[key];
      if (user.isCsvTarget) {
        const status = user.status.csvStatus;
        cybozuCsv.push({
          login_name: user.login_user,
          display_name: this.getUpdateValue(status, user.display_name),
          new_login_name: this.getUpdateValue(status, user.new_login_name),
          password: user.passwd,
          surname: this.getUpdateValue(status, user.surname),
          given_name: this.getUpdateValue(status, user.given_name),
          phonetic_surname: '*',
          phonetic_given_name: '*',
          localized_name: '*',
          language_for_localized_name: '*',
          email_address: this.getUpdateValue(status, user.email_address),
          status: status,
          language: 'auto',
          time_zone: '*',
          phone: this.getUpdateValue(status, user.phone),
          extension: '*',
          mobile_phone: this.getUpdateValue(status, user.mobile_phone),
          url: '*',
          employee_id: '*',
          hire_data: '*',
          birthday: '*',
          about_me: '*',
          display_order: '*',
          skype_name: '*',
          to_be_deleted: user.toBeDeleted,
          customize: '*',
        });
      }
    }
    return stringify(cybozuCsv);
  }

  /**
   * Entra ID と cybozu.com の登録状況に応じて、更新対象と削除対象に振り分ける
   */
  setUserExtensionParameters() {
    for (const key in this.users) {
      if (!Object.prototype.hasOwnProperty.call(this.users, key)) {
        continue;
      }
      const user = this.users[key];
      const userStatus = user.status;
      let status = '1';
      let passwd = '*';
      let toBeDeleted = '*';
      user.isCsvTarget = true;

      if (userStatus.cybozu === null) {
        if (!this.isAccountEnabled(userStatus.azureAd)) {
          user.isCsvTarget = false;
          continue;
        }
        passwd = this.existEnvConfigValue(this.envSetting.password)
          ? this.envSetting.password
          : '*';
      } else if (this.isAccountEnabled(userStatus.cybozu)) {
        if (userStatus.azureAd === null) {
          if (this.envSetting.toBeDeleted !== '1') {
            user.isCsvTarget = false;
            continue;
          }
          toBeDeleted = '1';
          status = '*';
        } else if (!this.isAccountEnabled(userStatus.azureAd)) {
          status = '0';
        }
      } else if (!this.isAccountEnabled(userStatus.cybozu)) {
        if (userStatus.azureAd === null) {
          if (this.envSetting.toBeDeleted !== '1') {
            user.isCsvTarget = false;
            continue;
          }
          toBeDeleted = '1';
          status = '*';
        } else if (!this.isAccountEnabled(userStatus.azureAd)) {
          user.isCsvTarget = false;
          continue;
        }
      }
      user.status.csvStatus = status;
      user.passwd = passwd;
      user.toBeDeleted = toBeDeleted;

      if (toBeDeleted === '1') {
        this.userIdTargets.delete.push(user.login_user);
      } else {
        this.userIdTargets.update.push(user.login_user);
      }
    }
  }

  /**
   * ログインIDを取得する
   * @param {string} loginId ログインID
   */
  getLoginId(loginId) {
    if (this.envSetting.userNameOnly === '1') {
      return this.getLoginIdFromAzureAdLoginId(loginId);
    }
    return loginId;
  }

  /**
   * Entra ID のユーザー名(@の前)を取得する
   * @param {string} azureAdLoginId Entra ID のユーザー名(@の前)
   */
  getLoginIdFromAzureAdLoginId(azureAdLoginId) {
    return azureAdLoginId.split('@')[0];
  }

  /**
   * ユーザーアカウントが有効か
   * @param {string} status ユーザーアカウントの状態
   * @return {boolean} ユーザーアカウントが有効なら true
   */
  isAccountEnabled(status) {
    return status === '1';
  }

  /**
   * ステータスに応じて更新するユーザー情報の項目の値を取得する
   * @param {*} status ステータス
   * @param {*} value ユーザー情報の項目の値
   * @return {string} 無効なら * を返す
   */
  getUpdateValue(status, value) {
    return status === 0 ? '*' : this.getUserInfoValue(value);
  }

  /**
   * ユーザー情報の項目の値を取得する
   * @param {string} value ユーザー情報の項目の値
   * @return {string} 空なら * を返す
   */
  getUserInfoValue(value) {
    const isEmpty = value === null || typeof value === 'undefined';
    return isEmpty ? '*' : value;
  }

  /**
   * Entra ID のユーザー情報の項目の値を取得する
   * @param {Object} user Entra ID のユーザー情報
   * @param {string} envValue Entra ID のユーザー項目名
   * @return {string} Entra ID のユーザー情報の項目の値(存在しなければ *)
   */
  getAzureUserInfoValue(user, envValue) {
    let result = '*';
    if (this.existEnvConfigValue(envValue)) {
      result = user[envValue] || '*';
    }
    return result;
  }

  /**
   * Entra ID のユーザーの使用状況
   * @param {Object} user Entra ID のユーザー情報
   * @param {string} envValue Entra ID のユーザー項目名
   * @return {string} Entra ID のユーザーの使用状況(存在しなければ空)
   */
  getAzureUserStatus(user, envValue) {
    let result = '';
    if (this.existEnvConfigValue(envValue)) {
      result = typeof user[envValue] === 'undefined' ? '' : user[envValue];
    }
    return result;
  }

  /**
   * 環境変数にキーに一致する値が存在するか
   * @param {string} envPropName 環境変数のキー
   * @return {boolean} 環境変数に設定されていたら true
   */
  existEnvConfigValue(envPropName) {
    return !(envPropName === '' || typeof envPropName === 'undefined');
  }

  /**
   * CSV ファイルをアップロードする
   */
  async uploadCsv() {
    this.setUserExtensionParameters();
    await super.uploadCsv(this.makeUserCsv());
  }

  /**
   * アップロードした CSV でユーザー情報を更新する
   */
  async applyCsv() {
    await super.applyCsv();
    this.changeCybozuStatusToActive();
  }

  /**
   * cybozu.com に登録済みフラグを立てる
   */
  changeCybozuStatusToActive() {
    for (const key in this.users) {
      if (!Object.prototype.hasOwnProperty.call(this.users, key)) {
        continue;
      }
      const status = {
        cybozu: this.users[key].status.cybozu,
        azureAd: this.users[key].status.azureAd,
      };
      if (status.cybozu === null && status.azureAd === '1') {
        this.users[key].status.cybozu = '1';
      }
    }
  }
}

/**
 * cybozu.com ユーザーの利用サービスに関する処理
 */
class CybozuService extends CybozuCom {
  /**
   *
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {Object} envSetting 環境変数
   */
  constructor(context, envSetting) {
    super(context, 'service.csv', '/v1/csv/userServices.json', envSetting);
    this.services = this.getCybozuService(envSetting.cybozuServices);
    this.userIds = [];
  }

  /**
   * 新規登録するユーザーの ID 一覧を設定する
   * @param {Array<string>} userIds
   */
  setNewUserIds(userIds) {
    this.userIds = userIds;
  }

  /**
   * 環境変数で設定した利用するサービスコードを配列に変換する
   * @param {Object} 環境変数で設定したサービスコード(カンマ区切り)
   * @return {Array<string>} 配列に変換したサービスコード一覧
   */
  getCybozuService(confServices) {
    const services = ['ki', 'gr'];
    return confServices
      .split(',')
      .map((service) => {
        return service.trim();
      })
      .filter((service) => {
        return services.indexOf(service) >= 0;
      });
  }

  /**
   * ユーザーの利用サービスインポートAPI を利用するための CSV を生成する
   * @return {Object} ユーザーの利用サービス情報の csv
   */
  makeCyServiceCsv() {
    const importCsv = [];
    this.userIds.forEach((userId) => {
      importCsv.push([userId, ...this.services]);
    });
    return stringify(importCsv);
  }

  /**
   * CSV ファイルをアップロードする
   */
  async uploadCsv() {
    await super.uploadCsv(this.makeCyServiceCsv());
  }

  /**
   * アップロードした CSV でユーザーの利用サービス情報を更新する
   */
  async applyCsv() {
    await super.applyCsv();
  }
}

/**
 * ユーザーの所属組織に関する処理
 */
class CybozuUserOrganization extends CybozuCom {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {Object} envSetting 環境変数
   */
  constructor(context, envSetting) {
    super(
      context,
      'userOrganization.csv',
      '/v1/csv/userOrganizations.json',
      envSetting
    );
    this.userOrgs = {};
    this.envSetting = envSetting;
  }

  /**
   * cybozu.com からユーザーの所属組織情報を取得する
   */
  async fetchUserOrganizationTitles() {
    const config = {
      url: `https://${this.subDomainName}/v1/csv/userOrganizations.csv`,
      headers: {
        'X-Cybozu-Authorization': this.auth,
      },
    };
    await axios(config)
      .then((response) => {
        this.userOrgs = this.convertUserOrganizationsList(response.data);
      })
      .catch((err) => {
        throw this.buildErrorMessage(err);
      });
  }

  /**
   * 取得したユーザーの所属する組織情報をログイン名をキーに持つオブジェクトに変換する
   * @param {Array<Object>} data cybozu.com から取得したユーザーの所属する組織情報
   * @return {Object} ログイン名をキーに持つユーザーの所属する組織情報オブジェクト
   */
  convertUserOrganizationsList(data) {
    const userOrgsCsv = parse(data, {relax_column_count: true});
    const userOrgs = {};
    userOrgsCsv.forEach((userOrgTitle) => {
      const loginId = userOrgTitle[0];
      const organizationTitles = [];
      for (let i = 1; i < userOrgTitle.length; i++) {
        if (i % 2 === 1) {
          const organizationName = userOrgTitle[i];
          const organizationTitle =
            i + 1 < userOrgTitle.length ? userOrgTitle[i + 1] : '';
          organizationTitles.push({
            organizationName: organizationName,
            organizationTitle: organizationTitle,
          });
        }
      }
      userOrgs[loginId] = {
        login_user: loginId,
        organizationTitles: organizationTitles,
      };
    });
    return userOrgs;
  }

  /**
   * ログイン名をキーに持つ変換したユーザーの所属する組織情報オブジェクトを取得する
   * @return {Object} ログイン名をキーに持つユーザーの所属する組織情報オブジェクト
   */
  getUserOrganizations() {
    return this.userOrgs;
  }

  /**
   * ユーザーの所属組織情報インポートAPI(CSV)を利用するための csv 情報を生成する
   * @param {Object} users ユーザー情報
   * @param {Object} orgs 組織情報
   * @param {Object} titles 役割情報
   * @return {Object} ユーザーの所属組織情報の csv
   */
  makeUserOrganizationCsv(users, orgs, titles) {
    const userOrgCsv = [];
    for (const key in users) {
      if (!Object.prototype.hasOwnProperty.call(users, key)) {
        continue;
      }
      if (users[key].status.cybozu === null || users[key].toBeDeleted === '1') {
        continue;
      }
      const newLoginId = users[key].new_login_name || users[key].login_user;
      const loginId =
        users[key].login_user === newLoginId
          ? users[key].login_user
          : newLoginId;
      const uploadUsers = [loginId];
      const orgTitles = this.ommitOrganizationTitles(
        users[key].organizationTitles,
        orgs,
        titles
      );
      if (orgTitles.length > 0) {
        orgTitles.forEach((orgTitle) => {
          uploadUsers.push(orgTitle.organization);
          uploadUsers.push(orgTitle.title);
        });
      }
      userOrgCsv.push(uploadUsers);
    }
    return stringify(userOrgCsv);
  }

  /**
   * `registerNewOrganizations` フラグが立っていないときに
   * ユーザーが所属する組織と役職の一覧から存在しない組織・役職情報がある場合を取り除く
   * @param {Array<Object>} organizationTitles ユーザーが所属する組織と役職の一覧
   * @param {Object} orgs 組織情報
   * @param {Object} titles 役割情報
   * @return {Array<Object>} ユーザーが所属する組織と役職の一覧
   */
  ommitOrganizationTitles(organizationTitles, orgs, titles) {
    if (this.envSetting.registerNewOrganizations === '1') {
      return organizationTitles;
    }
    return organizationTitles
      .filter((orgTitle) => orgTitle.organization in orgs)
      .map((orgTitle) => {
        orgTitle.title = orgTitle.title in titles ? orgTitle.title : '';
        return orgTitle;
      });
  }

  /**
   * CSV ファイルをアップロードする
   * @param {Object} users ユーザー情報
   * @param {Object} orgs 組織情報
   * @param {Object} titles 役割情報
   */
  async uploadCsv(users, orgs, titles) {
    await super.uploadCsv(this.makeUserOrganizationCsv(users, orgs, titles));
  }

  /**
   * アップロードした CSV でユーザーの所属組織情報を更新する
   */
  async applyCsv() {
    await super.applyCsv();
  }
}

/**
 * 組織情報に関する処理
 */
class CybozuOrganization extends CybozuCom {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {Object} envSetting 環境変数
   */
  constructor(context, envSetting) {
    super(context, 'organization.csv', '/v1/csv/organization.json', envSetting);
    this.orgs = {};
    this.newOrgs = {};
  }

  /**
   * 組織情報を取得する
   * @return {Object} 組織情報
   */
  getOrgs() {
    return this.orgs;
  }

  /**
   * 新規の組織情報を取得する
   */
  getNewOrgs() {
    return this.newOrgs;
  }

  /**
   * cybozu.com から組織情報を取得する
   */
  async fetchOrganizations() {
    const config = {
      url: `https://${this.subDomainName}/v1/csv/organization.csv`,
      headers: {
        'X-Cybozu-Authorization': this.auth,
      },
    };
    await axios(config)
      .then((response) => {
        this.orgs = this.convertOrganizationList(response.data);
        this.context.log.info(
          `cybozu.com から取得した組織の件数: ${
            Object.keys(this.orgs).length
          } 件`
        );
      })
      .catch((err) => {
        throw new Error(err);
      });
  }

  /**
   * 取得した組織情報の一覧を組織コードをキーに持つオブジェクトに変換する
   * @param {Array<Object>} data cybozu.com から取得した組織情報
   * @return {Object} 組織コードをキーに持つ変換した組織情報オブジェクト
   */
  convertOrganizationList(data) {
    const csv = parse(data);
    const orgs = {};
    csv.forEach((value) => {
      orgs[value[0]] = {
        department_code: value[0],
        display_name: value[1],
        new_department_code: value[2],
        localized_name: value[3],
        language_for_localizes_name: value[4],
        parent_department_code: value[5],
        description: value[6],
      };
    });
    return orgs;
  }

  /**
   * 新規の組織情報(Entra ID から取得した組織情報のうち、cybozu.com に登録されていない組織情報)を設定する
   * @param {Array<Object>} otherOrganizations Entra ID から取得した組織情報
   */
  setNewOrganizations(otherOrganizations) {
    otherOrganizations.forEach((otherOrgName) => {
      if (!(otherOrgName in this.orgs || otherOrgName in this.newOrgs)) {
        this.newOrgs[otherOrgName] = {
          department_code: otherOrgName,
          display_name: otherOrgName,
          parent_department_code: '',
        };
      }
    });
  }

  /**
   * 組織情報インポートAPI(CSV)を利用するための csv 情報を生成する
   * @return {Object} 組織情報の csv
   */
  makeNewOrganizationCsv() {
    const newOrgCsv = [];
    const organizations = {...this.orgs, ...this.newOrgs};
    for (const key in organizations) {
      if (!Object.prototype.hasOwnProperty.call(organizations, key)) {
        continue;
      }
      newOrgCsv.push({
        department_code: organizations[key].department_code,
        display_name: organizations[key].display_name,
        new_department_code: '*',
        localized_name: '*',
        language_for_localizes_name: '*',
        parent_department_code: organizations[key].parent_department_code,
        description: '*',
      });
    }
    this.context.log.info(
      `新規登録する組織の件数: ${Object.keys(this.newOrgs).length} 件`
    );
    return stringify(newOrgCsv);
  }

  /**
   * CSV ファイルをアップロードする
   */
  async uploadCsv() {
    await super.uploadCsv(this.makeNewOrganizationCsv());
  }

  /**
   * アップロードした CSV でユーザー情報を更新する
   */
  async applyCsv() {
    await super.applyCsv();
  }
}

/**
 * 役職情報に関する処理
 */
class CybozuTitle extends CybozuCom {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {Object} envSetting 環境変数
   */
  constructor(context, envSetting) {
    super(context, 'title.csv', '/v1/csv/title.json', envSetting);
    this.titles = {};
    this.newTitles = {};
  }

  /**
   * 役職情報を取得する
   * @return {Object} 役職情報
   */
  getTitles() {
    return this.titles;
  }

  /**
   * 新規の役職情報を取得する
   * @return {Object} 新規の役職情報
   */
  getNewTitles() {
    return this.newTitles;
  }

  /**
   * cybozu.com から役職情報を取得する
   */
  async fetchTitles() {
    const config = {
      url: `https://${this.subDomainName}/v1/csv/title.csv`,
      headers: {
        'X-Cybozu-Authorization': this.auth,
      },
    };
    await axios(config)
      .then((response) => {
        this.titles = this.convertTitleList(response.data);
        this.context.log.info(
          `cybozu.com から取得した役職の件数: ${
            Object.keys(this.titles).length
          } 件`
        );
      })
      .catch((err) => {
        throw this.buildErrorMessage(err);
      });
  }

  /**
   * 取得した役職情報の一覧を役職コードをキーに持つオブジェクトに変換する
   * @param {Object} data cybozu.com から取得した役職情報
   * @return {Object} 役職コードをキーに持つ役職情報オブジェクト
   */
  convertTitleList(data) {
    const csv = parse(data);
    const titles = {};
    csv.forEach((value) => {
      titles[value[0]] = {
        title_code: value[0],
        title_name: value[1],
        new_title_code: value[2],
        description: value[3],
        to_be_deleted: value[4],
      };
    });
    return titles;
  }

  /**
   * 新規の役職情報(Entra ID から取得した役職のうち、cybozu.com 登録されていないもの)を設定する
   * @param {Object} Entra ID から取得した役職情報
   */
  setNewTitles(otherTitles) {
    otherTitles.forEach((otherTitle) => {
      if (!(otherTitle in this.titles || otherTitle in this.newTitles)) {
        this.newTitles[otherTitle] = {
          title_code: otherTitle,
          title_name: otherTitle,
        };
      }
    });
  }

  /**
   * 役職インポートAPI(CSV)を利用するための csv 情報を生成する
   * @return {Object} 役職情報(新規のみ)の csv
   */
  makeNewTitleCsv() {
    const newTitleCsv = [];
    for (const key in this.newTitles) {
      if (!Object.prototype.hasOwnProperty.call(this.newTitles, key)) {
        continue;
      }
      newTitleCsv.push({
        title_code: this.newTitles[key].title_code,
        title_name: this.newTitles[key].title_name,
        new_title_code: '*',
        description: '*',
        to_be_deleted: '*',
      });
    }
    this.context.log.info(
      `新規登録する役職の件数: ${Object.keys(this.newTitles).length} 件`
    );
    return stringify(newTitleCsv);
  }

  /**
   * CSV ファイルをアップロードする
   */
  async uploadCsv() {
    await super.uploadCsv(this.makeNewTitleCsv());
  }

  /**
   * アップロードした CSV でユーザーの利用サービス情報を更新する
   */
  async applyCsv() {
    await super.applyCsv();
  }
}

/**
 * Entra ID に関する処理
 */
class AzureAd {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   * @param {Object} envSetting 環境変数
   */
  constructor(context, envSetting) {
    this.context = context;
    this.envSetting = envSetting;
    this.users = [];
    this.orgs = [];
    this.titles = [];
    this.appSettings = {};
  }

  /**
   * ユーザー情報を取得する
   * @return {Array<Object>} ユーザー情報
   */
  getUsers() {
    return this.users;
  }

  /**
   * Entra ID から取得する項目の一覧を取得する
   * @return {Array<string>} Entra IDから取得する項目の一覧
   */
  getRequestItems() {
    return [
      this.envSetting.loginName,
      this.envSetting.displayName,
      this.envSetting.surname,
      this.envSetting.givenName,
      this.envSetting.emailAddress,
      this.envSetting.status,
      this.envSetting.phone,
      this.envSetting.mobilePhone,
      this.envSetting.departmentCode,
      this.envSetting.jobTitleCode,
    ].filter((item) => {
      return !!item;
    });
  }

  /**
   * アクセストークンを設定した GraphAPI クライアントを生成する
   * @returns GraphAPI クライアント
   */
  async buildAuthenticatedClient() {
    const credential = new DefaultAzureCredential();
    const tokenResponse = await credential.getToken(
      'https://graph.microsoft.com/.default'
    );
    const client = graph.Client.init({
      authProvider: (done) => {
        done(null, tokenResponse.token);
      },
    });

    return client;
  }

  /**
   * Entra ID からユーザー情報を取得する
   */
  async fetchUserInfo() {
    const graphClient = await this.buildAuthenticatedClient();
    const items = [...this.getRequestItems(), 'isResourceAccount'];
    const resp = await graphClient
      .api(`/users?$select=${items.join(',')}`)
      .get();
    this.users = this.addOrgAndTitlesToUserInfoList(resp.value);
    this.context.log.info(
      `Entra ID から取得したユーザの件数: ${Object.keys(this.users).length} 件`
    );
  }

  /**
   * 取得したユーザー情報に組織情報と役職情報を追加する
   * @param {Array<Object>} data Entra ID から取得したユーザー情報
   * @return {Array<Object>} 組織情報と役職情報が追加されたユーザー情報
   */
  addOrgAndTitlesToUserInfoList(data) {
    const users = data.map((user) => {
      const departments = this.getAzureUserInfoValue(
        user,
        this.envSetting.departmentCode
      );
      const jobTitles = this.getAzureUserInfoValue(
        user,
        this.envSetting.jobTitleCode
      );
      user.organizationTitles = [];
      departments.forEach((dep, i) => {
        user.organizationTitles.push({
          organization: dep,
          title: typeof jobTitles[i] !== 'undefined' ? jobTitles[i] : '',
        });
      });
      return user;
    });
    return users;
  }

  /**
   * 組織情報を取得する
   * @return {Array<Object>} 組織情報
   */
  getOrganizations() {
    return this.orgs;
  }

  /**
   * 役職情報を取得する
   * @return {Array<Object>} 役職情報
   */
  getTitles() {
    return this.titles;
  }

  /**
   * ユーザー情報から項目名に一致する値を取得する
   * @param {Object} user ユーザー情報
   * @param {string} envValue 環境変数で設定された項目名
   * @return {string} ユーザー情報の項目の値
   */
  getAzureUserInfoValue(user, envValue) {
    let result = '';
    if (this.existEnvConfigValue(envValue)) {
      result =
        user[envValue] !== null
          ? user[envValue].split(',').map((val) => val.trim())
          : [];
    }
    return result;
  }

  /**
   * 環境変数にキーに一致する値が存在するか
   * @param {string} envPropName 環境変数のキー
   * @return {boolean} 環境変数に設定されていたら true
   */
  existEnvConfigValue(envPropName) {
    return !(envPropName === '' || typeof envPropName === 'undefined');
  }

  /**
   * 組織情報を設定する
   */
  setOrganizations() {
    this.users.forEach((user) => {
      if (user.accountEnabled) {
        user.organizationTitles.forEach((orgTitle) => {
          const org = orgTitle.organization;
          if (org === '' || this.orgs.indexOf(org) >= 0) {
            return;
          }
          this.orgs.push(org);
        });
      }
    });
  }

  /**
   * 役職情報を設定する
   */
  setTitles() {
    this.users.forEach((user) => {
      if (user.accountEnabled) {
        user.organizationTitles.forEach((orgTitle) => {
          const title = orgTitle.title;
          if (title === '' || this.titles.indexOf(title) >= 0) {
            return;
          }
          this.titles.push(title);
        });
      }
    });
  }
}

/**
 * ユーザー情報同期を行うメインクラス
 */
class UserSyncProgram {
  /**
   * @param {Object} context Azure Functions から渡される context オブジェクト
   */
  constructor(context) {
    this.envSetting = new EnvSetting(process.env);
    this.context = context;
    this.azureAd = new AzureAd(context, this.envSetting);
    this.cyUserInfo = new CybozuUserInfo(context, this.envSetting);
    this.cyService = new CybozuService(context, this.envSetting);
    this.cyOrganization = new CybozuOrganization(context, this.envSetting);
    this.cyUserOrganization = new CybozuUserOrganization(
      context,
      this.envSetting
    );
    this.cyTitle = new CybozuTitle(context, this.envSetting);
  }

  /**
   * 初期設定
   * 現在の cybozu.com / Entra ID のユーザー情報を取得する
   */
  async init() {
    try {
      await this.cyUserInfo.fetchUserInfo();
      await this.azureAd.fetchUserInfo();
      await this.cyUserOrganization.fetchUserOrganizationTitles();
      this.cyUserInfo.addUserOrganizationTitles(
        this.cyUserOrganization.getUserOrganizations()
      );
      this.cyUserInfo.setUserInfoFromAzureAd(this.azureAd.getUsers());
    } catch (err) {
      throw new Error(err);
    }
  }

  /**
   * ユーザー情報を同期する
   */
  async syncUserInfo() {
    try {
      await this.cyUserInfo.uploadCsv();
      await this.cyUserInfo.applyCsv();
      await this.cyUserInfo.checkResult();
      await this.syncUserService();
    } catch (err) {
      throw new Error(err);
    }
  }

  /**
   * ユーザーの利用サービス情報を同期する
   */
  async syncUserService() {
    try {
      this.cyService.setNewUserIds(this.cyUserInfo.getNewUserIds());
      await this.cyService.uploadCsv();
      await this.cyService.applyCsv();
      await this.cyService.checkResult();
    } catch (err) {
      throw new Error(err);
    }
  }

  /**
   * 組織情報を同期する
   */
  async syncOrganization() {
    try {
      await this.cyOrganization.fetchOrganizations();
      this.azureAd.setOrganizations();
      this.cyOrganization.setNewOrganizations(this.azureAd.getOrganizations());
      if (this.isConfigValid(this.envSetting.registerNewOrganizations)) {
        await this.cyOrganization.uploadCsv();
        await this.cyOrganization.applyCsv();
        await this.cyOrganization.checkResult();
      }
    } catch (err) {
      throw new Error(err);
    }
  }

  /**
   * 役職情報を同期する
   */
  async syncTitle() {
    try {
      await this.cyTitle.fetchTitles();
      this.azureAd.setTitles();
      this.cyTitle.setNewTitles(this.azureAd.getTitles());
      if (this.isConfigValid(this.envSetting.registerNewOrganizations)) {
        await this.cyTitle.uploadCsv();
        await this.cyTitle.applyCsv();
        await this.cyTitle.checkResult();
      }
    } catch (err) {
      throw new Error(err);
    }
  }

  /**
   * ユーザーの所属する組織情報を同期する
   */
  async syncUserOrganizationTitle() {
    try {
      await this.cyUserOrganization.uploadCsv(
        this.cyUserInfo.getUsers(),
        this.cyOrganization.getOrgs(),
        this.cyTitle.getTitles()
      );
      await this.cyUserOrganization.applyCsv();
      await this.cyUserOrganization.checkResult();
    } catch (err) {
      throw new Error(err);
    }
  }

  /**
   * 処理結果をログに表示する
   */
  showResultLog() {
    const logs = [];
    const num = {
      user: {
        new: this.cyUserInfo.getNewUserIds().length,
        update:
          this.cyUserInfo.getUpdateUserIds().length -
          this.cyUserInfo.getNewUserIds().length,
        delete: this.cyUserInfo.getDeleteUserIds().length,
      },
      org: Object.keys(this.cyOrganization.getNewOrgs()).length,
      title: Object.keys(this.cyTitle.getNewTitles()).length,
    };
    logs.push(`ユーザ新規登録: ${num.user.new} 件`);
    logs.push(`ユーザ更新: ${num.user.update} 件`);
    this.context.log.info(logs.join(', '));
  }

  /**
   * 設定のバリデーションを行う
   * @param {string} flag
   * @return {boolean} flag が 1 なら true を返す
   */
  isConfigValid(flag) {
    return flag === '1';
  }
}

/**
 * Azure Functions
 * @param {Object} context Azure Functions から渡される context オブジェクト
 * @param {Object} _req Azure Functions から渡される入力
 */
module.exports = async function(context, _req) {
  context.log.info('ユーザ連携処理開始');
  try {
    const userSync = new UserSyncProgram(context);
    await userSync.init();
    await userSync.syncUserInfo();
    await userSync.syncOrganization();
    await userSync.syncTitle();
    await userSync.syncUserOrganizationTitle();
    userSync.showResultLog();
  } catch (error) {
    context.log.error('エラーが発生しました: ' + error);
    throw new Error(error);
  }
  context.log.info('ユーザ連携処理終了');
};

おわりに

この記事で紹介した方法を行うと、Entra ID で管理していたユーザー情報を cybozu.com を同期が楽になります。

利用しているAPI

このカスタマイズは、サイボウズ オフィシャル SI パートナー クロス・ヘッド株式会社による有償サポートの対象カスタマイズです。
詳細は サイボウズ製品API開発サポート (External link) を参照してください。

また、クロス・ヘッド株式会社では、 サイボウズ製品のシングルサインオンサービス (External link) も提供しています。

information

この Tips は、2023 年 1 月版 cybozu.com で動作を確認しています。