GitHub Enterprise Server & Entra IDとのSSO設定

GitHub Enterprise Server (GHES) & Entra IDでSSOを設定する手順。
備忘録として纏めます。

## 前提
- GitHub Enterprise Server (GHES) ライセンスを持っていること
- Entra ID 管理者権限を持っていること

## GHES用 仮想マシンの作成
AzureでGHES用の仮想マシンを構築します。
以下の最小要件を参照しながら進めます。

https://docs.github.com/ja/enterprise-server@3.15/admin/installing-your-enterprise-server/setting-up-a-github-enterprise-server-instance/installing-github-enterprise-server-on-azure#%E6%9C%80%E5%B0%8F%E6%8E%A8%E5%A5%A8%E8%A6%81%E4%BB%B6

今回は検証用途で、作ったり壊してを繰り返したかったのでIaCに。
以下、Terraform 👇️

<details><summary>サンプルコード</summary>

```terraform:terraform.tf
terraform {
  required_version = ">=0.12"

  required_providers {
    azapi = {
      source  = "azure/azapi"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
    }
    random = {
      source  = "hashicorp/random"
    }
  }
}

provider "azurerm" {
  features {}
}
```

```terraform:variables.tf
variable "location" {
  type = string
  default = "japaneast"
}

variable "prefix" {
  type = string
  default = "ghes"
}

variable "resource_group_name" {
  type = string
  default = "rg-ghes"
}

variable "host_name" {
    type = string
    description = "value of vm host name"
    default = "×××××××××××"
}

variable "username" {
  type = string
  default = "azureuser"
}
```
```terraform:ssh.tf
resource "random_pet" "ssh_key_name" {
  prefix    = "ssh"
  separator = ""
}

resource "azapi_resource_action" "ssh_public_key_gen" {
  type        = "Microsoft.Compute/sshPublicKeys@2022-11-01"
  resource_id = azapi_resource.ssh_public_key.id
  action      = "generateKeyPair"
  method      = "POST"

  response_export_values = ["publicKey", "privateKey"]
}

resource "azapi_resource" "ssh_public_key" {
  type      = "Microsoft.Compute/sshPublicKeys@2022-11-01"
  name      = random_pet.ssh_key_name.id
  location  = azurerm_resource_group.rg.location
  parent_id = azurerm_resource_group.rg.id
}

resource "local_file" "public_key_file" {
  content =  azapi_resource_action.ssh_public_key_gen.output.publicKey
  filename = "./id_rsa.pub"
}

resource "local_file" "private_key_file" {
  content  = azapi_resource_action.ssh_public_key_gen.output.privateKey
  filename = "./id_rsa"
}
```
```terraform:main.tf
resource "azurerm_resource_group" "rg" {
  location = var.location
  name     = var.resource_group_name
}

# Vnet
resource "azurerm_virtual_network" "vnet" {
  address_space       = ["10.0.0.0/16"]
  location            = var.location
  name                = "vnet-${var.prefix}"
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_subnet" "subnet_default" {
  address_prefixes     = ["10.0.0.0/24"]
  name                 = "default"
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.vnet.name
}

resource "azurerm_public_ip" "public_ip" {
  allocation_method   = "Static"
  domain_name_label   = var.host_name
  location            = var.location
  name                = "ip-${var.prefix}"
  resource_group_name = var.resource_group_name
  sku                 = "Standard"
  depends_on = [
    azurerm_resource_group.rg,
  ]
}

resource "azurerm_network_interface" "nic" {
  location            = var.location
  name                = "nic-${var.prefix}"
  resource_group_name = var.resource_group_name

  ip_configuration {
    name                          = "ipconfig1"
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.public_ip.id
    subnet_id                     = azurerm_subnet.subnet_default.id
  }
}

resource "azurerm_network_security_group" "nsg" {
  location            = var.location
  name                = "nsg-${var.prefix}"
  resource_group_name = var.resource_group_name
  depends_on = [
    azurerm_resource_group.rg,
  ]
}

resource "azurerm_network_interface_security_group_association" "nsgassoc" {
  network_interface_id      = azurerm_network_interface.nic.id
  network_security_group_id = azurerm_network_security_group.nsg.id
}

resource "azurerm_network_security_rule" "allow_http_inbound" {
  access                      = "Allow"
  destination_address_prefix  = "*"
  destination_port_range      = "80"
  direction                   = "Inbound"
  name                        = "AllowAnyHTTPInbound"
  network_security_group_name = azurerm_network_security_group.nsg.name
  priority                    = 1025
  protocol                    = "Tcp"
  resource_group_name         = var.resource_group_name
  source_address_prefix       = "*"
  source_port_range           = "*"
}

resource "azurerm_network_security_rule" "allow_git_end_user" {
  access                      = "Allow"
  destination_address_prefix  = "*"
  destination_port_range      = "9418"
  direction                   = "Inbound"
  name                        = "Git-End_user"
  network_security_group_name = azurerm_network_security_group.nsg.name
  priority                    = 1050
  protocol                    = "Tcp"
  resource_group_name         = var.resource_group_name
  source_address_prefix       = "*"
  source_port_range           = "*"
}

resource "azurerm_network_security_rule" "allow_https_end_user" {
  access                      = "Allow"
  destination_address_prefix  = "*"
  destination_port_range      = "443"
  direction                   = "Inbound"
  name                        = "HTTPS-End_user"
  network_security_group_name = azurerm_network_security_group.nsg.name
  priority                    = 1030
  protocol                    = "Tcp"
  resource_group_name         = var.resource_group_name
  source_address_prefix       = "*"
  source_port_range           = "*"
}

resource "azurerm_network_security_rule" "allow_https_management_console" {
  access                      = "Allow"
  destination_address_prefix  = "*"
  destination_port_range      = "8443"
  direction                   = "Inbound"
  name                        = "HTTPS-Management_console"
  network_security_group_name = azurerm_network_security_group.nsg.name
  priority                    = 1010
  protocol                    = "Tcp"
  resource_group_name         = var.resource_group_name
  source_address_prefix       = "*"
  source_port_range           = "*"
}

resource "azurerm_network_security_rule" "allow_ssh_end_user" {
  access                      = "Allow"
  destination_address_prefix  = "*"
  destination_port_range      = "22"
  direction                   = "Inbound"
  name                        = "SSH-End_user"
  network_security_group_name = azurerm_network_security_group.nsg.name
  priority                    = 1040
  protocol                    = "Tcp"
  resource_group_name         = var.resource_group_name
  source_address_prefix       = "*"
  source_port_range           = "*"
}

resource "azurerm_network_security_rule" "allow_ssh_management_console" {
  access                      = "Allow"
  destination_address_prefix  = "*"
  destination_port_range      = "122"
  direction                   = "Inbound"
  name                        = "SSH-Management_console"
  network_security_group_name = azurerm_network_security_group.nsg.name
  priority                    = 1020
  protocol                    = "Tcp"
  resource_group_name         = var.resource_group_name
  source_address_prefix       = "*"
  source_port_range           = "*"
}

# add data disk
resource "azurerm_managed_disk" "data_disk" {
  create_option        = "Empty"
  location             = var.location
  name                 = "data-disk-${var.prefix}-0"
  resource_group_name  = var.resource_group_name
  storage_account_type = "Premium_LRS"
  disk_size_gb         = 512
  depends_on = [
    azurerm_resource_group.rg,
  ]
}

# vm
resource "azurerm_linux_virtual_machine" "vm" {
  admin_username        = var.username
  location              = var.location
  name                  = "vm-${var.prefix}"
  network_interface_ids = [azurerm_network_interface.nic.id]
  resource_group_name   = var.resource_group_name
  size                  = "Standard_E4s_v3"

  # Generate SSH key
  admin_ssh_key {
    public_key = azapi_resource_action.ssh_public_key_gen.output.publicKey
    username   = var.username
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
  }

  source_image_reference {
    offer     = "GitHub-Enterprise"
    publisher = "GitHub"
    sku       = "github-enterprise-gen2"
    version   = "latest"
  }
}

resource "azurerm_virtual_machine_data_disk_attachment" "diskattach" {
  caching            = "ReadWrite"
  lun                = 0
  managed_disk_id    = azurerm_managed_disk.data_disk.id
  virtual_machine_id = azurerm_linux_virtual_machine.vm.id
}
```
</details>

