Azure / Graph / SharePoint — the concrete rich example¶
SharePoint through Microsoft Graph is the cleanest place to see the app-attributed vs user-delegated distinction in the wild — both paths are supported, both have real-world names, and picking one commits you to a very specific audit story. Plus the operational gotcha every team hits once: app-role grants for a managed-identity service principal have no portal UX; you must use az rest.
Verified 2026-08-09
- Graph permission catalog +
Sites.Selected(id19432fd1-cce4-40ce-9a32-37f7b301e90f): learn.microsoft.com/…/graph/permissions-reference - Assign app role to a managed-identity SP via CLI (portal cannot): learn.microsoft.com/…/entra/identity/managed-identities-azure-resources/assign-app-role-managed-identity-azure-cli
- Grant an app permission to a specific site via
POST /sites/{site-id}/permissions: learn.microsoft.com/…/graph/api/site-post-permissions - OBO flow for a middle-tier API to call Graph on behalf of the user: learn.microsoft.com/…/entra/identity-platform/v2-oauth2-on-behalf-of-flow
The setup common to both paths¶
- App registration — an Entra app registration for your workload. On App Service / Container Apps / AKS via Workload Identity, this is the workload identity's service principal (either a system-assigned MI or a user-assigned MI or a standalone registered app).
- Verify who the workload is — via the passwordless-connect story from TA.1 and confirm the OID:
- Graph service principal in the tenant (auto-provisioned; find its object id):
Path 1 — App-attributed with Sites.Selected (least privilege)¶
The user is out of the picture; SharePoint sees your app as the writer. This is the right choice for background jobs and agent-driven writes where the effect matters more than whose behalf it was on.
Step 1 — Grant your MI the Sites.Selected app role¶
The role id is a Graph constant; look it up once from the Graph SP's appRoles:
ROLE_ID=$(az ad sp show --id "$GRAPH_SP" \
--query "appRoles[?value=='Sites.Selected'].id | [0]" -o tsv)
The portal cannot assign this to a managed-identity SP. The docs are explicit that this is a CLI-only path — the portal's "API permissions" page only works for user-created app registrations, not for the auto-created SP of a system-assigned or user-assigned MI. Use az rest:
MI_OID=$(az resource show --ids "$MY_MI_RESOURCE_ID" \
--query "identity.principalId" -o tsv)
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/$MI_OID/appRoleAssignments" \
--body "{
\"principalId\": \"$MI_OID\",
\"resourceId\": \"$GRAPH_SP\",
\"appRoleId\": \"$ROLE_ID\"
}"
Verify:
az rest --method GET \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/$MI_OID/appRoleAssignments" \
| jq '.value[] | {principal:.principalDisplayName, role:.appRoleId, resource:.resourceDisplayName}'
Step 2 — Grant the app write access to this specific site¶
Sites.Selected on its own grants zero sites — the whole point of the least-privilege pattern. Explicit per-site grant via Graph:
SITE_ID=$(az rest --method GET \
--url "https://graph.microsoft.com/v1.0/sites/root:/sites/YourSite" \
| jq -r '.id')
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/sites/$SITE_ID/permissions" \
--body "{
\"roles\": [\"write\"],
\"grantedToIdentities\": [
{ \"application\": { \"id\": \"$MI_CLIENT_ID\", \"displayName\": \"chiron-agent\" } }
]
}"
Grant read when only reading. Grant per site — never a tenant-wide grant if you can help it (Sites.ReadWrite.All is the over-broad tenant-wide alternative and is explicitly the wrong default for new apps).
Step 3 — The app writes¶
Using DefaultAzureCredential (which uses the MI on the App Service / Container App / AKS pod) and Graph SDK — no user token, no OBO:
from azure.identity import DefaultAzureCredential
from msgraph import GraphServiceClient # microsoft graph sdk for python
credential = DefaultAzureCredential()
graph = GraphServiceClient(
credentials=credential,
scopes=["https://graph.microsoft.com/.default"],
)
# The workload identity is the caller; SharePoint sees "chiron-agent"
# as the writer. The user does not appear in Graph's audit log.
await graph.sites.by_site_id(SITE_ID).lists.by_list_id(LIST_ID).items.post(new_item)
What lands in SharePoint's audit log¶
- Actor: the app's service principal display name (
chiron-agent). - User context: none.
- Provenance for humans: keep the human
oidin your own DB via the audit-column pattern — that's how you close the "who asked for this" loop.
Path 2 — User-delegated (OBO)¶
The user is the writer. SharePoint applies their ACLs. Your app is a middle-tier passing through the identity.
Requires:
- The user signs into your front-end app with an ID token whose aud is your middle-tier's client id (see edge auth).
- Your middle-tier is a confidential client with a client secret or certificate.
- Your middle-tier's app registration lists Sites.ReadWrite.All (or a narrower delegated scope like Sites.Selected) as a delegated permission and it's been admin-consented.
The OBO exchange¶
import os, httpx
def obo_graph_token(user_access_token: str) -> str:
"""Exchange the user's access token for a Graph token that lets the app
call SharePoint AS THE USER. Returns the delegated token."""
r = httpx.post(
f"https://login.microsoftonline.com/{os.environ['TENANT_ID']}/oauth2/v2.0/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_id": os.environ["MIDDLETIER_CLIENT_ID"],
"client_secret": os.environ["MIDDLETIER_CLIENT_SECRET"],
"assertion": user_access_token, # the user's token
"requested_token_use": "on_behalf_of",
"scope": "https://graph.microsoft.com/Sites.ReadWrite.All offline_access",
},
timeout=15,
)
r.raise_for_status()
return r.json()["access_token"]
The endpoint + params were fixed in the attribution-patterns compare — same exchange, different downstream scope and audience.
The write¶
Same Graph SDK, but a TokenCredential that returns the OBO token:
from msgraph import GraphServiceClient
class OboCredential:
def __init__(self, token: str): self._t = token
def get_token(self, *scopes, **_):
from azure.core.credentials import AccessToken
return AccessToken(self._t, expires_on=0) # SDK re-checks expiry itself
graph = GraphServiceClient(credentials=OboCredential(obo_graph_token(user_access_token)),
scopes=["https://graph.microsoft.com/.default"])
await graph.sites.by_site_id(SITE_ID).lists.by_list_id(LIST_ID).items.post(new_item)
What lands in SharePoint's audit log¶
- Actor: the user's UPN / Entra
oid. - User context: exactly as if the user had opened SharePoint in a browser and made the change.
- App context: the middle-tier's client id appears as the application that made the request; the user is the actor.
If the user doesn't have write on the site, the request 403s. That's the point — no privilege escalation via the app.
Side by side¶
| Concern | Sites.Selected (app-attributed) |
OBO (user-delegated) |
|---|---|---|
| SharePoint audit-log actor | App SP | End user (Entra oid / UPN) |
| ACL enforcement | App's per-site grant | User's ACLs on the site |
| Setup | 2 CLI calls (app role + per-site grant) | Middle-tier confidential client + delegated permission + user consent |
| Rev-share when user leaves | Site-level grant stays until you revoke | Access dies with the user's disablement in Entra |
| Works from a background job (no user session) | Yes | No — needs a user access token |
| Works from an agentic flow post-approval | Yes | Only if you captured a fresh user token at approval time |
| Right for AI-anchored writes | Usually — pair with actor_oid=agent+approver_oid=user in your DB |
Only when the user is genuinely the writer (e.g., "publish my draft") |
The one operational gotcha to remember¶
The Azure portal's "API permissions" page does not offer a way to assign a Graph app role to a managed-identity service principal. If you find yourself stuck there — the "Add permission" button doesn't show your MI, the "Grant admin consent" doesn't apply, the SP doesn't have an "API permissions" blade at all — you're on the right page for the wrong SP type. Use az rest -m POST -u https://graph.microsoft.com/v1.0/servicePrincipals/$MI_OID/appRoleAssignments -b '{"principalId":"'$MI_OID'","resourceId":"'$GRAPH_SP'","appRoleId":"'$ROLE_ID'"}' — same effect, only path that works, per the Entra docs.
What NOT to do¶
- Don't grant
Sites.ReadWrite.AllwhenSites.Selectedwill do. Least-privilege isn't a slogan —Sites.ReadWrite.Allgives your app or MI tenant-wide SharePoint write. If it leaks, so does everything. - Don't grant the site-level
writerole to a "team" identity. Grant to the specific app SP; tie down further with SharePoint's own container-level ACLs where possible. - Don't OBO into
Sites.ReadWrite.Allin a delegated context. Delegated tokens honor the user's ACLs, so the scope name is misleading — but combined with a scope name a security auditor doesn't like the look of. UseSites.Selectedas a delegated scope when SharePoint supports it, or (more common) narrow the middle-tier's consent to the specific sites the flow needs. - Don't lose the human in your own DB. External systems' audit logs are one witness. Your DB
actor_oid(agent) +approver_oid(human) column pair is the other, and it's the one you own end-to-end. See attributed writes + approval workflow.
Where the arc goes next¶
- Later — cross-tenant flows for external writes (B2B guest tenants, Workspace Federated Login), and the AWS-only Cognito Identity Pools pattern for per-user IAM policies keyed on
${cognito-identity.amazonaws.com:sub}.