Files
microsoft--agent-framework/docs/decisions/0031-hosted-per-user-session-storage-isolation.md
Roger Barreto 62f0024707 .NET: Foundry Hosting gracefully tolerates lacking user identity when run locally (#6870)
* .NET: Make Foundry Hosting resilient to missing user identity in local runs

AgentFrameworkResponseHandler threw InvalidOperationException (surfaced as a
500 on every request) when the isolation-key provider returned null, which
always happens locally because the platform x-agent-user-id header is absent.
Running a hosted image outside Foundry therefore failed out of the box.

The handler now branches on FoundryEnvironment.IsHosted: hosted stays strict
(null identity is still a hard error), but non-hosted (local docker run /
dotnet run) tolerates a null identity - per-user isolation is simply not
triggered, the request proceeds with userId null (no partition), and no
hosted context is stamped or validated.

Because local runs no longer need a fallback, the sample-side
DevTemporaryLocalUserIdProvider and AddDevTemporaryLocalContributorSetup are
removed from Hosted_Shared_Contributor_Setup and all sample Program.cs files.
To simulate distinct users locally, send an x-agent-user-id request header;
the default provider reads it exactly as it reads the platform-injected value.
The Memory sample smoke script now drives alice/bob against one container via
that header. AGENT_NAME defaults added to Hosted-ChatClientAgent and
Hosted-MemoryAgent so a hosted deploy (where AGENT_* is a reserved env var)
does not crash at startup.

Updates the two affected unit tests to assert the local-success path and
amends ADR 0031.

* Address review: correct isolation-guarantee and Memory-sample local docs

- AgentFrameworkResponseHandler: note the null/local case is unscoped/shared,
  not fully partitioned per user.
- HostedSessionIsolationKeyProvider XML docs: phrase the non-null UserId rule as
  a constraint on the returned-context case, since null is now allowed locally.
- Hosted-MemoryAgent: the PerUser() memory scope requires a resolved user, so a
  local run needs an x-agent-user-id header; corrected the Program.cs comment
  and README (removed the inaccurate "shared bucket locally" claim).
- Test: assert absence of any u-* per-user directory via a wildcard search
  rather than checking for a literal "u-" directory.
2026-07-02 09:32:22 +00:00

7.0 KiB

status, contact, date, deciders, consulted, informed
status contact date deciders consulted informed
accepted rogerbarreto 2026-06-30 rogerbarreto

Per-agent and per-user session-storage isolation for Foundry Hosting

Builds on ADR-0030.

Context and Problem Statement

A Foundry hosted container can serve many end users (and, in .NET, many agents) over its lifetime. The AgentSessionStore persists each turn's AgentSession (which for a workflow agent carries the workflow checkpoint, and which also carries the tool-approval mapping via ToolApprovalIdMap in the session state bag). ADR-0030 protected cross-user access only through the strict-resume identity check (a 403 when the persisted HostedSessionContext.UserId does not match the live request). The persisted artifacts themselves were keyed by conversationId (+ agent name), not physically partitioned per user, so a forged conversation_id would still resolve to another user's file path before the identity check rejected it.

The Python hosting package added physical per-user partitioning (<root>/<user_id>/<context_id>) plus a reject-style path-traversal guard. We want .NET to provide the same defense-in-depth, adapted to the .NET hosting model.

Decision Drivers

  • Defense in depth: a forged/guessed id must not even resolve to another tenant's storage path, independent of the identity check.
  • Multi-agent hosting: a single .NET container hosts multiple agents resolved from keyed DI, so the layout must isolate per agent as well as per user (Python hosts a single agent and needs no agent layer).
  • Path-traversal safety (CWE-22) for the untrusted, platform-injected user id.
  • Back-compat for local development (no x-agent-user-id header) and for direct/non-hosted store use.
  • Keep the change contained and avoid the async-iterator AsyncLocal revert hazard from ADR-0030.

Considered Options

  • Path partition inside FileSystemAgentSessionStore, threading the user id explicitly through the AgentSessionStore API, with self-describing prefixed segments.
  • A delegating store that prefixes the conversation id with the user id (the IsolationKeyScopedAgentSessionStore pattern from Microsoft.Agents.AI.Hosting). Rejected: still needs the user id on the read path and yields a flat key rather than nested per-tenant directories.
  • An AsyncLocal<string?> user-context set by the handler. Rejected: the session is saved in the handler's finally after the streaming yields, where an AsyncLocal set up front is reverted (the same hazard that forced explicit call-id re-application in ADR-0030). Explicit threading is safer and clearer.
  • A separate per-user approval store (as in Python). Rejected as unnecessary: see below.

Decision Outcome

Path layout with self-describing, prefixed segments; user id threaded explicitly:

{root}/ a-{agentName} / u-{userId} / c-{contextId}.json
  • a- (agent), u- (user), c- (context) are constant literals applied to the sanitized/validated value, so a collapsed layout is never ambiguous and a user id can never masquerade as an agent name.
  • contextId is HostedConversationKey.Resolve (conversation_id, else the partition of previous_response_id, else of the minted response id).
  • The agent and context layers are always present (Foundry always deploys a named agent). The only collapse is the u- layer: present when a user id is resolved (Foundry header, or local dev fallback), absent for raw local runs with no header ({root}/a-{agent}/c-{conv}.json). There is no user-only or no-agent layout.

Other elements:

  • string? userId was added as a required parameter (no default) on AgentSessionStore.GetSessionAsync / SaveSessionAsync (a contained, breaking change to the experimental Foundry abstraction; both in-tree implementations and the two handler call sites were updated). It is required rather than optional so a caller can never silently persist a session unscoped; a genuine no-user caller (local without the header, or a non-hosted direct caller) passes null explicitly. AgentFrameworkResponseHandler resolves the user id before loading the session.
  • Path-traversal guard: the user id is rejected (not sanitized) when it is not a single safe path segment (path separators, NUL, drive letters, rooted paths, all-dot segments). After building the path, the fully-resolved path is asserted to remain under the storage root.
  • The strict-resume 403 identity check from ADR-0030 is kept as the second defense layer (it still catches a session that reaches the wrong partition, e.g. via a non-partitioning custom store or in-process tampering).
  • No separate approval store. The tool-approval mapping lives in ToolApprovalIdMap -> AgentSessionStateBag, which is serialized into the session checkpoint, so partitioning the session path isolates pending approvals per tenant automatically. (Python needs a separate per-user approval store only because it models approvals as a standalone store.)

Consequences

Positive:

  • Cross-tenant isolation is now defense-in-depth: physical per-agent/per-user partitioning plus the identity check. Approvals and workflow checkpoints inherit the partitioning because they ride in the session.
  • Self-describing prefixes make the on-disk layout auditable and collision-free across collapse cases.

Negative:

  • Breaking change to the experimental Foundry AgentSessionStore API (added userId).
  • The on-disk layout and leaf filename change (<conv>.json -> c-<conv>.json), orphaning sessions written by the ADR-0030 release. Acceptable for an experimental package; a fresh session is created on next use.

Out of scope

  • Encryption at rest and quota enforcement remain platform concerns.
  • Non-Foundry hosting layers can adopt an equivalent scheme independently.

Update (2026-07-01): local runs no longer fail closed; sample dev provider removed

Superseding the ADR-0026/0030 behavior where a null result from HostedSessionIsolationKeyProvider always became a 500, AgentFrameworkResponseHandler now branches on FoundryEnvironment.IsHosted:

  • Hosted (IsHosted == true, production): a null identity is still a hard error (500). Isolation stays strict; the platform always injects x-agent-user-id.
  • Not hosted (local docker run / dotnet run): a null identity is tolerated. Per-user isolation is simply not triggered — the handler passes userId == null to the store (the documented "no user partition", {root}/a-{agent}/c-{conv}.json), stamps no HostedSessionContext, and runs no strict-resume check. Contributors can run a hosted image locally with zero extra setup.

Consequently the sample-side DevTemporaryLocalUserIdProvider and AddDevTemporaryLocalContributorSetup were removed. To simulate distinct users locally, send an x-agent-user-id request header; the default PlatformHostedSessionIsolationKeyProvider reads it via ResponseContext.PlatformContext.UserIdKey (the SDK's PlatformContext.FromRequest populates it from the header unconditionally, hosted or not).