From 26cb43688d7c6f858a826a6d329c12ac649f5a0c Mon Sep 17 00:00:00 2001 From: Jan-Kazlouski-elastic Date: Wed, 15 Jul 2026 18:57:04 +0300 Subject: [PATCH 1/7] fix: restrict unpublished SPO pages to owners/editors SharePoint Online does not update a page's ACLs when it is unpublished, so the connector kept granting view-only users access to draft/unpublished pages under DLS. Detect the published state from the page version (major.minor) and, for unpublished pages, only grant access to owners/editors: view-only members are excluded from both unique per-page role assignments and inherited site permissions. Also expose a `published` field on site page documents. Closes #3645 --- .../sharepoint/sharepoint_online/constants.py | 10 + .../sharepoint_online/datasource.py | 117 ++++++-- .../tests/sources/test_sharepoint_online.py | 264 +++++++++++++++++- 3 files changed, 368 insertions(+), 23 deletions(-) diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py index 13ef69838..d41958346 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py @@ -49,6 +49,7 @@ # See also: https://learn.microsoft.com/en-us/previous-versions/office/sharepoint-csom/ee536458(v=office.15) VIEW_ITEM_MASK = 0x1 # View items in lists, documents in document libraries, and Web discussion comments. VIEW_PAGE_MASK = 0x20000 # View pages in a Site. +EDIT_ITEM_MASK = 0x4 # Edit items in lists, edit documents in document libraries, and customize Web Part Pages in document libraries. # See https://github.com/pnp/pnpcore/blob/dev/src/sdk/PnP.Core/Model/SharePoint/Core/Public/Enums/RoleType.cs # See also: https://learn.microsoft.com/en-us/dotnet/api/microsoft.sharepoint.client.roletype?view=sharepoint-csom @@ -69,6 +70,15 @@ REVIEWER, SYSTEM, ] +# Role types that grant edit (or higher) access. READER and REVIEWER are +# view-only and therefore excluded. +EDIT_ROLE_TYPES = [ + CONTRIBUTOR, + WEB_DESIGNER, + ADMINISTRATOR, + EDITOR, + SYSTEM, +] # $expand param has a max of 20: see: https://developer.microsoft.com/en-us/graph/known-issues/?search=expand SPO_MAX_EXPAND_SIZE = 20 diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py index 36c13b336..2e1a81e41 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py @@ -30,6 +30,8 @@ ) from connectors.sources.sharepoint.sharepoint_online.constants import ( CURSOR_SITE_DRIVE_KEY, + EDIT_ITEM_MASK, + EDIT_ROLE_TYPES, EXCLUDED_SHAREPOINT_LIST_NAMES, MAX_DOCUMENT_SIZE, SPO_API_MAX_BATCH_SIZE, @@ -56,6 +58,29 @@ from connectors.utils import html_to_text, iterable_batches_generator +def _is_page_published(version_string): + """Determine whether a site page is published based on its version string. + + SharePoint uses major.minor versioning for site pages. Publishing a page + creates a major version (the minor component is ``0``, e.g. ``"3.0"``), + while a draft/unpublished page carries a minor version (e.g. ``"3.1"`` or + ``"0.1"``). When the version cannot be determined we assume the page is + published to avoid over-restricting access. + """ + if not version_string: + return True + + _, _, minor = str(version_string).partition(".") + + if not minor: + return True + + try: + return int(minor) == 0 + except ValueError: + return True + + class SharepointOnlineDataSource(BaseDataSource): """Sharepoint Online""" @@ -314,17 +339,25 @@ async def _site_access_control(self, site): [ "user":spo-admin" ] + - list: subset of the first list, applying only to members granted + edit (or higher) access to the site, plus all site-admins. Used + to restrict access to unpublished pages to owners/editors. + [ + "user:spo-admin", + "user:spo-editor" + ] """ self._logger.debug(f"Looking at site: {site['id']} with url {site['webUrl']}") if not self._dls_enabled(): - return [], [] + return [], [], [] def _is_site_admin(user): return user.get("IsSiteAdmin", False) access_control = set() site_admins_access_control = set() + editors_access_control = set() try: async for role_assignment in self.client.site_role_assignments( @@ -336,6 +369,12 @@ def _is_site_admin(user): await self._get_access_control_from_role_assignment(role_assignment) ) + editors_access_control.update( + await self._get_access_control_from_role_assignment( + role_assignment, require_edit_access=True + ) + ) + if _is_site_admin(member): # These are likely in the "Owners" group for the site site_admins_access_control |= member_access_control @@ -360,7 +399,14 @@ def _is_site_admin(user): ) raise PermissionsMissing(msg) from e - return list(access_control), list(site_admins_access_control) + # Owners/admins always retain access, so they are considered editors too. + editors_access_control |= site_admins_access_control + + return ( + list(access_control), + list(site_admins_access_control), + list(editors_access_control), + ) def _dls_enabled(self): if self._features is None: @@ -605,6 +651,7 @@ async def get_docs(self, filtering=None): ( site_access_control, site_admin_access_control, + site_editors_access_control, ) = await self._site_access_control(site) yield ( @@ -676,7 +723,9 @@ async def get_docs(self, filtering=None): yield list_item, download_func # Sync site pages - async for site_page in self.site_pages(site, site_access_control): + async for site_page in self.site_pages( + site, site_access_control, site_editors_access_control + ): # Always include site admins in site page access controls site_page = self._decorate_with_access_control( site_page, site_admin_access_control @@ -708,6 +757,7 @@ async def get_docs_incrementally(self, sync_cursor, filtering=None): ( site_access_control, site_admin_access_control, + site_editors_access_control, ) = await self._site_access_control(site) yield ( @@ -789,7 +839,10 @@ async def get_docs_incrementally(self, sync_cursor, filtering=None): # Sync site pages async for site_page in self.site_pages( - site, site_access_control, check_timestamp=True + site, + site_access_control, + site_editors_access_control, + check_timestamp=True, ): # Always include site admins in site page access controls site_page = self._decorate_with_access_control( @@ -1122,11 +1175,16 @@ async def site_lists(self, site, site_access_control, check_timestamp=False): yield site_list - async def _get_access_control_from_role_assignment(self, role_assignment): + async def _get_access_control_from_role_assignment( + self, role_assignment, require_edit_access=False + ): """Extracts access control from a role assignment. Args: role_assignment (dict): dictionary representing a role assignment. + require_edit_access (bool): when True, only members granted edit (or + higher) access are returned; view-only members are excluded. + Used to restrict access to unpublished pages to owners/editors. Returns: access_control (list): list of usernames and dynamic group ids, which have the role assigned. @@ -1135,7 +1193,7 @@ async def _get_access_control_from_role_assignment(self, role_assignment): If any role is assigned to a user this means at least "read" access. """ - def _has_limited_access(role_assignment): + def _grants_access(role_assignment): bindings = role_assignment.get("RoleDefinitionBindings", []) # If there is no permission information, default to restrict access @@ -1143,9 +1201,9 @@ def _has_limited_access(role_assignment): self._logger.debug( f"No RoleDefinitionBindings found for '{role_assignment.get('odata.id')}'" ) - return True + return False - # if any binding grants view access, this role assignment's member has view access + # if any binding grants the required access, this role assignment's member has access for binding in bindings: # full explanation of the bit-math: https://stackoverflow.com/questions/51897160/how-to-parse-getusereffectivepermissions-sharepoint-response-in-java # this approach was confirmed as valid by a Microsoft Sr. Support Escalation Engineer @@ -1153,18 +1211,21 @@ def _has_limited_access(role_assignment): nested_get_from_dict(binding, ["BasePermissions", "Low"], "0") # pyright: ignore ) role_type_kind = binding.get("RoleTypeKind", 0) - if ( + if require_edit_access: + if (base_permission_low & EDIT_ITEM_MASK) or ( + role_type_kind in EDIT_ROLE_TYPES + ): + return True + elif ( (base_permission_low & VIEW_ITEM_MASK) or (base_permission_low & VIEW_PAGE_MASK) or (role_type_kind in VIEW_ROLE_TYPES) ): - return False + return True - return ( - True # no evidence of view access was found, so assuming limited access - ) + return False # no evidence of the required access was found - if _has_limited_access(role_assignment): + if not _grants_access(role_assignment): return [] access_control = [] @@ -1189,7 +1250,13 @@ def _has_limited_access(role_assignment): return access_control - async def site_pages(self, site, site_access_control, check_timestamp=False): + async def site_pages( + self, + site, + site_access_control, + site_editors_access_control, + check_timestamp=False, + ): site_id = site["id"] url = site["webUrl"] async for site_page in self.client.site_pages(url): @@ -1206,6 +1273,13 @@ async def site_pages(self, site, site_access_control, check_timestamp=False): site_page["_id"] = f"{site_id}-site_page-{site_page['Id']}" site_page["object_type"] = "site_page" + # Unpublished (draft) pages must not be visible to view-only + # users, only to owners/editors. SharePoint does not update a + # page's ACLs when it is unpublished, so we restrict access here. + # See https://github.com/elastic/connectors/issues/3645 + published = _is_page_published(site_page.get("OData__UIVersionString")) + site_page["published"] = published + has_unique_role_assignments = False # ignore parent site permissions and use unique per page permissions ("unique permissions" means breaking the inheritance to the parent site) @@ -1233,7 +1307,8 @@ async def site_pages(self, site, site_access_control, check_timestamp=False): ): page_access_control.extend( await self._get_access_control_from_role_assignment( - role_assignment + role_assignment, + require_edit_access=not published, ) ) @@ -1241,10 +1316,16 @@ async def site_pages(self, site, site_access_control, check_timestamp=False): site_page, page_access_control ) - # set parent site access control + # set parent site access control. For unpublished pages only + # inherited owners/editors retain access, never view-only members. if not has_unique_role_assignments: + inherited_access_control = ( + site_access_control + if published + else site_editors_access_control + ) site_page = self._decorate_with_access_control( - site_page, site_access_control + site_page, inherited_access_control ) for html_field in [ diff --git a/app/connectors_service/tests/sources/test_sharepoint_online.py b/app/connectors_service/tests/sources/test_sharepoint_online.py index 17b129d40..023bf1873 100644 --- a/app/connectors_service/tests/sources/test_sharepoint_online.py +++ b/app/connectors_service/tests/sources/test_sharepoint_online.py @@ -49,6 +49,9 @@ EXCLUDED_SHAREPOINT_LIST_NAMES, WILDCARD, ) +from connectors.sources.sharepoint.sharepoint_online.datasource import ( + _is_page_published, +) from connectors.sources.sharepoint.sharepoint_online.utils import ( DeltaLinkExpired, SyncCursorEmpty, @@ -153,6 +156,18 @@ } ] +EDIT_BINDING = [ + { + "BasePermissions": {"High": "432", "Low": "1011030767"}, + "Description": "Can add, edit and delete lists; can view, add, update and delete list items and documents.", + "Hidden": False, + "Id": 1073741827, + "Name": "Edit", + "Order": 48, + "RoleTypeKind": 6, + } +] + SAMPLE_DRIVE_PERMISSIONS = [ { "id": "3", @@ -2563,7 +2578,7 @@ async def test_get_docs_with_access_control(self, patch_sharepoint_client): async with create_spo_source(use_document_level_security=True) as source: source._site_access_control = AsyncMock( - return_value=(expected_access_control, []) + return_value=(expected_access_control, [], expected_access_control) ) results = [] @@ -2673,7 +2688,7 @@ async def test_get_docs_incrementally_with_empty_cursor( @freeze_time(iso_utc()) async def test_get_docs_incrementally(self, patch_sharepoint_client): async with create_spo_source() as source: - source._site_access_control = AsyncMock(return_value=([], [])) + source._site_access_control = AsyncMock(return_value=([], [], [])) # mock cache lookup source.site_group_users = AsyncMock(return_value=self.site_group_users) @@ -3194,7 +3209,7 @@ async def test_drive_items_permissions_when_fetch_drive_item_permissions_enabled async with create_spo_source(use_document_level_security=True) as source: source._site_access_control = AsyncMock( - return_value=(site_access_controls, []) + return_value=(site_access_controls, [], site_access_controls) ) results = [] @@ -3243,7 +3258,11 @@ async def test_site_page_permissions_when_fetch_drive_item_permissions_enabled( async with create_spo_source(use_document_level_security=True) as source: source._site_access_control = AsyncMock( - return_value=([], admin_site_access_controls) + return_value=( + [], + admin_site_access_controls, + admin_site_access_controls, + ) ) results = [] @@ -3707,7 +3726,7 @@ async def test_site_access_control(self, patch_sharepoint_client): site = {"id": 1, "webUrl": "some url"} - access_control, _ = await source._site_access_control(site) + access_control, _, _ = await source._site_access_control(site) two_users = 2 two_groups = 2 @@ -4248,6 +4267,241 @@ async def test_get_access_control_from_role_assignment( identity in access_control for identity in expected_access_control ) + @pytest.mark.parametrize( + "role_assignment, expected_access_control", + [ + ( + # Reader (view-only) is excluded when edit access is required + { + "Member": { + "odata.type": "SP.User", + "UserPrincipalName": USER_ONE_EMAIL, + }, + "RoleDefinitionBindings": READ_BINDING, + }, + [], + ), + ( + # "Reader" role type (view-only) is excluded + { + "Member": { + "odata.type": "SP.User", + "UserPrincipalName": USER_ONE_EMAIL, + }, + "RoleDefinitionBindings": [{"RoleTypeKind": 2}], + }, + [], + ), + ( + # View item mask only is excluded when edit access is required + { + "Member": { + "odata.type": "SP.User", + "UserPrincipalName": USER_ONE_EMAIL, + }, + "RoleDefinitionBindings": [{"BasePermissions": {"Low": "1"}}], + }, + [], + ), + ( + # Editor binding grants edit access + { + "Member": { + "odata.type": "SP.User", + "UserPrincipalName": USER_ONE_EMAIL, + }, + "RoleDefinitionBindings": EDIT_BINDING, + }, + [_prefix_user(USER_ONE_EMAIL)], + ), + ( + # "Contributor" role type grants edit access + { + "Member": { + "odata.type": "SP.User", + "UserPrincipalName": USER_ONE_EMAIL, + }, + "RoleDefinitionBindings": [{"RoleTypeKind": 3}], + }, + [_prefix_user(USER_ONE_EMAIL)], + ), + ( + # Edit item mask only grants edit access + { + "Member": { + "odata.type": "SP.User", + "UserPrincipalName": USER_ONE_EMAIL, + }, + "RoleDefinitionBindings": [{"BasePermissions": {"Low": "4"}}], + }, + [_prefix_user(USER_ONE_EMAIL)], + ), + ], + ) + @pytest.mark.asyncio + async def test_get_access_control_from_role_assignment_require_edit_access( + self, role_assignment, expected_access_control + ): + async with create_spo_source() as source: + access_control = await source._get_access_control_from_role_assignment( + role_assignment, require_edit_access=True + ) + + assert len(access_control) == len(expected_access_control) + assert all( + identity in access_control for identity in expected_access_control + ) + + @pytest.mark.parametrize( + "version_string, expected_published", + [ + ("1.0", True), + ("3.0", True), + ("12.0", True), + ("0.1", False), + ("3.1", False), + ("2.5", False), + # Missing/undeterminable version defaults to published + (None, True), + ("", True), + ("5", True), + ("not-a-version", True), + ], + ) + def test_is_page_published(self, version_string, expected_published): + assert _is_page_published(version_string) == expected_published + + @pytest.mark.asyncio + @patch( + "connectors.sources.sharepoint.sharepoint_online.datasource.ACCESS_CONTROL", + ALLOW_ACCESS_CONTROL_PATCHED, + ) + async def test_site_pages_unpublished_page_with_unique_permissions_excludes_viewers( + self, patch_sharepoint_client + ): + viewer = { + "Member": {"odata.type": "SP.User", "UserPrincipalName": USER_ONE_EMAIL}, + "RoleDefinitionBindings": READ_BINDING, + } + editor = { + "Member": {"odata.type": "SP.User", "UserPrincipalName": USER_TWO_EMAIL}, + "RoleDefinitionBindings": EDIT_BINDING, + } + + async with create_spo_source(use_document_level_security=True) as source: + source.client.site_pages = AsyncIterator( + [ + { + "Id": "4", + "Modified": self.day_ago, + "OData__UIVersionString": "2.1", + } + ] + ) + source.client.site_page_has_unique_role_assignments = AsyncMock( + return_value=True + ) + source.client.site_page_role_assignments = AsyncIterator([viewer, editor]) + + pages = [ + page + async for page in source.site_pages( + {"id": "1", "webUrl": "http://fakesharepoint.com/site"}, + site_access_control=[], + site_editors_access_control=[], + ) + ] + + assert len(pages) == 1 + page = pages[0] + assert page["published"] is False + access_control = page[ALLOW_ACCESS_CONTROL_PATCHED] + assert _prefix_user(USER_TWO_EMAIL) in access_control + assert _prefix_user(USER_ONE_EMAIL) not in access_control + + @pytest.mark.asyncio + @patch( + "connectors.sources.sharepoint.sharepoint_online.datasource.ACCESS_CONTROL", + ALLOW_ACCESS_CONTROL_PATCHED, + ) + async def test_site_pages_published_page_with_unique_permissions_includes_viewers( + self, patch_sharepoint_client + ): + viewer = { + "Member": {"odata.type": "SP.User", "UserPrincipalName": USER_ONE_EMAIL}, + "RoleDefinitionBindings": READ_BINDING, + } + + async with create_spo_source(use_document_level_security=True) as source: + source.client.site_pages = AsyncIterator( + [ + { + "Id": "4", + "Modified": self.day_ago, + "OData__UIVersionString": "2.0", + } + ] + ) + source.client.site_page_has_unique_role_assignments = AsyncMock( + return_value=True + ) + source.client.site_page_role_assignments = AsyncIterator([viewer]) + + pages = [ + page + async for page in source.site_pages( + {"id": "1", "webUrl": "http://fakesharepoint.com/site"}, + site_access_control=[], + site_editors_access_control=[], + ) + ] + + assert len(pages) == 1 + page = pages[0] + assert page["published"] is True + assert _prefix_user(USER_ONE_EMAIL) in page[ALLOW_ACCESS_CONTROL_PATCHED] + + @pytest.mark.asyncio + @patch( + "connectors.sources.sharepoint.sharepoint_online.datasource.ACCESS_CONTROL", + ALLOW_ACCESS_CONTROL_PATCHED, + ) + async def test_site_pages_unpublished_inherited_page_uses_editors_access_control( + self, patch_sharepoint_client + ): + site_access_control = [_prefix_user("viewer")] + site_editors_access_control = [_prefix_user("editor")] + + async with create_spo_source(use_document_level_security=True) as source: + source.client.site_pages = AsyncIterator( + [ + { + "Id": "4", + "Modified": self.day_ago, + "OData__UIVersionString": "2.1", + } + ] + ) + source.client.site_page_has_unique_role_assignments = AsyncMock( + return_value=False + ) + + pages = [ + page + async for page in source.site_pages( + {"id": "1", "webUrl": "http://fakesharepoint.com/site"}, + site_access_control=site_access_control, + site_editors_access_control=site_editors_access_control, + ) + ] + + assert len(pages) == 1 + page = pages[0] + assert page["published"] is False + access_control = page[ALLOW_ACCESS_CONTROL_PATCHED] + assert _prefix_user("editor") in access_control + assert _prefix_user("viewer") not in access_control + @pytest.mark.parametrize( "raw_login_name, expected_login_name", [ From 5cc890f136e37f260649462291406c5c3ea4e8bc Mon Sep 17 00:00:00 2001 From: Jan-Kazlouski-elastic Date: Thu, 3 Sep 2026 11:55:25 +0300 Subject: [PATCH 2/7] fix: log when unpublished site page ACL is restricted --- .../sources/sharepoint/sharepoint_online/datasource.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py index 2e1a81e41..a62b94cf2 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py @@ -1279,6 +1279,10 @@ async def site_pages( # See https://github.com/elastic/connectors/issues/3645 published = _is_page_published(site_page.get("OData__UIVersionString")) site_page["published"] = published + if not published: + self._logger.debug( + f"Unpublished site page '{site_page['_id']}'; restricting ACL to owners/editors." + ) has_unique_role_assignments = False From 752b13b82fecc4f754110c5214f673035d168cd0 Mon Sep 17 00:00:00 2001 From: Elastic Machine Date: Thu, 3 Sep 2026 08:58:39 +0000 Subject: [PATCH 3/7] Update NOTICE.txt --- app/connectors_service/NOTICE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/connectors_service/NOTICE.txt b/app/connectors_service/NOTICE.txt index f24893e0b..b3e869f71 100644 --- a/app/connectors_service/NOTICE.txt +++ b/app/connectors_service/NOTICE.txt @@ -1750,7 +1750,7 @@ SOFTWARE. anyio -4.14.2 +4.15.0 MIT The MIT License (MIT) From f04825b70d617d35370398694f549782f93db389 Mon Sep 17 00:00:00 2001 From: Jan-Kazlouski-elastic Date: Thu, 3 Sep 2026 12:04:24 +0300 Subject: [PATCH 4/7] chore: tighten comments in unpublished page ACL fix --- .../sharepoint/sharepoint_online/constants.py | 3 +-- .../sharepoint_online/datasource.py | 27 +++---------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py index d41958346..e85c696c5 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/constants.py @@ -70,8 +70,7 @@ REVIEWER, SYSTEM, ] -# Role types that grant edit (or higher) access. READER and REVIEWER are -# view-only and therefore excluded. +# Excludes READER and REVIEWER (view-only). EDIT_ROLE_TYPES = [ CONTRIBUTOR, WEB_DESIGNER, diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py index a62b94cf2..0121f09af 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py @@ -59,14 +59,7 @@ def _is_page_published(version_string): - """Determine whether a site page is published based on its version string. - - SharePoint uses major.minor versioning for site pages. Publishing a page - creates a major version (the minor component is ``0``, e.g. ``"3.0"``), - while a draft/unpublished page carries a minor version (e.g. ``"3.1"`` or - ``"0.1"``). When the version cannot be determined we assume the page is - published to avoid over-restricting access. - """ + """True when the page version is a major release (e.g. "3.0").""" if not version_string: return True @@ -339,9 +332,7 @@ async def _site_access_control(self, site): [ "user":spo-admin" ] - - list: subset of the first list, applying only to members granted - edit (or higher) access to the site, plus all site-admins. Used - to restrict access to unpublished pages to owners/editors. + - list: site members with edit-or-higher access, plus site-admins [ "user:spo-admin", "user:spo-editor" @@ -399,7 +390,6 @@ def _is_site_admin(user): ) raise PermissionsMissing(msg) from e - # Owners/admins always retain access, so they are considered editors too. editors_access_control |= site_admins_access_control return ( @@ -1182,9 +1172,7 @@ async def _get_access_control_from_role_assignment( Args: role_assignment (dict): dictionary representing a role assignment. - require_edit_access (bool): when True, only members granted edit (or - higher) access are returned; view-only members are excluded. - Used to restrict access to unpublished pages to owners/editors. + require_edit_access (bool): when True, return only members with edit access. Returns: access_control (list): list of usernames and dynamic group ids, which have the role assigned. @@ -1203,7 +1191,6 @@ def _grants_access(role_assignment): ) return False - # if any binding grants the required access, this role assignment's member has access for binding in bindings: # full explanation of the bit-math: https://stackoverflow.com/questions/51897160/how-to-parse-getusereffectivepermissions-sharepoint-response-in-java # this approach was confirmed as valid by a Microsoft Sr. Support Escalation Engineer @@ -1223,7 +1210,7 @@ def _grants_access(role_assignment): ): return True - return False # no evidence of the required access was found + return False if not _grants_access(role_assignment): return [] @@ -1273,10 +1260,6 @@ async def site_pages( site_page["_id"] = f"{site_id}-site_page-{site_page['Id']}" site_page["object_type"] = "site_page" - # Unpublished (draft) pages must not be visible to view-only - # users, only to owners/editors. SharePoint does not update a - # page's ACLs when it is unpublished, so we restrict access here. - # See https://github.com/elastic/connectors/issues/3645 published = _is_page_published(site_page.get("OData__UIVersionString")) site_page["published"] = published if not published: @@ -1320,8 +1303,6 @@ async def site_pages( site_page, page_access_control ) - # set parent site access control. For unpublished pages only - # inherited owners/editors retain access, never view-only members. if not has_unique_role_assignments: inherited_access_control = ( site_access_control From 006df4c0694c7d512da00061bd269b202e8a62ce Mon Sep 17 00:00:00 2001 From: Elastic Machine Date: Fri, 11 Sep 2026 14:47:55 +0000 Subject: [PATCH 5/7] Update NOTICE.txt --- app/connectors_service/NOTICE.txt | 196 +++++++++++++++++++++++++++++- libs/connectors_sdk/NOTICE.txt | 194 ++++++++++++++++++++++++++++- 2 files changed, 383 insertions(+), 7 deletions(-) diff --git a/app/connectors_service/NOTICE.txt b/app/connectors_service/NOTICE.txt index 1c0b4ba44..bc611322c 100644 --- a/app/connectors_service/NOTICE.txt +++ b/app/connectors_service/NOTICE.txt @@ -2737,7 +2737,7 @@ SOFTWARE. build -1.6.0 +1.6.1 MIT Copyright © 2019 Filipe Laíns @@ -4241,7 +4241,7 @@ Apache Software License google-auth -2.57.1 +2.58.0 Apache Software License Apache License Version 2.0, January 2004 @@ -5784,9 +5784,197 @@ MIT License multidict -6.7.1 +6.8.0 Apache License 2.0 - Copyright 2016 Andrew Svetlov and aio-libs contributors +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright aio-libs contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/libs/connectors_sdk/NOTICE.txt b/libs/connectors_sdk/NOTICE.txt index d84e161a0..d0018a9bb 100644 --- a/libs/connectors_sdk/NOTICE.txt +++ b/libs/connectors_sdk/NOTICE.txt @@ -1135,7 +1135,7 @@ Apache Software License build -1.6.0 +1.6.1 MIT Copyright © 2019 Filipe Laíns @@ -1444,9 +1444,197 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. multidict -6.7.1 +6.8.0 Apache License 2.0 - Copyright 2016 Andrew Svetlov and aio-libs contributors +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright aio-libs contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 294661d14476018af33a1bdcb02e5663f0a036b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CJan-Kazlouski-elastic=E2=80=9C?= Date: Mon, 14 Sep 2026 16:51:08 +0300 Subject: [PATCH 6/7] fix: treat unparsable page minor version as unpublished An unparsable minor version in OData__UIVersionString is no evidence that a site page is published, so restrict its ACL to owners/editors instead of assuming the page is live. Missing or empty version strings still default to published, since an absent field would otherwise restrict every page on the site. --- .../sources/sharepoint/sharepoint_online/datasource.py | 4 +++- .../tests/sources/test_sharepoint_online.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py index 0121f09af..8868108ae 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py @@ -71,7 +71,9 @@ def _is_page_published(version_string): try: return int(minor) == 0 except ValueError: - return True + # A minor version we cannot parse is no evidence of a published page, + # so restrict access rather than assume the page is live. + return False class SharepointOnlineDataSource(BaseDataSource): diff --git a/app/connectors_service/tests/sources/test_sharepoint_online.py b/app/connectors_service/tests/sources/test_sharepoint_online.py index 023bf1873..90110196a 100644 --- a/app/connectors_service/tests/sources/test_sharepoint_online.py +++ b/app/connectors_service/tests/sources/test_sharepoint_online.py @@ -4366,6 +4366,9 @@ async def test_get_access_control_from_role_assignment_require_edit_access( ("", True), ("5", True), ("not-a-version", True), + # An unparsable minor version is not evidence of a published page + ("3.foo", False), + ("1.x", False), ], ) def test_is_page_published(self, version_string, expected_published): From bfa62ab15ab6815f03877a0c1ed0d1ed68825c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CJan-Kazlouski-elastic=E2=80=9C?= Date: Mon, 14 Sep 2026 16:55:43 +0300 Subject: [PATCH 7/7] chore: tighten unpublished page version comment --- .../sources/sharepoint/sharepoint_online/datasource.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py index 8868108ae..eb9f5367c 100644 --- a/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py +++ b/app/connectors_service/connectors/sources/sharepoint/sharepoint_online/datasource.py @@ -71,8 +71,7 @@ def _is_page_published(version_string): try: return int(minor) == 0 except ValueError: - # A minor version we cannot parse is no evidence of a published page, - # so restrict access rather than assume the page is live. + # An unparsable minor version is no evidence of a published page return False