## GHESの初期セットアップ
デプロイしたVMのホスト名にアクセス
(立ち上がりまでちょっと待つ)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/d74fb427-9bec-e042-6474-6a376b384f4e.png)

ラインセンスファイルをアップロードして管理サイト用のパスワードを入力

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/f11be88c-8b2c-097c-63dc-45782ca26d88.png)

「Set UP a new Instance」 を選択し、初期セットアップ画面に進み、まずはデフォルトのままセットアップを完了させる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/fa6f7b6b-58bc-bc38-164a-54a2e6fba93e.png)

これまたしばらく待つ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/96259236-b685-a505-a760-7ade2c4dd06b.png)

完了し、Enterpriseオーナーの作成画面が出てきたら、アカウントを作成する。

このアカウントは認証方法をSAMLに変更後も、予備的にID・パスワードでログインできるようにしておくためのもの。そのため、SAML認証に使用しないアドレス、ユーザー名にしておくこと。

ログインして、右上の 🚀 > Management Console > 管理サイト画面へ移動しておく。

## Entra ID 側の設定
以下のドキュメントを参考に進める。

https://learn.microsoft.com/en-us/entra/identity/saas-apps/github-ae-tutorial

Entra ID > Enterpriseアプリケーション > 新しいアプリケーション > GitHub Enterprise Server

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/4397422e-9abf-1845-fe11-a847816e19d3.png)
1. GitHub Enterprise Server アプリケーション設定に移動
2. 左側のサイドバーの **[シングル サインオン]** をクリックし、**[SAML]** を選択
3. **[基本的な SAML 構成]** セクションで、**[編集]** をクリックし、以下を追加
    - 識別子: `https://<HOSTNAME>.com`
    - 応答 URL: `https://<HOSTNAME>.com/saml/consume`
    - サインオン URL: `https://<HOSTNAME>.com/sso`
    
