Skip to content

Never re-mint a system key that already exists in the secrets store - #11904

Open
zahalzel2 wants to merge 5 commits into
Azure:devfrom
zahalzel2:user/zahalzel/eventgrid-key-overwrite-repro
Open

zahalzel2 wants to merge 5 commits into
Azure:devfrom
zahalzel2:user/zahalzel/eventgrid-key-overwrite-repro

Conversation

@zahalzel2

@zahalzel2 zahalzel2 commented Jul 31, 2026

Copy link
Copy Markdown

Issue describing the changes in this PR

No public issue - found while investigating an internal incident where Event Grid deliveries to a Functions app started returning 401 after a host restart. Happy to open a tracking issue if the team prefers one.

Related but distinct: #11834 reports EventGrid 401s during Flex Consumption cold start. That failure is transient - the reporter notes retries succeed once the key finishes loading. This one is permanent, because the key is overwritten rather than merely not-yet-loaded, so retries never recover. This PR does not fix #11834.

What's wrong

DefaultScriptWebHookProvider looked up an extension's system key in the in-memory host-secrets cache. On Flex Consumption, specialization seeds that cache from a startup context whose SystemKeys can be incomplete. When it is: the lookup misses, the provider generates a replacement, and AddOrUpdateFunctionSecretAsync blindly upserts it - overwriting the key the subscribing service already holds.

The webhook URL then carries a key the subscription doesn't have, so every delivery 401s. It is not self-healing: the host has no way to notify Event Grid, and the subscription stores the full URL, so recovery requires re-creating the subscription by hand.

The root cause is that a present-but-incomplete cache was treated as authoritative. A stale "no" was indistinguishable from a real one, and only one of those should authorize a write.

The fix

