# Thực hành: đồng bộ SharePoint, Confluence vào RAG với job quyền riêng

> Đồng bộ nội dung thì gần như team nào cũng làm được. Khó hơn nhiều là bắt kịp lúc khách rút quyền đọc của một người, nên hướng dẫn này dựng hai job riêng cho hai việc đó.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-rag-sharepoint-delta-dong-bo-quyen/

Thử hình dung: 9 giờ sáng, một nhân viên bị rút quyền đọc thư mục lương trên SharePoint. Nếu pipeline RAG của bạn chỉ đồng bộ nội dung, chatbot vẫn trích tài liệu lương cho người đó, vì file không hề bị sửa.

Bài phân tích của Wavect về RAG trên SharePoint và Confluence nhấn mạnh đúng điểm này: quyền đã chép vào chỉ mục không tự cập nhật khi nguồn thu hồi.

Một sự cố như vậy là đủ để khách dừng hẳn pilot. Vì thế, nhiều khả năng câu hỏi đầu tiên từ bộ phận bảo mật của khách sẽ xoay quanh quyền truy cập, chứ chưa phải chất lượng câu trả lời.

Hướng dẫn này dựng hai job. Job nội dung dùng delta query để đưa thay đổi của file vào chỉ mục vector. Job quyền chạy riêng, có nguồn tín hiệu riêng và có ngưỡng fail-closed. Code bên dưới là Python giả lập, đã đơn giản hóa cho dễ đọc, chưa phải SDK chạy được ngay.

## Cần chuẩn bị gì trước khi viết dòng code đầu tiên?

Bạn cần một app đăng ký trong tenant Microsoft 365 của khách, một tài khoản Confluence Cloud có API token, và một chỉ mục vector cho phép upsert và xóa theo khóa. Phần khó nhất lại không nằm ở kỹ thuật. Tài liệu hướng dẫn quét quy mô lớn của Microsoft ghi rằng muốn xử lý quyền cho đúng, app cần quyền **Sites.FullControl.All**.

Quyền này rất rộng, nên admin bên khách thường cần thời gian để duyệt. Hãy xin ngay tuần đầu, kèm một trang giải thích app đọc gì, ghi gì, và vì sao thiếu nó thì chatbot không lọc được tài liệu theo người hỏi.

## Bước 1: lần crawl đầu tiên và token deltaLink

Lần đầu bạn gọi delta không kèm tham số, Graph sẽ duyệt toàn bộ drive. Kết quả trả về nhiều trang, mỗi trang có `@odata.nextLink`. Chỉ trang cuối mới có `@odata.deltaLink`, và đó là thứ bạn cất đi cho lần đồng bộ sau.

```python
# Đơn giản hóa: chưa có retry, auth, phân trang song song
def sync_drive(state, drive_id):
    url = state.get("delta_link") or f"{GRAPH}/drives/{drive_id}/root/delta"
    while url:
        body = graph_get(url).json()
        for item in body["value"]:
            handle_item(item)
        url = body.get("@odata.nextLink")
        if not url:
            state["delta_link"] = body["@odata.deltaLink"]
            save(state)
```

**Kiểm tra:** chỉ lưu `delta_link` khi đã xử lý xong trang cuối. Nếu lưu sớm mà job chết giữa chừng, lần chạy sau sẽ bỏ qua luôn những trang chưa kịp xử lý.

## Bước 2: theo id, không theo đường dẫn

Delta feed chỉ trả trạng thái mới nhất của item, và một item có thể xuất hiện nhiều lần. Microsoft dặn luôn theo dõi item theo `id`. Lý do: khi đổi tên một thư mục, feed không trả lại các file con bên trong.

Nếu bạn lấy path làm khóa trong chỉ mục, chỉ cần đổi tên một thư mục là sinh ra cả loạt bản ghi mồ côi. Hãy lấy `id` làm khóa, còn path chỉ là metadata để hiển thị trích dẫn.

```python
def handle_item(item):
    doc_id = item["id"]
    if "deleted" in item:
        index.delete(doc_id)              # gỡ khỏi trạng thái cục bộ
        return
    if item.get("@microsoft.graph.sharedChanged") == "True":
        permission_queue.put(doc_id)      # để job quyền xử lý
    if "file" in item:
        content_queue.put(doc_id)         # tải, chunk, embed, upsert
```

Để ý hai lệnh `if` tách rời nhau. Vì feed chỉ trả trạng thái mới nhất, một file vừa bị sửa nội dung vừa bị đổi quyền có thể chỉ hiện ra một lần, mang annotation `sharedChanged`.

