From 38ad2fe178454b23bbe53688f9c12b7adbd830c2 Mon Sep 17 00:00:00 2001 From: Wenqi He Date: Wed, 19 Aug 2026 05:25:20 +0000 Subject: [PATCH 1/4] feat(acrauth): add ACR login helper that writes container auth files The oc-mirror image authenticates to ACR by running 'az acr login' with DOCKER_COMMAND pointed at a shim script, so that az's internal 'docker login' call is intercepted and the credential can be scraped out of the argv. That coupling broke silently when azure-cli 2.88.0 switched to --password-stdin, and the shim's jq rewrite also clobbered the source-registry credentials that the init container had already written to the same auth file. Do the exchange directly instead: trade the managed identity's Entra token for an ACR refresh token and upsert a single registry key into the auth file, leaving other registries and unmodelled fields intact. --- go.work | 1 + go.work.sum | 11 +-- tools/acrauth/acr.go | 100 +++++++++++++++++++++++++++ tools/acrauth/authfile.go | 115 +++++++++++++++++++++++++++++++ tools/acrauth/authfile_test.go | 90 ++++++++++++++++++++++++ tools/acrauth/command.go | 53 ++++++++++++++ tools/acrauth/go.mod | 28 ++++++++ tools/acrauth/go.sum | 54 +++++++++++++++ tools/acrauth/options.go | 122 +++++++++++++++++++++++++++++++++ 9 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 tools/acrauth/acr.go create mode 100644 tools/acrauth/authfile.go create mode 100644 tools/acrauth/authfile_test.go create mode 100644 tools/acrauth/command.go create mode 100644 tools/acrauth/go.mod create mode 100644 tools/acrauth/go.sum create mode 100644 tools/acrauth/options.go diff --git a/go.work b/go.work index 15b2c114..ea6e966f 100644 --- a/go.work +++ b/go.work @@ -4,6 +4,7 @@ use ( ./config ./pipelines ./testutil + ./tools/acrauth ./tools/cmdutils ./tools/grafanactl ./tools/helm diff --git a/go.work.sum b/go.work.sum index 8dad877d..c8df0681 100644 --- a/go.work.sum +++ b/go.work.sum @@ -337,6 +337,7 @@ github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84J github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= @@ -993,6 +994,7 @@ github.com/spyzhov/ajson v0.9.6/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHd github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -1114,7 +1116,6 @@ go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJ go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= @@ -1130,7 +1131,6 @@ go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6Yv go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= @@ -1171,6 +1171,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= @@ -1185,7 +1186,6 @@ golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -1208,6 +1208,7 @@ golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= @@ -1270,6 +1271,7 @@ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= @@ -1282,7 +1284,6 @@ golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caV golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= @@ -1296,6 +1297,7 @@ golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= @@ -1316,7 +1318,6 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= diff --git a/tools/acrauth/acr.go b/tools/acrauth/acr.go new file mode 100644 index 00000000..7f275718 --- /dev/null +++ b/tools/acrauth/acr.go @@ -0,0 +1,100 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package acrauth + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry" +) + +// NullGUIDUsername is the username ACR expects when the password is a refresh token. +const NullGUIDUsername = "00000000-0000-0000-0000-000000000000" + +const armScope = "https://management.azure.com/.default" + +// ExchangeForRefreshToken trades an Entra token for an ACR refresh token, which is what +// container tooling stores as the registry password. +func ExchangeForRefreshToken(ctx context.Context, cred azcore.TokenCredential, acrFQDN string) (string, error) { + endpoint, err := url.Parse(fmt.Sprintf("https://%s", acrFQDN)) + if err != nil { + return "", fmt.Errorf("failed to parse ACR endpoint: %w", err) + } + + client, err := azcontainerregistry.NewAuthenticationClient(endpoint.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create ACR authentication client: %w", err) + } + + armToken, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{armScope}}) + if err != nil { + return "", fmt.Errorf("failed to get ARM token: %w", err) + } + + response, err := client.ExchangeAADAccessTokenForACRRefreshToken( + ctx, + azcontainerregistry.PostContentSchemaGrantTypeAccessToken, + endpoint.Hostname(), + &azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions{ + AccessToken: &armToken.Token, + }, + ) + if err != nil { + return "", fmt.Errorf("failed to exchange AAD access token for ACR refresh token: %w", err) + } + if response.RefreshToken == nil { + return "", errors.New("got an empty response when exchanging AAD access token for ACR refresh token") + } + + return *response.RefreshToken, nil +} + +const ( + retryAttempts = 5 + retryInitial = 2 * time.Second + retryFactor = 2 +) + +// ExchangeForRefreshTokenWithRetry retries the exchange, which fails transiently while a +// freshly-granted role assignment propagates. +func ExchangeForRefreshTokenWithRetry(ctx context.Context, cred azcore.TokenCredential, acrFQDN string) (string, error) { + var lastErr error + delay := retryInitial + + for attempt := range retryAttempts { + if attempt > 0 { + select { + case <-ctx.Done(): + return "", fmt.Errorf("failed to exchange ACR refresh token: %w: last exchange error: %w", ctx.Err(), lastErr) + case <-time.After(delay): + } + delay *= retryFactor + } + + token, err := ExchangeForRefreshToken(ctx, cred, acrFQDN) + if err == nil { + return token, nil + } + lastErr = err + } + + return "", fmt.Errorf("failed to exchange ACR refresh token after %d attempts: %w", retryAttempts, lastErr) +} diff --git a/tools/acrauth/authfile.go b/tools/acrauth/authfile.go new file mode 100644 index 00000000..8a258513 --- /dev/null +++ b/tools/acrauth/authfile.go @@ -0,0 +1,115 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package acrauth + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// authFileMode is the mode os.CreateTemp gives the temporary file, which survives the rename. +const authFileMode fs.FileMode = 0o600 + +// UpsertCredential adds or replaces the entry for one registry in a container auth file, +// leaving every other registry and any fields we don't model untouched. +func UpsertCredential(path, registry, username, password string) error { + if registry == "" { + return errors.New("registry must not be empty") + } + + document, err := readAuthFile(path) + if err != nil { + return err + } + + auths, ok := document["auths"].(map[string]any) + if !ok { + auths = map[string]any{} + } + + entry, ok := auths[registry].(map[string]any) + if !ok { + entry = map[string]any{} + } + entry["auth"] = base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + + auths[registry] = entry + document["auths"] = auths + + return writeAuthFile(path, document) +} + +func readAuthFile(path string) (map[string]any, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return map[string]any{}, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read auth file %q: %w", path, err) + } + if len(raw) == 0 { + return map[string]any{}, nil + } + + var document map[string]any + if err := json.Unmarshal(raw, &document); err != nil { + return nil, fmt.Errorf("failed to parse auth file %q: %w", path, err) + } + if document == nil { + document = map[string]any{} + } + return document, nil +} + +// writeAuthFile writes through a temporary file in the same directory so a failure part-way +// through cannot leave the tool holding a truncated credential file. +func writeAuthFile(path string, document map[string]any) error { + raw, err := json.MarshalIndent(document, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize auth file: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %q: %w", dir, err) + } + + // CreateTemp opens with mode 0600, so the credential is never briefly world-readable. + tmp, err := os.CreateTemp(dir, ".auth-*.json") + if err != nil { + return fmt.Errorf("failed to create temporary auth file: %w", err) + } + defer func() { + _ = os.Remove(tmp.Name()) + }() + + if _, err := tmp.Write(raw); err != nil { + _ = tmp.Close() + return fmt.Errorf("failed to write temporary auth file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temporary auth file: %w", err) + } + + if err := os.Rename(tmp.Name(), path); err != nil { + return fmt.Errorf("failed to replace auth file %q: %w", path, err) + } + return nil +} diff --git a/tools/acrauth/authfile_test.go b/tools/acrauth/authfile_test.go new file mode 100644 index 00000000..dfcb6565 --- /dev/null +++ b/tools/acrauth/authfile_test.go @@ -0,0 +1,90 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package acrauth + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func expectedAuth(t *testing.T, username, password string) string { + t.Helper() + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +func readBack(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + require.NoError(t, err) + var document map[string]any + require.NoError(t, json.Unmarshal(raw, &document)) + return document +} + +func TestUpsertCredentialPreservesOtherRegistries(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + existing := `{ + "auths": { + "registry.redhat.io": { "auth": "cmVkaGF0OnNlY3JldA==", "email": "noreply@redhat.com" } + } +}` + require.NoError(t, os.WriteFile(path, []byte(existing), 0o600)) + + require.NoError(t, UpsertCredential(path, "arohcpocpstg.azurecr.io", NullGUIDUsername, "token")) + + auths := readBack(t, path)["auths"].(map[string]any) + require.Len(t, auths, 2) + + upstream := auths["registry.redhat.io"].(map[string]any) + require.Equal(t, "cmVkaGF0OnNlY3JldA==", upstream["auth"]) + require.Equal(t, "noreply@redhat.com", upstream["email"], "fields we do not model must survive a rewrite") + + added := auths["arohcpocpstg.azurecr.io"].(map[string]any) + require.Equal(t, expectedAuth(t, NullGUIDUsername, "token"), added["auth"]) +} + +func TestUpsertCredentialReplacesSameRegistry(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + require.NoError(t, UpsertCredential(path, "acr.azurecr.io", NullGUIDUsername, "first")) + require.NoError(t, UpsertCredential(path, "acr.azurecr.io", NullGUIDUsername, "second")) + + auths := readBack(t, path)["auths"].(map[string]any) + require.Len(t, auths, 1) + entry := auths["acr.azurecr.io"].(map[string]any) + require.Equal(t, expectedAuth(t, NullGUIDUsername, "second"), entry["auth"]) +} + +func TestUpsertCredentialCreatesMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "containers", "auth.json") + + require.NoError(t, UpsertCredential(path, "acr.azurecr.io", NullGUIDUsername, "token")) + + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, authFileMode, info.Mode().Perm(), "credential file must not be world-readable") + + auths := readBack(t, path)["auths"].(map[string]any) + require.Contains(t, auths, "acr.azurecr.io") +} + +func TestUpsertCredentialRejectsEmptyRegistry(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + require.Error(t, UpsertCredential(path, "", NullGUIDUsername, "token")) +} diff --git a/tools/acrauth/command.go b/tools/acrauth/command.go new file mode 100644 index 00000000..4ad6d906 --- /dev/null +++ b/tools/acrauth/command.go @@ -0,0 +1,53 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package acrauth + +import ( + "fmt" + "os" + "os/signal" + + "github.com/spf13/cobra" +) + +func NewLoginCommand() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate to an Azure Container Registry and record the credential in a container auth file", + SilenceUsage: true, + SilenceErrors: true, + } + + opts := DefaultOptions() + if err := BindOptions(opts, cmd); err != nil { + return nil, fmt.Errorf("failed to bind options: %w", err) + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + + validated, err := opts.Validate() + if err != nil { + return err + } + completed, err := validated.Complete() + if err != nil { + return err + } + return completed.Login(ctx) + } + + return cmd, nil +} diff --git a/tools/acrauth/go.mod b/tools/acrauth/go.mod new file mode 100644 index 00000000..a6d4df5e --- /dev/null +++ b/tools/acrauth/go.mod @@ -0,0 +1,28 @@ +module github.com/Azure/ARO-Tools/tools/acrauth + +go 1.25.0 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 + github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.0 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/acrauth/go.sum b/tools/acrauth/go.sum new file mode 100644 index 00000000..ab8c84a1 --- /dev/null +++ b/tools/acrauth/go.sum @@ -0,0 +1,54 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 h1:4gRPBpN1f6xt88yi4WR26m7XaD9OlWtVT6bWPdGUIok= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0/go.mod h1:G7QVLxw1j1JVyrO1MA95S8m8HStaaleDZYTcfGgjB2o= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= +github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 h1:ldKsKtEIblsgsr6mPwrd9yRntoX6uLz/K89wsldwx/k= +github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3/go.mod h1:MAm7bk0oDLmD8yIkvfbxPW04fxzphPyL+7GzwHxOp6Y= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/acrauth/options.go b/tools/acrauth/options.go new file mode 100644 index 00000000..66381c41 --- /dev/null +++ b/tools/acrauth/options.go @@ -0,0 +1,122 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package acrauth + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +func DefaultOptions() *RawOptions { + return &RawOptions{} +} + +func BindOptions(opts *RawOptions, cmd *cobra.Command) error { + cmd.Flags().StringVar(&opts.Registry, "registry", opts.Registry, "Fully-qualified registry login server, e.g. myregistry.azurecr.io.") + cmd.Flags().StringVar(&opts.AuthFile, "auth-file", opts.AuthFile, "Path to the container auth file to update.") + cmd.Flags().StringVar(&opts.ClientID, "client-id", opts.ClientID, "Client ID of the user-assigned managed identity to authenticate with. Defaults to the ambient credential.") + + if err := cmd.MarkFlagFilename("auth-file"); err != nil { + return fmt.Errorf("failed to mark flag %q as a file: %w", "auth-file", err) + } + return nil +} + +// RawOptions holds input values. +type RawOptions struct { + Registry string + AuthFile string + ClientID string +} + +// validatedOptions is a private wrapper that enforces a call of Validate() before Complete() can be invoked. +type validatedOptions struct { + *RawOptions +} + +type ValidatedOptions struct { + // Embed a private pointer that cannot be instantiated outside of this package. + *validatedOptions +} + +// completedOptions is a private wrapper that enforces a call of Complete() before Login can be invoked. +type completedOptions struct { + Registry string + AuthFile string + Credential azcore.TokenCredential +} + +type Options struct { + // Embed a private pointer that cannot be instantiated outside of this package. + *completedOptions +} + +func (o *RawOptions) Validate() (*ValidatedOptions, error) { + if o.Registry == "" { + return nil, errors.New("--registry is required") + } + if o.AuthFile == "" { + return nil, errors.New("--auth-file is required") + } + return &ValidatedOptions{validatedOptions: &validatedOptions{RawOptions: o}}, nil +} + +func (o *ValidatedOptions) Complete() (*Options, error) { + credential, err := newCredential(o.ClientID) + if err != nil { + return nil, err + } + return &Options{completedOptions: &completedOptions{ + Registry: o.Registry, + AuthFile: o.AuthFile, + Credential: credential, + }}, nil +} + +func newCredential(clientID string) (azcore.TokenCredential, error) { + if clientID == "" { + credential, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, fmt.Errorf("failed to create default Azure credential: %w", err) + } + return credential, nil + } + + credential, err := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ + ID: azidentity.ClientID(clientID), + }) + if err != nil { + return nil, fmt.Errorf("failed to create managed identity credential for client %q: %w", clientID, err) + } + return credential, nil +} + +// Login exchanges an Entra token for an ACR refresh token and records it in the auth file. +func (o *Options) Login(ctx context.Context) error { + token, err := ExchangeForRefreshTokenWithRetry(ctx, o.Credential, o.Registry) + if err != nil { + return err + } + if err := UpsertCredential(o.AuthFile, o.Registry, NullGUIDUsername, token); err != nil { + return err + } + return nil +} From 9dda67af5acd46800e4601ad444249e4aabdf1f9 Mon Sep 17 00:00:00 2001 From: Wenqi He Date: Fri, 21 Aug 2026 07:10:36 +0000 Subject: [PATCH 2/4] fix(acrauth): fall back to an in-place write when the auth file cannot be renamed over A bind-mounted auth file cannot be replaced with rename(2) -- the kernel returns EBUSY. The oc-mirror container mounts auth.json directly, so the atomic write path fails there and the login aborts. Fall back to writing through the existing file in that case. --- tools/acrauth/authfile.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/acrauth/authfile.go b/tools/acrauth/authfile.go index 8a258513..0f7518ec 100644 --- a/tools/acrauth/authfile.go +++ b/tools/acrauth/authfile.go @@ -24,7 +24,7 @@ import ( "path/filepath" ) -// authFileMode is the mode os.CreateTemp gives the temporary file, which survives the rename. +// authFileMode keeps the credential readable only by its owner. const authFileMode fs.FileMode = 0o600 // UpsertCredential adds or replaces the entry for one registry in a container auth file, @@ -109,7 +109,10 @@ func writeAuthFile(path string, document map[string]any) error { } if err := os.Rename(tmp.Name(), path); err != nil { - return fmt.Errorf("failed to replace auth file %q: %w", path, err) + // A bind-mounted auth file cannot be renamed over (EBUSY), so write through it instead. + if fallbackErr := os.WriteFile(path, raw, authFileMode); fallbackErr != nil { + return fmt.Errorf("failed to replace auth file %q: %w: in-place write also failed: %w", path, err, fallbackErr) + } } return nil } From 61471d27f6de0f51c7c03a3b8005d601f8556ffe Mon Sep 17 00:00:00 2001 From: Wenqi He Date: Fri, 21 Aug 2026 07:36:11 +0000 Subject: [PATCH 3/4] fix(acrauth): reject an empty ACR refresh token A non-nil pointer to an empty string would have been written out as a credential and reported as success -- the same silent-success failure this tool exists to remove. --- tools/acrauth/acr.go | 2 +- tools/acrauth/options.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/acrauth/acr.go b/tools/acrauth/acr.go index 7f275718..2d086cc0 100644 --- a/tools/acrauth/acr.go +++ b/tools/acrauth/acr.go @@ -60,7 +60,7 @@ func ExchangeForRefreshToken(ctx context.Context, cred azcore.TokenCredential, a if err != nil { return "", fmt.Errorf("failed to exchange AAD access token for ACR refresh token: %w", err) } - if response.RefreshToken == nil { + if response.RefreshToken == nil || *response.RefreshToken == "" { return "", errors.New("got an empty response when exchanging AAD access token for ACR refresh token") } diff --git a/tools/acrauth/options.go b/tools/acrauth/options.go index 66381c41..774aeb28 100644 --- a/tools/acrauth/options.go +++ b/tools/acrauth/options.go @@ -30,7 +30,7 @@ func DefaultOptions() *RawOptions { } func BindOptions(opts *RawOptions, cmd *cobra.Command) error { - cmd.Flags().StringVar(&opts.Registry, "registry", opts.Registry, "Fully-qualified registry login server, e.g. myregistry.azurecr.io.") + cmd.Flags().StringVar(&opts.Registry, "registry", opts.Registry, "Fully-qualified registry login server (e.g. myregistry.azurecr.io).") cmd.Flags().StringVar(&opts.AuthFile, "auth-file", opts.AuthFile, "Path to the container auth file to update.") cmd.Flags().StringVar(&opts.ClientID, "client-id", opts.ClientID, "Client ID of the user-assigned managed identity to authenticate with. Defaults to the ambient credential.") From e6d42e125d4686a454cea1acb3a8c08cfe435ce3 Mon Sep 17 00:00:00 2001 From: Wenqi He Date: Tue, 25 Aug 2026 04:04:12 +0000 Subject: [PATCH 4/4] fix(acrauth): restrict permissions after an in-place auth file write os.WriteFile only applies its mode argument when it creates the file, so the fallback path left a pre-existing credential file at whatever mode it already had -- observed as 0644 in a container run. Chmod it explicitly, and stop claiming atomicity for a path that cannot have it. --- tools/acrauth/authfile.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/acrauth/authfile.go b/tools/acrauth/authfile.go index 0f7518ec..7dc09c4a 100644 --- a/tools/acrauth/authfile.go +++ b/tools/acrauth/authfile.go @@ -79,7 +79,8 @@ func readAuthFile(path string) (map[string]any, error) { } // writeAuthFile writes through a temporary file in the same directory so a failure part-way -// through cannot leave the tool holding a truncated credential file. +// through cannot leave the tool holding a truncated credential file. A bind-mounted auth file +// cannot be renamed over, so that path falls back to a non-atomic in-place write. func writeAuthFile(path string, document map[string]any) error { raw, err := json.MarshalIndent(document, "", " ") if err != nil { @@ -113,6 +114,10 @@ func writeAuthFile(path string, document map[string]any) error { if fallbackErr := os.WriteFile(path, raw, authFileMode); fallbackErr != nil { return fmt.Errorf("failed to replace auth file %q: %w: in-place write also failed: %w", path, err, fallbackErr) } + // WriteFile only applies the mode when it creates the file, so an existing one keeps its own. + if chmodErr := os.Chmod(path, authFileMode); chmodErr != nil { + return fmt.Errorf("failed to restrict permissions on auth file %q: %w", path, chmodErr) + } } return nil }