Đọc hộp thư Outlook của khách bằng Microsoft Graph mà không mở cả tenant
Lấy được token chỉ là phần dễ; phần khó là chứng minh với IT của khách rằng app của bạn không đọc được hộp thư nào ngoài phạm vi đã thỏa thuận.
Tóm tắt nhanh
- Application permission rất rộng: không có người đăng nhập, app truy cập được mọi dữ liệu thuộc phạm vi của quyền đó.
- Mail.ReadBasic.All là quyền application thấp nhất để liệt kê thư; cần nhiều hơn thì phải xin quyền cao hơn.
- RBAC for Applications khóa app vào hộp thư cụ thể, và bằng chứng tốt nhất là lần app bị từ chối ở hộp thư ngoài scope.
Chỉ một lần admin đồng ý cấp Mail.Read cho connector đọc hộp thư hóa đơn là app đã có thể đọc mọi hộp thư trong tenant. Một bài năm 2021 trên blog rakhesh.com đã mô tả thực tế này: Graph chỉ cấp quyền đọc thư trên mọi hộp thư, muốn giới hạn phải làm thêm ở phía Exchange.
Nếu agent của bạn cần đọc email của khách, hãy chuẩn bị sẵn câu trả lời cho câu hỏi “nó đọc được những gì?” từ phía IT. Sau bài thực hành này, bạn sẽ trả lời được câu đó bằng kết quả kiểm thử.
Bạn sẽ dựng gì, và cần gì trước khi bắt đầu
Tình huống giả định: khách muốn agent đọc hộp thư chung invoices@, và chỉ hộp thư đó. Bạn sẽ dựng một app gọi Graph không cần người đăng nhập, liệt kê thư, rồi khóa app vào đúng hộp thư này.
Bạn cần một tenant thử nghiệm có quyền admin, hai hộp thư (một trong scope, một ngoài scope), curl, jq và một phiên Exchange Online PowerShell. Gửi vài thư vào cả hai hộp thư, và tuyệt đối đừng tập trên tenant của khách.
Hãy hỏi một câu trước khi viết dòng code nào: có người dùng đăng nhập khi app chạy không? Microsoft khuyên ưu tiên delegated permission nếu loại quyền đó đáp ứng được yêu cầu. App-only hợp với job nền không có ai ngồi trước màn hình.
Bước 1: đăng ký app và xin quyền hẹp nhất
Hãy đăng ký app, tạo credential, rồi thêm application permission Mail.ReadBasic.All. Với API liệt kê thư, đây là quyền application thấp nhất; Mail.Read và Mail.ReadWrite đều cao hơn.
Quyền này phục vụ bước 3; đến bước 4, bạn sẽ gỡ nó để chuyển sang quyền có scope. Trong lab, secret là đường ngắn nhất; tại khách, hãy hỏi chính sách credential của họ trước khi chọn.
Application permission luôn cần admin đồng ý. Kiểm tra: quyền đã được admin consent trong tenant. Nếu đang tự làm lab, bạn tự bấm; tại khách, đây là cuộc hẹn phải có trong kế hoạch.
Bước 2: lấy token
Bản dưới đây dùng HTTP thô để bạn nhìn rõ từng tham số, và chỉ dành cho lab, đừng đưa nguyên lên production. Token endpoint của tenant nằm trong tài liệu Get access without a user. Scope .default gói toàn bộ quyền cấp app mà admin đã đồng ý, nên bạn không liệt kê từng quyền ở đây.
# Rút gọn cho lab: TOKEN_URL là token endpoint của tenant
export TOKEN_URL='<token endpoint của tenant>'
export CLIENT_ID='<application id>'
read -rs CLIENT_SECRET
TOKEN=$(curl -s -X POST "$TOKEN_URL" \
-d "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials" | jq -r .access_token)
if [ -n "$TOKEN" ] && [ "$TOKEN" != "null" ]; then
echo "co token"
else
echo "khong co token"
fi
Kiểm tra: terminal in co token. Nếu không, hãy chạy lại lệnh mà bỏ phần jq để đọc phản hồi lỗi, nhưng đừng dán phản hồi có token vào chat hay ticket.
Bước 3: đọc thư của một hộp thư cụ thể
Để đọc thư của một người dùng cụ thể, bạn gọi /users/{id | userPrincipalName}/messages. Đoạn dưới dùng $select để chỉ lấy đúng trường cần dùng.
Đây là bản rút gọn, chỉ để xác nhận app đọc được thư, chưa phải code đọc toàn bộ hộp thư; chi tiết tham số có ở tài liệu List messages.
export MAILBOX='invoices@ten-mien-thu-nghiem'
URL="https://graph.microsoft.com/v1.0/users/$MAILBOX/messages?\$select=subject,receivedDateTime"
curl -s -H "Authorization: Bearer $TOKEN" "$URL" \
| jq '{so_thu: (.value | length)}'
Kiểm tra: so_thu lớn hơn 0, vì bạn đã gửi thư vào hộp thư này. Hãy đối chiếu dữ liệu trả về với việc agent phải làm: đó là căn cứ để quyết định có cần xin quyền cao hơn hay không.
Vì sao app đã chạy mà công việc mới đi được nửa đường
Đến đây app chạy tốt, nhưng nó vẫn rộng. Microsoft xếp application permission vào loại quyền rất lớn, vì app có thể truy cập và sửa dữ liệu mà không cần ai đăng nhập. Tài liệu của hãng cũng nói thẳng: cấp cho app nhiều quyền hơn mức cần là cách làm kém an toàn.
Bảng dưới đây cho thấy vì sao một mình Mail.ReadBasic.All chưa khép lại câu chuyện. Khi agent cần nhiều dữ liệu hơn, bạn phải xin quyền cao hơn, và lúc đó phạm vi hộp thư trở thành thứ phải khóa.
| Cấu hình | Mức quyền | Đọc của ai |
|---|---|---|
Mail.ReadBasic.All từ Entra |
Thấp nhất để liệt kê thư | Không giới hạn theo hộp thư |
Mail.Read từ Entra |
Cao hơn | Mọi hộp thư |
Role Application Mail.Read kèm scope |
Đọc thư qua Exchange RBAC | Chỉ hộp thư trong scope |
Bước 4: đổi quyền và khóa app vào đúng hộp thư
Giờ đề bài đổi: giả sử agent cần đọc cả file PDF hóa đơn và quyền thấp nhất không còn đủ. Bạn phải lên mức Mail.Read, nhưng lần này không cấp ở Entra mà qua role Application Mail.Read của Exchange, gắn với một scope.
RBAC for Applications trong Exchange Online ghép quyền với một resource scope để quy định app được truy cập những hộp thư nào, và nó thay thế Application Access Policies.
Bẫy nằm ở đây: quyền hiệu lực là phép hợp của quyền từ Entra và quyền từ Exchange RBAC. Như Tony Redmond cảnh báo trên Practical365, để nguyên Mail.Read ở Entra thì nó lấn át mọi kiểm soát của RBAC.
Làm một phép tính với số giả định: tenant có 800 hộp thư, scope chỉ chứa invoices@. Còn Mail.Read ở Entra, hợp của 800 hộp thư và 1 hộp thư vẫn là 800. Gỡ consent xong, app chỉ còn đúng 1 hộp thư.
Vì thế, hãy dọn Entra trước. Quyền Entra nào còn lại cũng cộng dồn vào quyền hiệu lực, nên Mail.ReadBasic.All cấp ở bước 1 phải gỡ cùng Mail.Read nếu bạn từng thêm: mở app trong Enterprise applications, thu hồi admin consent, rồi xóa các quyền đó khỏi cấu hình của app.
Kiểm tra: danh sách quyền đã consent của app không còn quyền Mail nào. Giao diện có thể đổi vị trí nút bấm, nên hãy bám theo tiêu chí này.
Tiếp theo, chuẩn bị một admin unit chỉ chứa invoices@ rồi ghi lại ID của nó; hướng dẫn RBAC for Applications của Microsoft là nơi đối chiếu toàn bộ quy trình. Sau đó điền ba giá trị và chạy hai lệnh dưới đây.
# AppId và ObjectId lấy ở trang Enterprise applications
$AppId = '<Application ID>'
$ObjectId = '<Object ID>'
$AdminUnitId = '<ID của admin unit>'
New-ServicePrincipal -AppId $AppId -ObjectId $ObjectId
New-ManagementRoleAssignment -App $AppId -Role "Application Mail.Read" -RecipientAdministrativeUnitScope $AdminUnitId
Nếu khách đã quản lý hộp thư bằng management scope, hãy thay tham số cuối bằng -CustomResourceScope kèm tên scope đó. Kiểm tra: cả hai lệnh chạy không báo lỗi. Không báo lỗi vẫn chưa chứng minh ID hay scope đúng; điều đó phải đợi bước 5.
Bước 5: chứng minh app bị chặn ở nơi cần chặn
Chạy Test-ServicePrincipalAuthorization cho cả hai hộp thư. Cmdlet này bỏ qua cache nên cho kết quả ngay, trong khi thay đổi quyền với app thật có thể mất từ 30 phút đến 2 giờ, tùy mức dùng gần đây của app.
Test-ServicePrincipalAuthorization -Identity $AppId -Resource 'invoices@ten-mien-thu-nghiem'
Test-ServicePrincipalAuthorization -Identity $AppId -Resource 'ngoai-scope@ten-mien-thu-nghiem'
Kiểm tra: tìm role Application Mail.Read trong kết quả của mỗi lần chạy. Role phải có hiệu lực ở invoices@ và không có hiệu lực ở hộp thư còn lại. Nếu cả hai đều có hiệu lực, hãy nghi scope quá rộng; nếu cả hai đều không, hãy xem lại ID và scope.
Một lệnh gọi Graph ngay sau khi đổi quyền chưa nói lên điều gì, dù nó thành công hay thất bại. Vì thế, hãy đợi hết khoảng cache rồi chạy lại bước 2 để lấy token mới.
Đừng dùng lại nguyên đoạn code ở bước 3 cho phép thử này. Nó chỉ in độ dài của value, nên một lời từ chối cũng hiện so_thu: 0, y hệt một hộp thư rỗng. Đoạn dưới đây giữ lại mã HTTP và mã lỗi.
RESP=$(mktemp)
for MAILBOX in 'invoices@ten-mien-thu-nghiem' 'ngoai-scope@ten-mien-thu-nghiem'; do
URL="https://graph.microsoft.com/v1.0/users/$MAILBOX/messages?\$top=1&\$select=id"
STATUS=$(curl -s -o "$RESP" -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" "$URL")
echo "$MAILBOX | HTTP $STATUS | $(jq -r '"so_thu=\(.value | length) loi=\(.error.code // "khong")"' "$RESP")"
done
rm -f "$RESP"
Kiểm tra: dòng invoices@ phải là HTTP 200, so_thu=1, loi=khong. Dòng ngoài scope phải có mã HTTP khác 200 và một mã lỗi cụ thể. Nếu dòng này ra HTTP 200 kèm thư, hãy quay lại phần dọn consent ở bước 4.
Dòng ngoài scope mới là bằng chứng có giá trị. Hãy lưu nguyên mã HTTP và mã lỗi của nó để đưa cho IT của khách.
Hai cái bẫy khiến bạn tưởng đã xong
Bẫy đầu tiên là lấy ID từ trang App registrations vì đó là nơi bạn vừa đăng ký app. Trang này hiện giá trị khác với Enterprise applications, và New-ServicePrincipal sẽ sai nếu dùng chúng.
Bẫy thứ hai là coi kết quả đẹp của Test-ServicePrincipalAuthorization là đủ. Cmdlet này không tính quyền cấp riêng ở Entra, nên nó chỉ nói về phần RBAC. Đó là lý do phép thử bằng lệnh gọi Graph thật ở bước 5 không thể bỏ.
Mang gì đến cuộc họp với IT của khách?
Vì application permission luôn cần admin consent, bạn không thể làm xong việc này một mình. Hãy mang đến cuộc họp với IT một trang duy nhất: quyền xin cấp, lý do không thể dùng quyền thấp hơn, danh sách hộp thư trong scope, cách thu hồi, và mã HTTP cùng mã lỗi ở hộp thư ngoài scope.
Kỹ năng này cũng dễ đưa vào CV. Thay vì ghi “tích hợp Microsoft Graph”, hãy ghi rõ bạn đã giới hạn app vào những hộp thư cụ thể và kiểm chứng ra sao. Khi đọc mô tả công việc FDE có nhắc đến dữ liệu doanh nghiệp, cứ coi đây là câu chuyện phỏng vấn nên chuẩn bị sẵn.
Lần tới khi khách hỏi agent của bạn đọc được những gì, câu trả lời tốt nhất là cho họ xem lần nó bị từ chối.
Bài này có hữu ích không?
Cảm ơn bạn đã góp ý!
6 nguồn
- Get access without a user - Microsoft Graph | Microsoft Learn · 2025-08-29
- Overview of Microsoft Graph permissions - Microsoft Graph | Microsoft Learn · 2025-12-26
- List messages - Microsoft Graph v1.0 | Microsoft Learn · 2024-06-21
- Role Based Access Control for Applications in Exchange Online | Microsoft Learn · 2025-11-25
- Microsoft Launches Role-Based Access Control for Applications · 2022-12-07
- Limit app registration to specific mailbox - rakhesh.com · 2021-07-10