Nếu dùng `elif`, phần nội dung mới của file đó sẽ bị bỏ rơi mà không ai hay. Đẩy vào cả hai hàng đợi thì an toàn hơn, và cái giá chỉ là embed thừa một lần, vô hại nếu bước 3 làm đúng.

**Kiểm tra:** xóa một file trên SharePoint rồi chạy lại. Item có facet `deleted` phải biến mất khỏi chỉ mục chứ không chỉ khỏi bảng metadata.

## Bước 3: upsert phải chạy lại được nhiều lần

Tài liệu tổng quan về delta query của Graph nói rõ ứng dụng phải chịu được replay, tức cùng một thay đổi có thể quay lại trong những lần phản hồi sau. Vì thế bước ghi vào chỉ mục phải idempotent. Hãy xóa toàn bộ chunk cũ của `doc_id` rồi ghi chunk mới, hoặc đặt khóa chunk theo dạng `doc_id + số thứ tự`.

**Lỗi hay gặp:** append chunk mỗi lần thấy item. Sau một tuần, cùng một đoạn văn xuất hiện ba lần trong top-k, và người dùng phàn nàn câu trả lời lặp ý.

## Bước 4: 410 Gone không phải lỗi để bỏ qua

Khi token quá cũ hoặc hết hạn, Graph trả `HTTP 410 Gone` kèm header `Location` chứa một nextLink mới để duyệt lại từ đầu. Lần duyệt lại này phải đi kèm bước đối chiếu với trạng thái cục bộ: id nào có trong chỉ mục mà không xuất hiện trong lần duyệt toàn phần thì là tài liệu đã mất ở nguồn.

```python
if resp.status_code == 410:
    url = resp.headers["Location"]
    state["full_resync"] = True       # cuối vòng: so tập id, xóa phần thừa
    seen_ids.clear()
```

Thêm một quy tắc: khi gặp 429 hoặc 503, chờ đúng thời gian ghi trong `Retry-After` và dừng **mọi** request, không chỉ luồng vừa bị chặn. SharePoint throttle theo từng ứng dụng trên mỗi tenant, nên mười worker tranh nhau retry chỉ khiến cả app bị chặn lâu hơn.

## Bước 5: webhook để biết sớm, delta để lấy dữ liệu

Microsoft gợi ý một mô hình ba phần: crawl bằng delta, nhận webhook, và chạy thêm delta định kỳ, tối đa một lần mỗi ngày, để không sót thay đổi nào. Webhook chỉ báo "có gì đó vừa đổi". Khi nhận được nó, bạn gọi delta bằng `delta_link` đã cất. Đừng bao giờ dùng payload webhook làm nguồn dữ liệu.

**Điểm mấu chốt:** Webhook cho bạn biết có gì đó thay đổi, còn đổi cái gì thì phải hỏi lại nguồn.

## Bước 6: job quyền là một hệ thống riêng

Mặc định, delta kèm thông tin sharing cho mọi item thay đổi, và bạn phải gọi thêm để lấy quyền của từng item. Header đầu tiên nên thêm là `Prefer: deltashowsharingchanges`. Nó gắn annotation `sharedChanged` cho những item xuất hiện vì đổi quyền, nhưng chỉ có trên SharePoint và OneDrive for Business.

Header thứ hai là `Prefer: hierarchicalsharing`, được Microsoft giới thiệu như cách để hiểu rõ hơn quyền đã thay đổi như thế nào. Mục đích thực tế của nó là cắt bớt số lần phải gọi thêm để lấy quyền cho từng item một.

Job quyền lấy id từ `permission_queue`, gọi API liệt kê quyền của file rồi dựng ACL. Quyền hiệu lực gồm quyền gán trực tiếp cộng quyền kế thừa từ thư mục cha, và thuộc tính `inheritedFrom` cho biết quyền đó kế thừa từ đâu.

```python
# Đơn giản hóa: list_permissions() bọc API liệt kê quyền của file
def permission_worker():
    while True:
        doc_id = permission_queue.get()
        acl = {"direct": [], "inherited": []}
        for perm in list_permissions(doc_id):
            if perm.get("inheritedFrom"):
                acl["inherited"].append(perm)
            else:
                acl["direct"].append(perm)
        index.set_acl(doc_id, acl)   # chỉ ghi trường ACL, không embed lại
```

**Kiểm tra:** đổi quyền một thư mục cha có vài file con. Mọi file con đang kế thừa từ thư mục đó phải được tính lại ACL, không chỉ riêng thư mục.

Còn một nguồn tín hiệu nữa: SharePoint và OneDrive có webhook riêng cho sự kiện bảo mật, báo khi quyền của một item thay đổi. Hãy nối webhook này thẳng vào job quyền, không cho đi qua hàng đợi nội dung, vì việc thu hồi quyền không nên phải xếp hàng sau một file PDF 200 trang đang chờ embed.

