Logic AppsのMIでSPOリスト登録(Graph API使用)
はじめに
Azure Logic Apps(Consumption)から、共有シークレットを使わず ユーザー割当マネージド ID (UAMI) で Microsoft Graph API を叩き、SharePoint Online の List にアイテムを登録する構成を検証しました。
SPO の Connector だとユーザプリンシパルで認証するため、条件付きアクセスやMFAにひっかかりやすく、不安定になりやすいです。
- 想定読者: Azure / Microsoft 365 を扱うエンジニア
- 得られる知見:
Sites.Selectedによる最小権限付与、Graph 経由の List 登録、そして Portal テスト実行で踏みやすいtriggerBody()の落とし穴
MCP化してFoundry 等から呼び出したい場合には、以下の記事参照。
結論
- ✅ UAMI +
Sites.Selected+ Graph で、シークレットレスに SPO List へアイテム登録できる - ✅ トリガー body は
json(string(triggerBody()))で正規化すると堅牢になる - ⚠️ List のフィールドは実スキーマに厳密に合わせる必要がある(存在しない列は 400)
アーキテクチャ
環境
| 項目 | 値 |
|---|---|
| Logic Apps | Consumption |
| Microsoft Graph | v1.0 |
| Graph app role |
Sites.Selected(UAMI)/ Sites.FullControl.All(一時管理アプリ) |
| 認証 | User-Assigned Managed Identity |
Steps
前提条件
- Logic Apps(Comsumption)のデプロイ
- SPOにList作成(今回はTemplateのIssue Trackerそのまま)
- ユーザ割当マネージドID作成
Step 1: UAMI にサイト権限を付与(Sites.Selected)
サイト全体ではなく サイト単位 で権限を付与するのがポイントです。流れは次の通り。
- Graph app role
Sites.Selectedを UAMI に付与 -
Sites.FullControl.Allを持つ一時管理アプリを作成(短命シークレット+管理者同意) - そのトークンで
POST /sites/{siteId}/permissionsに UAMI の権限を付与 - 一時管理アプリを削除して後始末
#!/usr/bin/env bash
#
# grant-spo-site-permission.sh
#
# 対象 SharePoint サイトに、ユーザー割当マネージドID(UAMI)のアプリへ
# サイト権限(read/write/fullcontrol)を Sites.Selected モデルで付与する。
# Graph アプリロール Sites.Selected の UAMI への付与も内包する。
#
# 冪等性:
# - Sites.Selected が既に UAMI に付与済みなら Skip
# - 対象サイトに当該 UAMI アプリの権限が既にあれば Skip(ロールも表示)
# - 一時管理アプリは毎回作成し、終了時に必ず削除(後始末は冪等)
#
# 仕組み:
# 1. Graph アプリロール Sites.Selected を UAMI(SP) に付与(冪等)
# 2. Sites.FullControl.All を持つ「一時管理用アプリ」を作成+短命シークレット+管理者同意
# 3. アプリ専用トークンで対象サイトに UAMI アプリの権限を付与(冪等)
# 4. 後始末: 一時管理アプリ(SP/シークレット込み)を削除し機微情報を消去
# ※ UAMI への Sites.Selected とサイト権限(目的物)は残す
#
# 前提:
# - 実行者に「アプリ作成 + 管理者同意 + AppRoleAssignment 付与」権限
# (Privileged Role Administrator 等)
# - az CLI ログイン済み(Cloud Shell 可)
#
# 既定のターゲット(引数なしで実行するとこの List を対象にする):
# List : https://<host>.sharepoint.com/sites/<site>/Lists/<list>/AllItems.aspx
# UAMI : uami-logic-app-tmp-ncus (Logic App logic-app-tmp-ncus に割当済み / RG: tmp)
# → この UAMI へサイト の権限を付与し、リストへ
# アイテムを登録/更新できるようにする。
#
# 使い方:
# # 既定ターゲット(上記 List)へ付与する場合は引数不要:
# ./grant-spo-site-permission.sh
#
# # 別のサイト/リストを対象にする場合は上書き:
# ./grant-spo-site-permission.sh \
# --rg <resource-group> --uami <uami-name> \
# --host <tenant>.sharepoint.com --site sites/<site-path> \
# [--role read|write|fullcontrol] [--list "<list-display-name>"] \
# [--secret-hours <n>] [--app-name <temp-app-name>]
#
set -euo pipefail
# ========= 既定値 =========
ROLE="fullcontrol" # アイテム登録/更新のみなら write でも可(最小権限)
SECRET_HOURS="1"
APP_NAME="app-spo-sites-admin-temp"
GRAPH_APP_ID="00000003-0000-0000-c000-000000000000" # Microsoft Graph (全テナント共通)
ADMIN_ROLE_VALUE="Sites.FullControl.All"
SELECTED_ROLE_VALUE="Sites.Selected"
# ========= ターゲット既定値(引数で上書き可) =========
# 対象 List: https://<host>.sharepoint.com/sites/<site>/Lists/<list>/AllItems.aspx
RG="tmp"
UAMI_NAME="uami-logic-app-tmp-ncus"
SP_HOST="<host>.sharepoint.com"
SITE_PATH="sites/<site>"
LIST_NAME="<list>" # Space入ってもOK
usage() {
cat <<USAGE
Usage: $0 [options]
※ 既定で下記ターゲット List を対象にするため、引数なしで実行可能。
別ターゲットにする場合のみ該当オプションで上書きする。
Target (既定値。引数で上書き可):
--rg UAMI が属するリソースグループ名 (既定: ${RG})
--uami ユーザー割当マネージドID名 (既定: ${UAMI_NAME})
--host SharePoint ホスト名 (既定: ${SP_HOST})
--site サイトの相対パス ※先頭スラッシュ不要 (既定: ${SITE_PATH})
--list リスト表示名。items 取得用 Graph URI を末尾に出力 (既定: ${LIST_NAME})
Options:
--role 付与ロール read|write|fullcontrol (既定: ${ROLE})
--secret-hours 一時シークレットの有効時間(時) (既定: ${SECRET_HOURS})
--app-name 一時管理アプリの表示名 (既定: ${APP_NAME})
-h, --help このヘルプを表示
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--rg) RG="$2"; shift 2;;
--uami) UAMI_NAME="$2"; shift 2;;
--host) SP_HOST="$2"; shift 2;;
--site) SITE_PATH="$2"; shift 2;;
--role) ROLE="$2"; shift 2;;
--list) LIST_NAME="$2"; shift 2;;
--secret-hours) SECRET_HOURS="$2"; shift 2;;
--app-name) APP_NAME="$2"; shift 2;;
-h|--help) usage; exit 0;;
*) echo "ERROR: 不明な引数: $1" >&2; usage; exit 2;;
esac
done
# ========= 必須チェック =========
missing=""
[ -z "$RG" ] && missing="$missing --rg"
[ -z "$UAMI_NAME" ] && missing="$missing --uami"
[ -z "$SP_HOST" ] && missing="$missing --host"
[ -z "$SITE_PATH" ] && missing="$missing --site"
if [ -n "$missing" ]; then
echo "ERROR: 必須引数が不足:$missing" >&2
usage
exit 2
fi
case "$ROLE" in read|write|fullcontrol) ;; *) echo "ERROR: --role は read|write|fullcontrol" >&2; exit 2;; esac
SITE_PATH="${SITE_PATH#/}"
# ========= 後始末 =========
ADMIN_APP_ID=""
cleanup() {
echo
echo "========= クリーンアップ ========="
if [ -n "$ADMIN_APP_ID" ]; then
echo "一時管理アプリを削除: $ADMIN_APP_ID"
az ad app delete --id "$ADMIN_APP_ID" 2>/dev/null \
&& echo "削除完了(SP・クライアントシークレットも同時に削除)" \
|| echo "削除に失敗。手動で 'az ad app delete --id $ADMIN_APP_ID' を実行してください。"
fi
unset CLIENT_SECRET APP_TOKEN 2>/dev/null || true
rm -f "/tmp/spo_grant.$$.json" "/tmp/spo_perms.$$.json" 2>/dev/null || true
echo "機微情報(シークレット/トークン)を消去しました。"
}
trap cleanup EXIT INT TERM
# ========= 0. 共通 ID 解決 =========
echo "========= 0. ID 解決 ========="
GRAPH_SP_ID=$(az ad sp show --id "$GRAPH_APP_ID" --query id -o tsv)
SELECTED_ROLE_ID=$(az ad sp show --id "$GRAPH_APP_ID" \
--query "appRoles[?value=='$SELECTED_ROLE_VALUE' && contains(allowedMemberTypes,'Application')].id | [0]" -o tsv)
ADMIN_ROLE_ID=$(az ad sp show --id "$GRAPH_APP_ID" \
--query "appRoles[?value=='$ADMIN_ROLE_VALUE' && contains(allowedMemberTypes,'Application')].id | [0]" -o tsv)
UAMI_PRINCIPAL_ID=$(az identity show -g "$RG" -n "$UAMI_NAME" --query principalId -o tsv)
UAMI_CLIENT_ID=$(az identity show -g "$RG" -n "$UAMI_NAME" --query clientId -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
echo "Graph SP objectId = $GRAPH_SP_ID"
echo "Sites.Selected roleId = $SELECTED_ROLE_ID"
echo "Sites.FullControl roleId = $ADMIN_ROLE_ID"
echo "UAMI principalId(SP) = $UAMI_PRINCIPAL_ID"
echo "UAMI clientId = $UAMI_CLIENT_ID"
# ========= 1. UAMI へ Sites.Selected を付与(冪等) =========
echo
echo "========= 1. Sites.Selected 付与(UAMI) ========="
SEL_EXISTS=$(az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/${UAMI_PRINCIPAL_ID}/appRoleAssignments" \
--query "value[?appRoleId=='${SELECTED_ROLE_ID}' && resourceId=='${GRAPH_SP_ID}'] | length(@)" -o tsv)
if [ "$SEL_EXISTS" != "0" ]; then
echo "Skip: Sites.Selected は既に UAMI に付与済み"
else
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/${UAMI_PRINCIPAL_ID}/appRoleAssignments" \
--headers "Content-Type=application/json" \
--body "{\"principalId\":\"${UAMI_PRINCIPAL_ID}\",\"resourceId\":\"${GRAPH_SP_ID}\",\"appRoleId\":\"${SELECTED_ROLE_ID}\"}" \
-o none
echo "付与しました: Sites.Selected -> UAMI"
fi
# ========= 2. 一時管理アプリ作成 + 短命シークレット + 管理者同意 =========
echo
echo "========= 2. 一時管理アプリ(Sites.FullControl.All) ========="
ADMIN_APP_ID=$(az ad app create \
--display-name "$APP_NAME" \
--sign-in-audience AzureADMyOrg \
--required-resource-accesses "[{
\"resourceAppId\": \"$GRAPH_APP_ID\",
\"resourceAccess\": [{\"id\": \"$ADMIN_ROLE_ID\", \"type\": \"Role\"}]
}]" \
--query appId -o tsv)
az ad sp create --id "$ADMIN_APP_ID" -o none
ADMIN_SP_ID=$(az ad sp show --id "$ADMIN_APP_ID" --query id -o tsv)
echo "Admin appId = $ADMIN_APP_ID"
echo "Admin SP objectId = $ADMIN_SP_ID"
END_DATE=$(date -u -d "+${SECRET_HOURS} hour" +"%Y-%m-%dT%H:%M:%SZ")
CLIENT_SECRET=$(az ad app credential reset --id "$ADMIN_APP_ID" --end-date "$END_DATE" --query password -o tsv)
echo "Client secret 有効期限 = $END_DATE"
# 管理者同意(冪等: 既存割り当てがあれば Skip)
ADMIN_GRANT_EXISTS=$(az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/${ADMIN_SP_ID}/appRoleAssignments" \
--query "value[?appRoleId=='${ADMIN_ROLE_ID}' && resourceId=='${GRAPH_SP_ID}'] | length(@)" -o tsv)
if [ "$ADMIN_GRANT_EXISTS" != "0" ]; then
echo "Skip: Sites.FullControl.All は既に一時アプリに付与済み"
else
sleep 20
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/${ADMIN_SP_ID}/appRoleAssignments" \
--headers "Content-Type=application/json" \
--body "{\"principalId\":\"${ADMIN_SP_ID}\",\"resourceId\":\"${GRAPH_SP_ID}\",\"appRoleId\":\"${ADMIN_ROLE_ID}\"}" \
-o none
echo "付与しました: Sites.FullControl.All -> 一時アプリ"
fi
# ========= 3. siteId 解決 + アプリトークン取得 =========
echo
echo "========= 3. サイト解決 / アプリトークン ========="
SITE_ID=$(az rest --method GET \
--url "https://graph.microsoft.com/v1.0/sites/${SP_HOST}:/${SITE_PATH}" \
--query id -o tsv)
echo "siteId = $SITE_ID"
# 新しいアプリ専用トークンを取得して APP_TOKEN にセット
acquire_app_token() {
APP_TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${ADMIN_APP_ID}" \
-d "scope=https://graph.microsoft.com/.default" \
-d "client_secret=${CLIENT_SECRET}" \
-d "grant_type=client_credentials" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))")
[ -n "$APP_TOKEN" ]
}
# アプリロール(Sites.FullControl.All)同意の伝播待ち:
# トークンを再取得しつつ GET /permissions が 200 を返すまでリトライ。
# 成功時は $PERMS_TMP に本文を保存して 0、最終的にダメなら 1。
PERMS_TMP="/tmp/spo_perms.$$.json"
wait_until_authorized() {
local attempt=1 max=20 http
while [ $attempt -le $max ]; do
if acquire_app_token; then
http=$(curl -s -o "$PERMS_TMP" -w "%{http_code}" \
"https://graph.microsoft.com/v1.0/sites/${SITE_ID}/permissions" \
-H "Authorization: Bearer ${APP_TOKEN}")
case "$http" in
200) echo "権限管理トークンが有効化されました(GET permissions HTTP 200)"; return 0;;
401|403) echo "attempt $attempt: HTTP $http(アプリロール同意の伝播待ち)";;
*) echo "attempt $attempt: HTTP $http(一時エラー、再試行)";;
esac
else
echo "attempt $attempt: トークン取得待ち"
fi
attempt=$((attempt+1)); sleep 15
done
echo "ERROR: 規定時間内に権限管理トークンが有効化されませんでした。" >&2
return 1
}
echo "アプリロール同意の伝播を待機します(最大 ~5分)..."
wait_until_authorized
# ========= 4. サイト権限付与(冪等・伝播待ちリトライ) =========
echo
echo "========= 4. サイト権限付与 ========="
# wait_until_authorized 成功時点で $PERMS_TMP に最新の権限一覧がある。これを判定。
parse_existing() {
python3 - "$UAMI_CLIENT_ID" "$PERMS_TMP" <<'PY'
import sys,json
uami=sys.argv[1]; path=sys.argv[2]
d=json.load(open(path))
found=None
for p in d.get('value',[]):
for g in p.get('grantedToIdentitiesV2',[]):
a=g.get('application',{})
if a.get('id')==uami:
found=(p.get('id'), p.get('roles') or [])
print(("EXISTING %s %s" % (found[0], ",".join(found[1]))) if found else "NONE")
PY
}
CHECK=$(parse_existing)
case "$CHECK" in
EXISTING*)
PERM_ID=$(echo "$CHECK" | awk '{print $2}')
PERM_ROLES=$(echo "$CHECK" | awk '{print $3}')
echo "Skip: 当該 UAMI アプリの権限は既に存在 (permId=$PERM_ID roles=$PERM_ROLES)"
echo " ロールを変更したい場合は当該権限を削除してから再実行してください。"
;;
NONE)
grant_attempt=1; grant_max=8; granted=0
while [ $grant_attempt -le $grant_max ]; do
acquire_app_token || { echo "attempt $grant_attempt: トークン取得待ち"; grant_attempt=$((grant_attempt+1)); sleep 15; continue; }
HTTP=$(curl -s -o "/tmp/spo_grant.$$.json" -w "%{http_code}" -X POST \
"https://graph.microsoft.com/v1.0/sites/${SITE_ID}/permissions" \
-H "Authorization: Bearer ${APP_TOKEN}" -H "Content-Type: application/json" \
-d "{\"roles\":[\"${ROLE}\"],\"grantedToIdentities\":[{\"application\":{\"id\":\"${UAMI_CLIENT_ID}\",\"displayName\":\"${UAMI_NAME}\"}}]}")
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
echo "サイト権限付与に成功(HTTP $HTTP)"; cat "/tmp/spo_grant.$$.json"; echo; granted=1; break
fi
echo "attempt $grant_attempt: HTTP $HTTP(権限伝播待ち)"; cat "/tmp/spo_grant.$$.json"; echo
grant_attempt=$((grant_attempt+1)); sleep 15
done
[ "$granted" = "1" ] || { echo "ERROR: サイト権限付与に失敗(伝播遅延または権限不足)。" >&2; exit 1; }
;;
*)
echo "WARN: 既存権限の判定に失敗($CHECK)。付与は行わず終了します。" >&2
exit 1
;;
esac
# ========= 5. 最終確認(ベストエフォート: 失敗しても本処理結果は確定済み) =========
echo
echo "========= 5. 確認(当該 UAMI アプリのサイト権限) ========="
VERIFY_TMP="/tmp/spo_verify.$$.json"
verify_attempt=1; verify_max=5; verified=0
while [ $verify_attempt -le $verify_max ]; do
acquire_app_token || { echo "確認 attempt $verify_attempt: トークン取得待ち"; verify_attempt=$((verify_attempt+1)); sleep 10; continue; }
VHTTP=$(curl -s -o "$VERIFY_TMP" -w "%{http_code}" \
"https://graph.microsoft.com/v1.0/sites/${SITE_ID}/permissions" \
-H "Authorization: Bearer ${APP_TOKEN}")
if [ "$VHTTP" = "200" ] && python3 - "$UAMI_CLIENT_ID" "$VERIFY_TMP" <<'PY'
import sys,json
uami=sys.argv[1]; path=sys.argv[2]
try:
d=json.load(open(path))
except Exception:
sys.exit(3)
hit=False
for p in d.get('value',[]):
for g in p.get('grantedToIdentitiesV2',[]):
a=g.get('application',{})
if a.get('id')==uami:
hit=True
print("permId=",p.get('id'),"roles=",p.get('roles'),"app=",a.get('displayName'))
sys.exit(0 if hit else 4)
PY
then
verified=1; break
fi
echo "確認 attempt $verify_attempt: HTTP $VHTTP(再試行)"
verify_attempt=$((verify_attempt+1)); sleep 10
done
rm -f "$VERIFY_TMP" 2>/dev/null || true
if [ "$verified" != "1" ]; then
echo "NOTE: 確認 GET は失敗しましたが、ステップ4で付与は成功済み(HTTP 201)です。"
echo " 後日 Cloud Shell から以下で確認できます(Sites.FullControl.All を持つ主体が必要):"
echo " GET https://graph.microsoft.com/v1.0/sites/${SITE_ID}/permissions"
fi
echo "完了。一時管理アプリは EXIT 時に自動削除されます。"
# ========= 6. (任意) リスト items 取得 Graph URI を出力 =========
if [ -n "$LIST_NAME" ]; then
echo
echo "========= 6. リスト URI 組み立て(--list 指定時) ========="
LIST_TMP="/tmp/spo_lists.$$.json"
list_attempt=1; list_max=5; LIST_ID=""
while [ $list_attempt -le $list_max ]; do
acquire_app_token || { echo "list attempt $list_attempt: トークン取得待ち"; list_attempt=$((list_attempt+1)); sleep 10; continue; }
LHTTP=$(curl -s -o "$LIST_TMP" -w "%{http_code}" \
"https://graph.microsoft.com/v1.0/sites/${SITE_ID}/lists?\$select=id,name,displayName" \
-H "Authorization: Bearer ${APP_TOKEN}")
if [ "$LHTTP" = "200" ]; then
LIST_ID=$(python3 - "$LIST_NAME" "$LIST_TMP" <<'PY'
import sys,json
name=sys.argv[1]; path=sys.argv[2]
d=json.load(open(path))
hit=""
for l in d.get('value',[]):
if l.get('displayName')==name or l.get('name')==name:
hit=l.get('id'); break
print(hit)
PY
)
break
fi
echo "list attempt $list_attempt: HTTP $LHTTP(再試行)"
list_attempt=$((list_attempt+1)); sleep 10
done
rm -f "$LIST_TMP" 2>/dev/null || true
if [ -z "$LIST_ID" ]; then
echo "WARN: リスト '${LIST_NAME}' の解決に失敗しました(名前不一致 or 取得失敗)。URI は出力しません。" >&2
else
echo "siteId = $SITE_ID"
echo "listId = $LIST_ID"
echo "uri = https://graph.microsoft.com/v1.0/sites/${SITE_ID}/lists/${LIST_ID}/items?\$expand=fields"
fi
fi
Scriptを実行。
bash ./grant-spo-site-permission.sh \
--rg <resource-group> --uami <uami-name> \
--host <tenant>.sharepoint.com --site sites/<site-path> \
出力される以下の部分はLogic Apps で Graph APIを呼び出すときに必要なので注意。
前略
siteId = <host>.sharepoint.com,<site collection id>,<site id>
中略
listId = <listid>
後略
アイテムの登録/更新だけなら roles は write で十分です(最小権限)。fullcontrol はサイト内の全 List に及びます。
Step 2: List スキーマを実測してマッピング
対象 List の書き込み可能列を Graph で実測します。存在しない列に値を送ると 400 になります。
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/sites/${SITE_ID}/lists/${LIST_ID}/columns?\$select=name,displayName,readOnly"
今回の List の書き込み可能列は Title / Description / Priority(choice) / Status(choice) などでした。choice 列は選択肢に一致する値を送ります。
Step 3: ワークフロー(Graph で items を POST)
Azure Portalでワークフローをコードビューで実装。
以下のJSONをコードビューで貼り付けます。その後にSite Collection ID, Site ID, List IDを置換。Step2で出力した値を使用。
SharePoint REST API を使って以下のエンドポイントにGETでアクセスし、<d:Id m:type="Edm.Guid"></d:Id>内の値でも取得可能
- Site Collection ID:
https://<host>.sharepoint.com/sites/<site>/_api/web?$select=Id - Site ID:
https://<host>.sharepoint.com/sites/<site>/_api/site?$select=Id - List ID:
https://<host>.sharepoint.com/sites/<site>/_api/lists?$select=Id,Title
{
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"parameters": {},
"triggers": {
"When_a_HTTP_request_is_received": {
"type": "Request",
"kind": "Http",
"inputs": {
"method": "POST",
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"priority": {
"type": "string"
},
"status": {
"type": "string"
}
},
"required": [
"title"
]
}
}
}
},
"actions": {
"Parse_trigger_body": {
"type": "Compose",
"runAfter": {},
"inputs": "@json(string(triggerBody()))"
},
"Compose_list_item_fields": {
"type": "Compose",
"runAfter": {
"Parse_trigger_body": [
"Succeeded"
]
},
"inputs": {
"fields": {
"Title": "@{outputs('Parse_trigger_body')?['title']}",
"Description": "@{coalesce(outputs('Parse_trigger_body')?['description'], '')}",
"Priority": "@{coalesce(outputs('Parse_trigger_body')?['priority'], 'Normal')}",
"Status": "@{coalesce(outputs('Parse_trigger_body')?['status'], 'New')}"
}
}
},
"Create_SharePoint_list_item_via_Graph": {
"type": "Http",
"runAfter": {
"Compose_list_item_fields": [
"Succeeded"
]
},
"inputs": {
"method": "POST",
"uri": "https://graph.microsoft.com/v1.0/sites/<host>.sharepoint.com,< サイトコレクション ID>,<site id>/lists/<list id>/items",
"headers": {
"Accept": "application/json",
"Content-Type": "application/json"
},
"body": "@outputs('Compose_list_item_fields')",
"authentication": {
"type": "ManagedServiceIdentity",
"identity": "/subscriptions/<subscription id>/resourcegroups/tmp/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-logic-app-tmp-ncus",
"audience": "https://graph.microsoft.com"
}
},
"runtimeConfiguration": {
"contentTransfer": {
"transferMode": "Chunked"
}
}
},
"Response": {
"type": "Response",
"kind": "Http",
"runAfter": {
"Create_SharePoint_list_item_via_Graph": [
"Succeeded"
]
},
"inputs": {
"statusCode": 201,
"headers": {
"Content-Type": "application/json"
},
"body": {
"status": "created",
"itemId": "@{body('Create_SharePoint_list_item_via_Graph')?['id']}",
"webUrl": "@{body('Create_SharePoint_list_item_via_Graph')?['webUrl']}",
"fields": "@{coalesce(body('Create_SharePoint_list_item_via_Graph')?['fields'], json('{}'))}",
"timestampJst": "@{convertTimeZone(utcNow(), 'UTC', 'Tokyo Standard Time', 'yyyy-MM-dd HH:mm:ss')}"
}
}
},
"Response_on_error": {
"type": "Response",
"kind": "Http",
"runAfter": {
"Create_SharePoint_list_item_via_Graph": [
"Failed",
"TimedOut"
]
},
"inputs": {
"statusCode": 502,
"headers": {
"Content-Type": "application/json"
},
"body": {
"status": "create_error",
"graphStatusCode": "@{outputs('Create_SharePoint_list_item_via_Graph')?['statusCode']}",
"graphBody": "@{body('Create_SharePoint_list_item_via_Graph')}",
"timestampJst": "@{convertTimeZone(utcNow(), 'UTC', 'Tokyo Standard Time', 'yyyy-MM-dd HH:mm:ss')}"
}
}
}
}
},
"kind": "Stateful"
}
検証結果
Bodyに{ "title": "テスト起票", "description": "説明", "priority": "High", "status": "New" }を入れて実行。

