Skip to content

Commit 5200dad

Browse files
authored
feat: efficient sharepoint role fetching (#13813)
1 parent 9d8b8b5 commit 5200dad

9 files changed

Lines changed: 386 additions & 18 deletions

File tree

backend/ee/onyx/external_permissions/sharepoint/permission_utils.py

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
SHARED_DOCUMENTS_MAP_REVERSE,
3131
sleep_and_retry,
3232
)
33+
from onyx.connectors.sharepoint.connector_utils import (
34+
SharepointGroup,
35+
SharepointGroupExpansion,
36+
SharepointPermissionCache,
37+
)
3338
from onyx.db.enums import HierarchyNodeType
3439
from onyx.utils.logger import setup_logger
3540
from onyx.utils.retry_after import parse_retry_after_seconds
@@ -44,6 +49,7 @@
4449
SHAREPOINT_GROUP_PRINCIPAL_TYPE = 8 # SharePoint site groups (local to the site)
4550
MICROSOFT_DOMAIN = ".onmicrosoft"
4651
SHAREPOINT_GROUP_SCOPE_SEPARATOR = "::"
52+
GROUP_CACHE_KEY_SEPARATOR = ":"
4753
# PnP RoleType defines Guest=1 and RestrictedGuest=9:
4854
# https://github.com/pnp/pnpcore/blob/4e4f58fcac797f2957bfcd14fedcecd690dfe7ee/src/sdk/PnP.Core/Model/SharePoint/Core/Public/Enums/RoleType.cs
4955
LIMITED_ACCESS_ROLE_TYPES = frozenset({1, 9})
@@ -145,19 +151,16 @@ def _normalize_email(email: str) -> str:
145151
return email
146152

147153

148-
class SharepointGroup(BaseModel):
149-
model_config = {"frozen": True}
150-
151-
name: str
152-
login_name: str
153-
principal_type: int
154-
155-
156154
class GroupsResult(BaseModel):
157155
groups_to_emails: dict[str, set[str]]
158156
found_public_group: bool
159157

160158

159+
class DocumentGroupsResult(BaseModel):
160+
group_ids: set[str]
161+
found_public_group: bool
162+
163+
161164
def _get_azuread_group_guid_by_name(
162165
graph_client: GraphClient, group_name: str
163166
) -> str | None:
@@ -515,10 +518,88 @@ def _get_groups_and_members_recursively(
515518
)
516519

517520

