maik.ing | the terminal garden
4 days ago

Searching SharePoint Programmatically Without Breaking the Permission Model

Building, Breaking, and Automating.

Sometimes the problem is not that a document does not exist.

The problem is that nobody knows where it is.

A recent request was a good example: years of historical contracts and documents were spread across departmental folders, subfolders, old structures, and SharePoint libraries. The SharePoint UI worked well enough for normal day-to-day browsing, but not particularly well for finding a specific document somewhere inside years of accumulated content.

The question was simple:

Can we search SharePoint programmatically without giving the application more access than the user already has?

Yes.

And the important part is not the search API.

It is the authentication model.


The Requirement

The goal was to build a small tool that could:

  • search SharePoint documents programmatically;
  • search across folders and subfolders;
  • use filenames, metadata, and indexed document content;
  • return direct links to matching documents;
  • remain read-only;
  • and, most importantly, never expose documents the authenticated user could not already access.

The environment made this slightly more interesting because the documents were not neatly isolated inside a dedicated Finance SharePoint site.

Multiple departments shared parts of the same SharePoint structure.

That immediately ruled out one tempting design.


The Wrong Approach: App-Only Access

My first instinct with Microsoft Graph integrations is often an Entra application using client credentials:

Application
|
| Client ID + Secret / Certificate
v
Microsoft Graph
|
v
SharePoint

That works extremely well for unattended automation.

But it was the wrong security model here.

With the OAuth client credentials flow, there is no user in the access token.

The question Graph effectively answers is:

Is the application allowed to access this document?

not:

Is the currently logged-in employee allowed to access this document?

For a search tool used interactively by employees, that distinction matters a lot.

Broad application permissions such as:

Files.Read.All
Sites.Read.All
Sites.FullControl.All

can give an application access independently of the individual user's SharePoint permissions.

Even Sites.Selected, while much safer for app-only scenarios, was not quite what I wanted here. It is useful when an application should have access to a specific SharePoint site, but the requirement was different:

Use the permissions of whoever is currently using the tool.

That points directly to delegated permissions.


Delegated Access

The final model looks like this:

User
|
| Interactive Microsoft 365 Login
v
Microsoft Entra ID
|
| Delegated Access Token
v
Microsoft Graph
|
v
SharePoint

The application uses a delegated permission such as:

Files.Read.All

The important word here is:

Delegated

In this configuration, Graph performs the operation in the context of the signed-in user.

So the effective authorization becomes:

Can the user access the document?
|
+--+--+
| |
Yes No
| |
return hide / deny

This is exactly what I wanted.

The tool does not become a privileged SharePoint crawler.

It becomes another interface through which the user exercises their existing permissions.


Creating the Entra Application

The application itself is intentionally simple.

I registered a new single-tenant application in Microsoft Entra ID.

Conceptually:

Supported account types:
Single tenant Platform:
Mobile and desktop applications Redirect URI:
http://localhost Microsoft Graph:
Files.Read.All Delegated
User.Read Delegated

No client secret is required.

That is another nice property of this architecture.

The user authenticates interactively through Microsoft's login flow, so there is no application password sitting inside a Python script.


Authentication with Python and MSAL

For the prototype I used Python together with Microsoft's MSAL library.

Install the dependencies:

python -m pip install msal requests

The authentication code is tiny:

import msal

CLIENT_ID = "<client-id>"
TENANT_ID = "<tenant-id>" AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" SCOPES = [
"Files.Read.All",
"User.Read",
] app = msal.PublicClientApplication(
CLIENT_ID,
authority=AUTHORITY,
) result = app.acquire_token_interactive(
scopes=SCOPES
) if "access_token" not in result:
print(result)
raise SystemExit("Authentication failed") access_token = result["access_token"]

Running the script opens the normal Microsoft authentication flow.

MFA and existing Conditional Access policies continue to apply.

No password handling.

No client secret.

No custom authentication.

Exactly how I like it.


First Test: Who Am I?

Before touching SharePoint, I always like to verify the identity behind the token.

Graph makes that trivial:

GET https://graph.microsoft.com/v1.0/me

Using Python:

import requests

response = requests.get(
"https://graph.microsoft.com/v1.0/me",
headers={
"Authorization": f"Bearer {access_token}"
}
) print(response.json())

The result tells you exactly which account Graph sees.

For testing, I authenticated with two different accounts.

The result behaved exactly as expected:

Login as User A
-> /me returns User A Login as User B
-> /me returns User B

That sounds obvious, but it is an important validation when testing delegated access.


Searching SharePoint

Microsoft Graph exposes a search endpoint that can search SharePoint and OneDrive content:

POST https://graph.microsoft.com/v1.0/search/query

For document searches, the interesting entity type is:

driveItem

A minimal request looks like this:

headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
} body = {
"requests": [
{
"entityTypes": ["driveItem"],
"query": {
"queryString": "contract"
},
"from": 0,
"size": 50,
}
]
} response = requests.post(
"https://graph.microsoft.com/v1.0/search/query",
headers=headers,
json=body,
) print(response.json())

And this is where things become useful.

The query is not limited to filenames.

Depending on indexing, SharePoint Search can also return matches based on metadata and document content.

So a search such as:

contract
agreement
invoice
tax
supplier name
customer name
year

can surface documents buried several folders deep without manually navigating the structure.


The Interesting Security Test

Authentication working does not prove that authorization is correct.

So I created a more useful test.

Instead of only searching for documents, I authenticated using a dedicated test account and inspected which SharePoint content Graph could actually return.

The search results included documents from several different departmental areas.

At first glance that might look alarming.

But it actually revealed something useful:

The test account itself had access to those locations.

The delegated Graph application was simply reflecting the existing SharePoint permission model.

That is exactly what it was supposed to do.

This is also an important reminder when troubleshooting SharePoint:

Sometimes an API does not reveal an authorization problem.

It reveals a SharePoint permission problem that already existed.


Listing Visible Folders

Search results are useful, but when validating permissions I prefer something simpler.

List the folders the user can actually browse.

For a document library:

GET /drives/{drive-id}/root/children

For example:

response = requests.get(
f"https://graph.microsoft.com/v1.0/drives/{drive_id}/root/children",
headers={
"Authorization": f"Bearer {access_token}"
}
) for item in response.json().get("value", []):
if "folder" in item:
print(item["name"])

That produces a much clearer picture of the visible SharePoint structure.

Something like:

Accounting
Finance
Legal
Marketing
HR
Operations

If a test user sees more departments than expected, the next question should not be:

Why is Graph giving the app too much access?

The better question is:

Why does this user already have access to those SharePoint locations?

That distinction saved quite a bit of debugging time.


Adding a Second Boundary

Delegated permissions solve the authorization problem, but I still prefer applying defense in depth.

Imagine the user legitimately has access to:

Finance
Marketing
Legal
Product

but the tool is intended exclusively for Finance research.

Technically, delegated Graph access could search all four.

So the application should apply its own search boundary as well.

For example:

User must have access
AND
document must be inside the configured Finance scope

SharePoint Search supports path restrictions, which makes this easy.

Conceptually:

contract path:"<configured-sharepoint-location>"

This gives two independent controls:

SharePoint permissions
+
Application search scope

Neither replaces the other.


Why I Prefer This Model

For interactive internal tooling, delegated Graph permissions have several advantages.

Existing permissions remain authoritative

There is no second permission universe to maintain.

If access is removed in SharePoint, the API access disappears as well.

No service account

No shared account with a password that eventually ends up in a password manager nobody owns.

No application secret

For the desktop prototype, authentication uses the user's Microsoft identity directly.

MFA still applies

The user goes through the existing Entra authentication process.

Conditional Access still applies

The application does not bypass existing identity controls.

Auditing makes more sense

Actions happen in a user context rather than behind one giant technical identity.


What I Would Not Do

For this particular use case, I would avoid building the tool around:

client_credentials

combined with:

Sites.FullControl.All

or:

Files.Read.All (Application)

Those permissions have valid uses.

This simply was not one of them.

Application permissions make much more sense for unattended processes such as:

Scheduled jobs
ETL processes
Backup tooling
Document processors
Integration services
CI/CD pipelines

For an employee sitting in front of a search tool, delegated permissions are usually a much more natural security boundary.


One More Lesson: API Access Does Not Fix SharePoint Architecture

The Graph API solved the immediate search problem.

It did not solve the underlying information architecture.

If historical documents are scattered across:

Department folders
Old project folders
Personal conventions
Nested subfolders
Multiple document libraries
Inconsistent metadata

then a better search interface is useful, but it is still treating a symptom.

The longer-term work remains:

Information architecture
Permissions
Retention
Metadata
Naming conventions
Ownership
Lifecycle management

The API simply gives us a much better flashlight while exploring the basement.


Final Architecture

The final prototype is pleasantly boring:

Python
|
v
MSAL
|
v
Interactive Microsoft Login
|
v
Delegated Microsoft Graph Token
|
v
Microsoft Graph Search
|
v
SharePoint
|
+-- Existing user permissions
|
+-- Configured search scope

No credentials in source code.

No privileged service account.

No global SharePoint application access.

Just the user's existing permissions, exposed through a much more useful interface.

Sometimes the best automation is not about gaining more access.

It is about making the access you already have usable.


Tags: Microsoft 365 SharePoint Microsoft Graph Entra ID Python MSAL Automation Security

maik.ing | the terminal garden

Building, Breaking, and Automating.
Welcome to maik.ing. This is my digital toolbox for everything related to Microsoft 365, PowerShell automation, and cloud infrastructure. Here, I share functional scribbles, in-depth M365 insights, and the occasional Nordic breeze from my everyday IT life.
Subscribe via RSS here