Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions .quality/exceptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -313,23 +313,6 @@
"review": {"status": "active", "reference": "https://github.com/Azure/GPT-RAG/issues/681#issuecomment-5601804634"},
"review_by_stage": "initial"
},
{
"id": "sharepoint-run-failure-observation",
"module_id": "jobs.sharepoint_indexer",
"symbol": "SharePointIndexer._run_pipeline",
"handler_fingerprint": "db49e21755ec02b963f00bf183190e826369bec3e186f2c2bad75ae90269d19a",
"caught_types": ["Exception"],
"boundary": "SharePoint run failed-summary observation followed by exception propagation",
"reason": "A failed collection must mark the run failed and propagate to the scheduler/audit wrapper, not become a finished run with zero failed items. The handler preserves the existing summary schema with safe error text, then re-raises. Its outer resource guard closes clients on configuration, collection and cancellation paths.",
"failure_outcome": "explicit-failure-translation",
"diagnostic_path": "Failed terminal summary, safe run error and original exception propagation",
"evidence_tests": [
"tests/test_sharepoint_failure_boundaries.py::test_sharepoint_run_propagates_failures_and_always_closes[configuration]",
"tests/test_sharepoint_failure_boundaries.py::test_sharepoint_run_propagates_failures_and_always_closes[collection]"
],
"review": {"status": "active", "reference": "https://github.com/Azure/GPT-RAG/issues/681#issuecomment-5601804634"},
"review_by_stage": "initial"
},
{
"id": "sharepoint-summary-best-effort",
"module_id": "jobs.sharepoint_indexer",
Expand All @@ -344,25 +327,6 @@
"review": {"status": "active", "reference": "https://github.com/Azure/GPT-RAG/issues/681#issuecomment-5601804634"},
"review_by_stage": "initial"
},
{
"id": "sharepoint-item-failure",
"module_id": "jobs.sharepoint_indexer",
"symbol": "SharePointIndexer._process_collection.worker",
"handler_fingerprint": "6a5e6b398d3dcba804b8825b7bbfe04003fe9e2c1e99d1bd05b0284bf8828013",
"caught_types": ["Exception"],
"boundary": "SharePoint per-item processing failure, retry/block state and failed-count translation",
"reason": "The existing per-item boundary must record explicit failed items while siblings proceed. Permission acquisition now occurs within this bounded operation, so its failure records an error and cannot create empty ACLs or unobserved task exceptions. Failures before this boundary propagate through observed collection tasks; cancellation is not converted into a file success.",
"failure_outcome": "explicit-failure-translation",
"diagnostic_path": "Safe per-file error, failed item counter and no Search operation after ACL failure",
"evidence_tests": [
"tests/test_sharepoint_failure_boundaries.py::test_collection_observes_item_failures_instead_of_finishing_empty[acl]",
"tests/test_sharepoint_failure_boundaries.py::test_collection_observes_item_failures_instead_of_finishing_empty[lookup]",
"tests/test_sharepoint_failure_boundaries.py::test_collection_failure_cancels_and_observes_its_children[late-page]",
"tests/test_sharepoint_failure_boundaries.py::test_collection_failure_cancels_and_observes_its_children[timeout]"
],
"review": {"status": "active", "reference": "https://github.com/Azure/GPT-RAG/issues/681#issuecomment-5601804634"},
"review_by_stage": "initial"
},
{
"id": "blob-run-failure-observation",
"module_id": "jobs.blob_storage_indexer",
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [v3.1.3] - 2026-10-27

### Fixed

- **SharePoint permission-only synchronization.** Refresh the supported Entra
user/group ACLs on every indexed item document and document-library chunk,
independently of content timestamps, without re-downloading or embedding
unchanged content. Document libraries read Graph's effective drive sharing
permissions, including inherited grants. Reconcile all permission pages,
replace revoked principals, skip equal ACLs, and confirm every Search merge.
Lookup and partial-update failures fail the job and remain retryable; content
retry blocking no longer prevents ACL refresh (Azure/agent-landing-zone#720).

## [v3.1.2] - 2026-10-27

### Changed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.2
3.1.3
88 changes: 51 additions & 37 deletions jobs/sharepoint_graph_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import logging
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
from urllib.parse import quote, urlencode
from urllib.parse import quote, urlencode, urlsplit
from uuid import UUID

import aiohttp
Expand All @@ -13,7 +13,7 @@


class GraphRequestError(RuntimeError):
"""A Graph transport operation exhausted its bounded retry budget."""
"""A Graph operation failed or returned an incomplete response."""


class SharePointGraphClient:
Expand Down Expand Up @@ -340,48 +340,62 @@ async def get_item_permission_principal_ids(
site_id: str,
collection_id: str,
item_id: str,
drive_item: Optional[Dict[str, Any]] = None,
) -> Tuple[List[str], List[str]]:
"""Return (user_object_ids, group_object_ids) that have explicit permissions on an item."""
# The permissions resource for list items currently exists only in the beta Graph surface.
url = (
f"{self._graph_beta_base}/sites/{site_id}/lists/{collection_id}/items/{item_id}/permissions"
"?$select=id,grantedToIdentitiesV2,grantedTo"
)
try:
data = await self._gget(session, url)
except ClientResponseError as exc:
if exc.status in (400, 401, 403, 404, 501):
logging.debug(
f"{LOG_SCOPE} permissions lookup failed ({exc.status}) for list {collection_id} item {item_id}"
)
return ([], [])
raise
"""Read all Graph-visible Entra grants; drive permissions include inherited sharing."""
if drive_item is not None:
drive_id = drive_item.get("parentReference", {}).get("driveId")
drive_item_id = drive_item.get("id")
if not drive_id or not drive_item_id:
raise GraphRequestError("Drive permission lookup requires drive and item IDs.")
resource = f"{self._graph_base}/drives/{quote(str(drive_id), safe='')}/items/{quote(str(drive_item_id), safe='')}/permissions"
else:
resource = f"{self._graph_beta_base}/sites/{site_id}/lists/{collection_id}/items/{item_id}/permissions"
url = resource + "?$select=id,grantedToIdentitiesV2,grantedToV2,grantedTo"

user_ids: List[str] = []
group_ids: List[str] = []
seen_users: set[str] = set()
seen_groups: set[str] = set()
visited: set[str] = set()

for perm in data.get("value", []):
identities = perm.get("grantedToIdentitiesV2") or perm.get("grantedToIdentities") or []
for identity in identities:
kind, obj_id = self._extract_identity_principal(identity)
if kind == "user" and obj_id and obj_id not in seen_users:
seen_users.add(obj_id)
user_ids.append(obj_id)
elif kind == "group" and obj_id and obj_id not in seen_groups:
seen_groups.add(obj_id)
group_ids.append(obj_id)

granted_to = perm.get("grantedToV2") or perm.get("grantedTo")
if isinstance(granted_to, dict):
kind, obj_id = self._extract_identity_principal(granted_to)
if kind == "user" and obj_id and obj_id not in seen_users:
seen_users.add(obj_id)
user_ids.append(obj_id)
elif kind == "group" and obj_id and obj_id not in seen_groups:
seen_groups.add(obj_id)
group_ids.append(obj_id)
while url:
if url in visited:
raise GraphRequestError("Graph permission pagination repeated a page.")
visited.add(url)
data = await self._gget(session, url)
if not isinstance(data, dict) or not isinstance(data.get("value"), list):
raise GraphRequestError("Graph permission response has no permission collection.")
for perm in data["value"]:
if not isinstance(perm, dict):
raise GraphRequestError("Graph permission response contains an invalid entry.")
identities = perm.get("grantedToIdentitiesV2", perm.get("grantedToIdentities", []))
if identities is None:
identities = []
if not isinstance(identities, list):
raise GraphRequestError("Graph permission response contains invalid identities.")
granted_to = perm.get("grantedToV2", perm.get("grantedTo"))
if granted_to is not None:
identities = [*identities, granted_to]
for identity in identities:
if not isinstance(identity, dict):
raise GraphRequestError("Graph permission response contains an invalid identity.")
kind, obj_id = self._extract_identity_principal(identity)
if kind == "user" and obj_id and obj_id not in seen_users:
seen_users.add(obj_id)
user_ids.append(obj_id)
elif kind == "group" and obj_id and obj_id not in seen_groups:
seen_groups.add(obj_id)
group_ids.append(obj_id)
next_url = data.get("@odata.nextLink")
if next_url is not None:
if not isinstance(next_url, str):
raise GraphRequestError("Graph permission pagination link is invalid.")
target = urlsplit(next_url)
expected = urlsplit(resource)
if (target.scheme, target.netloc, target.path) != (expected.scheme, expected.netloc, expected.path):
raise GraphRequestError("Graph permission pagination left the permission resource.")
url = next_url

return (user_ids, group_ids)

Expand Down
Loading
Loading