521+
def _group_cache_key(
522+
client_context: ClientContext,
523+
group: SharepointGroup,
524+
) -> str:
525+
identity = group.login_name
526+
if group.principal_type == SHAREPOINT_GROUP_PRINCIPAL_TYPE:
527+
identity = _get_site_scoped_group_name(client_context, identity)
528+
elif guid := _extract_guid_from_claims_token(identity):
529+
identity = guid
530+
return f"{group.principal_type}{GROUP_CACHE_KEY_SEPARATOR}{identity}"
531+
532+
533+
def _get_cached_group_expansion(
534+
client_context: ClientContext,
535+
graph_client: GraphClient,
536+
group: SharepointGroup,
537+
permission_cache: SharepointPermissionCache,
538+
) -> SharepointGroupExpansion:
539+
cache_key = _group_cache_key(client_context, group)
540+
cached_expansion = permission_cache.group_expansions.get(cache_key)
541+
if cached_expansion is not None:
542+
return cached_expansion
543+
544+
try:
545+
if group.principal_type == SHAREPOINT_GROUP_PRINCIPAL_TYPE:
546+
nested_groups, _ = _get_sharepoint_groups(
547+
client_context, group.login_name, graph_client
548+
)
549+
else:
550+
nested_groups, _ = _get_azuread_groups(graph_client, group.login_name)
551+
except ClientRequestException as e:
552+
if (
553+
group.principal_type != AZURE_AD_GROUP_PRINCIPAL_TYPE
554+
or e.response is None
555+
or e.response.status_code != 404
556+
):
557+
raise
558+
logger.warning("Group %s not found", group.login_name)
559+
nested_groups = set()
560+
561+
expansion = SharepointGroupExpansion(nested_groups=nested_groups)
562+
permission_cache.group_expansions[cache_key] = expansion
563+
return expansion
564+
565+
566+
def _resolve_document_groups(
567+
client_context: ClientContext,
568+
graph_client: GraphClient,
569+
groups: set[SharepointGroup],
570+
permission_cache: SharepointPermissionCache,
571+
) -> DocumentGroupsResult:
572+
group_queue: deque[SharepointGroup] = deque(groups)
573+
visited_group_keys: set[str] = set()
574+
group_ids: set[str] = set()
575+
576+
while group_queue:
577+
group = group_queue.popleft()
578+
if _is_public_login_name(group.login_name):
579+
return DocumentGroupsResult(group_ids=set(), found_public_group=True)
580+
581+
group_ids.add(group.name)
582+
cache_key = _group_cache_key(client_context, group)
583+
if cache_key in visited_group_keys:
584+
continue
585+
visited_group_keys.add(cache_key)
586+
587+
expansion = _get_cached_group_expansion(
588+
client_context, graph_client, group, permission_cache
589+
)
590+
group_queue.extend(expansion.nested_groups)
591+
592+
return DocumentGroupsResult(
593+
group_ids=group_ids,
594+
found_public_group=False,
595+
)
596+
597+
518598
def _get_external_access_from_securable_object(
519599
client_context: ClientContext,
520600
graph_client: GraphClient,
521601
securable_object: SecurableObject,
602+
permission_cache: SharepointPermissionCache,
522603
add_prefix: bool = False,
523604
) -> ExternalAccess:
524605
groups: set[SharepointGroup] = set()
@@ -578,17 +659,20 @@ def add_user_and_group_to_sets(
578659
"get_external_access_from_sharepoint",
579660
)
580661