## Confluence: CQL không thấy quyền space

Với Confluence, job nội dung có thể truy vấn CQL theo `lastModified`. Nhưng một câu trả lời trên diễn đàn developer của Atlassian chỉ ra rằng thay đổi quyền space không hiện qua CQL, vì đó không phải cập nhật nội dung. Muốn bắt được, bạn cần Audit API hoặc một job quyền chạy theo lịch.

Ở cấp trang, nhóm API content restrictions của Confluence REST trả về các hạn chế trên từng nội dung, đủ để dựng ACL cho từng tài liệu. Khi gặp 429, Atlassian khuyên dùng exponential backoff có jitter và tôn trọng `Retry-After`.

| | Job nội dung | Job quyền |
|---|---|---|
| Tín hiệu SharePoint | Webhook file, delta định kỳ | Annotation sharedChanged, webhook bảo mật |
| Tín hiệu Confluence | CQL lastModified | Audit API, quét restrictions theo lịch |
| Ghi vào | Chunk và embedding theo id | Trường ACL của từng tài liệu |
| Khi tụt lại phía sau | Câu trả lời cũ | Lộ dữ liệu: phải fail-closed |

## Ngưỡng fail-closed: chậm thì từ chối

Change feed chỉ là tín hiệu, nên bạn vẫn phải reconcile quyền định kỳ và đặt ngưỡng độ trễ. Giả sử bạn chọn 15 phút: nếu lần reconcile thành công gần nhất của một nguồn đã cũ hơn 15 phút, bước retrieval loại hết tài liệu của nguồn đó thay vì tin vào ACL cũ.

Người dùng sẽ thấy "tạm thời không truy cập được", một kết quả dễ giải thích hơn nhiều so với một vụ lộ bảng lương.

Con số ngưỡng là chuyện thỏa thuận với khách. Hãy để bộ phận bảo mật của họ chọn, rồi ghi nó vào tài liệu nghiệm thu.

## Mang kỹ năng này đến site khách và vào CV

Quay lại kịch bản thư mục lương ở đầu bài: câu hỏi khó nhất ở site khách nhiều khả năng không nằm ở recall, mà là "nếu tôi rút quyền của anh A thì bao lâu sau chatbot mới biết". FDE trả lời được bằng một con số, một cơ chế và một hành vi rõ ràng khi hệ thống chậm sẽ dễ được tin hơn.

Khi đọc JD, hãy để ý các cụm như "permission-aware retrieval", "connectors" hay "enterprise data sync". Trong CV, viết cụ thể: "Dựng connector SharePoint/Confluence cho RAG bằng delta query, tách job ACL, fail-closed khi quyền trễ quá ngưỡng." Một repo demo có test cho replay, 410 và item bị xóa thuyết phục hơn mọi tính từ.

Đồng bộ nội dung giúp chatbot trả lời đúng. Còn job quyền mới quyết định khách có dám mở chatbot cho cả công ty dùng hay không.

**Thử ngay tuần này:**

- Chạy một lần delta không tham số trên một drive thử, đếm số trang nextLink rồi lưu deltaLink lại. Sau đó sửa và xóa một file, gọi lại để xem facet deleted.
- Gửi thêm header Prefer: deltashowsharingchanges, đổi quyền một file và kiểm tra xem annotation sharedChanged có xuất hiện không.
- Viết một câu lên Slack nội bộ: chỉ mục của bạn đang chậm bao lâu so với quyền thật ở nguồn, và khi vượt ngưỡng thì hệ thống làm gì.

## Nguồn

- [driveItem: delta - Microsoft Graph v1.0 | Microsoft Learn](https://learn.microsoft.com/en-us/graph/api/driveitem-delta)

- [Best practices for discovering files and detecting changes at scale - OneDrive dev center | Microsoft Learn](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/concepts/scan-guidance)

- [Use delta query to track changes in Microsoft Graph data - Microsoft Graph | Microsoft Learn](https://learn.microsoft.com/en-us/graph/delta-query-overview)

- [List who has access to a file - Microsoft Graph v1.0 | Microsoft Learn](https://learn.microsoft.com/en-us/graph/api/driveitem-list-permissions)

- [Rate limiting](https://developer.atlassian.com/cloud/confluence/rate-limiting/)

- [How to get the space permission changes through CQL (or by any other event if possible)](https://community.developer.atlassian.com/t/how-to-get-the-space-permission-changes-through-cql-or-by-any-other-event-if-possible/55537)

- [Content restrictions - Confluence Cloud REST API](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/)

- [RAG sobre SharePoint, Confluence y Drive: permisos](https://wavect.io/es/blog/rag-permissions-sharepoint-confluence-drive/)