Move get-or-create into SecretManager, the only component that can tell them apart:

  • GetOrCreateSystemKeyAsync returns a cached hit unchanged. On a miss it invalidates only the snapshot it missed on (Interlocked.CompareExchange, so a concurrent reload isn't discarded), then performs one authoritative repository read before concluding the key doesn't exist.
  • Creation is gated on Created/Updated. Any other result means nothing was persisted, and returning the value would mint a webhook URL carrying a key that authorizes nothing.
  • DefaultScriptWebHookProvider now only derives the key name.

AddOrUpdateFunctionSecretAsync is unchanged - KeysController still depends on its result codes for HTTP status mapping.

Known limitation

The create path is still a last-writer-wins upsert, so a key persisted concurrently by another instance can be replaced. That window only opens during true first creation - not on restarts, scale-out, or specialization. Closing it needs an ETag/CAS write no ISecretsRepository exposes today. The interface documents this rather than over-promising.

Tests

On the miss path: zero WriteAsync/WriteSnapshotAsync, exactly one ReadAsync, and the invalidated cache heals. A second test proves a cached hit never touches the repository. A third seeds a host document missing only the target key and asserts exactly one write whose persisted ciphertext decrypts to the returned value. DefaultScriptWebHookProviderTests verifies the exact derived key name.

Mutation-checked: disabling the invalidation fails with Expected invocation on the mock should never have been performed, but was 1 times: WriteAsync - the incident itself, caught by assertion rather than inferred from a returned value.

Pull request checklist

  • Backporting to the in-proc branch is not required
    • Maintainer input wanted. The trigger is Flex-specific (specialization seeding a partial cache) but the code path is shared. I don't know whether in-proc can reach the same state, so I'm not asserting either way.
  • My changes do not require documentation changes
  • My changes should not be added to the release notes for the next release
    • This is customer-visible, so it should be - the release_notes.md entry is included in this PR.
  • My changes do not need to be backported to a previous version
    • Maintainer input wanted - depends on which released versions carry the same path.
  • My changes do not require diagnostic events changes
    • No events added or changed. The existing Host secret ... Updated event simply stops firing spuriously.
  • I have added all required tests (Unit tests, E2E tests)

DefaultScriptWebHookProvider looked up an extension's system key in the
in-memory host-secrets cache. When Flex Consumption specialization seeds that
cache from a startup context whose SystemKeys is incomplete, the lookup misses,
the provider generates a replacement, and the blind upsert overwrites the key
already held by the subscribing service - producing 401s on every subsequent
delivery. A present-but-partial cache was treated as authoritative, so a stale
"no" was indistinguishable from a real one.

Move get-or-create into SecretManager, the only component that can tell those
apart:

- GetOrCreateSystemKeyAsync returns a cached hit unchanged. On a miss it
  invalidates only the snapshot it missed on, via Interlocked.CompareExchange
  so a concurrent reload is not discarded, then performs one authoritative
  repository read before concluding the key does not exist.
- Creation is gated on Created/Updated. Any other result means nothing was
  persisted, and returning the value would mint a webhook URL carrying a key
  that authorizes nothing.
- DefaultScriptWebHookProvider now only derives the key name.

The interface documents that the create path remains a last-writer-wins upsert,
so a key persisted concurrently by another instance can still be replaced;
closing that needs an ETag/CAS write the repositories do not expose today.

Tests assert zero writes and exactly one read on the miss path, that the
invalidated cache heals, that a cached hit never touches the repository, that
the provider passes the exact derived key name, and that a created key is
persisted encrypted and decrypts to the returned value.
@zahalzel2
zahalzel2 requested a review from a team as a code owner July 31, 2026 16:00
Copilot AI review requested due to automatic review settings July 31, 2026 16:00
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The new public GetOrCreateSystemKeyAsync API should validate keyName upfront (and a new test helper should use ordinal string comparison) to avoid unclear exceptions and culture-sensitive behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR prevents extension webhook system keys from being re-minted and overwritten when the in-memory host-secrets cache is present but incomplete (notably during Flex Consumption specialization), which could otherwise break external webhook callers (e.g., Event Grid) after host restarts.

Changes:

  • Moved “get-or-create system key” logic into SecretManager, including cache miss invalidation and a single authoritative repository read before creating a new key.
  • Updated DefaultScriptWebHookProvider to only derive the system key name and delegate resolution/creation to SecretManager.
  • Added/updated unit tests and documented the behavior in release notes.
File summaries
File Description
test/WebJobs.Script.Tests/Security/SecretManagerTests.cs Adds coverage for cache-miss invalidation, repository-read behavior, and persistence gating for system key creation.
test/WebJobs.Script.Tests/DefaultScriptWebHookProviderTests.cs Updates tests to validate derived key name while delegating key resolution to ISecretManager.GetOrCreateSystemKeyAsync.
test/WebJobs.Script.Tests.Shared/TestSecretManager.cs Extends the test ISecretManager implementation with GetOrCreateSystemKeyAsync.
src/WebJobs.Script.WebHost/WebHooks/DefaultScriptWebHookProvider.cs Stops generating/upserting keys in the provider; delegates to SecretManager.
src/WebJobs.Script.WebHost/Security/KeyManagement/SecretManager.cs Introduces GetOrCreateSystemKeyAsync with targeted cache invalidation + authoritative read before create.
src/WebJobs.Script.WebHost/Security/KeyManagement/ISecretManager.cs Adds the new API contract and documents remaining last-writer-wins limitation.
release_notes.md Adds a release note describing the customer-visible fix for 401s after restart due to overwritten extension system keys.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +1945 to +1946
reader.Setup(r => r.ReadValue(It.IsAny<Key>()))
.Returns<Key>(k => new Key(k.Name, k.Value.StartsWith("enc:") ? k.Value.Substring(4) : k.Value) { IsStale = false });
Comment on lines +294 to +300
public async Task<string> GetOrCreateSystemKeyAsync(string keyName)
{
// A cached hit is as trustworthy as it is today. Only a cached MISS is challenged,
// because the startup context may contain a partial host secret snapshot.
var cached = _hostSecrets;
if (cached != null && cached.SystemKeys.TryGetValue(keyName, out string value))
{
@zahalzel2

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Microsoft"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

First EventGrid delivery fails due to cold-start race in Flex Consumption

3 participants