Add GCS IAM authentication for S3-compatible storage (#40303) (#40374)

Closes #40303


### Summary
Adds support for Google Application Default Credentials (ADC) bearer
token authentication when using GCS's S3-compatible endpoint. This
allows Fleet deployments on GCP to use workload identity instead of
static HMAC keys.
 Changes
- Add `s3_software_installers_gcs_iam_auth` config option for software
installer storage
- Add `s3_carves_gcs_iam_auth` config option for file carving storage  
- Implement OAuth2 bearer token auth in S3 client via middleware
(removes AWS SigV4 signing)
- Add validation to ensure GCS IAM auth requires endpoint URL containing
`storage.googleapis.com`
- Add Helm chart values and deployment env vars for both options
- Add documentation for new configuration options
- Add tests for GCS IAM auth validation and integration

### Usage
Enable GCS IAM auth by setting the endpoint URL to Google's
S3-compatible endpoint and enabling the IAM auth flag:

```yaml
s3:
  software_installers_endpoint_url: https://storage.googleapis.com
  software_installers_gcs_iam_auth: true
  software_installers_bucket: my-bucket
  software_installers_force_s3_path_style: true
```
Or via environment variables:
```
FLEET_S3_SOFTWARE_INSTALLERS_ENDPOINT_URL=https://storage.googleapis.com
FLEET_S3_SOFTWARE_INSTALLERS_GCS_IAM_AUTH=true
FLEET_S3_SOFTWARE_INSTALLERS_BUCKET=my-bucket
FLEET_S3_SOFTWARE_INSTALLERS_FORCE_S3_PATH_STYLE=true
```

### Testing
- Unit tests validate configuration requirements (GCS endpoint, no HMAC
keys, no STS role)
- Integration test verifies bearer token is correctly injected into
requests
**Related issue:** Resolves #

## Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests

- [TODO] QA'd all new/changed functionality manually

## New Fleet configuration settings

- [X] Setting(s) is/are explicitly excluded from GitOps

> [!NOTE] 
These are infrastructure-level server settings (env vars/config file),
not app-level settings managed via GitOps YAML.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added Google Cloud Storage (GCS) IAM authentication support for file
carving and software installer storage using Google Application Default
Credentials

* **Configuration**
* New authentication configuration option available for both carving and
software installer S3 storage in Helm deployments and configuration
files

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/40374)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Carlo <1778532+cdcme@users.noreply.github.com>
This commit is contained in:
edwardsb
2026-06-05 09:04:35 -05:00
committed by GitHub
co-authored by Carlo
parent 78e4647d54
commit bb1d09fc90
6 changed files with 311 additions and 15 deletions
+2
View File
@@ -0,0 +1,2 @@
- Added GCS IAM authentication support for software installers S3 storage using Google Application Default Credentials (ADC) bearer tokens instead of S3 HMAC keys. Configurable via `s3_software_installers_gcs_iam_auth`.
- Added GCS IAM authentication support for file carving S3 storage. Configurable via `s3_carves_gcs_iam_auth`.
+5 -1
View File
@@ -146,6 +146,8 @@ spec:
value: "{{ .Values.fleet.carving.s3.endpointURL }}"
- name: FLEET_S3_FORCE_S3_PATH_STYLE
value: "{{ .Values.fleet.carving.s3.forceS3PathStyle }}"
- name: FLEET_S3_CARVES_GCS_IAM_AUTH
value: "{{ .Values.fleet.carving.s3.gcsIAMAuth }}"
- name: FLEET_S3_CARVES_REGION
value: "{{ .Values.fleet.carving.s3.region }}"
{{- if ne .Values.fleet.carving.s3.accessKeyID "" }}
@@ -171,6 +173,8 @@ spec:
value: "{{ .Values.fleet.softwareInstallers.s3.endpointURL }}"
- name: FLEET_S3_SOFTWARE_INSTALLERS_FORCE_S3_PATH_STYLE
value: "{{ .Values.fleet.softwareInstallers.s3.forceS3PathStyle }}"
- name: FLEET_S3_SOFTWARE_INSTALLERS_GCS_IAM_AUTH
value: "{{ .Values.fleet.softwareInstallers.s3.gcsIAMAuth }}"
- name: FLEET_S3_SOFTWARE_INSTALLERS_REGION
value: "{{ .Values.fleet.softwareInstallers.s3.region }}"
{{- if ne .Values.fleet.softwareInstallers.s3.accessKeyID "" }}
@@ -598,4 +602,4 @@ spec:
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
+3 -1
View File
@@ -113,6 +113,7 @@ fleet:
region: ""
endpointURL: ""
forceS3PathStyle: false
gcsIAMAuth: false
stsAssumeRoleARN: ""
softwareInstallers:
s3:
@@ -123,6 +124,7 @@ fleet:
region: ""
endpointURL: ""
forceS3PathStyle: false
gcsIAMAuth: false
stsAssumeRoleARN: ""
license:
secretName: ""
@@ -136,7 +138,7 @@ fleet:
runAsUser: 3333
runAsGroup: 3333
# Add additional CA's to the Fleet container truststore
# To add CA's, set enabled: true
# To add CA's, set enabled: true
# Supports adding CA's from Config maps and Secrets
# configMaps:
# - name: fleet-ca-config
+9
View File
@@ -481,6 +481,7 @@ type S3Config struct {
CarvesStsExternalID string `yaml:"carves_sts_external_id"`
CarvesDisableSSL bool `yaml:"carves_disable_ssl"`
CarvesForceS3PathStyle bool `yaml:"carves_force_s3_path_style"`
CarvesGCSIAMAuth bool `yaml:"carves_gcs_iam_auth"`
SoftwareInstallersBucket string `yaml:"software_installers_bucket"`
SoftwareInstallersPrefix string `yaml:"software_installers_prefix"`
@@ -492,6 +493,7 @@ type S3Config struct {
SoftwareInstallersStsExternalID string `yaml:"software_installers_sts_external_id"`
SoftwareInstallersDisableSSL bool `yaml:"software_installers_disable_ssl"`
SoftwareInstallersForceS3PathStyle bool `yaml:"software_installers_force_s3_path_style"`
SoftwareInstallersGCSIAMAuth bool `yaml:"software_installers_gcs_iam_auth"`
SoftwareInstallersCloudFrontURL string `yaml:"software_installers_cloudfront_url"`
SoftwareInstallersCloudFrontURLSigningPublicKeyID string `yaml:"software_installers_cloudfront_url_signing_public_key_id"`
SoftwareInstallersCloudFrontURLSigningPrivateKey string `yaml:"software_installers_cloudfront_url_signing_private_key"`
@@ -553,6 +555,7 @@ func (s S3Config) SoftwareInstallersToInternalCfg() S3ConfigInternal {
StsExternalID: s.SoftwareInstallersStsExternalID,
DisableSSL: s.SoftwareInstallersDisableSSL,
ForceS3PathStyle: s.SoftwareInstallersForceS3PathStyle,
GCSIAMAuth: s.SoftwareInstallersGCSIAMAuth,
}
if s.SoftwareInstallersCloudFrontSigner != nil {
configInternal.CloudFrontConfig = &S3CloudFrontConfig{
@@ -609,6 +612,7 @@ func (s S3Config) CarvesToInternalCfg() S3ConfigInternal {
if !s.CarvesForceS3PathStyle {
internal.ForceS3PathStyle = s.ForceS3PathStyle
}
internal.GCSIAMAuth = s.CarvesGCSIAMAuth
return internal
}
@@ -625,6 +629,7 @@ type S3ConfigInternal struct {
StsExternalID string
DisableSSL bool
ForceS3PathStyle bool
GCSIAMAuth bool
CloudFrontConfig *S3CloudFrontConfig
}
@@ -1579,6 +1584,7 @@ func (man Manager) addConfigs() {
man.addConfigString("s3.carves_sts_external_id", "", "Optional unique identifier that can be used by the principal assuming the role to assert its identity.")
man.addConfigBool("s3.carves_disable_ssl", false, "Disable SSL (typically for local testing)")
man.addConfigBool("s3.carves_force_s3_path_style", false, "Set this to true to force path-style addressing, i.e., `http://s3.amazonaws.com/BUCKET/KEY`")
man.addConfigBool("s3.carves_gcs_iam_auth", false, "Use Google ADC bearer tokens for GCS endpoint authentication instead of S3 HMAC keys")
// S3 for software installers
man.addConfigString("s3.software_installers_bucket", "", "Bucket where to store uploaded software installers")
@@ -1591,6 +1597,7 @@ func (man Manager) addConfigs() {
man.addConfigString("s3.software_installers_sts_external_id", "", "Optional unique identifier that can be used by the principal assuming the role to assert its identity.")
man.addConfigBool("s3.software_installers_disable_ssl", false, "Disable SSL (typically for local testing)")
man.addConfigBool("s3.software_installers_force_s3_path_style", false, "Set this to true to force path-style addressing, i.e., `http://s3.amazonaws.com/BUCKET/KEY`")
man.addConfigBool("s3.software_installers_gcs_iam_auth", false, "Use Google ADC bearer tokens for GCS endpoint authentication instead of S3 HMAC keys")
man.addConfigString("s3.software_installers_cloudfront_url", "", "CloudFront URL for software installers")
man.addConfigString("s3.software_installers_cloudfront_url_signing_public_key_id", "", "CloudFront public key ID for URL signing")
man.addConfigString("s3.software_installers_cloudfront_url_signing_private_key", "", "CloudFront private key for URL signing")
@@ -2117,6 +2124,7 @@ func (man Manager) loadS3Config() S3Config {
CarvesStsExternalID: man.getConfigString("s3.carves_sts_external_id"),
CarvesDisableSSL: man.getConfigBool("s3.carves_disable_ssl"),
CarvesForceS3PathStyle: man.getConfigBool("s3.carves_force_s3_path_style"),
CarvesGCSIAMAuth: man.getConfigBool("s3.carves_gcs_iam_auth"),
Bucket: man.getConfigString("s3.bucket"),
Prefix: man.getConfigString("s3.prefix"),
@@ -2139,6 +2147,7 @@ func (man Manager) loadS3Config() S3Config {
SoftwareInstallersStsExternalID: man.getConfigString("s3.software_installers_sts_external_id"),
SoftwareInstallersDisableSSL: man.getConfigBool("s3.software_installers_disable_ssl"),
SoftwareInstallersForceS3PathStyle: man.getConfigBool("s3.software_installers_force_s3_path_style"),
SoftwareInstallersGCSIAMAuth: man.getConfigBool("s3.software_installers_gcs_iam_auth"),
SoftwareInstallersCloudFrontURL: man.getConfigString("s3.software_installers_cloudfront_url"),
SoftwareInstallersCloudFrontURLSigningPublicKeyID: man.getConfigString("s3.software_installers_cloudfront_url_signing_public_key_id"),
SoftwareInstallersCloudFrontURLSigningPrivateKey: man.getConfigString("s3.software_installers_cloudfront_url_signing_private_key"),
+98 -13
View File
@@ -21,9 +21,17 @@ import (
types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const awsRegionHint = "us-east-1"
const (
awsRegionHint = "us-east-1"
gcsReadWriteScope = "https://www.googleapis.com/auth/devstorage.read_write"
signingMiddlewareID = "Signing"
)
var findDefaultGoogleCredentials = google.FindDefaultCredentials
type s3store struct {
s3Client *s3.Client
@@ -47,6 +55,38 @@ func (p installerNotFoundError) IsNotFound() bool {
// newS3Store initializes an S3 Datastore.
func newS3Store(cfg config.S3ConfigInternal) (*s3store, error) {
var opts []func(*aws_config.LoadOptions) error
gcsEndpoint := cfg.EndpointURL != "" && isGCS(cfg.EndpointURL)
if cfg.GCSIAMAuth {
switch {
case cfg.EndpointURL == "":
return nil, errors.New("gcs iam auth requires endpoint_url to be set (e.g. https://storage.googleapis.com)")
case !gcsEndpoint:
return nil, fmt.Errorf("gcs iam auth requires endpoint_url to contain storage.googleapis.com (got %q)", cfg.EndpointURL)
}
if cfg.AccessKeyID != "" || cfg.SecretAccessKey != "" {
return nil, errors.New("gcs iam auth cannot be used with access key credentials")
}
if cfg.StsAssumeRoleArn != "" {
return nil, errors.New("gcs iam auth cannot be used with sts assume role")
}
}
var gcsTokenSource oauth2.TokenSource
if cfg.GCSIAMAuth {
creds, err := findDefaultGoogleCredentials(context.Background(), gcsReadWriteScope)
if err != nil {
return nil, fmt.Errorf("finding default google credentials: %w", err)
}
gcsTokenSource = creds.TokenSource
// Even with SigV4 middleware removed, AWS SDK may still resolve credentials.
// Set a local static provider to avoid IMDS/network credential lookups.
opts = append(opts, aws_config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
"gcs-iam-auth",
"gcs-iam-auth",
"",
)))
}
// The service endpoint is deprecated in AWS, but required for S3 workalikes elsewhere
if cfg.EndpointURL != "" {
@@ -78,18 +118,23 @@ func newS3Store(cfg config.S3ConfigInternal) (*s3store, error) {
}
if cfg.Region == "" {
// Attempt to deduce region from bucket.
conf, err := aws_config.LoadDefaultConfig(context.Background(),
append(opts, aws_config.WithRegion(awsRegionHint))...,
)
if err != nil {
return nil, fmt.Errorf("failed to create default config to get bucket region: %w", err)
if cfg.GCSIAMAuth {
// GCS doesn't expose AWS region APIs. Keep AWS SDK happy with a fixed hint.
cfg.Region = awsRegionHint
} else {
// Attempt to deduce region from bucket.
conf, err := aws_config.LoadDefaultConfig(context.Background(),
append(opts, aws_config.WithRegion(awsRegionHint))...,
)
if err != nil {
return nil, fmt.Errorf("failed to create default config to get bucket region: %w", err)
}
bucketRegion, err := manager.GetBucketRegion(context.Background(), s3.NewFromConfig(conf), cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("get bucket region: %w", err)
}
cfg.Region = bucketRegion
}
bucketRegion, err := manager.GetBucketRegion(context.Background(), s3.NewFromConfig(conf), cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("get bucket region: %w", err)
}
cfg.Region = bucketRegion
}
opts = append(opts, aws_config.WithRegion(cfg.Region))
@@ -111,12 +156,17 @@ func newS3Store(cfg config.S3ConfigInternal) (*s3store, error) {
// Apply workaround if using Google Cloud Storage (GCS) endpoint
// This fixes signature issues with AWS SDK v2 when using GCS
// See: https://github.com/aws/aws-sdk-go-v2/issues/1816#issuecomment-1927281540
if cfg.EndpointURL != "" && isGCS(cfg.EndpointURL) {
if gcsEndpoint && !cfg.GCSIAMAuth {
// GCS alters the Accept-Encoding header which breaks the request signature
ignoreSigningHeaders(o, []string{"Accept-Encoding"})
}
if gcsEndpoint {
// GCS also has issues with trailing checksums in UploadPart and PutObject operations
disableTrailingChecksumForGCS(o)
}
if cfg.GCSIAMAuth {
useGCSBearerAuth(o, gcsTokenSource)
}
})
return &s3store{
@@ -127,6 +177,41 @@ func newS3Store(cfg config.S3ConfigInternal) (*s3store, error) {
}, nil
}
func useGCSBearerAuth(o *s3.Options, tokenSource oauth2.TokenSource) {
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
if tokenSource == nil {
return errors.New("gcs bearer auth requested but no google token source was configured")
}
// Remove SigV4 signing. GCS IAM auth uses OAuth bearer tokens.
if _, err := stack.Finalize.Remove(signingMiddlewareID); err != nil {
return fmt.Errorf("removing signing middleware: %w", err)
}
return stack.Finalize.Add(gcsBearerTokenAuth(tokenSource), middleware.After)
})
}
func gcsBearerTokenAuth(tokenSource oauth2.TokenSource) middleware.FinalizeMiddleware {
return middleware.FinalizeMiddlewareFunc(
"GCSBearerTokenAuth",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return out, metadata, fmt.Errorf("(gcsBearerTokenAuth) unexpected request middleware type %T", in.Request)
}
token, err := tokenSource.Token()
if err != nil {
return out, metadata, fmt.Errorf("getting google access token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
return next.HandleFinalize(ctx, in)
},
)
}
// CreateTestBucket creates a bucket with the provided name and a default
// bucket config. Only recommended for local testing.
func (s *s3store) CreateTestBucket(ctx context.Context, name string) error {
+194
View File
@@ -0,0 +1,194 @@
package s3
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
func TestNewS3StoreGCSIAMAuthRequiresGCSEndpoint(t *testing.T) {
_, err := newS3Store(config.S3ConfigInternal{
Bucket: "bucket",
Prefix: "prefix",
Region: "us-east-1",
EndpointURL: "https://s3.amazonaws.com",
GCSIAMAuth: true,
})
require.ErrorContains(t, err, "gcs iam auth requires endpoint_url to contain storage.googleapis.com")
require.ErrorContains(t, err, `"https://s3.amazonaws.com"`)
}
func TestNewS3StoreGCSIAMAuthRequiresEndpointSet(t *testing.T) {
_, err := newS3Store(config.S3ConfigInternal{
Bucket: "bucket",
Prefix: "prefix",
Region: "us-east-1",
GCSIAMAuth: true,
})
require.ErrorContains(t, err, "gcs iam auth requires endpoint_url to be set")
}
func TestNewS3StoreGCSIAMAuthDisallowsStaticKeys(t *testing.T) {
_, err := newS3Store(config.S3ConfigInternal{
Bucket: "bucket",
Prefix: "prefix",
Region: "us-east-1",
EndpointURL: "https://storage.googleapis.com",
GCSIAMAuth: true,
AccessKeyID: "id",
SecretAccessKey: "secret",
})
require.ErrorContains(t, err, "gcs iam auth cannot be used with access key credentials")
}
func TestNewS3StoreGCSIAMAuthDisallowsSTSRole(t *testing.T) {
_, err := newS3Store(config.S3ConfigInternal{
Bucket: "bucket",
Prefix: "prefix",
Region: "us-east-1",
EndpointURL: "https://storage.googleapis.com",
GCSIAMAuth: true,
StsAssumeRoleArn: "arn:aws:iam::123456789012:role/test",
})
require.ErrorContains(t, err, "gcs iam auth cannot be used with sts assume role")
}
func TestNewS3StoreGCSIAMAuthCredentialLookupError(t *testing.T) {
originalFindDefaultGoogleCredentials := findDefaultGoogleCredentials
t.Cleanup(func() {
findDefaultGoogleCredentials = originalFindDefaultGoogleCredentials
})
findDefaultGoogleCredentials = func(context.Context, ...string) (*google.Credentials, error) {
return nil, errors.New("lookup failed")
}
_, err := newS3Store(config.S3ConfigInternal{
Bucket: "bucket",
Prefix: "prefix",
Region: "us-east-1",
EndpointURL: "https://storage.googleapis.com",
GCSIAMAuth: true,
})
require.ErrorContains(t, err, "finding default google credentials")
require.ErrorContains(t, err, "lookup failed")
}
func TestSoftwareInstallerStoreGCSIAMAuthUsesBearerToken(t *testing.T) {
type requestInfo struct {
AuthHeader string
Method string
Path string
}
reqCh := make(chan requestInfo, 1)
testSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCh <- requestInfo{
AuthHeader: r.Header.Get("Authorization"),
Method: r.Method,
Path: r.URL.Path,
}
w.WriteHeader(http.StatusOK)
}))
defer testSrv.Close()
originalFindDefaultGoogleCredentials := findDefaultGoogleCredentials
t.Cleanup(func() {
findDefaultGoogleCredentials = originalFindDefaultGoogleCredentials
})
const token = "test-bearer-token"
findDefaultGoogleCredentials = func(context.Context, ...string) (*google.Credentials, error) {
return &google.Credentials{
TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}),
}, nil
}
store, err := NewSoftwareInstallerStore(config.S3Config{
SoftwareInstallersBucket: "bucket",
SoftwareInstallersPrefix: "prefix",
SoftwareInstallersEndpointURL: testSrv.URL + "/storage.googleapis.com",
SoftwareInstallersForceS3PathStyle: true,
SoftwareInstallersGCSIAMAuth: true,
})
require.NoError(t, err)
exists, err := store.Exists(context.Background(), "installer-id")
require.NoError(t, err)
require.True(t, exists)
select {
case req := <-reqCh:
require.Equal(t, http.MethodHead, req.Method)
require.Equal(t, "Bearer "+token, req.AuthHeader)
require.NotContains(t, req.AuthHeader, "AWS4-HMAC-SHA256")
require.Contains(t, req.Path, "/bucket/", "expected bucket in request path, got %s", req.Path)
case <-time.After(2 * time.Second):
t.Fatal("did not receive request to test server")
}
}
func TestCarveStoreGCSIAMAuthUsesBearerToken(t *testing.T) {
type requestInfo struct {
AuthHeader string
Method string
Path string
}
reqCh := make(chan requestInfo, 1)
testSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case reqCh <- requestInfo{
AuthHeader: r.Header.Get("Authorization"),
Method: r.Method,
Path: r.URL.Path,
}:
default:
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><IsTruncated>false</IsTruncated></ListBucketResult>`))
}))
defer testSrv.Close()
originalFindDefaultGoogleCredentials := findDefaultGoogleCredentials
t.Cleanup(func() {
findDefaultGoogleCredentials = originalFindDefaultGoogleCredentials
})
const token = "carves-bearer-token"
findDefaultGoogleCredentials = func(context.Context, ...string) (*google.Credentials, error) {
return &google.Credentials{
TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}),
}, nil
}
store, err := NewCarveStore(config.S3Config{
CarvesBucket: "carves-bucket",
CarvesPrefix: "carves-prefix",
CarvesEndpointURL: testSrv.URL + "/storage.googleapis.com",
CarvesForceS3PathStyle: true,
CarvesGCSIAMAuth: true,
}, nil)
require.NoError(t, err)
_, err = store.listS3Carves(context.Background(), "", 10)
require.NoError(t, err)
select {
case req := <-reqCh:
require.Equal(t, "Bearer "+token, req.AuthHeader)
require.NotContains(t, req.AuthHeader, "AWS4-HMAC-SHA256")
require.Contains(t, req.Path, "/carves-bucket", "expected carves bucket in request path, got %s", req.Path)
case <-time.After(2 * time.Second):
t.Fatal("did not receive request to test server")
}
}