From 8cd25be70e2d9e6f9bb457c15fdad51b55bd4bdf Mon Sep 17 00:00:00 2001 From: Michal Suba Date: Fri, 11 Sep 2026 11:14:03 +0200 Subject: [PATCH] feat(storage): sign Azure layer-file uploads with a SAS and return upload headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure's Put Blob requires the request header "x-ms-blob-type: BlockBlob". A SAS can only pin response headers, so no signed URL alone can satisfy it and the provider refused to issue one at all — every template build with a COPY instruction failed on Azure at the get-signed-URL step. UploadSignedURL now returns {URL, Headers} instead of a bare URL, and the header travels with the URL through the gRPC and public API responses for the upload client to apply. Azure mints a create+write blob SAS with whichever credential the provider already authenticated with: a user delegation key for a token credential (the managed identity path), or the shared key when one is configured. A SAS-only connection string can sign neither and now says so at construction and at the call. GCS, S3 and the filesystem provider return nil headers and byte-identical URLs. The proto and OpenAPI additions are additive — url stays optional, and a client that ignores the new field parses the response unchanged. Co-Authored-By: Claude Opus 5 --- packages/api/internal/api/api.gen.go | 234 +++++++++--------- .../handlers/template_layer_files_upload.go | 9 +- .../template_layer_files_upload_test.go | 41 +++ .../sandbox/template/peerclient/storage.go | 4 +- .../server/upload_layer_files_template.go | 7 +- .../upload_layer_files_template_test.go | 24 +- packages/orchestrator/template-manager.proto | 2 + .../template-manager/template-manager.pb.go | 101 ++++---- .../pkg/storage/mock_storageprovider.go | 16 +- packages/shared/pkg/storage/storage.go | 10 +- packages/shared/pkg/storage/storage_aws.go | 6 +- .../shared/pkg/storage/storage_aws_test.go | 38 +++ packages/shared/pkg/storage/storage_azure.go | 139 ++++++++--- .../storage/storage_azure_integration_test.go | 60 ++++- .../shared/pkg/storage/storage_azure_test.go | 184 +++++++++++++- packages/shared/pkg/storage/storage_cache.go | 2 +- packages/shared/pkg/storage/storage_fs.go | 6 +- packages/shared/pkg/storage/storage_google.go | 8 +- .../pkg/storage/storage_localupload_test.go | 7 +- spec/openapi.yml | 5 + tests/integration/internal/api/generated.go | 3 + 21 files changed, 672 insertions(+), 234 deletions(-) create mode 100644 packages/api/internal/handlers/template_layer_files_upload_test.go diff --git a/packages/api/internal/api/api.gen.go b/packages/api/internal/api/api.gen.go index b162858036..1bfa350085 100644 --- a/packages/api/internal/api/api.gen.go +++ b/packages/api/internal/api/api.gen.go @@ -1335,6 +1335,9 @@ type TemplateBuild struct { // TemplateBuildFileUpload defines model for TemplateBuildFileUpload. type TemplateBuildFileUpload struct { + // Headers Request headers that must be sent with the upload request + Headers *map[string]string `json:"headers,omitempty"` + // Present Whether the file is already present in the cache Present bool `json:"present"` @@ -20827,121 +20830,122 @@ var swaggerSpec = []string{ "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", + "/w==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index a583cafd11..906e16fd4d 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -68,8 +68,13 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - c.JSON(http.StatusCreated, &api.TemplateBuildFileUpload{ + upload := api.TemplateBuildFileUpload{ Present: resp.GetPresent(), Url: resp.Url, - }) + } + if headers := resp.GetUploadHeaders(); len(headers) > 0 { + upload.Headers = &headers + } + + c.JSON(http.StatusCreated, &upload) } diff --git a/packages/api/internal/handlers/template_layer_files_upload_test.go b/packages/api/internal/handlers/template_layer_files_upload_test.go new file mode 100644 index 0000000000..c3a5f82283 --- /dev/null +++ b/packages/api/internal/handlers/template_layer_files_upload_test.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/api" +) + +// A client written against the pre-headers response must keep parsing the new one, and the +// providers that need no request headers must keep producing the old bytes exactly. +func TestTemplateBuildFileUploadHeadersAreAdditive(t *testing.T) { + t.Parallel() + + t.Run("no headers keeps the response bytes unchanged", func(t *testing.T) { + t.Parallel() + + body, err := json.Marshal(api.TemplateBuildFileUpload{Present: false, Url: new("https://bucket.example/signed")}) + require.NoError(t, err) + assert.JSONEq(t, `{"present":false,"url":"https://bucket.example/signed"}`, string(body)) + }) + + t.Run("a client ignoring headers still parses the response", func(t *testing.T) { + t.Parallel() + + var legacy struct { + Present bool `json:"present"` + Url *string `json:"url"` + } + require.NoError(t, json.Unmarshal( + []byte(`{"present":false,"url":"https://account.example/signed","headers":{"x-ms-blob-type":"BlockBlob"}}`), + &legacy)) + + assert.False(t, legacy.Present) + require.NotNil(t, legacy.Url) + assert.Equal(t, "https://account.example/signed", *legacy.Url) + }) +} diff --git a/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go b/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go index 09699b957c..78b7962e0e 100644 --- a/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go +++ b/packages/orchestrator/pkg/sandbox/template/peerclient/storage.go @@ -94,7 +94,7 @@ func (p *routingProvider) DeleteObjectsWithPrefix(ctx context.Context, prefix st return p.base.DeleteObjectsWithPrefix(ctx, prefix) } -func (p *routingProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (p *routingProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (storage.UploadURL, error) { return p.base.UploadSignedURL(ctx, path, ttl) } @@ -168,7 +168,7 @@ func (p *peerStorageProvider) DeleteObjectsWithPrefix(ctx context.Context, prefi return p.base.DeleteObjectsWithPrefix(ctx, prefix) } -func (p *peerStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (p *peerStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (storage.UploadURL, error) { return p.base.UploadSignedURL(ctx, path, ttl) } diff --git a/packages/orchestrator/pkg/template/server/upload_layer_files_template.go b/packages/orchestrator/pkg/template/server/upload_layer_files_template.go index a02d01e0f2..79d6ecf94e 100644 --- a/packages/orchestrator/pkg/template/server/upload_layer_files_template.go +++ b/packages/orchestrator/pkg/template/server/upload_layer_files_template.go @@ -36,7 +36,7 @@ func (s *ServerStore) InitLayerFileUpload(ctx context.Context, in *templatemanag return nil, fmt.Errorf("failed to check if layer files exists: %w", err) } - signedUrl, err := s.buildStorage.UploadSignedURL(ctx, path, signedUrlExpiration) + upload, err := s.buildStorage.UploadSignedURL(ctx, path, signedUrlExpiration) if err != nil { // A cache hit needs no upload URL, so a provider that cannot sign one is fatal // only on a miss. @@ -48,7 +48,8 @@ func (s *ServerStore) InitLayerFileUpload(ctx context.Context, in *templatemanag } return &templatemanager.InitLayerFileUploadResponse{ - Present: exists, - Url: &signedUrl, + Present: exists, + Url: &upload.URL, + UploadHeaders: upload.Headers, }, nil } diff --git a/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go b/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go index 8e62f6f39c..d6aa589416 100644 --- a/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go +++ b/packages/orchestrator/pkg/template/server/upload_layer_files_template_test.go @@ -21,7 +21,7 @@ const ( testFilesHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ) -func newInitLayerFileUploadServer(t *testing.T, exists bool, signedURL string, signErr error) *ServerStore { +func newInitLayerFileUploadServer(t *testing.T, exists bool, upload storage.UploadURL, signErr error) *ServerStore { t.Helper() blob := storage.NewMockBlob(t) @@ -30,7 +30,7 @@ func newInitLayerFileUploadServer(t *testing.T, exists bool, signedURL string, s provider := storage.NewMockStorageProvider(t) path := paths.GetLayerFilesCachePath(testTemplateID, testFilesHash) provider.EXPECT().OpenBlob(mock.Anything, path).Return(blob, nil) - provider.EXPECT().UploadSignedURL(mock.Anything, path, signedUrlExpiration).Return(signedURL, signErr) + provider.EXPECT().UploadSignedURL(mock.Anything, path, signedUrlExpiration).Return(upload, signErr) return &ServerStore{buildStorage: provider} } @@ -50,7 +50,7 @@ func TestInitLayerFileUploadUnsignableProvider(t *testing.T) { t.Run("cache hit reports present without a url", func(t *testing.T) { t.Parallel() - s := newInitLayerFileUploadServer(t, true, "", unsupported) + s := newInitLayerFileUploadServer(t, true, storage.UploadURL{}, unsupported) resp, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.NoError(t, err) @@ -61,7 +61,7 @@ func TestInitLayerFileUploadUnsignableProvider(t *testing.T) { t.Run("cache miss still fails", func(t *testing.T) { t.Parallel() - s := newInitLayerFileUploadServer(t, false, "", unsupported) + s := newInitLayerFileUploadServer(t, false, storage.UploadURL{}, unsupported) _, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.ErrorIs(t, err, storage.ErrSignedUploadURLUnsupported) @@ -74,7 +74,7 @@ func TestInitLayerFileUploadSigningErrorOnCacheHit(t *testing.T) { t.Parallel() signErr := errors.New("failed to parse GCP service account") - s := newInitLayerFileUploadServer(t, true, "", signErr) + s := newInitLayerFileUploadServer(t, true, storage.UploadURL{}, signErr) _, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.ErrorIs(t, err, signErr) @@ -89,12 +89,24 @@ func TestInitLayerFileUploadKeepsURLOnCacheHit(t *testing.T) { t.Run(fmt.Sprintf("exists=%v", exists), func(t *testing.T) { t.Parallel() - s := newInitLayerFileUploadServer(t, exists, "https://bucket.example/signed", nil) + s := newInitLayerFileUploadServer(t, exists, storage.UploadURL{URL: "https://bucket.example/signed"}, nil) resp, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) require.NoError(t, err) assert.Equal(t, exists, resp.GetPresent()) assert.Equal(t, "https://bucket.example/signed", resp.GetUrl()) + assert.Empty(t, resp.GetUploadHeaders(), "providers that need no request headers must not send any") }) } } + +func TestInitLayerFileUploadForwardsUploadHeaders(t *testing.T) { + t.Parallel() + + headers := map[string]string{"x-ms-blob-type": "BlockBlob"} + s := newInitLayerFileUploadServer(t, false, storage.UploadURL{URL: "https://account.example/signed", Headers: headers}, nil) + + resp, err := s.InitLayerFileUpload(t.Context(), initLayerFileUploadRequest()) + require.NoError(t, err) + assert.Equal(t, headers, resp.GetUploadHeaders()) +} diff --git a/packages/orchestrator/template-manager.proto b/packages/orchestrator/template-manager.proto index b05540ec13..9c098df30e 100644 --- a/packages/orchestrator/template-manager.proto +++ b/packages/orchestrator/template-manager.proto @@ -16,6 +16,8 @@ message InitLayerFileUploadRequest { message InitLayerFileUploadResponse{ bool present = 1; optional string url = 2; + // Request headers the upload client must send on the PUT to `url`. + map uploadHeaders = 3; } message TemplateStep { diff --git a/packages/shared/pkg/grpc/template-manager/template-manager.pb.go b/packages/shared/pkg/grpc/template-manager/template-manager.pb.go index 69f816c1fb..551218a5f0 100644 --- a/packages/shared/pkg/grpc/template-manager/template-manager.pb.go +++ b/packages/shared/pkg/grpc/template-manager/template-manager.pb.go @@ -232,9 +232,11 @@ func (x *InitLayerFileUploadRequest) GetCacheScope() string { } type InitLayerFileUploadResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"` - Url *string `protobuf:"bytes,2,opt,name=url,proto3,oneof" json:"url,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"` + Url *string `protobuf:"bytes,2,opt,name=url,proto3,oneof" json:"url,omitempty"` + // Request headers the upload client must send on the PUT to `url`. + UploadHeaders map[string]string `protobuf:"bytes,3,rep,name=uploadHeaders,proto3" json:"uploadHeaders,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -283,6 +285,13 @@ func (x *InitLayerFileUploadResponse) GetUrl() string { return "" } +func (x *InitLayerFileUploadResponse) GetUploadHeaders() map[string]string { + if x != nil { + return x.UploadHeaders + } + return nil +} + type TemplateStep struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` @@ -1370,10 +1379,14 @@ const file_template_manager_proto_rawDesc = "" + "\n" + "cacheScope\x18\x03 \x01(\tH\x00R\n" + "cacheScope\x88\x01\x01B\r\n" + - "\v_cacheScope\"V\n" + + "\v_cacheScope\"\xef\x01\n" + "\x1bInitLayerFileUploadResponse\x12\x18\n" + "\apresent\x18\x01 \x01(\bR\apresent\x12\x15\n" + - "\x03url\x18\x02 \x01(\tH\x00R\x03url\x88\x01\x01B\x06\n" + + "\x03url\x18\x02 \x01(\tH\x00R\x03url\x88\x01\x01\x12U\n" + + "\ruploadHeaders\x18\x03 \x03(\v2/.InitLayerFileUploadResponse.UploadHeadersEntryR\ruploadHeaders\x1a@\n" + + "\x12UploadHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + "\x04_url\"\x8c\x01\n" + "\fTemplateStep\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x12\n" + @@ -1517,7 +1530,7 @@ func file_template_manager_proto_rawDescGZIP() []byte { } var file_template_manager_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_template_manager_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_template_manager_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_template_manager_proto_goTypes = []any{ (LogLevel)(0), // 0: LogLevel (LogsDirection)(0), // 1: LogsDirection @@ -1538,44 +1551,46 @@ var file_template_manager_proto_goTypes = []any{ (*TemplateBuildLogEntry)(nil), // 16: TemplateBuildLogEntry (*TemplateBuildStatusReason)(nil), // 17: TemplateBuildStatusReason (*TemplateBuildStatusResponse)(nil), // 18: TemplateBuildStatusResponse - nil, // 19: TemplateBuildLogEntry.FieldsEntry - (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp - (*orchestrator.SchedulingMetadata)(nil), // 21: SchedulingMetadata - (*emptypb.Empty)(nil), // 22: google.protobuf.Empty + nil, // 19: InitLayerFileUploadResponse.UploadHeadersEntry + nil, // 20: TemplateBuildLogEntry.FieldsEntry + (*timestamppb.Timestamp)(nil), // 21: google.protobuf.Timestamp + (*orchestrator.SchedulingMetadata)(nil), // 22: SchedulingMetadata + (*emptypb.Empty)(nil), // 23: google.protobuf.Empty } var file_template_manager_proto_depIdxs = []int32{ - 7, // 0: FromImageRegistry.aws:type_name -> AWSRegistry - 8, // 1: FromImageRegistry.gcp:type_name -> GCPRegistry - 9, // 2: FromImageRegistry.general:type_name -> GeneralRegistry - 5, // 3: TemplateConfig.steps:type_name -> TemplateStep - 6, // 4: TemplateConfig.fromTemplate:type_name -> FromTemplateConfig - 10, // 5: TemplateConfig.fromImageRegistry:type_name -> FromImageRegistry - 11, // 6: TemplateCreateRequest.template:type_name -> TemplateConfig - 0, // 7: TemplateStatusRequest.level:type_name -> LogLevel - 20, // 8: TemplateStatusRequest.start:type_name -> google.protobuf.Timestamp - 20, // 9: TemplateStatusRequest.end:type_name -> google.protobuf.Timestamp - 1, // 10: TemplateStatusRequest.direction:type_name -> LogsDirection - 21, // 11: TemplateBuildMetadata.schedulingMetadata:type_name -> SchedulingMetadata - 20, // 12: TemplateBuildLogEntry.timestamp:type_name -> google.protobuf.Timestamp - 0, // 13: TemplateBuildLogEntry.level:type_name -> LogLevel - 19, // 14: TemplateBuildLogEntry.fields:type_name -> TemplateBuildLogEntry.FieldsEntry - 2, // 15: TemplateBuildStatusResponse.status:type_name -> TemplateBuildState - 15, // 16: TemplateBuildStatusResponse.metadata:type_name -> TemplateBuildMetadata - 16, // 17: TemplateBuildStatusResponse.logEntries:type_name -> TemplateBuildLogEntry - 17, // 18: TemplateBuildStatusResponse.reason:type_name -> TemplateBuildStatusReason - 12, // 19: TemplateService.TemplateCreate:input_type -> TemplateCreateRequest - 13, // 20: TemplateService.TemplateBuildStatus:input_type -> TemplateStatusRequest - 14, // 21: TemplateService.TemplateBuildDelete:input_type -> TemplateBuildDeleteRequest - 3, // 22: TemplateService.InitLayerFileUpload:input_type -> InitLayerFileUploadRequest - 22, // 23: TemplateService.TemplateCreate:output_type -> google.protobuf.Empty - 18, // 24: TemplateService.TemplateBuildStatus:output_type -> TemplateBuildStatusResponse - 22, // 25: TemplateService.TemplateBuildDelete:output_type -> google.protobuf.Empty - 4, // 26: TemplateService.InitLayerFileUpload:output_type -> InitLayerFileUploadResponse - 23, // [23:27] is the sub-list for method output_type - 19, // [19:23] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 19, // 0: InitLayerFileUploadResponse.uploadHeaders:type_name -> InitLayerFileUploadResponse.UploadHeadersEntry + 7, // 1: FromImageRegistry.aws:type_name -> AWSRegistry + 8, // 2: FromImageRegistry.gcp:type_name -> GCPRegistry + 9, // 3: FromImageRegistry.general:type_name -> GeneralRegistry + 5, // 4: TemplateConfig.steps:type_name -> TemplateStep + 6, // 5: TemplateConfig.fromTemplate:type_name -> FromTemplateConfig + 10, // 6: TemplateConfig.fromImageRegistry:type_name -> FromImageRegistry + 11, // 7: TemplateCreateRequest.template:type_name -> TemplateConfig + 0, // 8: TemplateStatusRequest.level:type_name -> LogLevel + 21, // 9: TemplateStatusRequest.start:type_name -> google.protobuf.Timestamp + 21, // 10: TemplateStatusRequest.end:type_name -> google.protobuf.Timestamp + 1, // 11: TemplateStatusRequest.direction:type_name -> LogsDirection + 22, // 12: TemplateBuildMetadata.schedulingMetadata:type_name -> SchedulingMetadata + 21, // 13: TemplateBuildLogEntry.timestamp:type_name -> google.protobuf.Timestamp + 0, // 14: TemplateBuildLogEntry.level:type_name -> LogLevel + 20, // 15: TemplateBuildLogEntry.fields:type_name -> TemplateBuildLogEntry.FieldsEntry + 2, // 16: TemplateBuildStatusResponse.status:type_name -> TemplateBuildState + 15, // 17: TemplateBuildStatusResponse.metadata:type_name -> TemplateBuildMetadata + 16, // 18: TemplateBuildStatusResponse.logEntries:type_name -> TemplateBuildLogEntry + 17, // 19: TemplateBuildStatusResponse.reason:type_name -> TemplateBuildStatusReason + 12, // 20: TemplateService.TemplateCreate:input_type -> TemplateCreateRequest + 13, // 21: TemplateService.TemplateBuildStatus:input_type -> TemplateStatusRequest + 14, // 22: TemplateService.TemplateBuildDelete:input_type -> TemplateBuildDeleteRequest + 3, // 23: TemplateService.InitLayerFileUpload:input_type -> InitLayerFileUploadRequest + 23, // 24: TemplateService.TemplateCreate:output_type -> google.protobuf.Empty + 18, // 25: TemplateService.TemplateBuildStatus:output_type -> TemplateBuildStatusResponse + 23, // 26: TemplateService.TemplateBuildDelete:output_type -> google.protobuf.Empty + 4, // 27: TemplateService.InitLayerFileUpload:output_type -> InitLayerFileUploadResponse + 24, // [24:28] is the sub-list for method output_type + 20, // [20:24] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_template_manager_proto_init() } @@ -1605,7 +1620,7 @@ func file_template_manager_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_template_manager_proto_rawDesc), len(file_template_manager_proto_rawDesc)), NumEnums: 3, - NumMessages: 17, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/packages/shared/pkg/storage/mock_storageprovider.go b/packages/shared/pkg/storage/mock_storageprovider.go index d40a0f402e..9d887b570d 100644 --- a/packages/shared/pkg/storage/mock_storageprovider.go +++ b/packages/shared/pkg/storage/mock_storageprovider.go @@ -276,22 +276,22 @@ func (_c *MockStorageProvider_OpenSeekable_Call) RunAndReturn(run func(ctx conte } // UploadSignedURL provides a mock function for the type MockStorageProvider -func (_mock *MockStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (_mock *MockStorageProvider) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { ret := _mock.Called(ctx, path, ttl) if len(ret) == 0 { panic("no return value specified for UploadSignedURL") } - var r0 string + var r0 UploadURL var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) (string, error)); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) (UploadURL, error)); ok { return returnFunc(ctx, path, ttl) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) string); ok { + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Duration) UploadURL); ok { r0 = returnFunc(ctx, path, ttl) } else { - r0 = ret.Get(0).(string) + r0 = ret.Get(0).(UploadURL) } if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Duration) error); ok { r1 = returnFunc(ctx, path, ttl) @@ -337,12 +337,12 @@ func (_c *MockStorageProvider_UploadSignedURL_Call) Run(run func(ctx context.Con return _c } -func (_c *MockStorageProvider_UploadSignedURL_Call) Return(s string, err error) *MockStorageProvider_UploadSignedURL_Call { - _c.Call.Return(s, err) +func (_c *MockStorageProvider_UploadSignedURL_Call) Return(uploadURL UploadURL, err error) *MockStorageProvider_UploadSignedURL_Call { + _c.Call.Return(uploadURL, err) return _c } -func (_c *MockStorageProvider_UploadSignedURL_Call) RunAndReturn(run func(ctx context.Context, path string, ttl time.Duration) (string, error)) *MockStorageProvider_UploadSignedURL_Call { +func (_c *MockStorageProvider_UploadSignedURL_Call) RunAndReturn(run func(ctx context.Context, path string, ttl time.Duration) (UploadURL, error)) *MockStorageProvider_UploadSignedURL_Call { _c.Call.Return(run) return _c } diff --git a/packages/shared/pkg/storage/storage.go b/packages/shared/pkg/storage/storage.go index 75d57003b5..767d54adb8 100644 --- a/packages/shared/pkg/storage/storage.go +++ b/packages/shared/pkg/storage/storage.go @@ -65,9 +65,17 @@ func (t SeekableObjectType) String() string { } } +// UploadURL is a signed upload target for an external client. Headers must be sent +// verbatim on the PUT: Azure's Put Blob rejects the request without x-ms-blob-type, and a +// SAS can only pin response headers, never require a request one. +type UploadURL struct { + URL string + Headers map[string]string +} + type StorageProvider interface { DeleteObjectsWithPrefix(ctx context.Context, prefix string) error - UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) + UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) OpenBlob(ctx context.Context, path string) (Blob, error) OpenSeekable(ctx context.Context, path string) (Seekable, error) GetDetails() string diff --git a/packages/shared/pkg/storage/storage_aws.go b/packages/shared/pkg/storage/storage_aws.go index b0296602e5..5bf4151149 100644 --- a/packages/shared/pkg/storage/storage_aws.go +++ b/packages/shared/pkg/storage/storage_aws.go @@ -163,7 +163,7 @@ func (s *awsStorage) GetDetails() string { return fmt.Sprintf("[AWS Storage, bucket set to %s]", s.bucketName) } -func (s *awsStorage) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (s *awsStorage) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { input := &s3.PutObjectInput{ Bucket: aws.String(s.bucketName), Key: aws.String(path), @@ -172,10 +172,10 @@ func (s *awsStorage) UploadSignedURL(ctx context.Context, path string, ttl time. opts.Expires = ttl }) if err != nil { - return "", fmt.Errorf("failed to presign PUT URL: %w", err) + return UploadURL{}, fmt.Errorf("failed to presign PUT URL: %w", err) } - return resp.URL, nil + return UploadURL{URL: resp.URL}, nil } func (s *awsStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_aws_test.go b/packages/shared/pkg/storage/storage_aws_test.go index 295206bd0a..3eac3f9e50 100644 --- a/packages/shared/pkg/storage/storage_aws_test.go +++ b/packages/shared/pkg/storage/storage_aws_test.go @@ -690,3 +690,41 @@ func TestAWSDeleteObjectsWithPrefixRejectsEmptyPrefix(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "empty prefix") } + +// A presigned S3 PUT carries its authorization in the query string, so the upload client +// must not be asked to add request headers — an Azure-shaped header map leaking into the +// S3 or GCS response would break every upload that echoed it into the signature. +func TestS3UploadSignedURLNeedsNoRequestHeaders(t *testing.T) { + t.Parallel() + + backend := testBackend(t) + client := backend.newClient(t, nil) + provider := &awsStorage{ + client: client, + presignClient: s3.NewPresignClient(client), + bucketName: backend.bucket, + } + + key := testKey("presigned-upload.bin") + obj := backend.object(t, client, key) + + upload, err := provider.UploadSignedURL(t.Context(), key, time.Hour) + require.NoError(t, err) + require.NotEmpty(t, upload.URL) + assert.Nil(t, upload.Headers) + + body := []byte("presigned-upload-body") + req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, upload.URL, bytes.NewReader(body)) + require.NoError(t, err) + req.ContentLength = int64(len(body)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, res.Body.Close()) + require.Equal(t, http.StatusOK, res.StatusCode) + + var read bytes.Buffer + _, err = obj.WriteTo(t.Context(), &read) + require.NoError(t, err) + assert.Equal(t, body, read.Bytes()) +} diff --git a/packages/shared/pkg/storage/storage_azure.go b/packages/shared/pkg/storage/storage_azure.go index efef082668..b012fb1dfb 100644 --- a/packages/shared/pkg/storage/storage_azure.go +++ b/packages/shared/pkg/storage/storage_azure.go @@ -22,6 +22,8 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/sas" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/service" "go.uber.org/zap" "github.com/e2b-dev/infra/packages/shared/pkg/consts" @@ -35,6 +37,9 @@ const ( azureReadTimeout = 15 * time.Second azureUploadBlockSize = 10 * 1024 * 1024 // 10 MB + + // Backdated SAS start time, so clock skew cannot reject a just-issued token. + azureSASClockSkew = 5 * time.Minute ) type azureStorage struct { @@ -42,6 +47,11 @@ type azureStorage struct { container *container.Client containerName string limiter *limit.Limiter + + // How the client authenticated decides how an upload SAS is signed: a shared key signs + // locally, a token credential fetches a user delegation key. A SAS-only one does neither. + sharedKey *azblob.SharedKeyCredential + canDelegate bool } var _ StorageProvider = (*azureStorage)(nil) @@ -66,19 +76,26 @@ var ( // key (AZURE_STORAGE_ACCOUNT_KEY) when present, falling back to // azidentity.NewDefaultAzureCredential. func newAzureStorage(ctx context.Context, containerName string, limiter *limit.Limiter) (*azureStorage, error) { - var client *azblob.Client + var ( + client *azblob.Client + sharedKey *azblob.SharedKeyCredential + canDelegate bool + ) if connectionString := consts.AzureStorageConnectionString(); connectionString != "" { - // Any connection string the SDK accepts is accepted here, including a - // least-privilege SAS one (BlobEndpoint=...;SharedAccessSignature=...): - // every reachable operation works without a shared key. Signed upload - // URLs would have needed one, but they are unsupported on Azure anyway - // (see UploadSignedURL). var err error client, err = azblob.NewClientFromConnectionString(connectionString, nil) if err != nil { return nil, fmt.Errorf("failed to create Azure client from connection string: %w", err) } + + // The SDK keeps the credential it parsed private, so signing needs its own copy. + if accountName, accountKey, ok := parseConnectionStringSharedKey(connectionString); ok { + sharedKey, err = azblob.NewSharedKeyCredential(accountName, accountKey) + if err != nil { + return nil, fmt.Errorf("failed to create Azure shared key credential: %w", err) + } + } } else { accountName := consts.AzureStorageAccountName() if accountName == "" { @@ -88,7 +105,8 @@ func newAzureStorage(ctx context.Context, containerName string, limiter *limit.L serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName) if accountKey := consts.AzureStorageAccountKey(); accountKey != "" { - sharedKey, err := azblob.NewSharedKeyCredential(accountName, accountKey) + var err error + sharedKey, err = azblob.NewSharedKeyCredential(accountName, accountKey) if err != nil { return nil, fmt.Errorf("failed to create Azure shared key credential: %w", err) } @@ -107,25 +125,45 @@ func newAzureStorage(ctx context.Context, containerName string, limiter *limit.L if err != nil { return nil, fmt.Errorf("failed to create Azure client: %w", err) } + + canDelegate = true } } - // A stated property of the provider, surfaced at construction rather than - // discovered in staging: template layer-file uploads go through - // UploadSignedURL, which is unsupported on Azure (see its doc comment), so - // template builds fail at the get-signed-URL step until the proxied-upload - // follow-up lands. - logger.L().Warn(ctx, "Azure storage does not support signed upload URLs: template layer-file uploads are unavailable until uploads are proxied through the orchestrator", - zap.String("container", containerName)) + if sharedKey == nil && !canDelegate { + logger.L().Warn(ctx, "Azure storage cannot sign upload URLs with a SAS-only connection string: template layer-file uploads will fail on a cache miss", + zap.String("container", containerName)) + } return &azureStorage{ client: client, container: client.ServiceClient().NewContainerClient(containerName), containerName: containerName, limiter: limiter, + sharedKey: sharedKey, + canDelegate: canDelegate, }, nil } +// ok is false for a connection string that authenticates with a SAS instead of a key. +func parseConnectionStringSharedKey(connectionString string) (accountName, accountKey string, ok bool) { + for part := range strings.SplitSeq(connectionString, ";") { + key, value, found := strings.Cut(part, "=") + if !found { + continue + } + + switch strings.TrimSpace(key) { + case "AccountName": + accountName = value + case "AccountKey": + accountKey = value + } + } + + return accountName, accountKey, accountName != "" && accountKey != "" +} + func (s *azureStorage) DeleteObjectsWithPrefix(ctx context.Context, prefix string) error { // An empty prefix would match, and delete, every blob in the container. if prefix == "" { @@ -174,23 +212,62 @@ func (s *azureStorage) GetDetails() string { return fmt.Sprintf("[Azure Storage, container set to %s]", s.containerName) } -// UploadSignedURL is deliberately unsupported on Azure, and fails loudly rather than -// handing back a URL the caller cannot use. -// -// Azure's Put Blob requires the request header "x-ms-blob-type: BlockBlob". S3 and GCS -// presigned PUTs have no equivalent, and a SAS cannot carry a required REQUEST header -- -// SAS only pins response headers (rsct/rscd). The URL therefore reaches an external -// client (the public API returns only {present, url}, and the PUT is performed by the SDK -// or CLI, not by this repo) which does not send the header, so the upload fails with -// MissingRequiredHeader while the same code path works on the other two providers. -// -// Returning an error keeps that failure at the API boundary, where it names its cause, -// instead of surfacing as an opaque Azure 4xx in someone's build. Azure uploads will -// instead be proxied through the orchestrator in a follow-up to this stack (the same -// shape as the filesystem provider's local upload path, and no proto or public-API -// change); SAS PUT URLs are deliberately never issued. -func (s *azureStorage) UploadSignedURL(_ context.Context, path string, _ time.Duration) (string, error) { - return "", fmt.Errorf("%w: Azure (%q) Put Blob requires the x-ms-blob-type request header, which a SAS cannot carry and external upload clients do not send", ErrSignedUploadURLUnsupported, path) +// Put Blob also requires the request header "x-ms-blob-type", which a SAS cannot carry (it +// only pins response headers), so it travels back for the external client to send; without +// it the upload fails with MissingRequiredHeader. +func (s *azureStorage) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { + blobURL := s.container.NewBlobClient(path).URL() + + now := time.Now().UTC() + values := sas.BlobSignatureValues{ + Protocol: sasProtocolFor(blobURL), + StartTime: now.Add(-azureSASClockSkew), + ExpiryTime: now.Add(ttl), + Permissions: (&sas.BlobPermissions{Create: true, Write: true}).String(), + ContainerName: s.containerName, + BlobName: path, + } + + var ( + params sas.QueryParameters + err error + ) + + switch { + case s.sharedKey != nil: + params, err = values.SignWithSharedKey(s.sharedKey) + case s.canDelegate: + var credential *service.UserDelegationCredential + credential, err = s.client.ServiceClient().GetUserDelegationCredential(ctx, service.KeyInfo{ + Start: new(values.StartTime.Format(sas.TimeFormat)), + Expiry: new(values.ExpiryTime.Format(sas.TimeFormat)), + }, nil) + if err != nil { + return UploadURL{}, fmt.Errorf("failed to get Azure user delegation key for %q: %w", path, err) + } + + params, err = values.SignWithUserDelegation(credential) + default: + return UploadURL{}, fmt.Errorf("%w: Azure (%q) needs AZURE_STORAGE_ACCOUNT_KEY, an AccountKey in AZURE_STORAGE_CONNECTION_STRING, or a credential allowed to fetch a user delegation key", ErrSignedUploadURLUnsupported, path) + } + + if err != nil { + return UploadURL{}, fmt.Errorf("failed to sign Azure upload SAS for %q: %w", path, err) + } + + return UploadURL{ + URL: blobURL + "?" + params.Encode(), + Headers: map[string]string{"x-ms-blob-type": string(blob.BlobTypeBlockBlob)}, + }, nil +} + +// HTTPS-only against a real account; the storage emulator serves plain HTTP. +func sasProtocolFor(blobURL string) sas.Protocol { + if strings.HasPrefix(blobURL, "https://") { + return sas.ProtocolHTTPS + } + + return sas.ProtocolHTTPSandHTTP } func (s *azureStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_azure_integration_test.go b/packages/shared/pkg/storage/storage_azure_integration_test.go index 6927562aaf..b790c2d520 100644 --- a/packages/shared/pkg/storage/storage_azure_integration_test.go +++ b/packages/shared/pkg/storage/storage_azure_integration_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "math/rand" + "net/http" "os" "path/filepath" "testing" @@ -335,12 +336,31 @@ func TestAzureIntegration(t *testing.T) { require.ErrorContains(t, provider.DeleteObjectsWithPrefix(ctx, ""), "empty prefix") }) - t.Run("UploadSignedURLIsRefused", func(t *testing.T) { - // The interface method must fail loudly rather than return a URL an external - // client cannot use; see its doc comment. - _, err := provider.UploadSignedURL(ctx, "signed/refused.bin", time.Hour) - require.ErrorIs(t, err, ErrSignedUploadURLUnsupported) - assert.Contains(t, err.Error(), "x-ms-blob-type") + t.Run("UploadSignedURLRequiresTheReturnedHeaders", func(t *testing.T) { + path := "signed/upload.bin" + body := []byte("signed-upload-body") + + upload, err := provider.UploadSignedURL(ctx, path, time.Hour) + require.NoError(t, err) + require.Equal(t, map[string]string{"x-ms-blob-type": "BlockBlob"}, upload.Headers) + + // The header is what the fix is for: a SAS alone cannot satisfy Put Blob. The + // emulator rejects the request before the API handler, so it answers a bare 400 + // where real Azure names the cause (MissingRequiredHeader) in the body. + status, _, err := putSignedURL(ctx, t, upload.URL, nil, body) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, status) + + status, responseBody, err := putSignedURL(ctx, t, upload.URL, upload.Headers, body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, status, responseBody) + + object, err := provider.OpenBlob(ctx, path) + require.NoError(t, err) + var read bytes.Buffer + _, err = object.WriteTo(ctx, &read) + require.NoError(t, err) + assert.Equal(t, body, read.Bytes()) }) t.Run("DeleteNonexistentIsIdempotent", func(t *testing.T) { @@ -400,3 +420,31 @@ func TestAzureIntegrationMissingContainerIsNotObjectNotExist(t *testing.T) { assert.NotErrorIs(t, err, ErrObjectNotExist) }) } + +// putSignedURL PUTs body to a signed upload URL with exactly the given headers. +func putSignedURL(ctx context.Context, t *testing.T, url string, headers map[string]string, body []byte) (int, string, error) { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return 0, "", err + } + + req.ContentLength = int64(len(body)) + for name, value := range headers { + req.Header.Set(name, value) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "", err + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return 0, "", err + } + + return res.StatusCode, string(responseBody), nil +} diff --git a/packages/shared/pkg/storage/storage_azure_test.go b/packages/shared/pkg/storage/storage_azure_test.go index 8277aae99f..4c7fca6f64 100644 --- a/packages/shared/pkg/storage/storage_azure_test.go +++ b/packages/shared/pkg/storage/storage_azure_test.go @@ -1,10 +1,19 @@ package storage import ( + "context" "encoding/base64" + "io" "math" + "net/http" + "net/url" + "strings" "testing" + "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/storage/azblob" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -156,9 +165,8 @@ func TestAzurePartUploaderBlockIDsAreUploadScoped(t *testing.T) { func TestNewAzureStorageAcceptsKeylessConnectionString(t *testing.T) { // A least-privilege SAS connection string (no AccountKey) is a working config for - // every reachable operation — nothing in the provider needs a shared key. (Signed - // upload URLs would have, but UploadSignedURL is hard-disabled on Azure; the AAD - // path is likewise accepted without one.) + // every reachable operation except minting an upload SAS, which needs either a + // shared key or a credential allowed to fetch a user delegation key. t.Setenv("AZURE_STORAGE_CONNECTION_STRING", "BlobEndpoint=https://myaccount.blob.core.windows.net;SharedAccessSignature=sv=2022-11-02&ss=b&sig=fake") @@ -220,3 +228,173 @@ func TestClampAzureUploadConcurrency(t *testing.T) { }) } } + +func TestNewAzureStorageSigningCredential(t *testing.T) { + t.Run("connection string with an account key can sign locally", func(t *testing.T) { + t.Setenv("AZURE_STORAGE_CONNECTION_STRING", + "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=ZmFrZS1hY2NvdW50LWtleS1mb3ItdGVzdHMtb25seS1ub3QtYS1jcmVkZW50aWFs;EndpointSuffix=core.windows.net") + + s, err := newAzureStorage(t.Context(), "fc-templates", nil) + require.NoError(t, err) + assert.NotNil(t, s.sharedKey) + assert.False(t, s.canDelegate) + + upload, err := s.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.NoError(t, err) + assert.Equal(t, map[string]string{"x-ms-blob-type": "BlockBlob"}, upload.Headers) + assert.Contains(t, upload.URL, "sp=cw", "the SAS must grant exactly create+write") + }) + + t.Run("SAS-only connection string cannot sign", func(t *testing.T) { + t.Setenv("AZURE_STORAGE_CONNECTION_STRING", + "BlobEndpoint=https://myaccount.blob.core.windows.net;SharedAccessSignature=sv=2022-11-02&ss=b&sig=fake") + + s, err := newAzureStorage(t.Context(), "fc-templates", nil) + require.NoError(t, err) + assert.Nil(t, s.sharedKey) + assert.False(t, s.canDelegate) + + _, err = s.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.ErrorIs(t, err, ErrSignedUploadURLUnsupported) + }) + + t.Run("account name and key env pair signs locally", func(t *testing.T) { + t.Setenv("AZURE_STORAGE_ACCOUNT_NAME", "myaccount") + t.Setenv("AZURE_STORAGE_ACCOUNT_KEY", "ZmFrZS1hY2NvdW50LWtleS1mb3ItdGVzdHMtb25seS1ub3QtYS1jcmVkZW50aWFs") + + s, err := newAzureStorage(t.Context(), "fc-templates", nil) + require.NoError(t, err) + assert.NotNil(t, s.sharedKey) + + upload, err := s.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(upload.URL, "https://myaccount.blob.core.windows.net/"), upload.URL) + assert.Contains(t, upload.URL, "spr=https", "a real account must only accept HTTPS") + }) +} + +func TestParseConnectionStringSharedKey(t *testing.T) { + t.Parallel() + + // Base64 keys end in '=' padding, so the value must be split on the first '=' only. + const key = "ZmFrZS1hY2NvdW50LWtleS1mb3ItdGVzdHMtb25seS1ub3QtYS1jcmVk==" + + for _, tt := range []struct { + name string + connectionString string + wantName string + wantKey string + wantOK bool + }{ + { + name: "account key with base64 padding", + connectionString: "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=" + key + ";EndpointSuffix=core.windows.net", + wantName: "myaccount", + wantKey: key, + wantOK: true, + }, + { + name: "sas only", + connectionString: "BlobEndpoint=https://myaccount.blob.core.windows.net;SharedAccessSignature=sv=2022-11-02&ss=b&sig=fake", + wantOK: false, + }, + { + name: "account name without a key", + connectionString: "AccountName=myaccount;SharedAccessSignature=sig=fake", + wantName: "myaccount", + wantOK: false, + }, + { + name: "empty", + connectionString: "", + wantOK: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + accountName, accountKey, ok := parseConnectionStringSharedKey(tt.connectionString) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantName, accountName) + assert.Equal(t, tt.wantKey, accountKey) + }) + } +} + +// staticTokenCredential stands in for a managed identity: the user-delegation path needs a +// bearer token, and the pipeline demands one before it will talk to the fake transport. +type staticTokenCredential struct{} + +func (staticTokenCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "fake-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +// userDelegationKeyTransport answers the Get User Delegation Key call with a canned key and +// records the request, so the request shape can be asserted without an AAD-backed account. +type userDelegationKeyTransport struct { + query url.Values + calls int +} + +func (t *userDelegationKeyTransport) Do(req *http.Request) (*http.Response, error) { + t.calls++ + t.query = req.URL.Query() + + // Azure's own documented example key value; it only ever signs in this test. + body := ` + + 11111111-1111-1111-1111-111111111111 + 22222222-2222-2222-2222-222222222222 + 2026-09-11T09:00:00Z + 2026-09-11T09:30:00Z + b + 2026-06-06 + ZmFrZS11c2VyLWRlbGVnYXRpb24ta2V5LWZvci10ZXN0cy1vbmx5 +` + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/xml"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil +} + +// The managed-identity path signs with a user delegation key fetched from the service; only +// the RBAC grant behind that fetch cannot be exercised here. +func TestAzureUploadSignedURLSignsWithUserDelegation(t *testing.T) { + t.Parallel() + + transport := &userDelegationKeyTransport{} + client, err := azblob.NewClient("https://myaccount.blob.core.windows.net/", staticTokenCredential{}, + &azblob.ClientOptions{ClientOptions: azcore.ClientOptions{Transport: transport}}) + require.NoError(t, err) + + provider := &azureStorage{ + client: client, + container: client.ServiceClient().NewContainerClient("fc-templates"), + containerName: "fc-templates", + canDelegate: true, + } + + upload, err := provider.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 30*time.Minute) + require.NoError(t, err) + + assert.Equal(t, 1, transport.calls) + assert.Equal(t, "userdelegationkey", transport.query.Get("comp")) + assert.Equal(t, "service", transport.query.Get("restype")) + + assert.Equal(t, map[string]string{"x-ms-blob-type": "BlockBlob"}, upload.Headers) + + signed, err := url.Parse(upload.URL) + require.NoError(t, err) + assert.Equal(t, "/fc-templates/templates/abc/layer.tar", signed.Path) + + params := signed.Query() + assert.Equal(t, "11111111-1111-1111-1111-111111111111", params.Get("skoid"), "a user-delegation SAS is identified by skoid/sktid") + assert.Equal(t, "22222222-2222-2222-2222-222222222222", params.Get("sktid")) + assert.Equal(t, "cw", params.Get("sp")) + assert.Equal(t, "b", params.Get("sr")) + assert.Equal(t, "https", params.Get("spr")) + assert.NotEmpty(t, params.Get("sig")) +} diff --git a/packages/shared/pkg/storage/storage_cache.go b/packages/shared/pkg/storage/storage_cache.go index 61982f3ee7..71a3832c5d 100644 --- a/packages/shared/pkg/storage/storage_cache.go +++ b/packages/shared/pkg/storage/storage_cache.go @@ -81,7 +81,7 @@ func (c cache) DeleteObjectsWithPrefix(ctx context.Context, prefix string) error return c.inner.DeleteObjectsWithPrefix(ctx, prefix) } -func (c cache) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (string, error) { +func (c cache) UploadSignedURL(ctx context.Context, path string, ttl time.Duration) (UploadURL, error) { return c.inner.UploadSignedURL(ctx, path, ttl) } diff --git a/packages/shared/pkg/storage/storage_fs.go b/packages/shared/pkg/storage/storage_fs.go index ca71381622..9ed0303e23 100644 --- a/packages/shared/pkg/storage/storage_fs.go +++ b/packages/shared/pkg/storage/storage_fs.go @@ -58,9 +58,9 @@ func (s *fsStorage) GetDetails() string { return fmt.Sprintf("[Local file storage, base path set to %s]", s.basePath) } -func (s *fsStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (string, error) { +func (s *fsStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (UploadURL, error) { if s.uploadURL == "" || s.hmacKey == nil { - return "", errors.New("file system storage does not support signed URLs (no local upload endpoint configured)") + return UploadURL{}, errors.New("file system storage does not support signed URLs (no local upload endpoint configured)") } expiresSec := time.Now().Add(ttl).Unix() @@ -69,7 +69,7 @@ func (s *fsStorage) UploadSignedURL(_ context.Context, path string, ttl time.Dur u := fmt.Sprintf("%s/upload?path=%s&expires=%d&token=%s", s.uploadURL, url.QueryEscape(path), expiresSec, url.QueryEscape(token)) - return u, nil + return UploadURL{URL: u}, nil } func (s *fsStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_google.go b/packages/shared/pkg/storage/storage_google.go index 24e1e78f74..bf23cd196e 100644 --- a/packages/shared/pkg/storage/storage_google.go +++ b/packages/shared/pkg/storage/storage_google.go @@ -122,10 +122,10 @@ func (s *gcpStorage) GetDetails() string { return fmt.Sprintf("[GCP Storage, bucket set to %s]", s.bucket.BucketName()) } -func (s *gcpStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (string, error) { +func (s *gcpStorage) UploadSignedURL(_ context.Context, path string, ttl time.Duration) (UploadURL, error) { token, err := parseServiceAccountBase64(consts.GoogleServiceAccountSecret) if err != nil { - return "", fmt.Errorf("failed to parse GCP service account: %w", err) + return UploadURL{}, fmt.Errorf("failed to parse GCP service account: %w", err) } opts := &storage.SignedURLOptions{ @@ -137,10 +137,10 @@ func (s *gcpStorage) UploadSignedURL(_ context.Context, path string, ttl time.Du url, err := storage.SignedURL(s.bucket.BucketName(), path, opts) if err != nil { - return "", fmt.Errorf("failed to create signed URL for GCS object (%s): %w", path, err) + return UploadURL{}, fmt.Errorf("failed to create signed URL for GCS object (%s): %w", path, err) } - return url, nil + return UploadURL{URL: url}, nil } func (s *gcpStorage) OpenSeekable(_ context.Context, path string) (Seekable, error) { diff --git a/packages/shared/pkg/storage/storage_localupload_test.go b/packages/shared/pkg/storage/storage_localupload_test.go index 86c759978b..99e79e3b2c 100644 --- a/packages/shared/pkg/storage/storage_localupload_test.go +++ b/packages/shared/pkg/storage/storage_localupload_test.go @@ -151,9 +151,10 @@ func TestUploadSignedURL(t *testing.T) { signedURL, err := p.UploadSignedURL(t.Context(), "templates/abc/layer.tar", 5*time.Minute) require.NoError(t, err) + assert.Nil(t, signedURL.Headers) // Parse the URL and verify structure. - u, err := url.Parse(signedURL) + u, err := url.Parse(signedURL.URL) require.NoError(t, err) assert.Equal(t, "http", u.Scheme) @@ -184,7 +185,7 @@ func TestUploadSignedURL(t *testing.T) { signedURL, err := p.UploadSignedURL(t.Context(), "path with spaces/file name.tar", 5*time.Minute) require.NoError(t, err) - u, err := url.Parse(signedURL) + u, err := url.Parse(signedURL.URL) require.NoError(t, err) // Query().Get() returns the decoded value. @@ -214,7 +215,7 @@ func TestUploadSignedURL(t *testing.T) { require.NoError(t, err) // Parse the signed URL as a client would receive it. - u, err := url.Parse(signedURL) + u, err := url.Parse(signedURL.URL) require.NoError(t, err) q := u.Query() diff --git a/spec/openapi.yml b/spec/openapi.yml index b7a1bd9e14..6df979f231 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -1651,6 +1651,11 @@ components: url: description: Url where the file should be uploaded to type: string + headers: + description: Request headers that must be sent with the upload request + type: object + additionalProperties: + type: string LogLevel: type: string diff --git a/tests/integration/internal/api/generated.go b/tests/integration/internal/api/generated.go index 4c0d7c1fd9..404abcc988 100644 --- a/tests/integration/internal/api/generated.go +++ b/tests/integration/internal/api/generated.go @@ -1330,6 +1330,9 @@ type TemplateBuild struct { // TemplateBuildFileUpload defines model for TemplateBuildFileUpload. type TemplateBuildFileUpload struct { + // Headers Request headers that must be sent with the upload request + Headers *map[string]string `json:"headers,omitempty"` + // Present Whether the file is already present in the cache Present bool `json:"present"`