Files
poc_system/doc/Entra-Graph-AppOnly-Checklist-and-Test-Notes.md

372 lines
11 KiB
Markdown

# Ghi chú tránh lặp lại sai sót — Entra App + Microsoft Graph app-only cho SharePoint Ingestion
## 1) Tóm tắt sự cố đã gặp
### Hiện tượng
- Lấy token **thành công** bằng `client_credentials`.
- Token có các claim hợp lệ kiểu app-only:
- `aud = https://graph.microsoft.com`
- `idtyp = app`
- Nhưng gọi các API Graph như:
- `GET /sites/{hostname}`
- `GET /sites/{hostname}:/{server-relative-path}`
đều bị **401 Unauthorized**.
### Nguyên nhân gốc
App Registration đã được cấp **Delegated permissions** thay vì **Application permissions**.
Cụ thể lúc xảy ra lỗi, app đang có kiểu quyền như:
- `Files.Read.All`**Delegated**
- `Sites.Read.All`**Delegated**
- `User.Read`**Delegated**
Trong khi luồng đang dùng là:
- **OAuth 2.0 client credentials flow**
- **app-only / daemon / service-to-service**
- **không có user đăng nhập interactive**
=> Kết quả: token app-only **không có `roles`**, nên Microsoft Graph không chấp nhận token cho các API cần application permissions.
---
## 2) Bài học rút ra (phải kiểm tra trước khi test Graph)
### Quy tắc số 1
Nếu dùng **client credentials flow** thì **bắt buộc** phải cấp quyền ở dạng:
- **Microsoft Graph → Application permissions**
**Không dùng Delegated permissions** cho bài toán ingestion app-only.
### Quy tắc số 2
Sau khi sửa permission trong Entra App:
1. Bấm **Grant admin consent**
2. Lấy **token mới hoàn toàn**
3. Decode token và **kiểm tra `roles`** trước khi gọi Graph
### Quy tắc số 3
Nếu token app-only **không có `roles`**, thì **không test Graph tiếp**.
Phải quay lại kiểm tra:
- Permission type có phải **Application** không
- Có phải **Microsoft Graph** không
- Đã **Grant admin consent** chưa
- Có cần đợi propagation vài phút không
---
## 3) Cấu hình đúng cho PoC hiện tại
### Tối thiểu cần có trong Entra App
#### Microsoft Graph → Application permissions
- `Sites.Read.All`
- `Files.Read.All`
### Không cần cho app-only ingestion hiện tại
- `User.Read` (Delegated)
- Interactive login
- Redirect URI cho user login
---
## 4) Checklist fail-fast trước khi test Graph
## Checklist nhanh
- [ ] App dùng **client credentials flow**
- [ ] API permissions nằm dưới **Microsoft Graph**
- [ ] Permission type là **Application**
- [ ]`Sites.Read.All`
- [ ]`Files.Read.All`
- [ ] Đã **Grant admin consent**
- [ ] Đã lấy **token mới** sau khi sửa permission
- [ ] Token decode ra có `roles`
- [ ] `roles` chứa ít nhất:
- `Sites.Read.All`
- `Files.Read.All`
Nếu **một trong các dòng trên fail**, không chạy tiếp phần test Graph site/drive.
---
## 5) Script test hoàn chỉnh (đã thêm guard để tránh lặp lỗi)
> Script này làm đúng 4 việc:
> 1. Lấy token mới
> 2. Decode token
> 3. **Kiểm tra `roles` trước**
> 4. Chỉ khi roles đúng mới test Graph site endpoints
```powershell
$ErrorActionPreference = "Stop"
# ==========================================
# Điền thông tin tại đây
# ==========================================
$TenantId = "<TENANT_ID_GUID>"
$ClientId = "<CLIENT_ID_GUID>"
$ClientSecret = "<CLIENT_SECRET_VALUE>"
$SharePointHost = "285pdg.sharepoint.com"
$SitePath = "/sites/poc_system"
# ==========================================
function Get-GraphToken {
param(
[string]$TenantId,
[string]$ClientId,
[string]$ClientSecret
)
$tokenUrl = "https://login.microsoftonline.com/" + $TenantId + "/oauth2/v2.0/token"
$body = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
return Invoke-RestMethod `
-Method Post `
-Uri $tokenUrl `
-ContentType "application/x-www-form-urlencoded" `
-Body $body
}
function Decode-JwtPayload {
param([string]$Jwt)
$parts = $Jwt.Split('.')
if ($parts.Length -lt 2) {
throw "JWT không hợp lệ."
}
$payload = $parts[1]
switch ($payload.Length % 4) {
2 { $payload += '==' }
3 { $payload += '=' }
}
$payload = $payload.Replace('-', '+').Replace('_', '/')
$bytes = [System.Convert]::FromBase64String($payload)
$json = [System.Text.Encoding]::UTF8.GetString($bytes)
return $json | ConvertFrom-Json
}
function Invoke-GraphGet {
param(
[string]$Url,
[string]$AccessToken,
[string]$Label
)
Write-Host ""
Write-Host ("=== " + $Label + " ===") -ForegroundColor Cyan
Write-Host $Url -ForegroundColor DarkGray
$headers = @{
Authorization = "Bearer " + $AccessToken
}
try {
$resp = Invoke-WebRequest `
-Method Get `
-Uri $Url `
-Headers $headers `
-UseBasicParsing
Write-Host "OK" -ForegroundColor Green
Write-Host ("HTTP " + [int]$resp.StatusCode) -ForegroundColor Green
if ($resp.Content) {
$json = $resp.Content | ConvertFrom-Json
$json | ConvertTo-Json -Depth 10
return $json
}
return $null
}
catch {
Write-Host "FAILED" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
if ($_.Exception.Response -ne $null) {
$resp = $_.Exception.Response
Write-Host ""
Write-Host "Status code:" -ForegroundColor Yellow
try {
Write-Host ([int]$resp.StatusCode)
}
catch {
Write-Host "Không đọc được StatusCode"
}
Write-Host ""
Write-Host "WWW-Authenticate:" -ForegroundColor Yellow
try {
Write-Host $resp.Headers["WWW-Authenticate"]
}
catch {
Write-Host "Không có WWW-Authenticate header"
}
Write-Host ""
Write-Host "Response body:" -ForegroundColor Yellow
try {
$stream = $resp.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
$bodyText = $reader.ReadToEnd()
Write-Host $bodyText
}
catch {
Write-Host "Không đọc được response detail."
}
}
return $null
}
}
# =======================================================
# 1) Lấy token mới
# =======================================================
$tokenResponse = Get-GraphToken `
-TenantId $TenantId `
-ClientId $ClientId `
-ClientSecret $ClientSecret
$accessToken = $tokenResponse.access_token
Write-Host "TOKEN OK" -ForegroundColor Green
Write-Host ("token_type : " + $tokenResponse.token_type)
Write-Host ("expires_in : " + $tokenResponse.expires_in)
# =======================================================
# 2) Decode token + kiểm tra claims quan trọng
# =======================================================
$claims = Decode-JwtPayload -Jwt $accessToken
Write-Host ""
Write-Host "=== TOKEN CLAIMS ===" -ForegroundColor Cyan
Write-Host ("aud : " + $claims.aud)
Write-Host ("appid : " + $claims.appid)
if ($claims.PSObject.Properties.Name -contains "idtyp") {
Write-Host ("idtyp : " + $claims.idtyp)
}
$roles = @()
if ($claims.PSObject.Properties.Name -contains "roles") {
$roles = @($claims.roles)
Write-Host "roles:" -ForegroundColor Yellow
$roles | ForEach-Object { Write-Host (" - " + $_) }
}
else {
Write-Host "roles: <KHÔNG CÓ>" -ForegroundColor Red
}
# =======================================================
# 3) Guard bắt buộc: nếu không có roles đúng thì stop luôn
# =======================================================
$hasSitesRead = $roles -contains "Sites.Read.All"
$hasFilesRead = $roles -contains "Files.Read.All"
if (-not $hasSitesRead) {
Write-Host ""
Write-Host "STOP: Token chưa có role 'Sites.Read.All'." -ForegroundColor Red
Write-Host "=> Kiểm tra lại Microsoft Graph -> Application permissions -> Sites.Read.All -> Grant admin consent." -ForegroundColor Yellow
return
}
if (-not $hasFilesRead) {
Write-Host ""
Write-Host "WARNING: Token chưa có role 'Files.Read.All'." -ForegroundColor Yellow
Write-Host "=> Site test có thể pass, nhưng drive/delta về sau sẽ fail." -ForegroundColor Yellow
}
# =======================================================
# 4) TEST 1 - GET /sites/{hostname}
# =======================================================
$rootByHostUrl = "https://graph.microsoft.com/v1.0/sites/" + $SharePointHost + "?`$select=id,webUrl"
$rootSite = Invoke-GraphGet `
-Url $rootByHostUrl `
-AccessToken $accessToken `
-Label "TEST 1 - GET /sites/{hostname}"
# =======================================================
# 5) TEST 2 - GET /sites/{hostname}:/{server-relative-path}
# =======================================================
$siteRef = $SharePointHost + ":" + $SitePath
$resolveSiteUrl = "https://graph.microsoft.com/v1.0/sites/" + $siteRef + "?`$select=id,displayName,webUrl"
$resolvedSite = Invoke-GraphGet `
-Url $resolveSiteUrl `
-AccessToken $accessToken `
-Label "TEST 2 - GET /sites/{hostname}:/{server-relative-path}"
# =======================================================
# 6) Summary
# =======================================================
Write-Host ""
Write-Host "=== SUMMARY ===" -ForegroundColor Cyan
if ($null -ne $rootSite) {
Write-Host "TEST 1: PASS" -ForegroundColor Green
}
else {
Write-Host "TEST 1: FAIL" -ForegroundColor Red
}
if ($null -ne $resolvedSite) {
Write-Host "TEST 2: PASS" -ForegroundColor Green
}
else {
Write-Host "TEST 2: FAIL" -ForegroundColor Red
}
```
---
## 6) Kỳ vọng đúng sau khi sửa quyền
### Token claim đúng
```text
aud : https://graph.microsoft.com
appid : <app-id>
idtyp : app
roles:
- Sites.Read.All
- Files.Read.All
```
### Test site đúng
- `TEST 1 - GET /sites/{hostname}` → PASS
- `TEST 2 - GET /sites/{hostname}:/{server-relative-path}` → PASS
---
## 7) Ghi chú vận hành nội bộ
### Khi nào được đi tiếp sang ingestion skeleton?
Chỉ khi đạt đủ 3 điều kiện:
1. Token có `roles`
2. `TEST 1` pass
3. `TEST 2` pass
### Nếu token vẫn không có `roles`
- Kiểm tra lại **Type** của permission có phải **Application** không
- Kiểm tra lại permission có nằm dưới **Microsoft Graph** không
- Kiểm tra đã bấm **Grant admin consent** chưa
- Đợi vài phút rồi lấy **token mới** lần nữa
---
## 8) Tên lỗi nội bộ để nhớ
> **Sai lầm đã gặp:**
> “Dùng `client_credentials` nhưng lại cấp **Delegated permissions** trong App Registration.”
### Cách nhớ nhanh
- **App-only** → **Application permissions**
- **Có user login** → **Delegated permissions**