🚪

Microsoft Entra ID アプリケーションのシークレットや証明書の有効期限を定期的にチェックする

2024/03/29に公開

はじめに

前回の記事で Microsoft Graph PowerShell を使用して、Microsoft Entra のアプリ登録のシークレット・証明書とエンタープライズ アプリの SAML 証明書の有効期限を確認する方法を整理しました。今回はこちらを Azure Functions に実装し、Logic Apps から定期的に実行、結果をメールに通知する仕組みを作ります。
https://zenn.dev/microsoft/articles/b7c4860c3c5a28

Azure Functions の設定

Azure Functions で Microsoft Graph PowerShell を実行するための準備はこちらです。なお、インポートするモジュールは Microsoft.Graph.Applications です。
https://zenn.dev/microsoft/articles/d89713e72f3e67

以下の Powershell スクリプトを HTTP Trigger の関数で作成します。なお、後続で処理しやすいようにアプトプットを json フォーマットにしています。(エラー ハンドリングはしてません。すいません。)

Get-ExpiringApplicationForAzFunctions.ps1
using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)

# Graph API PowerShell
# Import-Module Microsoft.Graph.Applications
Connect-MgGraph -Identity

# Get-MgServicePrincipal でアプリ登録の一覧を取得
$Applications = Get-MgApplication -All

# 30 日以内に期限が切れる証明書やシークレットを持つアプリ登録をリストアップ
$body = ""
$Applications | ForEach-Object {
    $Application = $_
    $ApplicationDisplayName = $Application.DisplayName
    $ApplicationKeyCredential = $Application.KeyCredentials
    $ApplicationPasswordCredential = $Application.PasswordCredentials

    $ApplicationKeyCredential | ForEach-Object {
        $KeyCredential = $_
        $KeyCredentialEndDate = $KeyCredential.EndDateTime
        $KeyCredentialKeyId = $KeyCredential.KeyId
        if ($KeyCredentialEndDate -ne $null) {
            $KeyCredentialEndDate = [DateTime]::Parse($KeyCredentialEndDate)
            $DaysToExpire = ($KeyCredentialEndDate - (Get-Date)).Days
            if ($DaysToExpire -lt 30) {
                # json 形式で $body に 追加
                $tmp = @{
                    "Type" = "Application"
                    "ApplicationDisplayName" = $ApplicationDisplayName
                    "CredentialType" = "Key"
                    "CredentialKeyId" = $KeyCredentialKeyId
                    "CredentialEndDate" = $KeyCredentialEndDate
                    "DaysToExpire" = $DaysToExpire
                } | ConvertTo-Json
                $body += $tmp + ",`r`n"
            }
        }
    }
    $ApplicationPasswordCredential | ForEach-Object {
        $PasswordCredential = $_
        $PasswordCredentialEndDate = $PasswordCredential.EndDateTime
        $PasswordCredentialKeyId = $PasswordCredential.KeyId
        if ($PasswordCredentialEndDate -ne $null) {
            $PasswordCredentialEndDate = [DateTime]::Parse($PasswordCredentialEndDate)
            $DaysToExpire = ($PasswordCredentialEndDate - (Get-Date)).Days
            if ($DaysToExpire -lt 30) {
                # json 形式で $body に 追加
                $tmp = @{
                    "Type" = "Application"
                    "ApplicationDisplayName" = $ApplicationDisplayName
                    "CredentialType" = "Password"
                    "CredentialKeyId" = $PasswordCredentialKeyId
                    "CredentialEndDate" = $PasswordCredentialEndDate
                    "DaysToExpire" = $DaysToExpire
                } | ConvertTo-Json
                $body += $tmp + ",`r`n"
            }
        }
    }
}

# $body の最後のカンマを削除し整形
if($body -ne "") {
    $body = "[`r`n" + $body.Substring(0, $body.Length - 3)  + "`r`n]"
}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $body
})
Get-ExpiringServicePrincipalForAzFunctions.ps1
using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)

# Graph API PowerShell
Connect-MgGraph -Identity

# Get-MgServicePrincipal でサービス プリンシパルの一覧を取得
$ServicePrincipals = Get-MgServicePrincipal -All