4. **[属性とクレーム]** セクションで **[編集]** をクリックし、次のクレームを追加
    - full_name
      - Join (user.givenname, " ", user.surname)
    - emails
      - user.userprincipalname
    - administrator
      - 要求条件:
        - ユーザータイプ: メンバー
        - グループ: グループを指定
        - ソース: 属性
        - 値: `true`
        - (例) Adminグループに属しているメンバーはEnterpriseオーナー & 管理サイト権限が付くようにする。[SAML属性の詳細はこちらを参照](https://docs.github.com/ja/enterprise-server@3.15/admin/managing-iam/iam-configuration-reference/saml-configuration-reference#saml-attributes)

<br>

5.**[SAML 認定資格証]** セクションで、**SAML認定資格証 (Base64)** をダウンロード
6. **[GitHub Enterprise Serverの設定]** セクションで、**ログインURL**と**Microsoft Entra識別子**をコピーしておく
7. 左側サイドバーの **[ユーザーとグループ]** からユーザーを割り当てる

## GHES 側の設定変更
ここからは下記ドキュメントを参考に。

https://docs.github.com/ja/enterprise-server@3.15/admin/managing-iam/using-saml-for-enterprise-iam/configuring-saml-single-sign-on-for-your-enterprise#configuring-saml-sso

管理サイト画面に戻り、Atutenticationセクションで **[SAML]** を選択。

- **[Allow built-in authentication]** (組み込み認証を許可)
    - チェックをいれる
        - シングルサインオンだけでなく、ID, パスワードでも入れるようにしておく
- **Single sign-on URL**
    - 先ほどコピーしたログインURL
- **Issuer**
    - Microsoft Entra 識別子
- **Verification certificate**
    - ダウンロードしたSAML認定資格証をアップロード
- **User attributes**
    - `full_name`
    - `emails`
    - `public_keys`
    - `gpg_keys`

設定後は以下のようになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/e82fbc06-04f8-1125-d6f3-ba77c5d4e5f0.png)

ここまで設定できたら、保存して更新をかけてしばらく待つ。

完了すると、ログイン画面が以下の様になり、SSOできるようになる。
カスタム属性のマッピングができていれば、管理者権限になってるはず。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/5069f18a-d546-129c-ae33-84cba665dc7f.png)


「Sign in with username and password」のリンクが画面下部に表示されているため、うまくいかない場合は組み込みのオーナーアカウントのIDとパスワードでログインして設定を確認できる。


## さいごに (というか備考)

IDPでユーザーを割り当て、または割り当て解除するときに、IDPはGHESと自動的に通信していない。ユーザーが初めてGHESにアクセスし、IDPを介して認証し、サインインしたときに、GHESは SAML Just-in-Time (JIT) プロビジョニングを使用してユーザー アカウントを作成している。

https://docs.github.com/ja/enterprise-server@3.15/admin/managing-iam/understanding-iam-for-enterprises/about-saml-for-enterprise-iam#about-creation-of-user-accounts

このJITプロビジョニングでは、IDPからユーザーを削除した場合、GHESインスタンス上のユーザーアカウントを**手動で停止する必要**がある。

https://docs.github.com/ja/enterprise-server@3.15/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/suspending-and-unsuspending-users

これはめんどくさい。
そこで、自動的にプロビジョニングされるよう、[SCIM](https://learn.microsoft.com/ja-jp/entra/architecture/sync-scim)を設定できる。

https://docs.github.com/ja/enterprise-server@3.15/admin/managing-iam/provisioning-user-accounts-with-scim/user-provisioning-with-scim-on-ghes

が…GHESでこの機能はまだベータ版のよう。

手順通りにやってみようとしたが、個人アクセストークン(Classic) のスコープに`scim:enterprise`が見当たらなかった。(なんで？😱)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/fc2afafd-b934-5d04-ce21-559526517c41.png)

これに関しては、もう少し調べてから、また今度やってみる事にする。