Make externalized large-payload token self-describing (token v2) - #766
Conversation
Embed the storage account in each externalized-payload token so the DTS dashboard and repointed workers can locate the blob without relying on the worker's currently-configured PayloadStore. - BlobPayloadStore emits self-describing "blob:v2:<fullBlobUrl>" tokens from the blob's absolute URI at write time. - DecodeToken parses both v2 (DNS- and Azurite path-style URLs) and legacy v1 tokens; IsKnownPayloadToken recognizes both so v2 tokens rehydrate. - DownloadAsync honors the token's account: fast-path when it matches the configured container; cross-account read via the configured TokenCredential (identity auth) when it differs; otherwise a clear PayloadStorageException (account-key auth cannot read another account), thrown before any network call. - Legacy v1 tokens remain readable (account implicit = configured store). - Update the LargePayloadConsoleApp sample token check and add focused BlobPayloadStore tests (encode/decode v1+v2, IsKnownPayloadToken, cross-account guard). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44c27836-c49c-45fd-ae2b-3309f9c3f0f0
…oad data loss The auto-purge delete path in BlobPayloadStore was v1-only. Once self-describing `blob:v2:<fullBlobUrl>` tokens ship (PR #766), a v2 token would hit the v1-only DecodeToken, throw ArgumentException, and DeleteExternalBlobActivity would classify it as a poison token -> Discarded -> the backend acks/clears the tombstone while the blob is never deleted -> orphaned payload (leak). Converge the shared token code with #766 verbatim (trivial future merge) and make the delete path v2-aware, mirroring DownloadAsync resolution: - Recognize both blob:v1: and blob:v2: prefixes; DecodeToken returns a 5-tuple and parses v2 URLs via BlobUriBuilder (DNS and Azurite path-style); v1 stays read-compatible. - UploadAsync now emits v2 tokens (EncodeToken(blob.Uri)). - DeleteAsync: v1 container-match check preserved; v2 same-account fast-path via IsConfiguredContainer; v2 cross-account with an identity credential deletes directly; v2 cross-account WITHOUT identity throws PayloadStorageException. DeleteIfExistsAsync keeps deletes idempotent. Poison classification is unchanged and verified: PayloadStorageException derives from InvalidOperationException (not ArgumentException), so a cross-account-without-identity delete falls through DeleteExternalBlobActivity's generic catch -> Retry (stays tombstoned until credentials are fixed), never Discarded. Tests: mirror #766 v2 encode/round-trip, DecodeToken v2 (DNS + Azurite) and v1 back-compat, and IsKnownPayloadToken v1+v2; add a v2 same-account happy-path delete and a v2 cross-account-without-identity DeleteAsync -> PayloadStorageException guard (throws before any network call); add activity tests asserting ArgumentException -> Discarded, PayloadStorageException -> Retry, and success -> Deleted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 44c27836-c49c-45fd-ae2b-3309f9c3f0f0
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:142
- In the v2 branch,
blobUriis declared asUri?from theDecodeTokentuple. With<Nullable>enable</Nullable>, passing it directly to theBlobClientconstructor will produce nullable warnings and can mask unexpected nulls. Use a null-forgiving operator (or an explicit null check) here since v2 tokens always populateblobUri.
else if (this.options.Credential != null)
{
// The payload lives in a different account (e.g. the store was repointed). Identity auth can still
// read it as long as the credential has RBAC access to that account.
blob = new BlobClient(blobUri, this.options.Credential, this.clientOptions);
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:200
DecodeToken(v2) doesn't validate that the parsed URI actually contains both a container and blob name. For inputs likeblob:v2:https://acct.blob.core.windows.net/container/(missing blob) orblob:v2:https://acct.blob.core.windows.net/(missing container), this returns empty names and later produces confusing failures. Fail fast with the same "Invalid external payload token format" exception used for other malformed tokens.
BlobUriBuilder builder = new(blobUri);
string container = builder.BlobContainerName;
string name = builder.BlobName;
builder.BlobName = string.Empty;
Uri containerUri = builder.ToUri();
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:150
- This exception text says the payload lives in a different storage account, but the check (
IsConfiguredContainer) actually compares the full container URI. If only the container differs (same account), this message will be misleading. Consider wording this as a mismatch with the configured payload store container (and avoid implying it's necessarily a different account).
throw new PayloadStorageException(
$"The externalized payload lives in a different storage account ('{containerUri}') than the " +
$"currently-configured payload store ('{this.containerClient.Uri}'). Cross-account payload reads " +
"require identity (AAD) authentication with access to both accounts; connection-string / " +
"account-key credentials are account-specific and cannot read another account.");
test/Grpc.IntegrationTests/BlobPayloadStoreTests.cs:97
- This assertion is coupled to the current exception wording. If the error message is updated to describe a configured-container mismatch (vs only "different storage account"), update this expectation accordingly so the test continues to validate the intended behavior.
// Assert
Assert.Contains("different storage account", ex.Message);
}
halspang
left a comment
There was a problem hiding this comment.
Nothing major, mostly styling nits.
| /// "blob:v2:<fullBlobUrl>", where the URL is the blob's absolute URI including the storage account. | ||
| /// Legacy "blob:v1:<container>:<blobName>" tokens are still recognized for read back-compatibility. |
There was a problem hiding this comment.
Nit: Can you fix the encoding on these strings, comments should be human readable.
| /// configured store. <c>BlobClient.Uri</c> contains no SAS or account key and is safe to persist. | ||
| /// </summary> | ||
| /// <param name="blobUri">The absolute URI of the blob holding the payload.</param> | ||
| /// <returns>An opaque payload token in the form "blob:v2:<fullBlobUrl>".</returns> |
| /// Decodes a payload token. Supports self-describing v2 tokens ("blob:v2:<fullBlobUrl>") and legacy v1 | ||
| /// tokens ("blob:v1:<container>:<blobName>"), the latter for read back-compatibility. |
There was a problem hiding this comment.
More encoded strings. I'll stop commenting on them but please fix them everywhere.
| /// A tuple describing the token: whether it is v2, the container and blob names, and (for v2 only) the | ||
| /// absolute blob URI and its container-level URI. | ||
| /// </returns> | ||
| internal static (bool IsV2, string Container, string Name, Uri? BlobUri, Uri? ContainerUri) DecodeToken( |
There was a problem hiding this comment.
Nit: With 4 items in the return, I think we've gotten to the point of having a DecodeTokenResult or something.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:150
- This exception message says the payload is in a different “storage account”, but the comparison is currently against the full container URI. If only the container differs (same account), this message is misleading. Adjust wording to reflect “container or account” (or similar) so the reported mismatch matches what was compared.
throw new PayloadStorageException(
$"The externalized payload lives in a different storage account ('{containerUri}') than the " +
$"currently-configured payload store ('{this.containerClient.Uri}'). Cross-account payload reads " +
"require identity (AAD) authentication with access to both accounts; connection-string / " +
"account-key credentials are account-specific and cannot read another account.");
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:201
- DecodeToken should reject v2 tokens that don’t actually include both a container name and blob name (e.g., a container-level URL or account root URL). Today those decode to empty container/blob values and will later fail in confusing ways (or produce misleading cross-account errors). Add validation after parsing the BlobUriBuilder fields.
BlobUriBuilder builder = new(blobUri);
string container = builder.BlobContainerName;
string name = builder.BlobName;
builder.BlobName = string.Empty;
Uri containerUri = builder.ToUri();
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:137
- For v2 tokens, the code treats any mismatch in the configured container URI as a cross-account scenario. That means if the storage account stays the same but the configured ContainerName changes (or tokens reference a different container in the same account), DownloadAsync will either (a) incorrectly require identity auth or (b) throw, even though connection-string/account-key auth can typically read other containers within the same account. Consider comparing account separately from container, and for same-account/different-container resolve via the token’s container (e.g., create a BlobServiceClient with the configured connection string and then get the token’s container client) instead of failing fast.
This issue also appears in the following locations of the same file:
- line 146
- line 197
if (this.IsConfiguredContainer(containerUri!))
{
// Same account and container as the configured store: reuse it (works with any auth mode).
blob = this.containerClient.GetBlobClient(name);
}
…esult
Replace the HTML-escaped angle brackets in the XML doc comments with <c>-wrapped
curly-brace placeholders (blob:v2:{fullBlobUrl}, blob:v1:{container}:{blobName}),
so the comments read naturally in source while staying valid XML.
Replace DecodeToken's 5-element tuple return with a named
DecodeTokenResult readonly record struct. The positional record auto-generates
Deconstruct, so existing deconstruction call sites are unchanged.
Purely mechanical: no runtime behavior, control flow, or message changes.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 11fc03d9-9844-4653-bcfb-7d04c173108e
…uring Follow-up to the DecodeTokenResult refactor: update DownloadAsync and the three test call sites to access named members rather than destructuring positionally, so reordering the record's parameters can no longer silently mis-bind a caller. Mechanical only. Control flow, exception types and rendered messages are unchanged; the existing null-forgiving on ContainerUri is preserved, and no test assertions were added, removed or reworded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 11fc03d9-9844-4653-bcfb-7d04c173108e
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:150
- This exception is thrown whenever the token's container URI doesn't match the configured container URI. That can happen due to a different container (not just a different storage account), so the message is currently misleading (it always says "different storage account"). Making the wording include container mismatch will make failures easier to understand without changing behavior.
throw new PayloadStorageException(
$"The externalized payload lives in a different storage account ('{decoded.ContainerUri}') than the " +
$"currently-configured payload store ('{this.containerClient.Uri}'). Cross-account payload reads " +
"require identity (AAD) authentication with access to both accounts; connection-string / " +
"account-key credentials are account-specific and cannot read another account.");
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:201
- DecodeToken() accepts any absolute URI after "blob:v2:" but doesn't validate that the parsed URI actually contains both a container and a blob name. For inputs like a service/account URI (or container-only URI), BlobUriBuilder will yield empty container/blob values and downstream code will throw confusing errors (or attempt to create a BlobClient with an empty name). Consider failing fast with the same "Invalid external payload token format" exception when container or blob name is empty.
BlobUriBuilder builder = new(blobUri);
string container = builder.BlobContainerName;
string name = builder.BlobName;
builder.BlobName = string.Empty;
Uri containerUri = builder.ToUri();
return new(true, container, name, blobUri, containerUri);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:150
- The thrown PayloadStorageException message says the payload is in a “different storage account”, but this branch can also be reached when only the container differs (same account) because IsConfiguredContainer compares the full container URI. Adjust the message to describe the actual mismatch (token target vs configured container) and reserve the “different account” wording for when you’ve explicitly compared account identity.
throw new PayloadStorageException(
$"The externalized payload lives in a different storage account ('{decoded.ContainerUri}') than the " +
$"currently-configured payload store ('{this.containerClient.Uri}'). Cross-account payload reads " +
"require identity (AAD) authentication with access to both accounts; connection-string / " +
"account-key credentials are account-specific and cannot read another account.");
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:135
- DownloadAsync's v2 path currently treats any mismatch with the configured container URI as a cross-account scenario and (when not using TokenCredential) throws. This means a v2 token that points to the same storage account but a different container (e.g. ContainerName changed) becomes unreadable even though the configured credentials could still access it; it also makes the subsequent exception text misleading (it says “different storage account” even for container-only differences). Consider comparing storage account identity separately from container, and when the account matches, resolve the blob via a container client for decoded.Container (so v2 truly honors both account and container).
This issue also appears on line 146 of the same file.
// v2 tokens are self-describing: honor the account and container encoded in the token.
BlobClient blob;
if (this.IsConfiguredContainer(decoded.ContainerUri!))
{
// Same account and container as the configured store: reuse it (works with any auth mode).
Preserve the self-describing v2 payload token and cross-account read changes from #766 while retaining the review-driven, caller-cancellable container initialization cache. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Summary
Makes the externalized large-payload token self-describing by embedding the storage account in it (token format v2). This is "Option A" for the dashboard-payload-access feature.
Today the token is
blob:v1:<container>:<blobName>and does not carry the storage account — the account is implicit (whatever the worker'sPayloadStoreis currently configured with). That breaks two scenarios:Fix: write the account into each token at upload time.
Token format
blob:v2:<fullBlobUrl>— everything afterblob:v2:is one complete absolute blob URI, e.g.blob:v2:https://acct.blob.core.windows.net/mycontainer/<guid>(DNS style)blob:v2:http://127.0.0.1:10000/devstoreaccount1/mycontainer/<guid>(Azurite path style)BlobClient.Uricarries no SAS/key, so the token is non-secret and safe to persist.blob:v1:<container>:<blobName>— account implicit = configured store.Changes
BlobPayloadStore.EncodeToken(Uri)emits v2 from the blob's absolute URI (blob.Uri) at upload time.DecodeTokenparses both v2 (viaBlobUriBuilder, handling DNS- and Azurite path-style URLs) and legacy v1.IsKnownPayloadTokenrecognizes bothblob:v1:andblob:v2:so v2 tokens rehydrate.DownloadAsynchonors the token's account:TokenCredential) is configured, read cross-account via a newBlobClientusing the same retry options; otherwise throw a clearPayloadStorageExceptionbefore any network call.DownloadFromBlobAsynchelper.LargePayloadConsoleApptoken check updated to also recognizeblob:v2:.Back-compat
Preview product; breaking existing
blob:v1:payloads would be acceptable, but v1 read back-compat is kept anyway (cheap, avoids breaking in-flight orchestrations).Caveat (by design)
Worker cross-account read only works with identity/managed-identity auth that has RBAC on both accounts. With connection-string/account-key auth the key is account-specific, so cross-account is impossible — the clear throw is the correct behavior. The dashboard (out of scope, different repo) is unaffected: it uses the user's own AAD token.
Tests
Added
test/Grpc.IntegrationTests/BlobPayloadStoreTests.cs(no live Azure required):PayloadStorageExceptionbefore any network call.Full solution builds with 0 errors and no new analyzer warnings; the 6 new tests pass.
Coordination note — draft PR #758 (auto-purge)
PR #758 (
yunchuwang-wangbill-blob-payload-autopurge-sdk) addsPayloadStore.DeleteAsync+BlobPayloadStore.DeleteAsync, which still use the v1-onlyDecodeToken/TokenPrefixand a v1-onlyIsKnownPayloadToken.DeleteExternalBlobActivityclassifies theArgumentExceptionthat the v1 decoder throws on an unrecognized prefix as a poison token →Discarded(acked so the backend clears the tombstone row).If #758 merges on top of this change without reconciliation, every v2 token handed to
DeleteAsyncwould fail theblob:v1:prefix check, throwArgumentException, and be silently discarded without deleting the blob (payload leak) — and v2 tokens wouldn't be recognized as known. Whoever merges second should port #758'sDeleteAsync/DecodeToken/IsKnownPayloadTokento the v2-aware versions (account-aware blob resolution, same fast-path/cross-account logic asDownloadAsync). There's also a mechanical merge conflict inBlobPayloadStore.cssince both branches edit the same token region.Superseded/related (closed separately, not touched here): protobuf #77, this repo's #765, and a backend ADO PR.