# 30 日以内に期限が切れる証明書やシークレットを持つ Service Principal をリストアップ
$body = ""
$ServicePrincipals | ForEach-Object {
    $ServicePrincipal = $_
    $ServicePrincipalDisplayName = $ServicePrincipal.DisplayName
    $ServicePrincilalType = $ServicePrincipal.ServicePrincipalType
    $ServicePrincipalKeyCredential = $ServicePrincipal.KeyCredentials
    $ServicePrincipalPasswordCredential = $ServicePrincipal.PasswordCredentials

    # マネージド ID 以外を対象
    if ($ServicePrincilalType -ne "ManagedIdentity") {
        $ServicePrincipalKeyCredential | ForEach-Object {
            $KeyCredential = $_
            $KeyCredentialEndDate = $KeyCredential.EndDateTime
            $KeyCredentialKeyId = $KeyCredential.KeyId
            if ($KeyCredentialEndDate -ne $null) {
                $KeyCredentialEndDate = [DateTime]::Parse($KeyCredentialEndDate)
                $DaysToExpire = ($KeyCredentialEndDate - (Get-Date)).Days
                if ($DaysToExpire -lt 30) {
                    # json 形式で $body に 追加
                    $tmp = @{
                        "Type" = "Service Principal"
                        "ServicePrincipalDisplayName" = $ServicePrincipalDisplayName
                        "CredentialType" = "Key"
                        "CredentialKeyId" = $KeyCredentialKeyId
                        "CredentialEndDate" = $KeyCredentialEndDate
                        "DaysToExpire" = $DaysToExpire
                    } | ConvertTo-Json
                    $body += $tmp + ",`r`n"
                }
            }
        }
        $ServicePrincipalPasswordCredential | ForEach-Object {
            $PasswordCredential = $_
            $PasswordCredentialEndDate = $PasswordCredential.EndDateTime
            $PasswordCredentialKeyId = $PasswordCredential.KeyId
            if ($PasswordCredentialEndDate -ne $null) {
                $PasswordCredentialEndDate = [DateTime]::Parse($PasswordCredentialEndDate)
                $DaysToExpire = ($PasswordCredentialEndDate - (Get-Date)).Days
                if ($DaysToExpire -lt 30) {
                    # json 形式で $body に 追加
                    $tmp = @{
                        "Type" = "Service Principal"
                        "ServicePrincipalDisplayName" = $ServicePrincipalDisplayName
                        "CredentialType" = "Password"
                        "CredentialKeyId" = $PasswordCredentialKeyId
                        "CredentialEndDate" = $PasswordCredentialEndDate
                        "DaysToExpire" = $DaysToExpire
                    } | ConvertTo-Json
                    $body += $tmp + ",`r`n"
                }
            }
        }
    }
}
# $body の最後のカンマを削除し整形
if($body -ne "") {
    $body = "[`r`n" + $body.Substring(0, $body.Length - 3)  + "`r`n]"
}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $body
})

こんな感じです。[テストと実行] で json フォーマットで出力することを確認します。

Logic Apps の設定

Logic Apps を以下の流れで設定します。全体像は以下の通りです。


まずトリガーに[Recurrence] を使用し、実行間隔を設定しています。下記では週 1 回 月曜日 0:00 に実行するように設定しています。


次に並列分岐を追加して [Azure Functions] アクションを追加し、先ほど作成した関数を選択します。


Azure Functions からのアウトプットである json をオブジェクトに変換するため、[JSON の解析] アクションを使用し、コンテンツに前段の Azure Functions の [本文] を指定、スキーマに以下をコピーします。以下のスキーマは Application 用と ServicePrincipal 用です。

Schema - Application
{
    "items": {
        "properties": {
            "ApplicationDisplayName": {
                "type": "string"
            },
            "CredentialEndDate": {
                "type": "string"
            },
            "CredentialKeyId": {
                "type": "string"
            },
            "CredentialType": {
                "type": "string"
            },
            "DaysToExpire": {
                "type": "integer"
            },
            "Type": {
                "type": "string"
            }
        },
        "required": [
            "ApplicationDisplayName",
            "CredentialEndDate",
            "CredentialKeyId",
            "CredentialType",
            "DaysToExpire",
            "Type"
        ],
        "type": "object"
    },
    "type": "array"
}
Schema - ServicePrincipal
{
    "items": {
        "properties": {
            "ServicePrincipalDisplayName": {
                "type": "string"
            },
            "CredentialEndDate": {
                "type": "string"
            },
            "CredentialKeyId": {
                "type": "string"
            },
            "CredentialType": {
                "type": "string"
            },
            "DaysToExpire": {
                "type": "integer"
            },
            "Type": {
                "type": "string"
            }
        },
        "required": [
            "ServicePrincipalDisplayName",
            "CredentialEndDate",
            "CredentialKeyId",
            "CredentialType",
            "DaysToExpire",
            "Type"
        ],
        "type": "object"
    },
    "type": "array"
}

フォーマットを整えるため、HTML テーブルに変換します。[HTML テーブルの作成] アクションを追加し、以下のように設定します。


同様に見栄えを良くするため、CSS を作成しておきます。[変数を初期化する] アクションで以下の CSS を設定しておきます。(自身の好みにカスタマイズしてください)

css
<style> table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #dddddd; text-align: left; padding: 8px; } th { background-color: #f2f2f2; } tr:nth-child(even) { background-color: #f9f9f9; } tr:hover { background-color: #e8e8e8; }</style>

