Skip to content

Commit 307b325

Browse files
committed
fix: Propagate original credential configs for requests to modules
1 parent a4f808a commit 307b325

7 files changed

Lines changed: 351 additions & 88 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package providercache_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"sync/atomic"
13+
"testing"
14+
15+
"github.com/google/uuid"
16+
"github.com/gruntwork-io/terragrunt/internal/providercache"
17+
"github.com/gruntwork-io/terragrunt/internal/tf/cache"
18+
"github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers"
19+
"github.com/gruntwork-io/terragrunt/internal/tf/cache/services"
20+
"github.com/gruntwork-io/terragrunt/internal/tf/cliconfig"
21+
"github.com/gruntwork-io/terragrunt/test/helpers"
22+
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
"golang.org/x/sync/errgroup"
26+
)
27+
28+
// fakeDiscoverer pretends the upstream registry advertised the given modules.v1
29+
// path during well-known discovery. Used to bypass real network discovery in tests.
30+
type fakeDiscoverer struct {
31+
modulesV1 string
32+
}
33+
34+
func (d *fakeDiscoverer) DiscoveryURL(_ context.Context, _ string) (*handlers.RegistryURLs, error) {
35+
return &handlers.RegistryURLs{
36+
ProvidersV1: "/v1/providers",
37+
ModulesV1: d.modulesV1,
38+
}, nil
39+
}
40+
41+
// TestNestedModuleCredentials reproduces issue #5970: when TG_PROVIDER_CACHE is on,
42+
// the cache server was forwarding nested module-registry requests with its own
43+
// x-api-key bearer token instead of the user's real upstream credentials, causing
44+
// 403s. The cache server must strip its own auth header and re-inject the user's
45+
// configured credentials when proxying modules.v1 requests upstream.
46+
func TestNestedModuleCredentials(t *testing.T) {
47+
t.Parallel()
48+
49+
const realUserToken = "real-user-token"
50+
51+
var (
52+
upstreamHits atomic.Int32
53+
upstreamAuth atomic.Value
54+
upstreamReject atomic.Int32
55+
)
56+
57+
const versionsBody = `{"modules":[{"versions":[{"version":"0.1.0"},{"version":"0.2.0"}]}]}`
58+
59+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
auth := r.Header.Get("Authorization")
61+
upstreamAuth.Store(auth)
62+
upstreamHits.Add(1)
63+
64+
if auth != "Bearer "+realUserToken {
65+
upstreamReject.Add(1)
66+
w.WriteHeader(http.StatusForbidden)
67+
68+
return
69+
}
70+
71+
switch r.URL.Path {
72+
case "/v1/modules/private/lambda/aws/versions":
73+
w.Header().Set("Content-Type", "application/json")
74+
75+
if _, err := io.WriteString(w, versionsBody); err != nil {
76+
t.Errorf("upstream write failed: %v", err)
77+
}
78+
default:
79+
w.WriteHeader(http.StatusNotFound)
80+
}
81+
}))
82+
t.Cleanup(upstream.Close)
83+
84+
registryName := strings.TrimPrefix(upstream.URL, "http://")
85+
86+
// Build a credentials source that has the user's real token for the upstream host.
87+
cliCfg := &cliconfig.Config{
88+
Credentials: []cliconfig.ConfigCredentials{
89+
{Name: "127.0.0.1", Token: realUserToken},
90+
},
91+
}
92+
credsSource := cliCfg.CredentialsSource()
93+
94+
// The fake discoverer returns the upstream's full URL as modules.v1, so the
95+
// proxy targets the httptest server (HTTP, not HTTPS) without DNS lookups.
96+
discoverer := &fakeDiscoverer{modulesV1: upstream.URL + "/v1/modules/"}
97+
98+
cacheToken := fmt.Sprintf("%s:%s", providercache.APIKeyAuth, uuid.New().String())
99+
100+
providerCacheDir := helpers.TmpDirWOSymlinks(t)
101+
pluginCacheDir := helpers.TmpDirWOSymlinks(t)
102+
103+
l := logger.CreateLogger()
104+
providerService := services.NewProviderService(providerCacheDir, pluginCacheDir, nil, l)
105+
proxyProviderHandler := handlers.NewProxyProviderHandler(l, credsSource)
106+
proxyModuleHandler := handlers.NewProxyModuleHandler(l, credsSource, discoverer)
107+
108+
server := cache.NewServer(
109+
cache.WithToken(cacheToken),
110+
cache.WithProviderService(providerService),
111+
cache.WithProxyProviderHandler(proxyProviderHandler),
112+
cache.WithProxyModuleHandler(proxyModuleHandler),
113+
cache.WithCacheProviderHTTPStatusCode(providercache.CacheProviderHTTPStatusCode),
114+
cache.WithLogger(l),
115+
)
116+
117+
ctx, cancel := context.WithCancel(t.Context())
118+
defer cancel()
119+
120+
ln, err := server.Listen(ctx)
121+
require.NoError(t, err)
122+
123+
t.Cleanup(func() {
124+
if err := ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
125+
t.Errorf("listener close failed: %v", err)
126+
}
127+
})
128+
129+
g, gctx := errgroup.WithContext(ctx)
130+
g.Go(func() error { return server.Run(gctx, ln) })
131+
132+
// Build the same URL OpenTofu/Terraform would hit via the host block:
133+
// <cache server>/v1/modules/<cache_request_id>/<registry>/<module path>
134+
moduleURL := server.ModuleController.URL()
135+
moduleURL.Path += "/" + uuid.New().String() + "/" + registryName + "/private/lambda/aws/versions"
136+
137+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, moduleURL.String(), nil)
138+
require.NoError(t, err)
139+
// OpenTofu sends the host block's TF_TOKEN_<host> value, which Terragrunt has
140+
// rewritten to the cache server's API key. The cache server must NOT forward
141+
// this token upstream; it must look up the user's real token instead.
142+
req.Header.Set("Authorization", "Bearer "+cacheToken)
143+
144+
resp, err := http.DefaultClient.Do(req)
145+
require.NoError(t, err)
146+
147+
body, err := io.ReadAll(resp.Body)
148+
require.NoError(t, resp.Body.Close())
149+
require.NoError(t, err)
150+
151+
assert.Equal(t, http.StatusOK, resp.StatusCode, "expected upstream success; body=%s", string(body))
152+
assert.JSONEq(t, versionsBody, string(body))
153+
154+
assert.Equal(t, int32(1), upstreamHits.Load(), "upstream registry should have been hit exactly once")
155+
assert.Equal(t, int32(0), upstreamReject.Load(), "upstream registry should not have rejected the request")
156+
assert.Equal(t, "Bearer "+realUserToken, upstreamAuth.Load(),
157+
"cache server must forward the user's real upstream credentials, not its own API key")
158+
159+
cancel()
160+
require.NoError(t, g.Wait())
161+
}

