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

11 KiB

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.AllDelegated
  • Sites.Read.AllDelegated
  • User.ReadDelegated

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
$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

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-onlyApplication permissions
  • Có user loginDelegated permissions