Run history に「失敗 → 修正後 成功」の遷移が残り、SharePoint List にアイテムが作成されました。

| シナリオ | 結果 | 備考 |
|---|---|---|
| UAMI へサイト権限付与 | ✅ |
Sites.Selected モデル |
| List スキーマにマッピング | ✅ | Title/Description/Priority/Status |
| アイテム登録 | ✅ | HTTP 201(正規化後) |
| UI 反映確認 | ✅ | テスト起票 / 説明 / High / New |
まとめ
- UAMI +
Sites.Selected+ Graph で、シークレットレスに SPO List を更新できる - List の列は実測してマッピングする(存在しない列は 400)
- Portal テスト実行の二重エンコードに注意。
json(string(triggerBody()))で正規化すると堅牢 - 残課題: person / lookup 列は LookupId 解決の追加実装が必要
ハマりポイント: triggerBody() が String になる
Property selection is not supported on values of type 'String'
triggerBody()?['title'] が評価できず Compose で失敗する。
原因: Azure Portal デザイナーの「テスト実行(Run draft)」は、テストペイロードを JSON 文字列としてダブルエンコードして送ることがあります。Content-Type: application/json で受けたトリガーはそれを 1 個の String としてパースし、プロパティ選択ができなくなります。実行時ダンプでは body が "{ \"title\": ... }"(クォート付きの文字列)になっているのが目印です。
解決策: どちらの形(オブジェクト / JSON文字列)でも動くよう、先頭に正規化アクションを追加します。
"Parse_trigger_body": {
"type": "Compose",
"runAfter": {},
"inputs": "@json(string(triggerBody()))"
}
以降は outputs('Parse_trigger_body')?['title'] を参照します。json(string(triggerBody())) は、オブジェクトなら string()→json() で、JSON文字列ならそのまま json() で、どちらも確実にオブジェクト化できます。
本番はデザイナーのテストではなく、トリガーの実コールバック URL に生 JSON を POST するのが確実です(二重エンコードが起きません)。Parse_trigger_body を残しておけば両対応で安全です。
curl -X POST "<トリガーの HTTP POST URL>" \
-H "Content-Type: application/json" \
-d '{ "title": "テスト起票", "description": "説明", "priority": "High", "status": "New" }'