internal/providercache/providercache.go

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,16 @@ func (pc *ProviderCache) Init(l log.Logger, pcOpts *pcoptions.ProviderCacheOptio
167167
// This avoids .well-known/terraform.json lookups for registries that don't support it.
168168
populateCustomHostDiscoveryCache(cliCfg.Hosts, providerHandlers)
169169

170+
proxyModuleHandler := handlers.NewProxyModuleHandler(l, cliCfg.CredentialsSource(), providerHandlers)
171+
170172
cacheServer := cache.NewServer(
171173
cache.WithHostname(pcOpts.Hostname),
172174
cache.WithPort(pcOpts.Port),
173175
cache.WithToken(pcOpts.Token),
174176
cache.WithProviderService(providerService),
175177
cache.WithProviderHandlers(providerHandlers...),
176178
cache.WithProxyProviderHandler(proxyProviderHandler),
179+
cache.WithProxyModuleHandler(proxyModuleHandler),
177180
cache.WithCacheProviderHTTPStatusCode(CacheProviderHTTPStatusCode),
178181
cache.WithLogger(l),
179182
)
@@ -413,8 +416,9 @@ func (pc *ProviderCache) createLocalCLIConfig(ctx context.Context, implementatio
413416
return pc.saveCLIConfig(cfg, filename)
414417
}
415418

416-
// configureRegistryHosts sets up host redirects for each registry, routing provider
417-
// requests through the cache server. Returns the list of provider installation includes.
419+
// configureRegistryHosts sets up host redirects for each registry, routing both
420+
// provider and module requests through the cache server. Returns the list of
421+
// provider installation includes.
418422
func (pc *ProviderCache) configureRegistryHosts(
419423
ctx context.Context,
420424
cfg *cliconfig.Config,
@@ -426,7 +430,7 @@ func (pc *ProviderCache) configureRegistryHosts(
426430
for _, registryName := range registryNames {
427431
includes = append(includes, registryName+"/*/*")
428432

429-
modulesURL, err := pc.resolveModulesURL(ctx, registryName)
433+
hasModules, err := pc.registrySupportsModules(ctx, registryName)
430434
if err != nil {
431435
return nil, err
432436
}
@@ -435,8 +439,11 @@ func (pc *ProviderCache) configureRegistryHosts(
435439
serviceProvidersV1: fmt.Sprintf("%s/%s/%s/", pc.ProviderController.URL(), cacheRequestID, registryName),
436440
}
437441

438-
if modulesURL != "" {
439-
hostServices[serviceModulesV1] = modulesURL
442+
if hasModules {
443+
// Route module requests through the cache server so it can swap the
444+
// cache server's API key (which TF_TOKEN_<host> is forced to) back out
445+
// for the user's real upstream credentials before forwarding upstream.
446+
hostServices[serviceModulesV1] = fmt.Sprintf("%s/%s/%s/", pc.ModuleController.URL(), cacheRequestID, registryName)
440447
}
441448

442449
cfg.AddHost(registryName, hostServices)
@@ -445,21 +452,22 @@ func (pc *ProviderCache) configureRegistryHosts(
445452
return includes, nil
446453
}
447454

448-
// resolveModulesURL returns the modules URL for a registry. For custom hosts, it uses
449-
// the service URL from the host block directly. For standard registries, it performs discovery.
450-
func (pc *ProviderCache) resolveModulesURL(ctx context.Context, registryName string) (string, error) {
455+
// registrySupportsModules reports whether the registry advertises a modules.v1
456+
// endpoint. For custom hosts it consults the host block; for standard registries
457+
// it performs discovery (using the populated discovery cache where available).
458+
func (pc *ProviderCache) registrySupportsModules(ctx context.Context, registryName string) (bool, error) {
451459
for _, host := range pc.cliCfg.Hosts {
452460
if host.Name == registryName {
453-
return host.Services[serviceModulesV1], nil
461+
return host.Services[serviceModulesV1] != "", nil
454462
}
455463
}
456464

457465
apiURLs, err := pc.DiscoveryURL(ctx, registryName)
458466
if err != nil {
459-
return "", err
467+
return false, err
460468
}
461469

462-
return ResolveModulesURL(registryName, apiURLs.ModulesV1), nil
470+
return apiURLs.ModulesV1 != "", nil
463471
}
464472

465473
// saveCLIConfig writes the CLI config to disk, creating the directory if needed.
@@ -722,14 +730,3 @@ func FilterRegistriesByImplementation(registryNames []string, implementation tfi
722730
// User explicitly set registry names, return as-is
723731
return registryNames
724732
}
725-
726-
// ResolveModulesURL resolves the modules.v1 URL from registry discovery.
727-
// If the URL is already absolute (contains "://"), it is returned as-is.
728-
// Otherwise, it is treated as a relative path and combined with the registry name.
729-
func ResolveModulesURL(registryName, modulesV1 string) string {
730-
if strings.Contains(modulesV1, "://") {
731-
return modulesV1
732-
}
733-
734-
return fmt.Sprintf("https://%s%s", registryName, modulesV1)
735-
}

internal/providercache/resolve_modules_url_test.go

Lines changed: 0 additions & 65 deletions
This file was deleted.

internal/tf/cache/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ func WithProxyProviderHandler(handler *handlers.ProxyProviderHandler) Option {
6565
}
6666
}
6767

68+
func WithProxyModuleHandler(handler *handlers.ProxyModuleHandler) Option {
69+
return func(cfg Config) Config {
70+
cfg.proxyModuleHandler = handler
71+
return cfg
72+
}
73+
}
74+
6875
func WithCacheProviderHTTPStatusCode(statusCode int) Option {
6976
return func(cfg Config) Config {
7077
cfg.cacheProviderHTTPStatusCode = statusCode
@@ -83,6 +90,7 @@ type Config struct {
8390
logger log.Logger
8491
providerService *services.ProviderService
8592
proxyProviderHandler *handlers.ProxyProviderHandler
93+
proxyModuleHandler *handlers.ProxyModuleHandler
8694
hostname string
8795
token string
8896
providerHandlers handlers.ProviderHandlers
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package controllers
2+
3+
import (
4+
"github.com/gruntwork-io/terragrunt/internal/tf/cache/handlers"
5+
"github.com/gruntwork-io/terragrunt/internal/tf/cache/router"
6+
"github.com/gruntwork-io/terragrunt/pkg/log"
7+
"github.com/labstack/echo/v4"
8+
)
9+
10+
const (
11+
moduleName = "modules.v1"
12+
modulePath = "/modules"
13+
)
14+
15+
// ModuleController exposes the modules.v1 registry protocol on the Terragrunt
16+
// cache server. It accepts requests authenticated with the cache server's API key
17+
// and forwards them to the upstream registry with the user's real credentials,
18+
// fixing 403s on nested module lookups when the user-set TF_TOKEN_<host> is
19+
// overridden by the cache server's token.
20+
type ModuleController struct {
21+
*router.Router
22+
23+
AuthMiddleware echo.MiddlewareFunc
24+
ProxyModuleHandler *handlers.ProxyModuleHandler
25+
Logger log.Logger
26+
}
27+
28+
// Endpoints implements controllers.Endpointer.
29+
func (c *ModuleController) Endpoints() map[string]any {
30+
return map[string]any{moduleName: c.URL().Path}
31+
}
32+
33+
// Register implements router.Controller.
34+
func (c *ModuleController) Register(r *router.Router) {
35+
c.Router = r.Group(modulePath)
36+
37+
if c.AuthMiddleware != nil {
38+
c.Use(c.AuthMiddleware)
39+
}
40+
41+
c.GET("/:cache_request_id/:registry_name/*", c.proxyAction)
42+
}
43+
44+
func (c *ModuleController) proxyAction(ctx echo.Context) error {
45+
registryName := ctx.Param("registry_name")
46+
rest := ctx.Param("*")
47+
48+
return c.ProxyModuleHandler.Proxy(ctx, registryName, rest)
49+
}

0 commit comments

Comments
 (0)