372 lines
11 KiB
Markdown
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**
|
|
- [ ] Có `Sites.Read.All`
|
|
- [ ] Có `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**
|
|
|