最後に通知用のメールを設定します。今回は Office365 Outlook を使用しています。この辺りのツールやフォーマットは用途に応じてかと思います。

実行結果

[トリガーの実行] で動作を確認します。意図したとおりにメールが受信できていることを確認できました。

参考

Logic Apps に関してはひとつずつアクションを説明しましたが、以下の ARM テンプレートでまとめてデプロイ可能です。Azure Functions 内の関数名を一致させる必要がある点と、デプロイ後に Office365 Outlook の接続を設定が必要です。

template.json
{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "workflows_name": {
            "defaultValue": "logicapp-expiringspapp",
            "type": "string"
        },
        "sites_functions_externalid": {
            "defaultValue": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/application-rg/providers/Microsoft.Web/sites/functionapps",
            "type": "string"
        },
        "DeployersUserName": {
            "defaultValue": "<username>@<domain>",
            "type": "string"
        },
        "MailList": {
            "defaultValue": "<user1>@<domain>;<user2>@<domain>;...",
            "type": "string"
        }
    },
    "variables": {
        "o365ConnectionName": "[concat('o365-', parameters('workflows_name'))]"
    },
    "resources": [
        {
            "type": "Microsoft.Web/connections",
            "apiVersion": "2016-06-01",
            "name": "[variables('o365ConnectionName')]",
            "location": "[resourceGroup().location]",
            "properties": {
                "displayName": "[parameters('DeployersUserName')]",
                "customParameterValues": {},
                "api": {
                    "id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/office365')]"
                }
            }
        },
        {
            "type": "Microsoft.Logic/workflows",
            "apiVersion": "2017-07-01",
            "name": "[parameters('workflows_name')]",
            "location": "japaneast",
            "dependsOn": [
                "[resourceId('Microsoft.Web/connections', variables('o365ConnectionName'))]"
            ],
            "identity": {
                "type": "SystemAssigned"
            },
            "properties": {
                "state": "Enabled",
                "definition": {
                    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
                    "contentVersion": "1.0.0.0",
                    "parameters": {
                        "$connections": {
                            "defaultValue": {},
                            "type": "Object"
                        }
                    },
                    "triggers": {
                        "Recurrence": {
                            "recurrence": {
                                "frequency": "Day",
                                "interval": 1,
                                "schedule": {
                                    "hours": [
                                        "0"
                                    ],
                                    "minutes": [
                                        0
                                    ]
                                },
                                "timeZone": "Tokyo Standard Time"
                            },
                            "evaluatedRecurrence": {
                                "frequency": "Day",
                                "interval": 1,
                                "schedule": {
                                    "hours": [
                                        "0"
                                    ],
                                    "minutes": [
                                        0
                                    ]
                                },
                                "timeZone": "Tokyo Standard Time"
                            },
                            "type": "Recurrence"
                        }
                    },
                    "actions": {
                        "GetExpiringApplication": {
                            "runAfter": {},
                            "type": "Function",
                            "inputs": {
                                "function": {
                                    "id": "[concat(parameters('sites_functions_externalid'), '/functions/GetExpiringApplication')]"
                                }
                            }
                        },
                        "GetExpiringServicePrincipal": {
                            "runAfter": {},
                            "type": "Function",
                            "inputs": {
                                "function": {
                                    "id": "[concat(parameters('sites_functions_externalid'), '/functions/GetExpiringServicePrincipal')]"
                                }
                            }
                        },
                        "Parse_Json_-_Application": {
                            "runAfter": {
                                "GetExpiringApplication": [
                                    "Succeeded"
                                ]
                            },
                            "type": "ParseJson",
                            "inputs": {
                                "content": "@body('GetExpiringApplication')",
                                "schema": {
                                    "items": {
                                        "properties": {
                                            "ApplicationDisplayName": {
                                                "type": "string"
                                            },
                                            "CredentialEndDate": {
                                                "type": "string"
                                            },
                                            "CredentialKeyId": {
                                                "type": "string"
                                            },
                                            "CredentialType": {
                                                "type": "string"
                                            },
                                            "DaysToExpire": {
                                                "type": "integer"
                                            },
                                            "Type": {
                                                "type": "string"
                                            }
                                        },
                                        "required": [
                                            "CredentialType",
                                            "DaysToExpire",
                                            "ApplicationDisplayName",
                                            "CredentialEndDate",
                                            "CredentialKeyId",
                                            "Type"
                                        ],
                                        "type": "object"
                                    },
                                    "type": "array"
                                }
                            }
                        },
                        "Parse_Json_-_Service_Principal": {
                            "runAfter": {
                                "GetExpiringServicePrincipal": [
                                    "Succeeded"
                                ]
                            },
                            "type": "ParseJson",
                            "inputs": {
                                "content": "@body('GetExpiringServicePrincipal')",
                                "schema": {
                                    "items": {
                                        "properties": {
                                            "CredentialEndDate": {
                                                "type": "string"
                                            },
                                            "CredentialKeyId": {
                                                "type": "string"
                                            },
                                            "CredentialType": {
                                                "type": "string"
                                            },
                                            "DaysToExpire": {
                                                "type": "integer"
                                            },
                                            "ServicePrincipalDisplayName": {
                                                "type": "string"
                                            },
                                            "Type": {
                                                "type": "string"
                                            }
                                        },
                                        "required": [
                                            "Type",
                                            "DaysToExpire",
                                            "CredentialType",
                                            "ServicePrincipalDisplayName",
                                            "CredentialEndDate",
                                            "CredentialKeyId"
                                        ],
                                        "type": "object"
                                    },
                                    "type": "array"
                                }
                            }
                        },
                        "Create_HTML_Table_-_Application": {
                            "runAfter": {
                                "Parse_Json_-_Application": [
                                    "Succeeded"
                                ]
                            },
                            "type": "Table",
                            "inputs": {
                                "columns": [
                                    {
                                        "header": "Type",
                                        "value": "@item()['Type']"
                                    },
                                    {
                                        "header": "ApplicationDisplayName",
                                        "value": "@item()?['ApplicationDisplayName']"
                                    },
                                    {
                                        "header": "CredentialType",
                                        "value": "@item()['CredentialType']"
                                    },
                                    {
                                        "header": "CredentialEndDate",
                                        "value": "@item()['CredentialEndDate']"
                                    },
                                    {
                                        "header": "DaysToExpire",
                                        "value": "@item()['DaysToExpire']"
                                    }
                                ],
                                "format": "HTML",
                                "from": "@body('Parse_Json_-_Application')"
                            }
                        },
                        "Create_HTML_Table_-_Service_Principal": {
                            "runAfter": {
                                "Parse_Json_-_Service_Principal": [
                                    "Succeeded"
                                ]
                            },
                            "type": "Table",
                            "inputs": {
                                "columns": [
                                    {
                                        "header": "Type",
                                        "value": "@item()['Type']"
                                    },
                                    {
                                        "header": "ServicePrincipalDisplayName",
                                        "value": "@item()['ServicePrincipalDisplayName']"
                                    },
                                    {
                                        "header": "CredentialType",
                                        "value": "@item()['CredentialType']"
                                    },
                                    {
                                        "header": "CredentialEndDate",
                                        "value": "@item()['CredentialEndDate']"
                                    },
                                    {
                                        "header": "DaysToExpire",
                                        "value": "@item()['DaysToExpire']"
                                    }
                                ],
                                "format": "HTML",
                                "from": "@body('Parse_Json_-_Service_Principal')"
                            }
                        },
                        "Set_CSS": {
                            "runAfter": {
                                "Create_HTML_Table_-_Application": [
                                    "Succeeded"
                                ],
                                "Create_HTML_Table_-_Service_Principal": [
                                    "Succeeded"
                                ]
                            },
                            "type": "InitializeVariable",
                            "inputs": {
                                "variables": [
                                    {
                                        "name": "css",
                                        "type": "string",
                                        "value": "<style> table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #dddddd; text-align: left; padding: 8px; } th { background-color: #f2f2f2; } tr:nth-child(even) { background-color: #f9f9f9;  } tr:hover { background-color: #e8e8e8;  }</style>"
                                    }
                                ]
                            }
                        },
                        "メールの送信_(V2)": {
                            "runAfter": {
                                "Set_CSS": [
                                    "Succeeded"
                                ]
                            },
                            "type": "ApiConnection",
                            "inputs": {
                                "body": {
                                    "Body": "<p>@{variables('css')}<br>\n<br>\nアプリケーション<br>\n@{body('Create_HTML_Table_-_Application')}<br>\n<br>\nサービスプリンシパル (SAML証明書)<br>\n@{body('Create_HTML_Table_-_Service_Principal')}</p>",
                                    "Importance": "Normal",
                                    "Subject": "アプリケーション / サービスプリンシパルの有効期限",
                                    "To": "[parameters('MailList')]"
                                },
                                "host": {
                                    "connection": {
                                        "name": "@parameters('$connections')['office365']['connectionId']"
                                    }
                                },
                                "method": "post",
                                "path": "/v2/Mail"
                            }
                        }
                    },
                    "outputs": {}
                },
                "parameters": {
                    "$connections": {
                        "value": {
                            "office365": {
                                "connectionId": "[resourceId('Microsoft.Web/connections', variables('o365ConnectionName'))]",
                                "connectionName": "[variables('o365ConnectionName')]",
                                "id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/office365')]"
                            }
                        }
                    }
                }
            }
        }
    ]
}
Microsoft (有志)

Discussion