581-
groups_and_members = _get_groups_and_members_recursively(
582-
client_context, graph_client, groups
662+
resolved_groups = _resolve_document_groups(
663+
client_context,
664+
graph_client,
665+
groups,
666+
permission_cache,
583667
)
584-
if groups_and_members.found_public_group:
668+
if resolved_groups.found_public_group:
585669
return ExternalAccess(
586670
external_user_emails=set(),
587671
external_user_group_ids=set(),
588672
is_public=True,
589673
)
590674

591-
for group_name in groups_and_members.groups_to_emails:
675+
for group_name in resolved_groups.group_ids:
592676
if add_prefix:
593677
group_name = build_ext_group_name_for_onyx(
594678
group_name, DocumentSource.SHAREPOINT
@@ -612,7 +696,9 @@ def get_external_access_from_sharepoint(
612696
site_page: dict[str, Any] | None,
613697
add_prefix: bool = False,
614698
treat_sharing_link_as_public: bool = False,
699+
permission_cache: SharepointPermissionCache | None = None,
615700
) -> ExternalAccess:
701+
permission_cache = permission_cache or SharepointPermissionCache()
616702
if drive_item and drive_name:
617703
is_public = _is_public_item(drive_item, treat_sharing_link_as_public)
618704
if is_public:
@@ -655,6 +741,7 @@ def get_external_access_from_sharepoint(
655741
client_context,
656742
graph_client,
657743
item,
744+
permission_cache,
658745
add_prefix,
659746
)
660747

@@ -665,7 +752,9 @@ def get_hierarchy_node_external_access_from_sharepoint(
665752
node_type: HierarchyNodeType,
666753
drive_name: str | None,
667754
folder_url: str | None,
755+
permission_cache: SharepointPermissionCache | None = None,
668756
) -> ExternalAccess:
757+
permission_cache = permission_cache or SharepointPermissionCache()
669758
if node_type == HierarchyNodeType.SITE:
670759
securable_object = client_context.web
671760
elif node_type == HierarchyNodeType.DRIVE and drive_name:
@@ -683,6 +772,7 @@ def get_hierarchy_node_external_access_from_sharepoint(
683772
client_context,
684773
graph_client,
685774
securable_object,
775+
permission_cache,
686776
add_prefix=True,
687777
)
688778

backend/onyx/connectors/sharepoint/connector.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
TextSection,
7070
)
7171
from onyx.connectors.sharepoint.connector_utils import (
72+
SharepointPermissionCache,
7273
get_sharepoint_external_access,
7374
get_sharepoint_hierarchy_node_external_access,
7475
)
@@ -464,6 +465,9 @@ class SharepointConnectorCheckpoint(ConnectorCheckpoint):
464465
# Track yielded document IDs to avoid processing the same document twice.
465466
# The Microsoft Graph delta API can return the same item on multiple pages.
466467
seen_document_ids: set[str] = Field(default_factory=set)
468+
permission_cache: SharepointPermissionCache = Field(
469+
default_factory=SharepointPermissionCache
470+
)
467471

468472

469473
class SharepointAuthMethod(Enum):
@@ -799,12 +803,14 @@ def _convert_driveitem_to_document_with_permissions(
799803
access_token: str | None = None,
800804
treat_sharing_link_as_public: bool = False,
801805
raw_file_callback: RawFileCallback | None = None,
806+
permission_cache: SharepointPermissionCache | None = None,
802807
) -> Document | ConnectorFailure | None:
803808
if not driveitem.name or not driveitem.id:
804809
raise ValueError("DriveItem name/id is required")
805810

806811
if include_permissions and ctx is None:
807812
raise ValueError("ClientContext is required for permissions")
813+
permission_cache = permission_cache or SharepointPermissionCache()
808814

809815
mime_type = driveitem.mime_type
810816
if not mime_type or mime_type in OnyxMimeTypes.EXCLUDED_IMAGE_TYPES:
@@ -939,6 +945,7 @@ def _convert_driveitem_to_document_with_permissions(
939945
external_access = get_sharepoint_external_access(
940946
ctx=ctx,
941947
graph_client=graph_client,
948+
permission_cache=permission_cache,
942949
drive_item=sdk_item,
943950
drive_name=drive_name,
944951
add_prefix=True,
@@ -981,6 +988,7 @@ def _convert_sitepage_to_document(
981988
site_name: str | None,
982989
ctx: ClientContext | None,
983990
graph_client: GraphClient,
991+
permission_cache: SharepointPermissionCache,
984992
include_permissions: bool = False,
985993
parent_hierarchy_raw_node_id: str | None = None,
986994
treat_sharing_link_as_public: bool = False,
@@ -1111,6 +1119,7 @@ def _convert_sitepage_to_document(
11111119
external_access = get_sharepoint_external_access(
11121120
ctx=ctx, # ty: ignore[invalid-argument-type]
11131121
graph_client=graph_client,
1122+
permission_cache=permission_cache,
11141123
site_page=site_page,
11151124
add_prefix=True,
11161125
treat_sharing_link_as_public=treat_sharing_link_as_public,
@@ -1155,6 +1164,7 @@ def _convert_driveitem_to_slim_document(
11551164
drive_name: str,
11561165
ctx: ClientContext,
11571166
graph_client: GraphClient,
1167+
permission_cache: SharepointPermissionCache,
11581168
parent_hierarchy_raw_node_id: str | None = None,
11591169
treat_sharing_link_as_public: bool = False,
11601170
) -> SlimDocument:
@@ -1165,6 +1175,7 @@ def _convert_driveitem_to_slim_document(
11651175
external_access = get_sharepoint_external_access(
11661176
ctx=ctx,
11671177
graph_client=graph_client,
1178+
permission_cache=permission_cache,
11681179
drive_item=sdk_item,
11691180
drive_name=drive_name,
11701181
treat_sharing_link_as_public=treat_sharing_link_as_public,
@@ -1186,6 +1197,7 @@ def _convert_sitepage_to_slim_document(
11861197
site_page: dict[str, Any],
11871198
ctx: ClientContext | None,
11881199
graph_client: GraphClient,
1200+
permission_cache: SharepointPermissionCache,
11891201
parent_hierarchy_raw_node_id: str | None = None,
11901202
treat_sharing_link_as_public: bool = False,
11911203
) -> SlimDocument:
@@ -1197,6 +1209,7 @@ def _convert_sitepage_to_slim_document(
11971209
external_access = get_sharepoint_external_access(
11981210
ctx=ctx, # ty: ignore[invalid-argument-type]
11991211
graph_client=graph_client,
1212+
permission_cache=permission_cache,
12001213
site_page=site_page,
12011214
treat_sharing_link_as_public=treat_sharing_link_as_public,
12021215
)
@@ -2323,6 +2336,7 @@ def _fetch_slim_documents_from_sharepoint(
23232336
drive_name,
23242337
ctx,
23252338
self.graph_client,
2339+
temp_checkpoint.permission_cache,
23262340
parent_hierarchy_raw_node_id=parent_hierarchy_url,
23272341
treat_sharing_link_as_public=self.treat_sharing_link_as_public,
23282342
)
@@ -2372,6 +2386,7 @@ def _fetch_slim_documents_from_sharepoint(
23722386
site_page,
23732387
ctx,
23742388
self.graph_client,
2389+
temp_checkpoint.permission_cache,
23752390
parent_hierarchy_raw_node_id=site_descriptor.url,
23762391
treat_sharing_link_as_public=self.treat_sharing_link_as_public,
23772392
)
@@ -2570,6 +2585,7 @@ def _yield_site_hierarchy_node(
25702585
external_access = get_sharepoint_hierarchy_node_external_access(
25712586
ctx,
25722587
self.graph_client,
2588+
checkpoint.permission_cache,
25732589
HierarchyNodeType.SITE,
25742590
)
25752591

@@ -2604,6 +2620,7 @@ def _yield_drive_hierarchy_node(
26042620
external_access = get_sharepoint_hierarchy_node_external_access(
26052621
ctx,
26062622
self.graph_client,
2623+
checkpoint.permission_cache,
26072624
HierarchyNodeType.DRIVE,
26082625
drive_name=drive_name,
26092626
)
@@ -2657,6 +2674,7 @@ def _yield_folder_hierarchy_nodes(
26572674
external_access = get_sharepoint_hierarchy_node_external_access(
26582675
ctx,
26592676
self.graph_client,
2677+
checkpoint.permission_cache,
26602678
HierarchyNodeType.FOLDER,
26612679
folder_url=folder_url,
26622680
)
@@ -2794,6 +2812,7 @@ def _process_drive_item(
27942812
drive_name,
27952813
ctx,
27962814
self.graph_client,
2815+
permission_cache=checkpoint.permission_cache,
27972816
include_permissions=include_permissions,
27982817
parent_hierarchy_raw_node_id=parent_hierarchy_url,
27992818
graph_api_base=self.graph_api_base,
@@ -3187,6 +3206,7 @@ def _load_from_checkpoint(
31873206
site_descriptor.drive_name,
31883207
client_ctx,
31893208
self.graph_client,
3209+
permission_cache=checkpoint.permission_cache,
31903210
include_permissions=include_permissions,
31913211
# Site pages have the site as their parent
31923212
parent_hierarchy_raw_node_id=site_descriptor.url,
@@ -3449,6 +3469,7 @@ def _reindex_site_page(
34493469
site_descriptor.drive_name,
34503470
ctx,
34513471
self.graph_client,
3472+
permission_cache=dedup.permission_cache,
34523473
include_permissions=include_permissions,
34533474
parent_hierarchy_raw_node_id=site_descriptor.url,
34543475
treat_sharing_link_as_public=self.treat_sharing_link_as_public,

0 commit comments

Comments
 (0)