Skip to content

feat: log Azure credential and ACR token acquisition - #2876

Merged
fseldow merged 6 commits into
notaryproject:mainfrom
charleswool:feat/azure-credential-logging
Aug 7, 2026
Merged

feat: log Azure credential and ACR token acquisition#2876
fseldow merged 6 commits into
notaryproject:mainfrom
charleswool:feat/azure-credential-logging

Conversation

@charleswool

@charleswool charleswool commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Description

Follow-up to #2873 (request-path logging), covering the other half of the v2 logging gap: the Azure credential path is completely silent.

Compared to v1 there is no way to tell how ACR/AAD was accessed:

Package v1 v2 (today)
internal/cloudprovider/azure (token credential) n/a (v1: successfully refreshed AAD token, successfully refreshed azure managed identity token) 0 log calls
internal/store/credentialprovider/azure (ACR) v1 logged the exchange + Metrics Report: Duration=%dms, Host=%s 0 log calls

CreateCredentialChain also silently swallows both credential construction errors (if err == nil { append } with no else), so when workload identity isn't configured the chain just quietly has fewer sources and a later registry 401 has no explanation.

Change

internal/cloudprovider/azure/tokencredential.go

  • debug: whether the workload identity / managed identity credential is available, and the reason when it is not (previously discarded), plus the resulting source count.
  • The two duplicated availability blocks are folded into an appendCredential helper so both outcomes are unit-testable.
  • debug: whether the identity binding credential (added by feat: add Dapr distributed cache provider #2870) is being used as the sole credential source, and the reason when it cannot be constructed. That branch previously returned without logging anything.

internal/store/credentialprovider/azure/register.go

  • debug: resolving an ACR credential for a server (with clientID/tenantID and whether identity binding is active), AAD token acquisition + duration, the ACR refresh token exchange + duration, and the resolved credential TTL.
  • error: failure to build the credential chain, and failure to exchange the AAD token for an ACR refresh token.
  • warn: when the refresh token's TTL cannot be determined.

Logs use the request-scoped logger (logger.GetLogger(ctx, logOpt), component authProvider) where a context is available, so they carry the trace ID. The credential-chain constructors have no context parameter, so they use the standard logger.

No tokens or secrets are logged — only identity metadata (client/tenant ID, SNI name), durations, and TTL.

Bug fix: an expired ACR refresh token is no longer cached

Raised in review. Previously parseJWTTokenTTL returned a plain formatted error for both an unparseable exp and an already-expired token, and the caller applied DefaultACRTokenTTL (~3h) in either case. For an expired token that meant CachedProvider stored a dead refresh token and served it for hours, producing persistent registry 401s until eviction.

parseJWTTokenTTL now returns a sentinel errTokenExpired, and a new tokenCacheTTL helper separates the two cases:

Condition TTL Cached?
valid exp remaining lifetime minus 5m buffer yes
exp unparseable DefaultACRTokenTTL yes
token already expired 0 no (CachedProvider only caches when TTL > 0)

Testing

  • go build ./..., go vet, package tests and golangci-lint pass.
  • New unit tests: TestAppendCredential, TestTokenCacheTTL, TestTokenCacheTTL_ExpiredTokenIsNotCacheable.
  • Rebased/merged onto the identity binding work now on main; CreateCredentialChain is preserved as a thin wrapper over CreateCredentialChainWithIdentityBinding.

Enable the new logs with --set logger.level=debug (#2846).

The Azure credential path was completely silent: internal/cloudprovider/azure and internal/store/credentialprovider/azure had no logging at all, and CreateCredentialChain swallowed both credential construction errors, so there was no way to tell which identity was used or why registry auth failed.

Log which credential sources are available (workload identity, managed identity) and why one is skipped, the AAD token acquisition and the ACR refresh token exchange with their durations, and the resolved credential TTL. Failures to build the chain or exchange the token are now logged at error level, and a TTL parse fallback at warn level.

Signed-off-by: Charles Wu <yuewu2@microsoft.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 05:42
@github-actions github-actions Bot added the v2 label Aug 5, 2026
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.17647% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.03%. Comparing base (6ead8b6) to head (1f3925c).

Files with missing lines Patch % Lines
...nternal/store/credentialprovider/azure/register.go 86.95% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2876      +/-   ##
==========================================
+ Coverage   76.89%   77.03%   +0.13%     
==========================================
  Files          90       90              
  Lines        4276     4302      +26     
==========================================
+ Hits         3288     3314      +26     
  Misses        831      831              
  Partials      157      157              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

Pull request overview

This PR closes an observability gap in Ratify v2’s Azure authentication flow by adding debug/error/warn logging around Azure credential chain construction and ACR token acquisition, aligning v2 operator experience closer to v1 for diagnosing AAD/ACR access issues.

Changes:

  • Add request-scoped debug/error/warn logs in the ACR Azure credential provider for chain creation, token exchange durations, and resolved TTL.
  • Add debug logs in Azure credential chain construction indicating which credential sources are available/unavailable and the resulting chain size.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
internal/store/credentialprovider/azure/register.go Adds request-scoped authProvider logs for AAD token acquisition, ACR refresh token exchange, TTL parsing/fallback, and key error paths.
internal/cloudprovider/azure/tokencredential.go Adds debug logs for workload identity / managed identity credential availability and the final chained credential source count.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 116 to 120
if err != nil {
// If JWT parsing fails, fall back to the default TTL
log.Warnf("failed to parse ACR refresh token TTL for %s, falling back to the default: %v", serverAddress, err)
ttl = DefaultACRTokenTTL
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in 327e471.

parseJWTTokenTTL now returns a sentinel errTokenExpired instead of a formatted string, and a new tokenCacheTTL helper distinguishes the two failure modes:

  • already expired -> TTL 0. CachedProvider only writes to the cache when TTL > 0, so an expired refresh token is now never stored and every request re-exchanges. Logged at warn level.
  • unparseable expiry -> DefaultACRTokenTTL fallback, as before.

Unit tests cover both paths (TestTokenCacheTTL, TestTokenCacheTTL_ExpiredTokenIsNotCacheable).

charleswool and others added 3 commits August 6, 2026 11:58
parseJWTTokenTTL now returns a sentinel error for an expired token so the new tokenCacheTTL helper can tell it apart from an unparseable one. An expired token gets a zero TTL, which stops CachedProvider from storing it and serving 401s for hours; only a genuinely unparseable expiry falls back to the default TTL.

Credential-chain logging moves into an appendCredential helper so both the available and unavailable paths are covered by tests.

Signed-off-by: Charles Wu <yuewu2@microsoft.com>
…-logging

Signed-off-by: Charles Wu <yuewu2@microsoft.com>

# Conflicts:
#	internal/store/credentialprovider/azure/register.go
if err != nil {
// If JWT parsing fails, fall back to the default TTL
ttl = DefaultACRTokenTTL
log.Warnf("could not determine the ACR refresh token TTL for %s, caching it for %s instead: %v", serverAddress, ttl, err)

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.

ttl=DefaultACRTokenTTL logic is missing
BTW, it has no difference for expire error

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fair — fixed in 1f3925c.

You're right on the second point especially: the single warn message printed caching it for 0s instead for an expired token, which is simply wrong. It isn't cached for 0s, it isn't cached at all. And splitting ttl (helper) from the log (caller) is what hid DefaultACRTokenTTL from this hunk in the first place.

resolveTokenTTL now owns the TTL and the explanation, so all three outcomes are visible in one place:

func resolveTokenTTL(log dcontext.Logger, serverAddress, token string) time.Duration {
	ttl, err := parseJWTTokenTTL(token)
	switch {
	case errors.Is(err, errTokenExpired):
		log.Warnf("ACR returned an already-expired refresh token for %s; it will not be cached", serverAddress)
		return 0
	case err != nil:
		log.Warnf("failed to parse the ACR refresh token TTL for %s, falling back to %s: %v", serverAddress, DefaultACRTokenTTL, err)
		return DefaultACRTokenTTL
	default:
		log.Debugf("resolved ACR credential for %s, expires in %s", serverAddress, ttl)
		return ttl
	}
}

GetWithTTL step 3 is now one line. Actual output:

level=warning msg="failed to parse the ACR refresh token TTL for testregistry.azurecr.io, falling back to 2h55m0s: failed to parse JWT token: ..."
level=warning msg="ACR returned an already-expired refresh token for testregistry.azurecr.io; it will not be cached"

TestResolveTokenTTL_ExpiredAndUnparseableDiffer asserts the two messages are not identical so this can't regress back. resolveTokenTTL is at 100% coverage.

The default-TTL fallback had moved out of GetWithTTL into a helper that only returned (ttl, err), so the caller logged one message for both failure modes: an expired token reported "caching it for 0s", which is not what happens - it is not cached at all.

resolveTokenTTL now owns both the TTL and the explanation, naming DefaultACRTokenTTL where it is applied and logging the expired and unparseable cases distinctly. A test asserts the two messages differ.

Signed-off-by: Charles Wu <yuewu2@microsoft.com>
@fseldow
fseldow merged commit a79b4b4 into notaryproject:main Aug 7, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants