Skip to content

External-write provenance

The fifth deep page in the trust arc. When your app writes to an external system on the end user's behalf, whose identity does that system see? Two answers with very different tradeoffs — pick per-write, not per-app.

The two identities the external system can see

# Pattern Who the external system sees Who survives in its audit log When to use
1 App-attributed Your app / workload identity Only the app The write is inherent to the service (backup, sync, background job). No individual user is "responsible."
2 User-delegated (OBO / impersonation) The original end user (still) The user, exactly as if they'd clicked the button themselves The write reflects a specific human's intent + must survive scrutiny in the other system's audit log

Both are legitimate. The mistake is defaulting to one. Every external write should be a conscious choice: whose action does this actually represent?

The load-bearing test

Answer these three questions for the write:

  1. If audit comes calling six months later, whose name should appear next to this row in the external system's log?
  2. Who does the external system's authorization model actually check — an app service principal, or an individual user's ACL entries?
  3. If the user leaves the company, should this write's authority to exist leave with them?

  4. All three answers point at the user → user-delegated.

  5. All three point at the app → app-attributed.
  6. Mixed → split the operation. App-attributed for the mechanical parts, user-delegated for the parts where "who authorized this" is meaningful.

Concrete rich example — Microsoft Graph → SharePoint

The Azure / Microsoft Graph deep page walks both paths end to end because SharePoint's permission model exposes the distinction unusually clearly: Sites.Selected (app-attributed, per-site explicit grant, least privilege) vs the delegated OBO chain (user-attributed, honors the user's ACLs on the site). It also covers the operational gotcha that grants for a managed-identity service principal cannot be assigned via the Azure portal — only via az rest / Microsoft Graph PowerShell.

Generalized per-cloud equivalents

Not every cloud has a "SharePoint moment." Where the equivalents map, use them; where they don't, say so — don't force a fake analog.

Azure — Microsoft Graph / SharePoint

Both patterns are first-class. See the deep page. Summary:

  • App-attributed = client credentials + Sites.Selected application permission + explicit per-site grant.
  • User-delegated = OBO exchange from TA.3 → Graph delegated token → SharePoint sees the user.

GCP — service-account impersonation

  • App-attributed with role separation — iam.serviceAccounts.getAccessToken + roles/iam.serviceAccountTokenCreator lets one identity (typically your workload SA) mint a short-lived token as another SA (typically one with narrower rights). Still an app-attributed write; the audit log records both the caller and the impersonated SA per Cloud Docs on impersonation — a stronger audit trail than the equivalent Azure client-credentials flow.
  • User-delegated (Workspace only) — domain-wide delegation lets a service account act as a Workspace end user against Google Workspace APIs (Gmail, Drive, Calendar, etc.). Requires a Workspace admin approval; the resulting call in Gmail/Drive is attributed to the user. Does not apply to GCP APIs (BigQuery, Cloud Storage, Vertex, …) — those don't have a general "act as user" model. If you need user attribution against a GCP API, use the attributed-write pattern inside your database and record the human oid in your own audit column.

AWS — STS token exchange

  • App-attributed with role assumption — sts:AssumeRoleWithWebIdentity(RoleArn, RoleSessionName, WebIdentityToken) takes an OIDC token from your IdP and returns short-lived AWS credentials for the assumed role. The credentials are role-attributed — CloudTrail sees arn:aws:sts::…:assumed-role/FederatedRole/<RoleSessionName>, not the OIDC user directly. This is the AWS closest analog to Azure client-credentials for external writes.
  • Carrying user context through — three levers on top of the assumed role:
  • RoleSessionName = a per-user identifier that appears in CloudTrail (assumed-role/…/RoleSessionName).
  • Session tags (sts:TagSession on the IdP's principal) — key/value pairs the IdP asserts, which land as request context for ABAC + attribute-based policies.
  • sts:SourceIdentity condition key — force the caller to provide a source identity that CloudTrail records and that persists across role chains.
  • True user-delegated writes to AWS-managed services (S3, DynamoDB, …) exist only through Cognito Identity Pools with fine-grained IAM policies keyed on ${cognito-identity.amazonaws.com:sub} — a per-user resource-policy grant on the resource itself. Powerful, but AWS-specific and worth knowing you're building a custom identity plane.

Concretely: AWS does not have a general "app impersonates user against arbitrary service" story like Azure OBO. For non-Cognito setups, treat AWS writes as app-attributed with per-user CloudTrail via RoleSessionName/SourceIdentity, and keep the user attribution in your own DB via the audit-column pattern.

Decision matrix

Situation Choose
Nightly backup to blob storage; user identity irrelevant. App-attributed.
User clicks "Publish to SharePoint" and their permissions on that site determine whether it works. User-delegated (OBO).
Agent generates a document to file in SharePoint on behalf of an approved user; site ACLs govern who can read after. User-delegated (OBO) so the file is owned by the user, or app-attributed with Sites.Selected + an audit-column row in your DB carrying actor_oid = agent, approver_oid = user. Both are defensible; pick per-workflow.
Cloud Run job writes a per-user file to Drive (Google Workspace). Domain-wide delegation as the user, if a Workspace admin will approve; otherwise write to Cloud Storage under an app-attributed SA and record the user in your own DB.
Lambda writes an object to S3 in response to an API call from a signed-in end user. App-attributed with the Lambda's IRSA role; carry the user's IdP sub into RoleSessionName for CloudTrail + into your DB actor_oid column.
Middle-tier worker calls a downstream company API on the user's behalf. Whichever the downstream API accepts. If it takes a user token, OBO. If it only takes app credentials, app-attributed + audit-column in your DB.

What every path shares — the DB audit column stays

Regardless of which external-write pattern you pick, the row in your own database that records the outbound write MUST carry the human's oid/sub via the attributed-write pattern. The external system's audit log is one witness; your DB's audit column is the other, and it's the one you own. Ideal setup:

  • App-attributed external write → your DB row has actor_oid = agent_workload_sa + approver_oid = <human oid>.
  • User-delegated external write → your DB row has actor_oid = <human oid> directly (the user was the caller; no separate approver).

Either way, the human's oid never disappears from your side, even if the external system only recorded "the app did it."

Docs verified 2026-08-09

Where the arc goes next

  • Azure/Graph SharePoint — the concrete example — the two paths end to end, including the MI-service-principal CLI gotcha.
  • Later — cross-tenant federation for external writes (guest access, B2B guest tenants, Workspace Federated Login).