Skip to content

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

The setup common to both paths

  1. 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).
  2. Verify who the workload is — via the passwordless-connect story from TA.1 and confirm the OID:
    az resource show --ids <VM/App Service resource id> --query "identity.principalId" -o tsv
    
  3. Graph service principal in the tenant (auto-provisioned; find its object id):
    GRAPH_SP=$(az ad sp list --filter "appId eq '00000003-0000-0000-c000-000000000000'" \
                              --query '[0].id' -o tsv)
    

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 oid in 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.All when Sites.Selected will do. Least-privilege isn't a slogan — Sites.ReadWrite.All gives your app or MI tenant-wide SharePoint write. If it leaks, so does everything.
  • Don't grant the site-level write role 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.All in 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. Use Sites.Selected as 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}.